From 0511a2388691ecfb3b384feb66de38cb89d250c3 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 16:53:13 -0400 Subject: [PATCH] fix(06): WR-06 bound post-write targeted resync with 10s timeout so a hang cannot wedge drain --- apps/api/src/broker/outboxWorker.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 5e6a9da..129e607 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -155,6 +155,14 @@ export function assembleRruleString( */ let isDraining = false +/** + * WR-06: max time to wait on the post-write targeted re-sync before marking the + * outbox row 'done'. A stalled Fastmail connection cannot wedge the single-process + * drain loop beyond this cap; the PWA's next sync-status poll reconciles any cache + * that the timed-out re-sync did not refresh. + */ +const RESYNC_TIMEOUT_MS = 10_000 + // ── Credential + client loading ────────────────────────────────────────────── /** @@ -690,7 +698,20 @@ export async function runOutboxDrain(): Promise { // raced the re-sync and returned stale cache (deleted event still // present, edit not yet applied) — forcing a manual refresh. Re-syncing // first means 'done' guarantees the cache already reflects the write. - await triggerTargetedResync(row.calendarUrl, row.userId, clientCache) + // + // WR-06: bound the re-sync with a timeout. triggerTargetedResync does + // unbounded network I/O against Fastmail (fetchCalendars + syncCalendar); + // a hang would leave this row 'pending' from the DB's view for the full + // duration, the 15s isDraining guard would no-op the next cycle, and the + // PWA would poll 'pending' indefinitely — wedging the single-process + // drain loop. On timeout we proceed to mark 'done' and let the PWA's next + // poll/refetch reconcile (the same documented refetch path the eager + // re-sync was optimizing). triggerTargetedResync already swallows its own + // errors, so the race only needs to cap the wait. + await Promise.race([ + triggerTargetedResync(row.calendarUrl, row.userId, clientCache), + new Promise((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)), + ]) await db .update(calendarOutbox) .set({ status: 'done' })