feat(federation): demand-driven recovery tick; health-check tick keeps rotation only
This commit is contained in:
@@ -425,3 +425,100 @@ describe('outbox worker — terminal rejection reasons + rollback invocation', (
|
|||||||
expect(invokeRollbackMock).not.toHaveBeenCalled();
|
expect(invokeRollbackMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('unreachable transition resets probe pacing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crossing the threshold sets status=unreachable and resets pacing', async () => {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-thr', origin: 'https://peer.example', hmacSecret: 'secret',
|
||||||
|
status: 'active', consecutiveFailures: 9, probeAttempts: 4, lastProbeAt: 123,
|
||||||
|
lastSyncedAt: Date.now(), createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
seedOutboxEntry('e-thr', 'peer-thr', 'm-thr');
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down'));
|
||||||
|
|
||||||
|
const { processOutboxTick } = await import('./federationWorker.js');
|
||||||
|
await processOutboxTick();
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-thr')).get()!;
|
||||||
|
expect(row.status).toBe('unreachable');
|
||||||
|
expect(row.consecutiveFailures).toBe(10);
|
||||||
|
expect(row.probeAttempts).toBe(0);
|
||||||
|
expect(row.lastProbeAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processRecoveryTick — demand-driven recovery', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedUnreachable(id: string, attempts = 0, lastProbeAt: number | null = null): void {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id, origin: 'https://peer.example', hmacSecret: 'secret',
|
||||||
|
status: 'unreachable', consecutiveFailures: 10,
|
||||||
|
probeAttempts: attempts, lastProbeAt,
|
||||||
|
lastSyncedAt: Date.now(), createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('recovers a peer with queued mail when the probe succeeds', async () => {
|
||||||
|
seedUnreachable('peer-a'); // lastProbeAt=null → immediately due
|
||||||
|
seedOutboxEntry('e-a', 'peer-a', 'm-a'); // has pending mail
|
||||||
|
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 }));
|
||||||
|
|
||||||
|
const { processRecoveryTick } = await import('./federationWorker.js');
|
||||||
|
await processRecoveryTick();
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('https://peer.example/api/instance/info', expect.anything());
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-a')).get()!;
|
||||||
|
expect(row.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances backoff when the probe fails', async () => {
|
||||||
|
seedUnreachable('peer-b');
|
||||||
|
seedOutboxEntry('e-b', 'peer-b', 'm-b');
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 502 }));
|
||||||
|
|
||||||
|
const { processRecoveryTick } = await import('./federationWorker.js');
|
||||||
|
await processRecoveryTick();
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-b')).get()!;
|
||||||
|
expect(row.status).toBe('unreachable');
|
||||||
|
expect(row.probeAttempts).toBe(1);
|
||||||
|
expect(row.lastProbeAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT probe a silent peer before the 15-min backstop is due', async () => {
|
||||||
|
seedUnreachable('peer-c', 1, Date.now() - 60_000); // no outbox entry, probed 1m ago
|
||||||
|
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 }));
|
||||||
|
|
||||||
|
const { processRecoveryTick } = await import('./federationWorker.js');
|
||||||
|
await processRecoveryTick();
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT probe a pending peer before its backoff interval elapses', async () => {
|
||||||
|
seedUnreachable('peer-d', 0, Date.now() - 5_000); // BACKOFF[0]=30s, probed 5s ago
|
||||||
|
seedOutboxEntry('e-d', 'peer-d', 'm-d');
|
||||||
|
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 }));
|
||||||
|
|
||||||
|
const { processRecoveryTick } = await import('./federationWorker.js');
|
||||||
|
await processRecoveryTick();
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { getDmMessageWithUser } from '../routes/dm.js';
|
|||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import { generateThumbnail } from './thumbnail.js';
|
import { generateThumbnail } from './thumbnail.js';
|
||||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
||||||
import { onPeerActivated, startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
||||||
|
import { probePeerReachable, markPeerRecovered } from './federationRecovery.js';
|
||||||
import { backfillReplicatedProfileAssets } from '../routes/federation.js';
|
import { backfillReplicatedProfileAssets } from '../routes/federation.js';
|
||||||
import { invokePermanentFailureCallback } from './federationRollback.js';
|
import { invokePermanentFailureCallback } from './federationRollback.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -28,6 +29,9 @@ const FILE_QUEUE_INTERVAL_MS = 30_000; // 30 seconds
|
|||||||
// one grace window on each side, so rotation desync can't outlast the window
|
// one grace window on each side, so rotation desync can't outlast the window
|
||||||
// and trip spurious auth failures on the other peer.
|
// and trip spurious auth failures on the other peer.
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
const HEALTH_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const RECOVERY_TICK_INTERVAL_MS = 5_000; // 5 seconds — demand-driven recovery scan
|
||||||
|
/** Per-peer recovery-probe backoff (ms), indexed by probe_attempts (0-based). */
|
||||||
|
const RECOVERY_BACKOFF_MS: readonly number[] = [30_000, 60_000, 300_000, 900_000];
|
||||||
const JANITOR_INTERVAL_MS = 3_600_000; // 1 hour
|
const JANITOR_INTERVAL_MS = 3_600_000; // 1 hour
|
||||||
|
|
||||||
const OUTBOX_BATCH_LIMIT = 50;
|
const OUTBOX_BATCH_LIMIT = 50;
|
||||||
@@ -35,7 +39,6 @@ const FILE_QUEUE_BATCH_LIMIT = 5;
|
|||||||
|
|
||||||
const OUTBOX_FETCH_TIMEOUT_MS = 30_000;
|
const OUTBOX_FETCH_TIMEOUT_MS = 30_000;
|
||||||
const FILE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
const FILE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
const HEALTH_CHECK_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
/** Exponential backoff schedule by attempt number (1-indexed). Values in milliseconds. */
|
/** Exponential backoff schedule by attempt number (1-indexed). Values in milliseconds. */
|
||||||
const BACKOFF_SCHEDULE_MS: readonly number[] = [
|
const BACKOFF_SCHEDULE_MS: readonly number[] = [
|
||||||
@@ -75,11 +78,12 @@ const TERMINAL_REJECTION_REASONS = new Set<string>([
|
|||||||
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
|
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let fileQueueTimer: ReturnType<typeof setTimeout> | null = null;
|
let fileQueueTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let healthCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
let healthCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let janitorTimer: ReturnType<typeof setTimeout> | null = null;
|
let janitorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let outboxAbortController: AbortController | null = null;
|
let outboxAbortController: AbortController | null = null;
|
||||||
let fileQueueAbortController: AbortController | null = null;
|
let fileQueueAbortController: AbortController | null = null;
|
||||||
let healthCheckAbortController: AbortController | null = null;
|
let recoveryAbortController: AbortController | null = null;
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -445,24 +449,23 @@ function handleOutboxDeliveryFailure(
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
const newFailures = (peer?.consecutiveFailures ?? 0) + 1;
|
const newFailures = (peer?.consecutiveFailures ?? 0) + 1;
|
||||||
const updates: Record<string, number | string> = {
|
const isNowUnreachable = newFailures >= PEER_UNREACHABLE_THRESHOLD;
|
||||||
lastFailureAt: now,
|
|
||||||
consecutiveFailures: newFailures,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (newFailures >= PEER_UNREACHABLE_THRESHOLD) {
|
|
||||||
(updates as Record<string, number | string>)['status'] = 'unreachable';
|
|
||||||
console.warn(
|
|
||||||
`[federation-worker] Peer ${peerId} marked unreachable after ${newFailures} consecutive failures`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.update(schema.federationPeers)
|
db.update(schema.federationPeers)
|
||||||
.set(updates)
|
.set({
|
||||||
|
lastFailureAt: now,
|
||||||
|
consecutiveFailures: newFailures,
|
||||||
|
// On entry into unreachable, reset recovery pacing so processRecoveryTick
|
||||||
|
// fires an immediate first probe (lastProbeAt=null) with a fresh backoff.
|
||||||
|
...(isNowUnreachable ? { status: 'unreachable' as const, probeAttempts: 0, lastProbeAt: null } : {}),
|
||||||
|
})
|
||||||
.where(eq(schema.federationPeers.id, peerId))
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
if (newFailures >= PEER_UNREACHABLE_THRESHOLD) {
|
if (isNowUnreachable) {
|
||||||
|
console.warn(
|
||||||
|
`[federation-worker] Peer ${peerId} marked unreachable after ${newFailures} consecutive failures`,
|
||||||
|
);
|
||||||
onPeerDeactivated(peerId, 'network_threshold').catch(err =>
|
onPeerDeactivated(peerId, 'network_threshold').catch(err =>
|
||||||
console.error('[federation-worker] onPeerDeactivated from unreachable threshold failed:', err)
|
console.error('[federation-worker] onPeerDeactivated from unreachable threshold failed:', err)
|
||||||
);
|
);
|
||||||
@@ -1004,6 +1007,64 @@ async function processFileQueueEntry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Recovery Worker (demand-driven) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function scheduleRecoveryTick(): void {
|
||||||
|
recoveryTimer = setTimeout(() => {
|
||||||
|
processRecoveryTick().catch((err) => {
|
||||||
|
console.error('[federation-worker] Recovery tick error:', err);
|
||||||
|
}).finally(() => {
|
||||||
|
scheduleRecoveryTick();
|
||||||
|
});
|
||||||
|
}, RECOVERY_TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe unreachable peers and recover them. Cadence is demand-driven: peers with
|
||||||
|
* queued outbox mail are probed on RECOVERY_BACKOFF_MS (indexed by probe_attempts);
|
||||||
|
* silent peers fall back to the HEALTH_CHECK_INTERVAL_MS backstop. Probes run
|
||||||
|
* sequentially to bound outbound concurrency; the candidate set is small.
|
||||||
|
*/
|
||||||
|
export async function processRecoveryTick(): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const unreachablePeers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.status, 'unreachable'))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const peer of unreachablePeers) {
|
||||||
|
const pending = db
|
||||||
|
.select({ id: schema.federationOutbox.id })
|
||||||
|
.from(schema.federationOutbox)
|
||||||
|
.where(eq(schema.federationOutbox.peerId, peer.id))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const interval = pending
|
||||||
|
? RECOVERY_BACKOFF_MS[Math.min(peer.probeAttempts, RECOVERY_BACKOFF_MS.length - 1)]!
|
||||||
|
: HEALTH_CHECK_INTERVAL_MS;
|
||||||
|
|
||||||
|
const due = peer.lastProbeAt === null || (now - peer.lastProbeAt) >= interval;
|
||||||
|
if (!due) continue;
|
||||||
|
|
||||||
|
recoveryAbortController = new AbortController();
|
||||||
|
const reachable = await probePeerReachable(peer.origin, recoveryAbortController.signal);
|
||||||
|
|
||||||
|
if (reachable) {
|
||||||
|
await markPeerRecovered(peer.id);
|
||||||
|
console.log(`[federation-worker] Peer ${peer.origin} recovered — marked active`);
|
||||||
|
} else {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ probeAttempts: peer.probeAttempts + 1, lastProbeAt: now })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Health Check Worker ────────────────────────────────────────────────────
|
// ─── Health Check Worker ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function scheduleHealthCheckTick(): void {
|
function scheduleHealthCheckTick(): void {
|
||||||
@@ -1101,54 +1162,6 @@ async function processHealthCheckTick(): Promise<void> {
|
|||||||
console.warn(`[federation-worker] Auto-rotation failed for peer ${peer.origin}: ${message}`);
|
console.warn(`[federation-worker] Auto-rotation failed for peer ${peer.origin}: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreachablePeers = db
|
|
||||||
.select()
|
|
||||||
.from(schema.federationPeers)
|
|
||||||
.where(eq(schema.federationPeers.status, 'unreachable'))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (unreachablePeers.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const peer of unreachablePeers) {
|
|
||||||
healthCheckAbortController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${peer.origin}/api/instance/info`, {
|
|
||||||
signal: AbortSignal.any([
|
|
||||||
healthCheckAbortController.signal,
|
|
||||||
AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
db.update(schema.federationPeers)
|
|
||||||
.set({
|
|
||||||
status: 'active',
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
lastSeenAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(schema.federationPeers.id, peer.id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[federation-worker] Peer ${peer.origin} recovered — marked active`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await onPeerActivated(peer.id, 'health_check_recovery');
|
|
||||||
}
|
|
||||||
// If not ok, leave as unreachable — will check again next cycle
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Leave as unreachable — will check again next cycle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Federated Call Health Sweep ────────────────────────────────────────────
|
// ─── Federated Call Health Sweep ────────────────────────────────────────────
|
||||||
@@ -1215,6 +1228,7 @@ export function startFederationWorkers(): void {
|
|||||||
scheduleOutboxTick();
|
scheduleOutboxTick();
|
||||||
scheduleFileQueueTick();
|
scheduleFileQueueTick();
|
||||||
scheduleHealthCheckTick();
|
scheduleHealthCheckTick();
|
||||||
|
scheduleRecoveryTick();
|
||||||
scheduleJanitorTick();
|
scheduleJanitorTick();
|
||||||
federatedCallSentinelTimer = setInterval(() => {
|
federatedCallSentinelTimer = setInterval(() => {
|
||||||
runFederatedCallSentinelTick().catch(err =>
|
runFederatedCallSentinelTick().catch(err =>
|
||||||
@@ -1247,6 +1261,10 @@ export function stopFederationWorkers(): void {
|
|||||||
clearTimeout(healthCheckTimer);
|
clearTimeout(healthCheckTimer);
|
||||||
healthCheckTimer = null;
|
healthCheckTimer = null;
|
||||||
}
|
}
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer);
|
||||||
|
recoveryTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (janitorTimer) {
|
if (janitorTimer) {
|
||||||
clearTimeout(janitorTimer);
|
clearTimeout(janitorTimer);
|
||||||
@@ -1264,8 +1282,8 @@ export function stopFederationWorkers(): void {
|
|||||||
fileQueueAbortController?.abort();
|
fileQueueAbortController?.abort();
|
||||||
fileQueueAbortController = null;
|
fileQueueAbortController = null;
|
||||||
|
|
||||||
healthCheckAbortController?.abort();
|
recoveryAbortController?.abort();
|
||||||
healthCheckAbortController = null;
|
recoveryAbortController = null;
|
||||||
|
|
||||||
console.log('[federation-worker] Federation workers stopped');
|
console.log('[federation-worker] Federation workers stopped');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user