fix(workbench): share sync reads and retry failed invalidations

This commit is contained in:
2026-10-08 15:41:50 +08:00
parent 64ef058e0d
commit 7c6d06cb43
8 changed files with 274 additions and 74 deletions
@@ -75,6 +75,8 @@ export function useOperationalTasks(
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
const eventRetryMsRef = useRef(1_000);
const forceRefreshRef = useRef(false);
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
@@ -128,6 +130,7 @@ export function useOperationalTasks(
const events = syncEvents;
if (events.length === 0) return;
const controller = new AbortController();
let retryTimer: number | undefined;
const lastSequence = events.at(-1)!.event_seq;
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
if (deletedTaskIds.size > 0) {
@@ -154,13 +157,20 @@ export function useOperationalTasks(
setTasks(tasksRef.current);
}
acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000;
setError(null);
}).catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
}
});
return () => controller.abort();
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
return () => {
controller.abort();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
@@ -29,6 +29,17 @@ describe("workbench snapshot ordering", () => {
currentCursor: 100
})).toBe(true);
});
it("accepts an authoritative cursor reset without accepting stale concurrent responses", () => {
expect(shouldApplyWorkbenchSnapshot({
requestGeneration: 2, latestRequestGeneration: 2,
eventCursor: 0, currentCursor: 41, resetCursor: true
})).toBe(true);
expect(shouldApplyWorkbenchSnapshot({
requestGeneration: 1, latestRequestGeneration: 2,
eventCursor: 0, currentCursor: 41, resetCursor: true
})).toBe(false);
});
});
describe("work-order projection readiness", () => {
@@ -47,6 +58,7 @@ describe("work-order projection readiness", () => {
it("waits for authoritative projection events instead of fetching a premature snapshot", () => {
expect(shouldRefreshWorkbenchRuntime(event)).toBe(false);
expect(shouldRefreshWorkbenchRuntime({ ...event, aggregate_type: "operational_task" })).toBe(false);
expect(shouldRefreshWorkbenchRuntime({ ...event, aggregate_type: "operational_action" })).toBe(true);
expect(shouldRefreshWorkbenchRuntime({ ...event, topic: "workbench.alert.changed", aggregate_type: "workbench_alert" })).toBe(true);
});
@@ -17,7 +17,9 @@ const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
export function shouldRefreshWorkbenchRuntime(event: WorkbenchEvent) {
// Source changes wake the backend synchronizer. Its committed task events
// refresh the UI after the authoritative projection is ready.
return RUNTIME_TOPICS.has(event.topic) && event.aggregate_type !== "peripheral_work_order";
if (event.topic === "workbench.work_order.changed"
&& ["peripheral_work_order", "operational_task"].includes(event.aggregate_type)) return false;
return RUNTIME_TOPICS.has(event.topic);
}
export type WorkbenchSyncState = {
@@ -35,9 +37,10 @@ export function shouldApplyWorkbenchSnapshot(input: {
latestRequestGeneration: number;
eventCursor: number;
currentCursor: number;
resetCursor?: boolean;
}) {
return input.requestGeneration === input.latestRequestGeneration
&& input.eventCursor >= input.currentCursor;
&& (input.resetCursor || input.eventCursor >= input.currentCursor);
}
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
@@ -49,7 +52,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
const cursorRef = useRef(0);
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
const runtimeRefreshRef = useRef<number | undefined>(undefined);
const refreshSnapshotRef = useRef<((resetTaskDetails?: boolean) => Promise<void>) | null>(null);
const refreshSnapshotRef = useRef<((resetTaskDetails?: boolean) => Promise<boolean>) | null>(null);
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
}, []);
@@ -66,8 +69,19 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
let retryMs = 1_000;
let initialized = false;
let snapshotRequestGeneration = 0;
let resettingCursor = false;
let runtimeRetryMs = 1_000;
let runtimeDirtyCursor = 0;
let appliedSnapshotCursor = 0;
const loadSnapshot = async (resetTaskDetails = false) => {
const loadSnapshot = async (resetTaskDetails = false, resetCursor = false) => {
if (resetCursor) {
resettingCursor = true;
runtimeDirtyCursor = 0;
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
}
// Preserve a pending reset if another refresh supersedes this request.
const authoritativeReset = resettingCursor;
const requestGeneration = ++snapshotRequestGeneration;
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
if (
@@ -76,44 +90,67 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
requestGeneration,
latestRequestGeneration: snapshotRequestGeneration,
eventCursor: next.eventCursor,
currentCursor: cursorRef.current
currentCursor: cursorRef.current,
resetCursor: authoritativeReset
})
) return;
) return false;
cursorRef.current = next.eventCursor;
if (resetTaskDetails) setEvents([]);
appliedSnapshotCursor = next.eventCursor;
if (resetTaskDetails || authoritativeReset) setEvents([]);
setSnapshot(next);
if (resetTaskDetails) setDetailResetGeneration((current) => current + 1);
if (resetTaskDetails || authoritativeReset) setDetailResetGeneration((current) => current + 1);
resettingCursor = false;
setError(null);
initialized = true;
return true;
};
refreshSnapshotRef.current = loadSnapshot;
const scheduleRuntimeRefresh = (delayMs: number) => {
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
runtimeRefreshRef.current = window.setTimeout(() => {
runtimeRefreshRef.current = undefined;
void loadSnapshot().then((applied) => {
if (!applied && appliedSnapshotCursor < runtimeDirtyCursor && !controller.signal.aborted) {
scheduleRuntimeRefresh(runtimeRetryMs);
runtimeRetryMs = Math.min(runtimeRetryMs * 2, 30_000);
} else {
runtimeRetryMs = 1_000;
}
}).catch((nextError) => {
if (controller.signal.aborted) return;
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
scheduleRuntimeRefresh(runtimeRetryMs);
runtimeRetryMs = Math.min(runtimeRetryMs * 2, 30_000);
});
}, delayMs);
};
const connect = async () => {
try {
if (!initialized) await loadSnapshot();
if (!initialized || resettingCursor) await loadSnapshot();
if (controller.signal.aborted) return;
statusRef.current = "live";
setStatus("live");
retryMs = 1_000;
await consumeWorkbenchEvents({
after: cursorRef.current,
getAccessToken,
signal: controller.signal,
onReset: () => loadSnapshot(true),
onOpen: () => {
statusRef.current = "live";
setStatus("live");
setError(null);
retryMs = 1_000;
},
onReset: async () => { await loadSnapshot(true, true); },
onEvents: (nextEvents) => {
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
if (unseen.length === 0) return;
cursorRef.current = unseen.at(-1)!.event_seq;
setEvents((current) => [...current, ...unseen]);
if (unseen.some(shouldRefreshWorkbenchRuntime)) {
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
runtimeRefreshRef.current = window.setTimeout(() => {
void loadSnapshot().catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
}
});
}, 250);
runtimeDirtyCursor = Math.max(runtimeDirtyCursor,
...unseen.filter(shouldRefreshWorkbenchRuntime).map((event) => event.event_seq));
runtimeRetryMs = 1_000;
scheduleRuntimeRefresh(250);
}
}
});