From be3eb5284e6fa4ec61aa6716792f7b2fefd495d9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:42:57 +0200 Subject: [PATCH] feat(federation): permanent-failure callback registry --- .../src/utils/federationRollback.test.ts | 41 +++++++++++++++++++ .../server/src/utils/federationRollback.ts | 39 ++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/server/src/utils/federationRollback.test.ts create mode 100644 packages/server/src/utils/federationRollback.ts diff --git a/packages/server/src/utils/federationRollback.test.ts b/packages/server/src/utils/federationRollback.test.ts new file mode 100644 index 00000000..0e5bcb10 --- /dev/null +++ b/packages/server/src/utils/federationRollback.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + registerPermanentFailureCallback, + invokePermanentFailureCallback, + _resetCallbacks, +} from './federationRollback.js'; + +describe('permanent-failure callback registry', () => { + beforeEach(() => _resetCallbacks()); + + it('invokes the registered callback when called by eventType', () => { + const cb = vi.fn(); + registerPermanentFailureCallback('friend_request_create', cb); + invokePermanentFailureCallback('friend_request_create', 'msg-123', 'recipient_not_found'); + expect(cb).toHaveBeenCalledWith('msg-123', 'recipient_not_found'); + }); + + it('is a no-op for unregistered event types', () => { + expect(() => + invokePermanentFailureCallback('unknown_event_type', 'msg-1', 'whatever') + ).not.toThrow(); + }); + + it('replaces a previously-registered callback for the same eventType', () => { + const old = vi.fn(); + const fresh = vi.fn(); + registerPermanentFailureCallback('e1', old); + registerPermanentFailureCallback('e1', fresh); + invokePermanentFailureCallback('e1', 'msg-9', 'reason'); + expect(old).not.toHaveBeenCalled(); + expect(fresh).toHaveBeenCalledWith('msg-9', 'reason'); + }); + + it('swallows errors thrown by the callback (logs but does not throw)', () => { + registerPermanentFailureCallback('boom', () => { throw new Error('test'); }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => invokePermanentFailureCallback('boom', 'msg-x', 'r')).not.toThrow(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/packages/server/src/utils/federationRollback.ts b/packages/server/src/utils/federationRollback.ts new file mode 100644 index 00000000..8512e3d0 --- /dev/null +++ b/packages/server/src/utils/federationRollback.ts @@ -0,0 +1,39 @@ +/** + * Permanent-failure callback registry for outbox events. + * + * When the federation worker observes a receiver-acknowledged terminal + * rejection (4xx with a recognized reason like 'recipient_not_found'), + * it invokes the registered callback for the eventType so the originating + * instance can roll back any local state created at queue time. + * + * Callbacks are NEVER invoked on transient failures (5xx, network errors, + * retry exhaustion). Only on receiver-acknowledged terminal rejections. + * + * Errors thrown by callbacks are logged but not re-thrown — rollback failure + * must not prevent the outbox entry from being deleted. + */ +type PermanentFailureCallback = (messageId: string, reason: string) => void; + +const callbacks = new Map(); + +export function registerPermanentFailureCallback(eventType: string, cb: PermanentFailureCallback): void { + callbacks.set(eventType, cb); +} + +export function invokePermanentFailureCallback(eventType: string, messageId: string, reason: string): void { + const cb = callbacks.get(eventType); + if (!cb) return; + try { + cb(messageId, reason); + } catch (err) { + console.error( + `[federation-rollback] callback for ${eventType} (msg=${messageId}, reason=${reason}) threw:`, + err, + ); + } +} + +/** Test-only: clear the registry between tests. */ +export function _resetCallbacks(): void { + callbacks.clear(); +}