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);
|
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 ──────────────────────────────────────
|
// ─── Replicated Profile Asset Backfill ──────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { generateThumbnail } from './thumbnail.js';
|
|||||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
||||||
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
||||||
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.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 { invokePermanentFailureCallback } from './federationRollback.js';
|
||||||
import { refreshPeerEpochs, getInstanceId } from './federationEpoch.js';
|
import { refreshPeerEpochs, getInstanceId } from './federationEpoch.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -1305,6 +1305,13 @@ export function startFederationWorkers(): void {
|
|||||||
console.error('[federation-worker] Startup reset-detection sweep error:', err);
|
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
|
// Backfill any replicated user avatars/banners still stored as absolute URLs
|
||||||
// (legacy data from before file replication, or rows whose home was offline
|
// (legacy data from before file replication, or rows whose home was offline
|
||||||
// on a previous attempt). Best-effort and idempotent — safe to re-run.
|
// on a previous attempt). Best-effort and idempotent — safe to re-run.
|
||||||
|
|||||||
Reference in New Issue
Block a user