From a0238eabc2b4db667e8a8a5a7afe66cdd7d71297 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:05:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(federation):=20startup=20sweep=20removes?= =?UTF-8?q?=20dead-incarnation=20channels=20and=20self-homed=20stubs=20(de?= =?UTF-8?q?ad-incarnation=20spec=20=C2=A73.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../federation.deadIncarnationSweep.test.ts | 135 ++++++++++++++++++ packages/server/src/routes/federation.ts | 85 +++++++++++ packages/server/src/utils/federationWorker.ts | 9 +- 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/routes/federation.deadIncarnationSweep.test.ts diff --git a/packages/server/src/routes/federation.deadIncarnationSweep.test.ts b/packages/server/src/routes/federation.deadIncarnationSweep.test.ts new file mode 100644 index 00000000..6587c1a6 --- /dev/null +++ b/packages/server/src/routes/federation.deadIncarnationSweep.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +let _sf = 1; +vi.mock('../utils/snowflake.js', () => ({ + generateSnowflake: () => String(_sf++), + setWorkerId: vi.fn(), +})); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +// federation.ts also imports connectionManager/ws — stub minimal surface so +// the route module loads at test time. The function under test doesn't touch any of these. +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sql.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + _sf = 1; +}); + +function seedUser(row: Partial & { id: string; username: string }): void { + testDb.insert(schema.users).values({ + passwordHash: '!federation-replicated', createdAt: 1, ...row, + } as typeof schema.users.$inferInsert).run(); +} + +describe('sweepDeadIncarnationArtifacts', () => { + beforeEach(() => { + // Junk: self-homed stub (home.test == our domain) + channel with no native member. + seedUser({ id: 'junk-stub', username: 'youruser@home.test@home.test', homeInstance: 'home.test', homeUserId: 'dead-1' }); + seedUser({ id: 'remote-stub', username: 'bob@orbit.test', homeInstance: 'orbit.test', homeUserId: 'bob-home' }); + testDb.insert(schema.dmChannels).values({ id: 'junk-ch', federatedId: 'fed-junk', createdAt: 1 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'junk-ch', userId: 'junk-stub', closed: 0 }, + { dmChannelId: 'junk-ch', userId: 'remote-stub', closed: 0 }, + ]).run(); + testDb.insert(schema.dmMessages).values({ + id: 'junk-msg', dmChannelId: 'junk-ch', userId: 'junk-stub', content: 'jo', createdAt: 1, + }).run(); + // Legit: native alice + remote bob channel. + seedUser({ id: 'alice', username: 'alice', passwordHash: 'real-hash', homeInstance: null }); + testDb.insert(schema.dmChannels).values({ id: 'live-ch', federatedId: 'fed-live', createdAt: 1 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'live-ch', userId: 'alice', closed: 0 }, + { dmChannelId: 'live-ch', userId: 'remote-stub', closed: 0 }, + ]).run(); + testDb.insert(schema.dmMessages).values({ + id: 'live-msg', dmChannelId: 'live-ch', userId: 'remote-stub', content: 'hey', createdAt: 1, + }).run(); + // Junk friendship referencing the self-homed stub. + testDb.insert(schema.friends).values({ userId: 'alice', friendId: 'junk-stub', createdAt: 1 }).run(); + }); + + it('removes native-less channels (with contents) and self-homed stubs; keeps legit data', async () => { + const { sweepDeadIncarnationArtifacts } = await import('./federation.js'); + sweepDeadIncarnationArtifacts(); + + expect(testDb.select().from(schema.dmChannels).all().map(c => c.id)).toEqual(['live-ch']); + expect(testDb.select().from(schema.dmMessages).all().map(m => m.id)).toEqual(['live-msg']); + expect(testDb.select().from(schema.dmMembers).all().every(m => m.dmChannelId === 'live-ch')).toBe(true); + const userIds = testDb.select().from(schema.users).all().map(u => u.id).sort(); + expect(userIds).toEqual(['alice', 'remote-stub']); + expect(testDb.select().from(schema.friends).all()).toEqual([]); + }); + + it('is idempotent — second run is a no-op', async () => { + const { sweepDeadIncarnationArtifacts } = await import('./federation.js'); + sweepDeadIncarnationArtifacts(); + const snapshotUsers = testDb.select().from(schema.users).all(); + sweepDeadIncarnationArtifacts(); + expect(testDb.select().from(schema.users).all()).toEqual(snapshotUsers); + }); + + it('skips (does not delete) a self-homed stub that still authors a SPACE message', async () => { + testDb.insert(schema.spaces).values({ id: 's1', name: 'S', ownerId: 'alice', createdAt: 1 }).run(); + testDb.insert(schema.channels).values({ id: 'c1', spaceId: 's1', name: 'general', type: 'text', createdAt: 1 }).run(); + testDb.insert(schema.messages).values({ id: 'sm1', channelId: 'c1', userId: 'junk-stub', content: 'x', createdAt: 1 }).run(); + const { sweepDeadIncarnationArtifacts } = await import('./federation.js'); + sweepDeadIncarnationArtifacts(); + // Channel cleanup still ran, but the referenced stub survives (logged as skipped). + expect(testDb.select().from(schema.users).all().some(u => u.id === 'junk-stub')).toBe(true); + }); + + it('never touches DETACHED accounts (homed at the peer, not us)', async () => { + seedUser({ id: 'detached-1', username: 'dave@orbit.test', passwordHash: 'real-hash', homeInstance: 'orbit.test', homeUserId: 'dave-home', federationHomeOrphaned: 1 }); + const { sweepDeadIncarnationArtifacts } = await import('./federation.js'); + sweepDeadIncarnationArtifacts(); + expect(testDb.select().from(schema.users).all().some(u => u.id === 'detached-1')).toBe(true); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 40c51c5c..54591cea 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -6699,6 +6699,91 @@ export function processPresenceUpdateEvent( accepted.push(event.messageId); } +// ─── Dead-Incarnation Startup Sweep ───────────────────────────────────────── + +/** + * Remove dead-incarnation artifacts produced by pre-fix initial syncs + * (dead-incarnation spec §3.4): DM channels with no native member, and + * replicated stubs homed at this instance's own domain. Idempotent — + * a no-op on a clean database. Synchronous (better-sqlite3), runs once + * at startup from startFederationWorkers. + * + * Child rows are deleted explicitly: FK cascade enforcement cannot be + * assumed ON, and dm_messages.user_id has no cascade anyway. + */ +export function sweepDeadIncarnationArtifacts(): void { + const ourDomain = getOurIdentityDomain(); + if (!ourDomain) return; + const rawDb = getRawDb(); + const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`; + + // ── 1. DM channels with no native member. A legitimate channel always + // involves a native user; native-less channels are sync junk. ── + const junkChannelIds = (rawDb.prepare(` + SELECT c.id FROM dm_channels c + WHERE NOT EXISTS ( + SELECT 1 FROM dm_members m JOIN users u ON u.id = m.user_id + WHERE m.dm_channel_id = c.id AND u.home_instance IS NULL + ) + `).all() as Array<{ id: string }>).map(r => r.id); + + if (junkChannelIds.length > 0) { + const ph = junkChannelIds.map(() => '?').join(','); + rawDb.transaction(() => { + rawDb.prepare(`DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds); + rawDb.prepare(`DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds); + rawDb.prepare(`DELETE FROM dm_messages WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds); + rawDb.prepare(`DELETE FROM dm_members WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds); + rawDb.prepare(`DELETE FROM read_states WHERE channel_id IN (${ph})`).run(...junkChannelIds); + rawDb.prepare(`DELETE FROM dm_channels WHERE id IN (${ph})`).run(...junkChannelIds); + })(); + } + + // ── 2. Self-homed replicated stubs. Junk social rows referencing them go + // first; then stubs with no remaining non-cascading references. ── + const stubSelect = `SELECT id FROM users WHERE password_hash = '!federation-replicated' AND ${normHome} = ?`; + const allStubIds = (rawDb.prepare(stubSelect).all(ourDomain) as Array<{ id: string }>).map(r => r.id); + + let deletedStubs = 0; + if (allStubIds.length > 0) { + rawDb.transaction(() => { + rawDb.prepare(`DELETE FROM friends WHERE user_id IN (${stubSelect}) OR friend_id IN (${stubSelect})`).run(ourDomain, ourDomain); + rawDb.prepare(`DELETE FROM friend_requests WHERE from_id IN (${stubSelect}) OR to_id IN (${stubSelect})`).run(ourDomain, ourDomain); + + // Deletable = no rows left in any table whose FK to users.id does NOT + // cascade, and no surviving dm/space membership or authored message. + const deletable = (rawDb.prepare(` + ${stubSelect} + AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE user_id = users.id) + AND NOT EXISTS (SELECT 1 FROM messages WHERE user_id = users.id) + AND NOT EXISTS (SELECT 1 FROM dm_members WHERE user_id = users.id) + AND NOT EXISTS (SELECT 1 FROM space_members WHERE user_id = users.id) + AND NOT EXISTS (SELECT 1 FROM spaces WHERE owner_id = users.id) + AND NOT EXISTS (SELECT 1 FROM bans WHERE banned_by = users.id) + AND NOT EXISTS (SELECT 1 FROM join_requests WHERE decided_by = users.id) + AND NOT EXISTS (SELECT 1 FROM voice_restrictions WHERE moderator_id = users.id) + AND NOT EXISTS (SELECT 1 FROM invite_links WHERE created_by = users.id) + `).all(ourDomain) as Array<{ id: string }>).map(r => r.id); + + if (deletable.length > 0) { + const dph = deletable.map(() => '?').join(','); + // Explicit child cleanup for the cascade-declared tables too — FK + // enforcement cannot be assumed ON. + rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id IN (${dph})`).run(...deletable); + rawDb.prepare(`DELETE FROM reactions WHERE user_id IN (${dph})`).run(...deletable); + rawDb.prepare(`DELETE FROM read_states WHERE user_id IN (${dph})`).run(...deletable); + rawDb.prepare(`DELETE FROM users WHERE id IN (${dph})`).run(...deletable); + deletedStubs = deletable.length; + } + })(); + } + + const skipped = allStubIds.length - deletedStubs; + if (junkChannelIds.length > 0 || allStubIds.length > 0) { + console.log(`[federation] Dead-incarnation sweep: removed ${junkChannelIds.length} channels, ${deletedStubs} self-homed stubs${skipped > 0 ? `, skipped ${skipped} still-referenced stubs` : ''}`); + } +} + // ─── Replicated Profile Asset Backfill ────────────────────────────────────── /** diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 8d821557..3f396532 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -13,7 +13,7 @@ import { generateThumbnail } from './thumbnail.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js'; import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js'; -import { backfillReplicatedProfileAssets } from '../routes/federation.js'; +import { backfillReplicatedProfileAssets, sweepDeadIncarnationArtifacts } from '../routes/federation.js'; import { invokePermanentFailureCallback } from './federationRollback.js'; import { refreshPeerEpochs, getInstanceId } from './federationEpoch.js'; import fs from 'node:fs'; @@ -1305,6 +1305,13 @@ export function startFederationWorkers(): void { console.error('[federation-worker] Startup reset-detection sweep error:', err); }); + // Remove dead-incarnation artifacts left by pre-fix initial syncs (spec §3.4). + try { + sweepDeadIncarnationArtifacts(); + } catch (err) { + console.error('[federation-worker] Dead-incarnation sweep error:', err); + } + // Backfill any replicated user avatars/banners still stored as absolute URLs // (legacy data from before file replication, or rows whose home was offline // on a previous attempt). Best-effort and idempotent — safe to re-run.