fix(workbench): share sync reads and retry failed invalidations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user