Repository navigation
fix(explorer): cancel superseded query admissions #4823
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
856fad2
87a5dff
9f37cac
645072b
e4e23ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -157,6 +157,18 @@ interface ExplorerFacet { | |
| readonly value: string; | ||
| } | ||
|
|
||
| /** The request owns its admission scope, regardless of later UI selections. */ | ||
| interface PlannerRequest { | ||
| readonly scope: DashboardScope; | ||
| readonly query: string; | ||
| readonly generation: number; | ||
| } | ||
|
|
||
| interface AcceptedPlannerRun { | ||
| readonly scope: DashboardScope; | ||
| readonly id: string; | ||
| } | ||
|
|
||
| /** How far the coordinator has got, counted in sources rather than in a | ||
| * percentage: sources have incommensurable units, so "2 of 3 concluded" is the | ||
| * only progress figure with a real denominator. */ | ||
|
|
@@ -220,13 +232,40 @@ export function useExplorerController(): ExplorerController { | |
| const [peeked, setPeeked] = useState<Hit | null>(null); | ||
| const searching = submitted !== ''; | ||
| const terms = useMemo(() => queryTerms(submitted), [submitted]); | ||
| // A superseded POST can still be admitted after a newer search or after | ||
| // unmount. Its accepted run must be explicitly cancelled, not orphaned. | ||
| const requestGeneration = useRef(0); | ||
| const acceptedRun = useRef<AcceptedPlannerRun | null>(null); | ||
| const retireAcceptedRun = () => { | ||
| const previous = acceptedRun.current; | ||
| acceptedRun.current = null; | ||
| if (previous !== null) void cancelPlannerQuery(previous.scope, previous.id); | ||
| }; | ||
| useEffect(() => { | ||
| return () => { | ||
| requestGeneration.current += 1; | ||
| const previous = acceptedRun.current; | ||
| acceptedRun.current = null; | ||
| if (previous !== null) void cancelPlannerQuery(previous.scope, previous.id); | ||
| }; | ||
|
Comment on lines
+244
to
+250
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Deep-linked searches cancel during mount With Learn moreThe production root wraps the router in React.StrictMode. On initial mount, useExplorerUrlState submits a query from the URL in an effect guarded by Example: Open Recommended fix: Distinguish an actual unmount from StrictMode's effect replay, or make URL restoration re-dispatch when the replay invalidates its admission. Add an ExplorerPage test under StrictMode that opens a Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }, []); | ||
|
|
||
| const planner = useMutation({ | ||
| mutationFn: (nextQuery: string) => createPlannerQuery(scope, nextQuery), | ||
| onSuccess: (result) => { | ||
| mutationFn: async (request: PlannerRequest) => { | ||
| const result = await createPlannerQuery(request.scope, request.query); | ||
| if (result.outcome === 'envelope') { | ||
| setActiveRunId(result.envelope.payload.run_id); | ||
| const run = result.envelope.payload; | ||
| const live = !runIsTerminal(run.state); | ||
| // Record ownership as soon as the daemon admits the request. The | ||
| // observer can unmount before React Query runs its success callbacks. | ||
| if (request.generation === requestGeneration.current) { | ||
| acceptedRun.current = live ? { id: run.run_id, scope: request.scope } : null; | ||
| setActiveRunId(run.run_id); | ||
| } else if (live) { | ||
| void cancelPlannerQuery(request.scope, run.run_id); | ||
| } | ||
| } | ||
| return result; | ||
| }, | ||
| }); | ||
| const activeRunIdForQuery = activeRunId ?? ''; | ||
|
|
@@ -278,6 +317,19 @@ export function useExplorerController(): ExplorerController { | |
| runResult?.outcome === 'envelope' && runResult.envelope.payload.request.query === submitted | ||
| ? runResult.envelope.payload | ||
| : undefined; | ||
| const currentRunId = run?.run_id; | ||
| const currentRunState = run?.state; | ||
| useEffect(() => { | ||
| // A completed read no longer needs an explicit cancel on unmount/reset. | ||
| if ( | ||
| currentRunId !== undefined && | ||
| currentRunState !== undefined && | ||
| runIsTerminal(currentRunState) && | ||
| acceptedRun.current?.id === currentRunId | ||
| ) { | ||
| acceptedRun.current = null; | ||
| } | ||
| }, [currentRunId, currentRunState]); | ||
|
|
||
| const graphBrowse = useEnvelope( | ||
| ['explorer', 'graph-overview'], | ||
|
|
@@ -378,14 +430,18 @@ export function useExplorerController(): ExplorerController { | |
| }, [facet, laneFilter, lanes]); | ||
|
|
||
| const dispatch = (nextQuery: string) => { | ||
| requestGeneration.current += 1; | ||
| retireAcceptedRun(); | ||
| setActiveRunId(null); | ||
| // A fresh run starts at the top of the backoff ladder: the previous | ||
| // run's failures are not this one's, and must not slow its first reads. | ||
| transportFailures.current = 0; | ||
| planner.reset(); | ||
| // Nothing is dispatched under a scope that will refuse it; the lanes | ||
| // render the refusal instead. | ||
| if (writability.state === 'writable') planner.mutate(nextQuery); | ||
| if (writability.state === 'writable') { | ||
| planner.mutate({ query: nextQuery, scope, generation: requestGeneration.current }); | ||
| } | ||
| }; | ||
|
|
||
| // The rows on screen must belong to the scope in the register. When the | ||
|
|
@@ -407,6 +463,8 @@ export function useExplorerController(): ExplorerController { | |
| }, [requestScope]); | ||
|
|
||
| const reset = () => { | ||
| requestGeneration.current += 1; | ||
| retireAcceptedRun(); | ||
| setQuery(''); | ||
| setSubmitted(''); | ||
| setActiveRunId(null); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Failed cancellations leave orphaned runs
When a superseding search's DELETE fails,
retireAcceptedRundiscards the run without checking the typed result. The old run keeps consuming server work until its deadline, with no cancellation retry or visible failure.Learn more
fetchEnvelope returns
{ outcome: 'transport' }on offline and HTTP errors. The new fire-and-forget cancellation discards that result and clearsacceptedRunfirst; the same pattern occurs for stale POST responses and unmount cleanup. No subsequent action retains the run identity to complete the cancellation. The daemon keeps pending runs until cancelled or timed out, and its run registry has finite capacity.Example: Submit
alpha, thenbetawhile the DELETE foralphareceives HTTP 503. The controller forgetsalphaand showsbeta;alphacontinues doing work, and nothing reports the failed cancellation.Recommended fix: Centralize automatic cancellation and handle its
EnvelopeResultexplicitly. Retain failed cancellation identities for an appropriate bounded retry or expose a typed failure when retries are impossible; handle both non-2xx transport outcomes and rejected requests.Was this helpful? React with 👍 or 👎 to provide feedback.