Repository navigation
fix(cancellation): cascade from a fresh graph and take cancellation locks at once - #96
Merged
Merged
Conversation
…ocks at once Settlement cancels linked children through _private_cancel_linked_children after every write of its statement, so the cascade reads the graph with fresh snapshots and same-batch children in their new state. cancel_execution and race registration lock their whole set at once (_private_lock_executions). Supersede and invokeAndWait timeouts cancel through the same path; drop_queue locks queue rows first.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the cancellation findings from the review: C13 (O2-01, O6-04), C14 (O2-02, O3-04, O6-05, O3-08), C16 (O3-05), and the two follow-ups #85 left open: an
invokeAndWaittimeout and a dedupe supersede did not cascade to linked children.Root cause
Every path that cancels linked children found them in the snapshot of the statement that ended the parent, and then locked them in whatever order that statement happened to reach them:
linked_descendantsCTE walked the graph from the settlement statement's snapshot. Suppose a child suspends on a new grandchild while the settlement waits for that child's lock. The settlement then fails the child but never sees the grandchild, and nothing cancels the grandchild later.cancel_executionlocked a parent, then waited for its linked child inside the recursive call. This deadlocks when the parent waits for the child's result._private_register_event_racelocked awaited executions by id, but one of them can be waiting on another.drop_queuedeleted tasks before it locked the queue row.invoke()andclearWaitingStatefailed executions directly and never cascaded.clearWaitingStatealso did not setcancelledon a pending child.Fix
One cancellation path, which reads the graph fresh and takes its locks all at once:
_private_cancel_linked_children(parents)callscancel_executionfor the non-terminal linked children of ended executions, each with its parent's reason. It reads the children in a new statement, so its snapshot postdates the caller's locks. Callers:writesCTE that counts the rows of every write CTE. The filter on that count runs all writes to completion before the cascade starts. The cascade then sees a linked child settled in the same batch in its new state: a retried, sleeping, re-queued or suspended child is cancelled, and a completed one stays completed. This replaceslinked_descendants,cancelled_descendants, the three descendant writes and the sixcancelled_descendantsexclusions, so settlement now has 5 fewer references to the executions table.cancel_execution, for every row it fails.invoke(), for the execution it supersedes. The parent still ends as failed withsuperseded by reinvoke.clearWaitingState(theinvokeAndWaittimeout) cancels the awaited child withcancel_execution(child, 'Cancelled: parent timed out')before it clears the parent's wait. The child's waited-on chain and linked children are now cancelled too. A pending child is nowcancelled = true, and its result waiters see{status: 'cancelled'}instead of{status: 'failed'}; this is the follow-up feat(tasks): ctx.invoke(..., { cancelWithParent: true }) #85 asked for. A running child now also getslast_error._private_lock_executions(ids)locks a whole set withfor update nowait. If a row is busy, it waits for each row alone in a subtransaction it rolls back, then tries the whole set again, so it never waits while holding part of the set. Callers:cancel_execution, whose lock set now also includes the linked subtree of the rows it fails, so its recursive calls find their locks already held._private_register_event_race, for its awaited executions.drop_queuefirst locks its queue and the queues whose tasks dead-letter into it, in name order, the same order registration uses.The lock order is now written down in AGENTS.md (Lock Order): awaited before waiter, parent before linked child, sets taken all at once, queue rows before tasks.
Where this differs from the review's suggestions:
cancel_execution's recursion still waits while holding the parent (O3-04). Routing the settlement cascade throughcancel_executionfixes both. It also removes the same-batch exclusions, which existed only because the cascade ran in the same snapshot as the writes.nowaitplus a client retry on55P03._private_lock_executionswaits inside the function instead, so callers such asdrop_queueor a directcancel_executionnever see the error.Tests
The repro tests from
review/repro-r2moved intotests/integration/, with their barriers (pg_blocking_pids) kept. All of them failed onmainand pass here:cancellation-cascades.test.ts:removeOnFailnull and 0, plus thecancel_executioncontrol.removeOnFailnull and 0.invokeAndWaittimeout cancels the awaited child, its grandchild and its linked child.lock-order.test.ts: the five C14 pairs. In thedrop_queuetest, registration now takes its lock first anddrop_queueruns into it. A new variant covers a queue that dead-letters into the dropped one (O3-08's untested case). Both fail onmainwith40P01.tests/fixtures/sessions.ts.Assertions updated for the intended behaviour change: a pending child cancelled by a timeout now has
cancelled: true, and its waiters seecancelled(cancellation,invoke-supportandwait-for-resulttests).worker-lifecycle's fault injection now matches the new settlement text.just lint,just formatandpnpm typecheckpass, and the fullpnpm testpasses (560 tests, after rebasing onto #92).Settlement performance
I seeded a fresh database per run: each queue holds the given number of rows (a third completed, a third failed, 20% parented). Each run settled batches of 100 freshly claimed rows on one queue. Planning and execution times come from
pg_stat_statementswithtrack_planningon Postgres 15:EXPLAIN ANALYZEputs main's 12.5 s inside the failure-path CTEs even for an all-completed batch; that plan is gone here. One cost remains: a failure batch pays for_private_cancel_linked_childrenprobingparent_execution_idin every partition, 145 ms for 100 failures at 106 queues. That is for F10, which plans to move rare cross-queue paths into functions anyway.The harness was a throwaway script against a scratch container.
Follow-ups (out of scope)
_private_resolve_result_waitslocking several waiters by id._private_cancel_linked_childrencould skip partitions a queue's children never live in (F10).