feat(federation): startup sweep removes dead-incarnation channels and self-homed stubs (dead-incarnation spec §3.4)

This commit is contained in:
Jannis Braun
2026-07-03 01:05:26 +02:00
parent 3591773a4c
commit a0238eabc2
3 changed files with 228 additions and 1 deletions
@@ -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<typeof drizzle<typeof schema>>;
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<typeof import('../utils/federationAuth.js')>();
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<typeof schema.users.$inferInsert> & { 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);
});
});
+85
View File
@@ -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 ──────────────────────────────────────
/**
@@ -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.