feat(federation): startup sweep removes dead-incarnation channels and self-homed stubs (dead-incarnation spec §3.4)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user