mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user