fix(workbench): share sync reads and retry failed invalidations
This commit is contained in:
@@ -6,7 +6,10 @@ import {
|
|||||||
} from "./project-client";
|
} from "./project-client";
|
||||||
|
|
||||||
describe("workbench project client", () => {
|
describe("workbench project client", () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("selects the active project owned by the configured GeoServer workspace", async () => {
|
it("selects the active project owned by the configured GeoServer workspace", async () => {
|
||||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
|
||||||
@@ -36,23 +39,20 @@ describe("workbench project client", () => {
|
|||||||
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
|
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not share a caller-bound abort signal with other project consumers", async () => {
|
it("shares project lookup while cancelling only the caller that aborts", async () => {
|
||||||
const projectPage = JSON.stringify({
|
const projectPage = JSON.stringify({
|
||||||
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
||||||
total: 1,
|
total: 1,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
offset: 0
|
offset: 0
|
||||||
});
|
});
|
||||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
let resolveFetch!: (response: Response) => void;
|
||||||
if (!init?.signal) {
|
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
||||||
return Promise.resolve(new Response(projectPage, { status: 200 }));
|
new Promise<Response>((resolve, reject) => {
|
||||||
}
|
resolveFetch = resolve;
|
||||||
return new Promise<Response>((_resolve, reject) => {
|
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
init.signal?.addEventListener("abort", () => {
|
})
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
);
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
const getAccessToken = async () => null;
|
const getAccessToken = async () => null;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -63,8 +63,32 @@ describe("workbench project client", () => {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
|
|
||||||
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
resolveFetch(new Response(projectPage, { status: 200 }));
|
||||||
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
|
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes tokens, expires project metadata and evicts failed shared lookups", async () => {
|
||||||
|
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||||
|
const page = { items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
||||||
|
total: 1, limit: 1000, offset: 0 };
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "temporary" }), { status: 503 }))
|
||||||
|
.mockImplementation(async () => new Response(JSON.stringify(page), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
let token = "first";
|
||||||
|
const provider = vi.fn(async () => token);
|
||||||
|
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("temporary");
|
||||||
|
await findOwnedWorkbenchProject(provider);
|
||||||
|
token = "renewed";
|
||||||
|
expect((await findOwnedWorkbenchProject(provider)).accessToken).toBe("renewed");
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
clock.mockReturnValue(5 * 60_000);
|
||||||
|
await findOwnedWorkbenchProject(provider);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(new Headers(fetchMock.mock.calls[2][1].headers).get("Authorization")).toBe("Bearer renewed");
|
||||||
|
await findOwnedWorkbenchProject(async () => "another-identity");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads the authoritative project extent and access identity independently", async () => {
|
it("loads the authoritative project extent and access identity independently", async () => {
|
||||||
|
|||||||
@@ -69,22 +69,39 @@ export type WorkbenchProjectPresentation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||||
const projectContextPromises = new WeakMap<AccessTokenProvider, Promise<WorkbenchProject>>();
|
const PROJECT_CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
const projectContextPromises = new WeakMap<AccessTokenProvider, {
|
||||||
|
promise: Promise<WorkbenchProject>;
|
||||||
|
expiresAt: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal) {
|
||||||
|
if (!signal) return promise;
|
||||||
|
return new Promise<WorkbenchProject>((resolve, reject) => {
|
||||||
|
const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||||
|
if (signal.aborted) { abort(); return; }
|
||||||
|
signal.addEventListener("abort", abort, { once: true });
|
||||||
|
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function findOwnedWorkbenchProject(
|
export async function findOwnedWorkbenchProject(
|
||||||
getAccessToken?: AccessTokenProvider,
|
getAccessToken?: AccessTokenProvider,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
|
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
|
||||||
const accessToken = await getAccessToken?.();
|
const accessToken = await getAccessToken?.();
|
||||||
const cacheable = Boolean(getAccessToken && !signal);
|
signal?.throwIfAborted();
|
||||||
const existing = cacheable && getAccessToken
|
const existing = getAccessToken
|
||||||
? projectContextPromises.get(getAccessToken)
|
? projectContextPromises.get(getAccessToken)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (existing) return { project: await existing, accessToken };
|
if (existing && existing.expiresAt > Date.now()) {
|
||||||
|
return { project: await awaitProject(existing.promise, signal), accessToken };
|
||||||
|
}
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
|
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
|
||||||
headers: createWorkbenchHeaders(accessToken),
|
headers: createWorkbenchHeaders(accessToken),
|
||||||
signal
|
// One caller cancelling must not cancel other consumers of this lookup.
|
||||||
|
signal: getAccessToken ? AbortSignal.timeout(15_000) : signal
|
||||||
});
|
});
|
||||||
const payload: unknown = await response.json().catch(() => null);
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||||
@@ -96,15 +113,13 @@ export async function findOwnedWorkbenchProject(
|
|||||||
return project;
|
return project;
|
||||||
};
|
};
|
||||||
const pending = loadProject();
|
const pending = loadProject();
|
||||||
if (cacheable && getAccessToken) projectContextPromises.set(getAccessToken, pending);
|
if (getAccessToken) {
|
||||||
try {
|
projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
|
||||||
return { project: await pending, accessToken };
|
void pending.catch(() => {
|
||||||
} catch (error) {
|
if (projectContextPromises.get(getAccessToken)?.promise === pending) projectContextPromises.delete(getAccessToken);
|
||||||
if (cacheable && getAccessToken && projectContextPromises.get(getAccessToken) === pending) {
|
});
|
||||||
projectContextPromises.delete(getAccessToken);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
return { project: await awaitProject(pending, signal), accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWorkbenchProjectPresentation(
|
export async function fetchWorkbenchProjectPresentation(
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ vi.mock("./project-client", () => ({
|
|||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
describe("workbench event stream", () => {
|
describe("workbench event stream", () => {
|
||||||
|
it("does not report a failed HTTP connection as open", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 503 })));
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
await expect(consumeWorkbenchEvents({
|
||||||
|
after: 0, signal: new AbortController().signal, onOpen,
|
||||||
|
onEvents: vi.fn(), onReset: vi.fn(async () => undefined)
|
||||||
|
})).rejects.toThrow();
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("sends the authenticated cursor and stops for a snapshot reset", async () => {
|
it("sends the authenticated cursor and stops for a snapshot reset", async () => {
|
||||||
const payload = "event: reset_required\r\ndata: {}\r\n\r\n";
|
const payload = "event: reset_required\r\ndata: {}\r\n\r\n";
|
||||||
const bytes = new TextEncoder().encode(payload);
|
const bytes = new TextEncoder().encode(payload);
|
||||||
@@ -32,12 +42,14 @@ describe("workbench event stream", () => {
|
|||||||
}));
|
}));
|
||||||
const onReset = vi.fn(async () => undefined);
|
const onReset = vi.fn(async () => undefined);
|
||||||
const onEvents = vi.fn();
|
const onEvents = vi.fn();
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
|
||||||
await expect(consumeWorkbenchEvents({
|
await expect(consumeWorkbenchEvents({
|
||||||
after: 41,
|
after: 41,
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
onReset,
|
onReset,
|
||||||
onEvents
|
onEvents,
|
||||||
|
onOpen
|
||||||
})).resolves.toBe("reset");
|
})).resolves.toBe("reset");
|
||||||
|
|
||||||
expect(request?.headers.get("Authorization")).toBe("Bearer test-token");
|
expect(request?.headers.get("Authorization")).toBe("Bearer test-token");
|
||||||
@@ -46,6 +58,7 @@ describe("workbench event stream", () => {
|
|||||||
expect(new URL(request!.url).searchParams.get("after")).toBe("41");
|
expect(new URL(request!.url).searchParams.get("after")).toBe("41");
|
||||||
expect(onReset).toHaveBeenCalledOnce();
|
expect(onReset).toHaveBeenCalledOnce();
|
||||||
expect(onEvents).not.toHaveBeenCalled();
|
expect(onEvents).not.toHaveBeenCalled();
|
||||||
|
expect(onOpen).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delivers every event from one stream chunk as an ordered batch", async () => {
|
it("delivers every event from one stream chunk as an ordered batch", async () => {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export async function consumeWorkbenchEvents(input: {
|
|||||||
after: number;
|
after: number;
|
||||||
getAccessToken?: AccessTokenProvider;
|
getAccessToken?: AccessTokenProvider;
|
||||||
onEvents: (events: WorkbenchEvent[]) => void;
|
onEvents: (events: WorkbenchEvent[]) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
onReset: () => Promise<void>;
|
onReset: () => Promise<void>;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
}): Promise<"closed" | "reset"> {
|
}): Promise<"closed" | "reset"> {
|
||||||
@@ -98,8 +99,10 @@ export async function consumeWorkbenchEvents(input: {
|
|||||||
throw new Error(getServerError(payload, response.status, "工作台事件连接"));
|
throw new Error(getServerError(payload, response.status, "工作台事件连接"));
|
||||||
}
|
}
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
input.onOpen?.();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
try {
|
||||||
while (!input.signal.aborted) {
|
while (!input.signal.aborted) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
buffer += decoder.decode(value, { stream: !done });
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
@@ -124,4 +127,8 @@ export async function consumeWorkbenchEvents(input: {
|
|||||||
if (done) return "closed";
|
if (done) return "closed";
|
||||||
}
|
}
|
||||||
return "closed";
|
return "closed";
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export function useOperationalTasks(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||||
|
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
|
||||||
|
const eventRetryMsRef = useRef(1_000);
|
||||||
const forceRefreshRef = useRef(false);
|
const forceRefreshRef = useRef(false);
|
||||||
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ export function useOperationalTasks(
|
|||||||
const events = syncEvents;
|
const events = syncEvents;
|
||||||
if (events.length === 0) return;
|
if (events.length === 0) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
let retryTimer: number | undefined;
|
||||||
const lastSequence = events.at(-1)!.event_seq;
|
const lastSequence = events.at(-1)!.event_seq;
|
||||||
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
||||||
if (deletedTaskIds.size > 0) {
|
if (deletedTaskIds.size > 0) {
|
||||||
@@ -154,13 +157,20 @@ export function useOperationalTasks(
|
|||||||
setTasks(tasksRef.current);
|
setTasks(tasksRef.current);
|
||||||
}
|
}
|
||||||
acknowledgeEventsThrough(lastSequence);
|
acknowledgeEventsThrough(lastSequence);
|
||||||
|
eventRetryMsRef.current = 1_000;
|
||||||
|
setError(null);
|
||||||
}).catch((nextError) => {
|
}).catch((nextError) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
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();
|
return () => {
|
||||||
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
|
controller.abort();
|
||||||
|
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||||
|
};
|
||||||
|
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
|
||||||
|
|
||||||
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
|
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ describe("workbench snapshot ordering", () => {
|
|||||||
currentCursor: 100
|
currentCursor: 100
|
||||||
})).toBe(true);
|
})).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", () => {
|
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", () => {
|
it("waits for authoritative projection events instead of fetching a premature snapshot", () => {
|
||||||
expect(shouldRefreshWorkbenchRuntime(event)).toBe(false);
|
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, aggregate_type: "operational_action" })).toBe(true);
|
||||||
expect(shouldRefreshWorkbenchRuntime({ ...event, topic: "workbench.alert.changed", aggregate_type: "workbench_alert" })).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) {
|
export function shouldRefreshWorkbenchRuntime(event: WorkbenchEvent) {
|
||||||
// Source changes wake the backend synchronizer. Its committed task events
|
// Source changes wake the backend synchronizer. Its committed task events
|
||||||
// refresh the UI after the authoritative projection is ready.
|
// 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 = {
|
export type WorkbenchSyncState = {
|
||||||
@@ -35,9 +37,10 @@ export function shouldApplyWorkbenchSnapshot(input: {
|
|||||||
latestRequestGeneration: number;
|
latestRequestGeneration: number;
|
||||||
eventCursor: number;
|
eventCursor: number;
|
||||||
currentCursor: number;
|
currentCursor: number;
|
||||||
|
resetCursor?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return input.requestGeneration === input.latestRequestGeneration
|
return input.requestGeneration === input.latestRequestGeneration
|
||||||
&& input.eventCursor >= input.currentCursor;
|
&& (input.resetCursor || input.eventCursor >= input.currentCursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
||||||
@@ -49,7 +52,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
const cursorRef = useRef(0);
|
const cursorRef = useRef(0);
|
||||||
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
|
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
|
||||||
const runtimeRefreshRef = useRef<number | undefined>(undefined);
|
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) => {
|
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
|
||||||
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
|
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -66,8 +69,19 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
let retryMs = 1_000;
|
let retryMs = 1_000;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let snapshotRequestGeneration = 0;
|
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 requestGeneration = ++snapshotRequestGeneration;
|
||||||
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
|
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
|
||||||
if (
|
if (
|
||||||
@@ -76,44 +90,67 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
requestGeneration,
|
requestGeneration,
|
||||||
latestRequestGeneration: snapshotRequestGeneration,
|
latestRequestGeneration: snapshotRequestGeneration,
|
||||||
eventCursor: next.eventCursor,
|
eventCursor: next.eventCursor,
|
||||||
currentCursor: cursorRef.current
|
currentCursor: cursorRef.current,
|
||||||
|
resetCursor: authoritativeReset
|
||||||
})
|
})
|
||||||
) return;
|
) return false;
|
||||||
cursorRef.current = next.eventCursor;
|
cursorRef.current = next.eventCursor;
|
||||||
if (resetTaskDetails) setEvents([]);
|
appliedSnapshotCursor = next.eventCursor;
|
||||||
|
if (resetTaskDetails || authoritativeReset) setEvents([]);
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
if (resetTaskDetails) setDetailResetGeneration((current) => current + 1);
|
if (resetTaskDetails || authoritativeReset) setDetailResetGeneration((current) => current + 1);
|
||||||
|
resettingCursor = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
refreshSnapshotRef.current = loadSnapshot;
|
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 () => {
|
const connect = async () => {
|
||||||
try {
|
try {
|
||||||
if (!initialized) await loadSnapshot();
|
if (!initialized || resettingCursor) await loadSnapshot();
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
statusRef.current = "live";
|
|
||||||
setStatus("live");
|
|
||||||
retryMs = 1_000;
|
|
||||||
await consumeWorkbenchEvents({
|
await consumeWorkbenchEvents({
|
||||||
after: cursorRef.current,
|
after: cursorRef.current,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
signal: controller.signal,
|
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) => {
|
onEvents: (nextEvents) => {
|
||||||
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
|
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
|
||||||
if (unseen.length === 0) return;
|
if (unseen.length === 0) return;
|
||||||
cursorRef.current = unseen.at(-1)!.event_seq;
|
cursorRef.current = unseen.at(-1)!.event_seq;
|
||||||
setEvents((current) => [...current, ...unseen]);
|
setEvents((current) => [...current, ...unseen]);
|
||||||
if (unseen.some(shouldRefreshWorkbenchRuntime)) {
|
if (unseen.some(shouldRefreshWorkbenchRuntime)) {
|
||||||
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
|
runtimeDirtyCursor = Math.max(runtimeDirtyCursor,
|
||||||
runtimeRefreshRef.current = window.setTimeout(() => {
|
...unseen.filter(shouldRefreshWorkbenchRuntime).map((event) => event.event_seq));
|
||||||
void loadSnapshot().catch((nextError) => {
|
runtimeRetryMs = 1_000;
|
||||||
if (!controller.signal.aborted) {
|
scheduleRuntimeRefresh(250);
|
||||||
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 250);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||||
|
|
||||||
test("源工单推送等待投影完成后自动进入预约任务,无需刷新快照", async ({ page }) => {
|
async function expectPushedOrder(page: Page, failFirstDetail = false) {
|
||||||
await mockWorkbenchContext(page);
|
await mockWorkbenchContext(page);
|
||||||
const base = "https://tjwater-api.invalid/api/v1";
|
const base = "https://tjwater-api.invalid/api/v1";
|
||||||
const taskId = "72000000-0000-4000-8000-000000000041";
|
const taskId = "72000000-0000-4000-8000-000000000041";
|
||||||
@@ -53,6 +53,7 @@ test("源工单推送等待投影完成后自动进入预约任务,无需刷
|
|||||||
});
|
});
|
||||||
await page.route(`${base}/operational-tasks/${taskId}`, (route) => {
|
await page.route(`${base}/operational-tasks/${taskId}`, (route) => {
|
||||||
detailRequests += 1;
|
detailRequests += 1;
|
||||||
|
if (failFirstDetail && detailRequests === 1) return route.fulfill({ headers, status: 503, json: { detail: "temporary" } });
|
||||||
return route.fulfill({ headers, json: task });
|
return route.fulfill({ headers, json: task });
|
||||||
});
|
});
|
||||||
await page.route(`${base}/workbench/events**`, async (route) => {
|
await page.route(`${base}/workbench/events**`, async (route) => {
|
||||||
@@ -71,7 +72,12 @@ test("源工单推送等待投影完成后自动进入预约任务,无需刷
|
|||||||
occurred_at: now,
|
occurred_at: now,
|
||||||
metadata: sequence === 1 ? { source: "peripheral_sample", work_order_id: orderId } : { task_id: taskId }
|
metadata: sequence === 1 ? { source: "peripheral_sample", work_order_id: orderId } : { task_id: taskId }
|
||||||
};
|
};
|
||||||
return route.fulfill({ headers, contentType: "text/event-stream", body: `id: ${sequence}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n` });
|
const projection = { ...event, event_seq: 3,
|
||||||
|
topic: "workbench.work_order.changed", aggregate_type: "operational_task",
|
||||||
|
event_id: "75000000-0000-4000-8000-000000000003" };
|
||||||
|
const body = `id: ${sequence}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`
|
||||||
|
+ (sequence === 2 ? `id: 3\nevent: workbench_event\ndata: ${JSON.stringify(projection)}\n\n` : "");
|
||||||
|
return route.fulfill({ headers, contentType: "text/event-stream", body });
|
||||||
});
|
});
|
||||||
const waitingForProjection = page.waitForRequest((request) =>
|
const waitingForProjection = page.waitForRequest((request) =>
|
||||||
request.url().includes("/workbench/events?after=1")
|
request.url().includes("/workbench/events?after=1")
|
||||||
@@ -84,8 +90,84 @@ test("源工单推送等待投影完成后自动进入预约任务,无需刷
|
|||||||
await waitingForProjection;
|
await waitingForProjection;
|
||||||
expect(snapshotRequests).toBe(1);
|
expect(snapshotRequests).toBe(1);
|
||||||
await expect(panel).not.toContainText(title);
|
await expect(panel).not.toContainText(title);
|
||||||
|
const caughtUp = page.waitForRequest((request) => request.url().includes("/workbench/events?after=3"));
|
||||||
releaseProjection();
|
releaseProjection();
|
||||||
await expect(panel).toContainText(title);
|
await expect(panel).toContainText(title);
|
||||||
expect(detailRequests).toBe(1);
|
await caughtUp;
|
||||||
|
expect(detailRequests).toBe(failFirstDetail ? 2 : 1);
|
||||||
expect(snapshotRequests).toBe(1);
|
expect(snapshotRequests).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("源工单推送等待投影完成后自动进入预约任务,无需刷新快照", async ({ page }) => {
|
||||||
|
await expectPushedOrder(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("事件详情短暂失败后定向重试,无需新的业务事件或全量快照", async ({ page }) => {
|
||||||
|
await expectPushedOrder(page, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("事件日志清空后的合法游标重置会从零重连,不循环请求快照", async ({ page }) => {
|
||||||
|
await mockWorkbenchContext(page);
|
||||||
|
const base = "https://tjwater-api.invalid/api/v1";
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const runtime = {
|
||||||
|
availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||||
|
artifact_availability: "not_configured", schedule_availability: "not_configured",
|
||||||
|
work_order_availability: "not_configured", alert_availability: "not_configured",
|
||||||
|
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
|
||||||
|
};
|
||||||
|
let snapshots = 0;
|
||||||
|
const cursors: string[] = [];
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" };
|
||||||
|
await page.route(`${base}/workbench/sync-snapshot`, (route) => {
|
||||||
|
snapshots += 1;
|
||||||
|
return route.fulfill({ headers, json: {
|
||||||
|
schema: "workbench-sync-snapshot@1", event_cursor: snapshots === 1 ? 41 : 0,
|
||||||
|
runtime, tasks: [], notifications: [], generated_at: now
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
await page.route(`${base}/workbench/events**`, (route) => {
|
||||||
|
const cursor = new URL(route.request().url()).searchParams.get("after")!;
|
||||||
|
cursors.push(cursor);
|
||||||
|
return route.fulfill({ headers, contentType: "text/event-stream",
|
||||||
|
body: cursor === "41" ? "event: reset_required\ndata: {}\n\n" : ": keepalive\n\n" });
|
||||||
|
});
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await expect.poll(() => cursors.includes("0")).toBe(true);
|
||||||
|
expect(cursors.slice(0, 2)).toEqual(["41", "0"]);
|
||||||
|
expect(snapshots).toBe(2);
|
||||||
|
await expect(page.getByRole("button", { name: "工况任务", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("运行态事件的快照请求短暂失败后重试,不等待其他事件", async ({ page }) => {
|
||||||
|
await mockWorkbenchContext(page);
|
||||||
|
const base = "https://tjwater-api.invalid/api/v1";
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const runtime = {
|
||||||
|
availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||||
|
artifact_availability: "not_configured", schedule_availability: "not_configured",
|
||||||
|
work_order_availability: "not_configured", alert_availability: "not_configured",
|
||||||
|
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
|
||||||
|
};
|
||||||
|
let snapshots = 0;
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" };
|
||||||
|
await page.route(`${base}/workbench/sync-snapshot`, (route) => {
|
||||||
|
snapshots += 1;
|
||||||
|
if (snapshots === 2) return route.fulfill({ headers, status: 503, json: { detail: "temporary" } });
|
||||||
|
return route.fulfill({ headers, json: {
|
||||||
|
schema: "workbench-sync-snapshot@1", event_cursor: snapshots === 1 ? 41 : 42,
|
||||||
|
runtime, tasks: [], notifications: [], generated_at: now
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
await page.route(`${base}/workbench/events**`, (route) => {
|
||||||
|
const event = { schema: "workbench-event@1", event_seq: 42,
|
||||||
|
event_id: "75000000-0000-4000-8000-000000000042", project_id: TEST_PROJECT_ID,
|
||||||
|
topic: "analysis.run.changed", aggregate_type: "analysis_run", aggregate_id: "run-1",
|
||||||
|
aggregate_revision: 1, occurred_at: now, metadata: {} };
|
||||||
|
return route.fulfill({ headers, contentType: "text/event-stream", body:
|
||||||
|
new URL(route.request().url()).searchParams.get("after") === "41"
|
||||||
|
? `id: 42\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n` : ": keepalive\n\n" });
|
||||||
|
});
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await expect.poll(() => snapshots).toBe(3);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user