feat(web-sync): add background sync worker and retry strategy

This commit is contained in:
2026-04-06 01:15:50 +08:00
parent 661788ae75
commit c48e16a977
6 changed files with 953 additions and 162 deletions
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import {
countBlockedSyncOperations,
countPendingRemoteOperations,
countPendingSyncOperations,
getLocalSyncState
} from "@/services/local-sync-repo";
import type { WebSession } from "@/services/session-storage";
import { runSyncWorkerCycle } from "@/services/sync-worker";
const PERIODIC_SYNC_INTERVAL_MS = 30_000;
const MAX_RETRY_DELAY_MS = 60_000;
const BASE_RETRY_DELAY_MS = 2_000;
export type SyncEngineStatus = {
isOnline: boolean;
phase: "idle" | "syncing" | "offline" | "backoff" | "attention";
pendingCount: number;
blockedCount: number;
pendingRemoteCount: number;
lastSyncedAt: number | null;
nextRetryAt: number | null;
lastError: string | null;
};
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return "同步失败,请稍后重试";
}
function calculateRetryDelay(attempt: number): number {
return Math.min(BASE_RETRY_DELAY_MS * 2 ** Math.max(attempt - 1, 0), MAX_RETRY_DELAY_MS);
}
export function useSyncEngine(session: WebSession | null): {
status: SyncEngineStatus;
triggerSync: () => void;
} {
const userId = session?.user.id ?? "";
const pendingCount = useLiveQuery(async () => countPendingSyncOperations(), [userId]) ?? 0;
const blockedCount = useLiveQuery(async () => countBlockedSyncOperations(), [userId]) ?? 0;
const pendingRemoteCount =
useLiveQuery(async () => {
if (!userId) {
return 0;
}
return countPendingRemoteOperations(userId);
}, [userId]) ?? 0;
const storedSyncState =
useLiveQuery(async () => {
if (!userId) {
return null;
}
return getLocalSyncState(userId);
}, [userId]) ?? null;
const [isOnline, setIsOnline] = useState(() => window.navigator.onLine);
const [phase, setPhase] = useState<SyncEngineStatus["phase"]>(
window.navigator.onLine ? "idle" : "offline"
);
const [lastError, setLastError] = useState<string | null>(null);
const [nextRetryAt, setNextRetryAt] = useState<number | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const retryAttemptRef = useRef(0);
const runningRef = useRef(false);
useEffect(() => {
setLastSyncedAt(storedSyncState?.lastSyncedAt ?? null);
}, [storedSyncState]);
const runCycle = useCallback(async () => {
if (!userId || runningRef.current || !window.navigator.onLine) {
return;
}
runningRef.current = true;
setPhase("syncing");
setLastError(null);
setNextRetryAt(null);
try {
const result = await runSyncWorkerCycle(userId);
retryAttemptRef.current = 0;
setLastSyncedAt(result.lastSyncedAt);
if (result.hasFailures) {
const nextAttempt = retryAttemptRef.current + 1;
retryAttemptRef.current = nextAttempt;
const delay = calculateRetryDelay(nextAttempt);
setLastError(result.failureMessage ?? "同步失败");
setNextRetryAt(Date.now() + delay);
setPhase("backoff");
return;
}
setPhase(blockedCount > 0 ? "attention" : "idle");
} catch (error) {
const nextAttempt = retryAttemptRef.current + 1;
retryAttemptRef.current = nextAttempt;
const delay = calculateRetryDelay(nextAttempt);
setLastError(getErrorMessage(error));
setNextRetryAt(Date.now() + delay);
setPhase("backoff");
} finally {
runningRef.current = false;
}
}, [blockedCount, userId]);
const triggerSync = useCallback(() => {
void runCycle();
}, [runCycle]);
useEffect(() => {
function handleOnline(): void {
setIsOnline(true);
setPhase(blockedCount > 0 ? "attention" : "idle");
void runCycle();
}
function handleOffline(): void {
setIsOnline(false);
setNextRetryAt(null);
setPhase("offline");
}
function handleVisibilityChange(): void {
if (document.visibilityState === "visible" && window.navigator.onLine) {
void runCycle();
}
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [blockedCount, runCycle]);
useEffect(() => {
if (!userId || !isOnline) {
return;
}
if (pendingCount === 0 && pendingRemoteCount === 0) {
return;
}
void runCycle();
}, [isOnline, pendingCount, pendingRemoteCount, runCycle, userId]);
useEffect(() => {
if (!userId || !isOnline) {
return;
}
const intervalId = window.setInterval(() => {
void runCycle();
}, PERIODIC_SYNC_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [isOnline, runCycle, userId]);
useEffect(() => {
if (!nextRetryAt || !isOnline) {
return;
}
const timeoutId = window.setTimeout(
() => {
void runCycle();
},
Math.max(nextRetryAt - Date.now(), 0)
);
return () => {
window.clearTimeout(timeoutId);
};
}, [isOnline, nextRetryAt, runCycle]);
useEffect(() => {
if (!userId) {
setLastError(null);
setLastSyncedAt(null);
setNextRetryAt(null);
setPhase(window.navigator.onLine ? "idle" : "offline");
retryAttemptRef.current = 0;
}
}, [userId]);
const status = useMemo<SyncEngineStatus>(() => {
if (!isOnline) {
return {
isOnline,
phase: "offline",
pendingCount,
blockedCount,
pendingRemoteCount,
lastSyncedAt,
nextRetryAt: null,
lastError
};
}
if (blockedCount > 0 && phase !== "syncing") {
return {
isOnline,
phase: "attention",
pendingCount,
blockedCount,
pendingRemoteCount,
lastSyncedAt,
nextRetryAt,
lastError
};
}
return {
isOnline,
phase,
pendingCount,
blockedCount,
pendingRemoteCount,
lastSyncedAt,
nextRetryAt,
lastError
};
}, [
blockedCount,
isOnline,
lastError,
lastSyncedAt,
nextRetryAt,
pendingCount,
pendingRemoteCount,
phase
]);
return {
status,
triggerSync
};
}