fix(federation): treat duplicate rejection as terminal in outbox worker
Duplicate rejection means the peer already has the message (e.g.,
delivered earlier via outbox AND pulled via sync in the same
window). Retrying will fail identically forever until TTL expires.
Before this patch: duplicate-rejected outbox entries were retained
with attempts++ and exponential backoff, creating log noise and
outbox bloat for up to 30 days.
After: duplicate-rejected entityIds join the terminal set alongside
accepted ones and are deleted from the outbox. Logged at info level
('outbox entry removed (terminal)') to distinguish from warn-level
transient-rejection retries.
Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; some may also be terminal but are
deferred until observed accumulating.
This commit is contained in:
@@ -100,7 +100,7 @@ function scheduleOutboxTick(): void {
|
||||
}, OUTBOX_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function processOutboxTick(): Promise<void> {
|
||||
export async function processOutboxTick(): Promise<void> {
|
||||
if (!isFederationRelayEnabled()) {
|
||||
return;
|
||||
}
|
||||
@@ -230,26 +230,43 @@ async function processOutboxTick(): Promise<void> {
|
||||
if (response.ok) {
|
||||
const result = await response.json() as FederationRelayResponse;
|
||||
|
||||
// Delete accepted entries
|
||||
if (result.accepted.length > 0) {
|
||||
// Map accepted messageIds to outbox IDs
|
||||
const acceptedSet = new Set(result.accepted);
|
||||
const acceptedOutboxIds = peerEntries
|
||||
.filter((e) => acceptedSet.has(e.entityId))
|
||||
// Terminal outcomes = accepted + duplicate-rejected.
|
||||
// `duplicate` means the peer already has the message (e.g., delivered
|
||||
// earlier via outbox or pulled via sync). Retrying will fail with
|
||||
// `duplicate` forever until TTL expires — treat it as effectively-
|
||||
// accepted and remove the outbox entry.
|
||||
const terminalEntityIds = new Set<string>(result.accepted);
|
||||
for (const rejection of result.rejected) {
|
||||
if (rejection.reason === 'duplicate') {
|
||||
terminalEntityIds.add(rejection.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalEntityIds.size > 0) {
|
||||
const terminalOutboxIds = peerEntries
|
||||
.filter((e) => terminalEntityIds.has(e.entityId))
|
||||
.map((e) => e.outboxId);
|
||||
|
||||
if (acceptedOutboxIds.length > 0) {
|
||||
if (terminalOutboxIds.length > 0) {
|
||||
db.delete(schema.federationOutbox)
|
||||
.where(inArray(schema.federationOutbox.id, acceptedOutboxIds))
|
||||
.where(inArray(schema.federationOutbox.id, terminalOutboxIds))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Log rejected entries (they remain in outbox for retry)
|
||||
// Log rejected entries. Duplicate is terminal (outbox entry already
|
||||
// removed above) — log at info level. Other reasons are transient /
|
||||
// retained for retry — log at warn level.
|
||||
for (const rejection of result.rejected) {
|
||||
console.warn(
|
||||
`[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId}: ${rejection.reason}`,
|
||||
);
|
||||
if (rejection.reason === 'duplicate') {
|
||||
console.log(
|
||||
`[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId} as duplicate — outbox entry removed (terminal)`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId}: ${rejection.reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the peer's max upload size for informational display
|
||||
|
||||
Reference in New Issue
Block a user