fix(coordinator): give each tab ownership of its execution so a retarget cannot be overwritten - #2055
Conversation
…get cannot be overwritten
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Verified against a live runCaptured a trace from a build of this branch, browsing ~195 tables across two Postgres connections (one over an SSH tunnel, one local), deliberately clicking through the sidebar faster than queries could finish. The bug is gone.
The intended behaviour replaces it: One trace ( Two follow-ups this run exposedNeither blocks the correctness fix, but both are mine and should not be left implicit. 1. Superseding stalls the main thread over SSHPerfect correlation across the run:
2.
|
…try and cancel superseded reads off the main thread
Both follow-ups are now in this PRNo separate PR. 1. Superseding no longer blocks the main thread
guard reach == .userStop else {
DispatchQueue.global(qos: .utility).async {
for driver in targets { try? driver.cancelQuery() }
}
return
}This is safe precisely because of the claim gate: correctness never depended on the cancel landing before the successor starts, since the superseded claim already discards whatever comes back. The cancel exists only to stop the server doing unwanted work. That removes the 68-157ms hitch measured on every fast click over an SSH tunnel. 2.
|
Cleanup pass
Speculative API removed
The phase concept was the clearest offender: I maintained Trace noise removed
Stale naming from the refactor
One thing deleted, then put back
Verification127 tests green, |
Follow-ups filed
#2061 is the one to look at before merging. It is not a defect report, it is the honest state of this PR: the stall fix is sound in reasoning and green in tests, but the capture taken afterwards never hit the path, so nobody has seen it work. |
The bug
Clicking table B while table A's query was still running left the tab showing A's rows under B's name, and B never loaded at all. Reproduced live and captured with the new tracing:
It fired three times in one short browsing session. The tab is retargeted durably, not transiently:
applyPhase1ResultwritestableContext.tableNameback, and Refresh then confirms the wrong table rather than recovering.Why it needed a refactor rather than a patch
"Which navigation owns this tab's execution" was spread across five uncoordinated mechanisms, and a tab retarget participated in none of them:
queryGenerationexecution.isExecutingBoolcurrentQueryTasktableLoadTasksConnectionToolbarState.isExecuting35 sites mutate tab state after an
await. None validated both "same tab" and "same navigation".The counter could not represent this bug's shape. It only advanced when a successor execution started, and here the successor was refused by a stale flag, so nothing advanced and the old result stayed valid all the way into the grid.
The change
TabExecutionRegistry, keyed per tab, modelled on the existingConnectionAttemptRegistryone level down.claim(tabId)mints an epoch and invalidates the predecessor.invalidate(tabId)removes the entry rather than bumping a counter, so "the user navigated away and no successor ever ran" still invalidates.QueryTabManager.replaceTabContentfiresonTabRetargeted, wired to cancel the driver read and invalidate. The retarget is now an event the model sees.Two traps a design bake-off caught
Two independent reviewers each found a real defect in the other's preferred design. Both are avoided here:
(tableName, databaseName, schemaName).resolveTableTabSchemaIfNeededrewritesschemaNamemid-flight whenever the schema was unresolvable at tab creation, which is the ordinary session-restore path, so a field comparison would discard valid rows on restore.tableNamewrite is kept for query tabs.resolveTableEditabilityderives a query tab'stableNamefrom the SQL per result, so that write is load-bearing there and a tautology for table tabs.Data-loss fix shipped alongside
cancelRunningQueryiterated every running driver and its fallback ignored scoping, so Stop could alreadyKILL QUERYa commit or a DDL statement. Making navigation issue cancels would have turned that from rare into routine.DriverCancellationPolicy(untracked/cancellableRead/protectedWrite) now classifies every lease. The parameter has no default value, so a new call site cannot silently pick wrong.Behaviour change
The 11 blocking guards are gone: an entry point now supersedes instead of being silently ignored. CHANGELOG updated under Changed and Fixed.
Testing
TabExecutionRegistryTests(12) andTabRetargetInvalidationTests(10) pin each proven step of the chain, including the no-successor case the old counter could not express.DriverCancellationPolicyTestspins that a protected write is tracked but never a cancellation target.MainContentCoordinatorRefreshTestsconverted to the new model. Its in-flight setup was rewritten to take a real claim rather than setting the stale bool; assertions were not weakened.swiftlint --strictclean acrossTablePro/.No deterministic
TableProUITestscoverage is possible (CLAUDE.md rule 4): reproducing the race needs a query still in flight when a second table is clicked, which needs a live server with a slow query. The regression is pinned by the pure suites plus the retarget tests instead.Reviewer notes
cancelQueryfall back to the no-op default, so supersede there means the first statement still runs to completion holding the connection gate..inPlace(Redis) navigation branch now invalidates via the retarget hook, butpluginDispatchAsyncinstalls no cancellation handler, so a lateSELECTcan still leave the connection on a database no tab asked for. Not introduced here, not fixed here.