From 39c8a2235261a2733937a6f9e0549e18126be433 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:12:26 +0200 Subject: [PATCH] fix(offline): replay queued writes an interrupted flush left behind A flush marks each row 'syncing' before it sends, and only ever picks up rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA does exactly this - and the row stays 'syncing' forever: it keeps counting towards the pending badge, but nothing ever retries it, fails it or shows it as a conflict. Anything queued behind it that depends on it later fails with 'unresolved temp id'. Stamp the row when it goes in flight and reclaim it on the next flush. The axios instance times out at 8s, so a minute is well past anything that could still be running, and the mutation id doubles as the idempotency key, so replaying a write the server already applied is answered from its cache. --- client/src/db/offlineDb.ts | 7 ++++ client/src/sync/mutationQueue.ts | 22 ++++++++++- client/tests/unit/sync/mutationQueue.test.ts | 41 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 02328f250..a072bf761 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -51,6 +51,13 @@ export interface QueuedMutation { conflictServer?: unknown; /** When the conflict was detected (for ordering / display). */ conflictAt?: number; + /** + * When the row was marked 'syncing'. Only meaningful while it is — the flush + * that set it clears the row on success or moves it off 'syncing' on failure. + * A stamp that outlives its flush is how a killed tab is recognised on the + * next one (see mutationQueue's STUCK_SYNCING_MS). + */ + syncingSince?: number; } export interface SyncMeta { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 43112821b..ab7035107 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -39,6 +39,13 @@ export function generateUUID(): string { } let _flushing = false +// A row sits on 'syncing' only while its request is in flight, and the shared +// axios instance times out at 8s. Anything still 'syncing' a minute later +// belongs to a flush that never reached its catch — the tab was killed, the PWA +// was evicted, the device slept mid-request. flush() only ever selects +// 'pending', so without recovery that row is stranded: counted in the badge, +// never replayed, and blocking every dependent behind it. +const STUCK_SYNCING_MS = 60_000 // Monotonically increasing timestamp so same-millisecond enqueues // still get a deterministic FIFO order when sorted by createdAt. let _lastTs = 0 @@ -121,14 +128,25 @@ export const mutationQueue = { // without its base token, so one more pass overwrites the server cleanly. let needsRetry = false try { + // Reclaim what an interrupted flush left behind before picking up work. + // Replaying is safe: the mutation id doubles as the X-Idempotency-Key, so + // a write the server already applied is answered from its replay cache. + const stuckBefore = Date.now() - STUCK_SYNCING_MS + await offlineDb.mutationQueue + .where('status') + .equals('syncing') + .filter(m => (m.syncingSince ?? 0) < stuckBefore) + .modify(m => { m.status = 'pending'; m.syncingSince = undefined }) + const pending = await offlineDb.mutationQueue .where('status') .equals('pending') .sortBy('createdAt') for (const mutation of pending) { - // Mark as syncing so UI can show progress - await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) + // Mark as syncing so UI can show progress. The stamp is what lets the + // next flush tell an in-flight row from one a killed tab abandoned. + await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing', syncingSince: Date.now() }) // Resolve a temp-id reference now that earlier CREATEs in this flush // may have completed (FIFO order guarantees the CREATE ran first). diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts index 8e7868acf..19cfc6a5d 100644 --- a/client/tests/unit/sync/mutationQueue.test.ts +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -241,6 +241,47 @@ describe('mutationQueue.flush — offline guard', () => { // ── pending / pendingCount ──────────────────────────────────────────────────── +describe('mutationQueue.flush — interrupted flush recovery', () => { + it('replays a row an earlier flush abandoned on syncing', async () => { + let seen = 0; + server.use( + http.post('/api/trips/1/places', () => { + seen += 1; + return HttpResponse.json({ place: buildPlace({ id: 42 }) }); + }), + ); + + // What a killed tab leaves behind: marked syncing, stamped long enough ago + // that no live request could still be in flight. + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now() - 600_000, status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now() - 600_000, + }); + + await mutationQueue.flush(); + + expect(seen).toBe(1); + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + }); + + it('leaves a freshly marked syncing row alone (a concurrent flush owns it)', async () => { + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now(), status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now(), + }); + + await mutationQueue.flush(); + + expect((await offlineDb.mutationQueue.get(id))!.status).toBe('syncing'); + }); +}); + describe('mutationQueue.pending', () => { it('returns pending mutations for a trip', async () => { const id1 = generateUUID();