fix: persistent random Snowflake worker ID + clean reaction API

Two fixes addressing architectural review feedback:

1. Snowflake ID collisions: Replace process.pid-based worker ID with a
   cryptographically random value (0-1023) generated once at first boot
   and persisted to instance_settings.worker_id. Eliminates deterministic
   ID collisions between Docker instances that all run as PID 1.

2. Reaction API leak: Revert addReaction/removeReaction signatures to
   (messageId, emoji) — the store now resolves the channel internally by
   scanning its message cache, keeping routing logic out of the UI layer.
This commit is contained in:
Jannis Braun
2026-03-03 00:03:29 +01:00
parent 5194dbef25
commit 72e07c1cc1
6 changed files with 76 additions and 10 deletions
+12
View File
@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import { config } from '../config.js'; import { config } from '../config.js';
import * as schema from './schema.js'; import * as schema from './schema.js';
import { runMigrations } from './migrate.js'; import { runMigrations } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -180,6 +181,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS instance_settings ( CREATE TABLE IF NOT EXISTS instance_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
instance_name TEXT DEFAULT 'Backspace', instance_name TEXT DEFAULT 'Backspace',
worker_id INTEGER,
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000, max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500, min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500, bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
@@ -199,6 +201,16 @@ export function initDatabase() {
sqlite.pragma('foreign_keys = ON'); sqlite.pragma('foreign_keys = ON');
createTables(sqlite); createTables(sqlite);
runMigrations(sqlite); runMigrations(sqlite);
// Initialize Snowflake worker ID from persisted value (set by migration)
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
if (settings?.worker_id !== undefined && settings.worker_id !== null) {
setWorkerId(settings.worker_id);
console.log(`Snowflake worker ID: ${settings.worker_id}`);
} else {
throw new Error('Snowflake worker_id not found in instance_settings — migration failed');
}
console.log(`Database initialized at ${config.dbPath}`); console.log(`Database initialized at ${config.dbPath}`);
return drizzle(sqlite, { schema }); return drizzle(sqlite, { schema });
} }
+21 -1
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import crypto from 'crypto';
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
export function runMigrations(db: Database.Database): void { export function runMigrations(db: Database.Database): void {
@@ -65,7 +66,8 @@ export function runMigrations(db: Database.Database): void {
{ {
name: 'instance_settings', name: 'instance_settings',
columns: [ columns: [
{ name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" } { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" },
{ name: 'worker_id', type: 'INTEGER' }
] ]
} }
]; ];
@@ -104,6 +106,9 @@ export function runMigrations(db: Database.Database): void {
// ─── Instance settings: ensure default row exists ────────────────────────── // ─── Instance settings: ensure default row exists ──────────────────────────
migrateInstanceSettings(db); migrateInstanceSettings(db);
// ─── Worker ID: ensure a unique Snowflake worker ID is persisted ───────────
migrateWorkerId(db);
// ─── Admin flag: ensure at least one admin exists (first registered user) ── // ─── Admin flag: ensure at least one admin exists (first registered user) ──
migrateFirstAdmin(db); migrateFirstAdmin(db);
@@ -133,6 +138,21 @@ function migrateFirstAdmin(db: Database.Database): void {
} }
} }
/**
* Ensure a unique Snowflake worker ID is persisted for this instance.
* Generated randomly on first boot (0-1023) and never changed.
* This prevents ID collisions between federated instances that would
* otherwise share worker_id = 1 when running as Docker PID 1.
*/
function migrateWorkerId(db: Database.Database): void {
const row = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number | null } | undefined;
if (!row || row.worker_id === null) {
const workerId = crypto.randomInt(0, 1024); // 0-1023 (10-bit range)
db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId);
console.log(`Migrating: Generated Snowflake worker ID ${workerId} for this instance`);
}
}
/** For each server, ensure an @everyone role exists with id === server.id */ /** For each server, ensure an @everyone role exists with id === server.id */
function migrateEveryoneRoles(db: Database.Database): void { function migrateEveryoneRoles(db: Database.Database): void {
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[]; const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
+1
View File
@@ -180,6 +180,7 @@ export const serverFolderMembers = sqliteTable('server_folder_members', {
export const instanceSettings = sqliteTable('instance_settings', { export const instanceSettings = sqliteTable('instance_settings', {
id: integer('id').primaryKey().default(1), id: integer('id').primaryKey().default(1),
instanceName: text('instance_name').default('Backspace'), instanceName: text('instance_name').default('Backspace'),
workerId: integer('worker_id'),
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000), maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500), minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500), bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
+21 -1
View File
@@ -10,15 +10,35 @@
* - ~139 years of IDs from epoch * - ~139 years of IDs from epoch
* - 1024 workers * - 1024 workers
* - 4096 IDs per millisecond per worker * - 4096 IDs per millisecond per worker
*
* IMPORTANT: The worker ID MUST be unique per instance to prevent ID
* collisions in a federation setup. It is generated randomly at first boot
* and persisted to the database. Call setWorkerId() before generating any IDs.
*/ */
const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC
const WORKER_ID = BigInt(process.pid % 1024);
let WORKER_ID: bigint | null = null;
let sequence = 0n; let sequence = 0n;
let lastTimestamp = -1n; let lastTimestamp = -1n;
/**
* Set the worker ID for this instance. Must be called once during server
* startup, after the database is initialized, before any IDs are generated.
* The value is persisted in instance_settings.worker_id.
*/
export function setWorkerId(id: number): void {
if (id < 0 || id > 1023) {
throw new Error(`Worker ID must be 0-1023, got ${id}`);
}
WORKER_ID = BigInt(id);
}
export function generateSnowflake(): string { export function generateSnowflake(): string {
if (WORKER_ID === null) {
throw new Error('Snowflake worker ID not initialized — call setWorkerId() during startup');
}
let timestamp = BigInt(Date.now()); let timestamp = BigInt(Date.now());
if (timestamp === lastTimestamp) { if (timestamp === lastTimestamp) {
+2 -2
View File
@@ -60,9 +60,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const toggleReaction = (emoji: string) => { const toggleReaction = (emoji: string) => {
const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji);
if (hasReacted) { if (hasReacted) {
removeReaction(message.id, emoji, channelKey); removeReaction(message.id, emoji);
} else { } else {
addReaction(message.id, emoji, channelKey); addReaction(message.id, emoji);
} }
}; };
+19 -6
View File
@@ -43,8 +43,8 @@ interface ChatState {
addRealtimeMessage: (channelId: string, message: MessageWithUser) => void; addRealtimeMessage: (channelId: string, message: MessageWithUser) => void;
updateMessage: (message: MessageWithUser) => void; updateMessage: (message: MessageWithUser) => void;
removeMessage: (messageId: string, channelId: string) => void; removeMessage: (messageId: string, channelId: string) => void;
addReaction: (messageId: string, emoji: string, channelId: string) => void; addReaction: (messageId: string, emoji: string) => void;
removeReaction: (messageId: string, emoji: string, channelId: string) => void; removeReaction: (messageId: string, emoji: string) => void;
onReactionAdded: (messageId: string, reaction: any) => void; onReactionAdded: (messageId: string, reaction: any) => void;
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void; onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
setTyping: (channelId: string, userId: string, username: string) => void; setTyping: (channelId: string, userId: string, username: string) => void;
@@ -57,6 +57,16 @@ interface ChatState {
onChannelAck: (channelId: string, messageId: string) => void; onChannelAck: (channelId: string, messageId: string) => void;
} }
/** Find which channel a message belongs to by scanning the message cache. */
function findChannelForMessage(messages: Map<string, MessageWithUser[]>, messageId: string): string | null {
for (const [channelId, msgs] of messages) {
if (msgs.some(m => m.id === messageId)) {
return channelId;
}
}
return null;
}
export const useChatStore = create<ChatState>((set, get) => ({ export const useChatStore = create<ChatState>((set, get) => ({
messages: new Map(), messages: new Map(),
currentChannelId: null, currentChannelId: null,
@@ -356,13 +366,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
}); });
}, },
addReaction: (messageId: string, emoji: string, channelId: string) => { addReaction: (messageId: string, emoji: string) => {
const origin = getChannelOrigin(channelId); // Resolve the channel from our message cache so the UI doesn't need to pass it
const channelId = findChannelForMessage(get().messages, messageId);
const origin = channelId ? getChannelOrigin(channelId) : '';
wsSend({ type: 'reaction_add', messageId, emoji }, origin); wsSend({ type: 'reaction_add', messageId, emoji }, origin);
}, },
removeReaction: (messageId: string, emoji: string, channelId: string) => { removeReaction: (messageId: string, emoji: string) => {
const origin = getChannelOrigin(channelId); const channelId = findChannelForMessage(get().messages, messageId);
const origin = channelId ? getChannelOrigin(channelId) : '';
wsSend({ type: 'reaction_remove', messageId, emoji }, origin); wsSend({ type: 'reaction_remove', messageId, emoji }, origin);
}, },