fix(offline): harden sync catch + add queue/sync test coverage + atomic transactions
This commit is contained in:
@@ -2,7 +2,15 @@
|
|||||||
// src/lib/offline/queue.test.ts
|
// src/lib/offline/queue.test.ts
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { enqueueAction, dequeueAction, getQueuedActions, markSynced, markConflict } from "./queue";
|
import {
|
||||||
|
enqueueAction,
|
||||||
|
dequeueAction,
|
||||||
|
getQueuedActions,
|
||||||
|
markSynced,
|
||||||
|
markConflict,
|
||||||
|
markInFlight,
|
||||||
|
markFailed,
|
||||||
|
} from "./queue";
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clear the queue between tests
|
// Clear the queue between tests
|
||||||
@@ -61,4 +69,45 @@ describe("offline queue", () => {
|
|||||||
expect(all[0].status).toBe("conflict");
|
expect(all[0].status).toBe("conflict");
|
||||||
expect(all[0].error).toBe("stale updated_at");
|
expect(all[0].error).toBe("stale updated_at");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("markInFlight transitions the action to in-flight and records an attempt", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
const before = await getQueuedActions();
|
||||||
|
expect(before[0].attempts).toBe(0);
|
||||||
|
expect(before[0].status).toBe("pending");
|
||||||
|
expect(before[0].lastAttemptAt).toBeNull();
|
||||||
|
|
||||||
|
const beforeMs = Date.now();
|
||||||
|
await markInFlight(a.id);
|
||||||
|
const afterMs = Date.now();
|
||||||
|
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("in-flight");
|
||||||
|
expect(all[0].attempts).toBe(1);
|
||||||
|
expect(typeof all[0].lastAttemptAt).toBe("number");
|
||||||
|
expect(all[0].lastAttemptAt).not.toBeNull();
|
||||||
|
expect(all[0].lastAttemptAt!).toBeGreaterThanOrEqual(beforeMs);
|
||||||
|
expect(all[0].lastAttemptAt!).toBeLessThanOrEqual(afterMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markFailed transitions the action to failed and increments attempts", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
// Pretend it has already been tried once
|
||||||
|
await markInFlight(a.id);
|
||||||
|
|
||||||
|
const beforeMs = Date.now();
|
||||||
|
await markFailed(a.id, "server returned 500");
|
||||||
|
const afterMs = Date.now();
|
||||||
|
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].attempts).toBe(2); // markInFlight set it to 1, markFailed adds 1
|
||||||
|
expect(all[0].error).toBe("server returned 500");
|
||||||
|
expect(typeof all[0].lastAttemptAt).toBe("number");
|
||||||
|
expect(all[0].lastAttemptAt).not.toBeNull();
|
||||||
|
expect(all[0].lastAttemptAt!).toBeGreaterThanOrEqual(beforeMs);
|
||||||
|
expect(all[0].lastAttemptAt!).toBeLessThanOrEqual(afterMs);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-12
@@ -13,8 +13,15 @@ export async function enqueueAction(
|
|||||||
): Promise<QueuedAction> {
|
): Promise<QueuedAction> {
|
||||||
const db = await getOfflineDB();
|
const db = await getOfflineDB();
|
||||||
const id = options.id ?? uuidv4();
|
const id = options.id ?? uuidv4();
|
||||||
const existing = await db.get("queue", id);
|
// Atomic read-then-write inside a single readwrite transaction so two
|
||||||
if (existing) return existing;
|
// concurrent calls with the same explicit `id` can't both pass the
|
||||||
|
// existence check and write twice.
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const existing = await tx.store.get(id);
|
||||||
|
if (existing) {
|
||||||
|
await tx.done;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
const action: QueuedAction = {
|
const action: QueuedAction = {
|
||||||
id,
|
id,
|
||||||
actionName,
|
actionName,
|
||||||
@@ -24,7 +31,8 @@ export async function enqueueAction(
|
|||||||
lastAttemptAt: null,
|
lastAttemptAt: null,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
};
|
};
|
||||||
await db.put("queue", action);
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +57,17 @@ export async function dequeueAction(id: string): Promise<QueuedAction | null> {
|
|||||||
|
|
||||||
export async function markInFlight(id: string): Promise<void> {
|
export async function markInFlight(id: string): Promise<void> {
|
||||||
const db = await getOfflineDB();
|
const db = await getOfflineDB();
|
||||||
const action = await db.get("queue", id);
|
const tx = db.transaction("queue", "readwrite");
|
||||||
if (!action) return;
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
action.status = "in-flight";
|
action.status = "in-flight";
|
||||||
action.lastAttemptAt = Date.now();
|
action.lastAttemptAt = Date.now();
|
||||||
action.attempts += 1;
|
action.attempts += 1;
|
||||||
await db.put("queue", action);
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markSynced(id: string): Promise<void> {
|
export async function markSynced(id: string): Promise<void> {
|
||||||
@@ -64,20 +77,30 @@ export async function markSynced(id: string): Promise<void> {
|
|||||||
|
|
||||||
export async function markConflict(id: string, error: string): Promise<void> {
|
export async function markConflict(id: string, error: string): Promise<void> {
|
||||||
const db = await getOfflineDB();
|
const db = await getOfflineDB();
|
||||||
const action = await db.get("queue", id);
|
const tx = db.transaction("queue", "readwrite");
|
||||||
if (!action) return;
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
action.status = "conflict";
|
action.status = "conflict";
|
||||||
action.error = error;
|
action.error = error;
|
||||||
await db.put("queue", action);
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markFailed(id: string, error: string): Promise<void> {
|
export async function markFailed(id: string, error: string): Promise<void> {
|
||||||
const db = await getOfflineDB();
|
const db = await getOfflineDB();
|
||||||
const action = await db.get("queue", id);
|
const tx = db.transaction("queue", "readwrite");
|
||||||
if (!action) return;
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
action.status = "failed";
|
action.status = "failed";
|
||||||
action.error = error;
|
action.error = error;
|
||||||
action.attempts += 1;
|
action.attempts += 1;
|
||||||
action.lastAttemptAt = Date.now();
|
action.lastAttemptAt = Date.now();
|
||||||
await db.put("queue", action);
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,100 @@ describe("offline sync — replay", () => {
|
|||||||
expect(dispatcher).not.toHaveBeenCalled();
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
expect(result.synced).toBe(0);
|
expect(result.synced).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncPending is a no-op for an empty queue and does not call the dispatcher", async () => {
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ synced: 0, conflicts: 0, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending moves an action to failed once max attempts is reached", async () => {
|
||||||
|
// Seed an action that's already at the max-attempt threshold
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "max-attempts-id" });
|
||||||
|
const { getOfflineDB } = await import("./db");
|
||||||
|
const db = await getOfflineDB();
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const a = await tx.store.get("max-attempts-id");
|
||||||
|
if (!a) throw new Error("seed not found");
|
||||||
|
a.attempts = MAX_ATTEMPTS; // 5
|
||||||
|
await tx.store.put(a);
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].error).toBe("max attempts reached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending skips actions whose backoff window has not yet elapsed", async () => {
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "backoff-id" });
|
||||||
|
// Pretend a previous attempt happened 100ms ago. With attempts=0, the
|
||||||
|
// backoff schedule says we must wait 1000ms — so we should skip.
|
||||||
|
const fixedNow = 1_700_000_000_000;
|
||||||
|
const { getOfflineDB } = await import("./db");
|
||||||
|
const db = await getOfflineDB();
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const a = await tx.store.get("backoff-id");
|
||||||
|
if (!a) throw new Error("seed not found");
|
||||||
|
a.attempts = 0;
|
||||||
|
a.lastAttemptAt = fixedNow - 100;
|
||||||
|
await tx.store.put(a);
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, {
|
||||||
|
online: true,
|
||||||
|
sleep: () => Promise.resolve(),
|
||||||
|
now: () => fixedNow,
|
||||||
|
});
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
|
||||||
|
// Action is still pending (not advanced to in-flight or failed)
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending marks the action as failed when the dispatcher throws", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
const dispatcher = vi.fn().mockRejectedValue(new Error("network down"));
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).toHaveBeenCalledWith("markOrderReady", { orderId: "abc" }, a.id);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].error).toBe("network down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending leaves queued actions untouched when offline even if the queue is non-empty", async () => {
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "def" });
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: false, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ synced: 0, conflicts: 0, failed: 0 });
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
expect(all.every((x) => x.status === "pending")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+53
-33
@@ -27,47 +27,67 @@ export interface SyncResult {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-entrancy guard: concurrent `online` events shouldn't kick off parallel
|
||||||
|
// sync passes that race on the same `markInFlight`/`markSynced` records.
|
||||||
|
let inFlightSync: Promise<SyncResult> | null = null;
|
||||||
|
|
||||||
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
|
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
|
||||||
const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
|
if (inFlightSync) return inFlightSync;
|
||||||
if (!options.online) return result;
|
inFlightSync = (async () => {
|
||||||
|
const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
|
||||||
|
if (!options.online) return result;
|
||||||
|
|
||||||
const actions = await getQueuedActions();
|
const actions = await getQueuedActions();
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (action.status === "conflict" || action.status === "failed") {
|
if (action.status === "conflict" || action.status === "failed") {
|
||||||
// Don't auto-retry conflicts/failures; require manual user intervention
|
// Don't auto-retry conflicts/failures; require manual user intervention
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (action.attempts >= MAX_ATTEMPTS) {
|
if (action.attempts >= MAX_ATTEMPTS) {
|
||||||
await markFailed(action.id, "max attempts reached");
|
await markFailed(action.id, "max attempts reached");
|
||||||
result.failed += 1;
|
result.failed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backoff = calculateBackoff(action.attempts);
|
const backoff = calculateBackoff(action.attempts);
|
||||||
if (action.lastAttemptAt) {
|
if (action.lastAttemptAt) {
|
||||||
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
|
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
|
||||||
if (elapsed < backoff) continue; // not time yet
|
if (elapsed < backoff) continue; // not time yet
|
||||||
}
|
}
|
||||||
|
|
||||||
await markInFlight(action.id);
|
await markInFlight(action.id);
|
||||||
try {
|
try {
|
||||||
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
|
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
|
||||||
if (dispatchResult.ok) {
|
if (dispatchResult.ok) {
|
||||||
await markSynced(action.id);
|
await markSynced(action.id);
|
||||||
result.synced += 1;
|
result.synced += 1;
|
||||||
} else if ("conflict" in dispatchResult) {
|
} else if ("conflict" in dispatchResult) {
|
||||||
await markConflict(action.id, dispatchResult.conflict);
|
await markConflict(action.id, dispatchResult.conflict);
|
||||||
result.conflicts += 1;
|
result.conflicts += 1;
|
||||||
} else {
|
} else if ("error" in dispatchResult) {
|
||||||
await markFailed(action.id, dispatchResult.error);
|
await markFailed(action.id, dispatchResult.error);
|
||||||
|
result.failed += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A single poisoned record (IDB closed, quota, etc.) must not abort
|
||||||
|
// the whole sync pass. Swallow the secondary failure with a warn so
|
||||||
|
// it stays observable in dev.
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
try {
|
||||||
|
await markFailed(action.id, message);
|
||||||
|
} catch (innerErr) {
|
||||||
|
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
|
||||||
|
}
|
||||||
result.failed += 1;
|
result.failed += 1;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
await markFailed(action.id, (err as Error).message);
|
|
||||||
result.failed += 1;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await inFlightSync;
|
||||||
|
} finally {
|
||||||
|
inFlightSync = null;
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user