Repository navigation
feat(run): enforce the Fan-out Limit inside the Run Owner - #2571
Conversation
Bound how many of one Run's Invocations are upstream at once, without hiding queued calls from Reef or history and without changing which Approval Requests become visible (ADR 0018, slice 5 of #2378, #2384). A new bootstrap setting, `[runtime] fan_out_limit` (default 32), is resolved at process bootstrap into `RuntimeLimits` beside the Applied cap and reaches the Run module whole; it is deployment-wide, with no per-Workspace override. The Run Owner decides each dispatch start in `apply` against its in-memory count of dispatching Invocations: a ready Invocation below the limit takes a slot and joins the open chunk's fence batch; one at the limit waits in owner memory, `pending`, with no Event, no required action and no chunk of its own, and joins a batch in readiness order (mailbox arrival order) when an earlier dispatch ends. Waits for an account connection or an approval happen before any fence request and hold no slot; a refresh wait the fence parks frees its slot before the next fence, so every Approval Request a Run needs is visible at once. There is no separate deadline: a stop, the Run deadline, a lost claim or a retiring owner answers a waiting request as it answers an uncommitted batch, and termination ends the call as `run_terminated`. The slot is a guard the Invocation task holds from the fence's decision until the dispatch it decided is over. An upstream result carries the release: the owner frees the slot as it applies the result, so the Invocation that has waited longest fences in the same chunk the ended row rides, after it. Every other end (denied, superseded, parked on a refresh, a failure, the Run ending) frees the slot on drop, which tells the owner through `Message::DispatchEnded`. A decision comes only from a batch that committed and every request of a batch holds a slot, so the count is exact; nothing recomputes it from rows, since a recovered owner never dispatches. Once the Run takes no more Program requests (a stop, the deadline, the Program's end, a lost claim), every waiting request is answered at once, as one batch. The load harness reports `invocations.max_dispatching_at_once` per fan-out cell, from the rows' dispatch start and end instants.
|
Running ultrareview automatically — this PR appears to change concurrency-sensitive code such as locks, queues, or retries, where a missed race may only surface under production load, so a deeper multi-pass review is worth running. I'll post findings when complete. |
There was a problem hiding this comment.
Ultrareview ran automatically — this PR appears to change concurrency-sensitive code such as locks, queues, or retries, where a missed race may only surface under production load, so a deeper multi-pass review is worth running.
All reported issues were addressed across 21 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…end the scripted Program on a stop Cubic's ultrareview of #2571, three findings: - `FENCE_BATCH_CAP`'s comment claimed a batch reaches the cap only under a Fan-out Limit raised above the default; at the default a batch reaches it, and closes its chunk, whenever every slot's request joined one chunk. The comment and the apply table's batch-cap row say so now. - `SequencedUpstream` released sends by ordinal, so a send canceled by the Run's stop burned the permit meant for the next held send. Releases now go to a queue of the sends still waiting (a canceled send has left it), and a release with nobody waiting is banked for the next send. - `ScriptedCallsEngine` read the stop only while waiting for commands; a stop requested after `finish` is now honored before each call is awaited, as the type's doc says.
… back The doc comment on `Message::DispatchEnded` listed the cases in a shorthand nobody could read; it now names them and points at the result message that frees a slot otherwise.
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Authorization and Built-in Operation failures after the fence free the slot too, not only a failed send.
Saul's review of #2571, items 1 to 5: - A result the owner refused (over the Run Output Budget, or a Run no longer taking work) still freed its slot, while its row stayed `dispatching` until the Invocation task recorded the failure; the next waiter's fence could commit first, and persistence showed limit + 1 `dispatching` for one commit. The owner now frees the slot only on the branch that pushes the result's row work, and the task keeps the slot when the owner answers with an error, freeing it after the row's end is recorded. The apply table has the row. - The owner's count is `slots_held`, not `dispatching`: it counts batched fence requests from the moment they join a batch, which is not the glossary's `dispatching` row state. The queue is `waiting`, as its accessor already said. - `FanOutLimit::new` rejecting zero is tested in coral-run; the bootstrap test asserts only the setting each error names. - The stop, Program-end and lost-claim rows of the apply table are one table over the ending message and the error it makes. - `FENCE_BATCH_CAP` is a literal again: a chunk bound, not a config default; at the default limit a batch never exceeds it.
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
🤖 I have created a release *beep* *boop* --- ## [1.2.0](v1.1.0...v1.2.0) (2026-10-22) ### Features * **activity:** record authored source locations for calls and outputs ([#2535](#2535)) ([2d735ca](2d735ca)) * **activity:** show persistent Program source beside selected records ([#2569](#2569)) ([f403819](f403819)) * add the Claude recipe and read gzipped OpenAPI documents ([#2513](#2513)) ([07c27a2](07c27a2)) * **catalog:** share named TypeScript types across a Provider ([#2482](#2482)) ([256e37d](256e37d)) * **desktop:** run the runtime on a Postgres database named in desktop.json ([#2485](#2485)) ([96465a0](96465a0)) * **functions:** type coral.providers, coral.workspace and coral.functions at Saved Function save ([#2526](#2526)) ([a465850](a465850)) * **provider:** create the Coral Built-in Provider at coral.workspace in every new Workspace ([#2349](#2349)) ([06e65ca](06e65ca)) * **provider:** store an agent's createProvider request on its Setup ([#2593](#2593)) ([92847d2](92847d2)) * **provider:** update every Workspace's Built-in Provider catalog at boot ([#2355](#2355)) ([24615d9](24615d9)) * **reef:** add NextDNS provider recipe ([#2509](#2509)) ([e44880e](e44880e)) * **reef:** calmer providers table and a flush app shell ([#2462](#2462)) ([ffe279c](ffe279c)) * **reef:** improve the UI on small screens ([#2531](#2531)) ([75e020a](75e020a)) * **reef:** make the providers rail vertical tabs with an All providers entry ([#2552](#2552)) ([08d007a](08d007a)) * **reef:** providers page UI tweaks ([#2562](#2562)) ([7ca218e](7ca218e)) * **reef:** put the list pages' main action back in the top bar as a primary button ([#2546](#2546)) ([761ea6d](761ea6d)) * **reef:** read ^, $ and * in every filter field ([#2520](#2520)) ([d322356](d322356)) * **reef:** refuse a provider display name another provider uses ([#2518](#2518)) ([fdcd473](fdcd473)) * **reef:** review provider configuration changes in a dialog ([#2579](#2579)) ([24417e9](24417e9)) * **reef:** show search indexing failures on a Provider and its Operations ([#2504](#2504)) ([6cffed0](6cffed0)) * **reef:** show your recent activity summary on the workspace overview ([#2574](#2574)) ([0d1dba5](0d1dba5)) * **reef:** title list pages in their toolbar, with the main action beside it ([#2525](#2525)) ([fd44b72](fd44b72)) * **reef:** UI tweaks to provider's page ([#2566](#2566)) ([62204ca](62204ca)) * **reef:** unify Activity detail panels and action reviews ([#2544](#2544)) ([409d062](409d062)) * return only declared successes from Operation calls ([#2517](#2517)) ([0270bf1](0270bf1)) * **run:** enforce the Fan-out Limit inside the Run Owner ([#2571](#2571)) ([cb53419](cb53419)) * **run:** introduce the Run Owner for Program requests ([#2524](#2524)) ([245d19d](245d19d)) * **run:** let coral.workspace.createProvider configure and publish the Provider itself ([#2352](#2352)) ([088faaf](088faaf)) * **run:** let createProvider configure sign-in and link to Reef's sign-in step ([#2353](#2353)) ([8d5495c](8d5495c)) * **run:** list a workspace's providers with coral.workspace.listProviders ([#2374](#2374)) ([179c43a](179c43a)) * **run:** make the Run Owner survive uncertain commits and lost leases ([#2541](#2541)) ([3a1fad1](3a1fad1)) * **run:** name Tasks and Runs with four-word identifiers ([#2558](#2558)) ([c82259a](c82259a)) * **run:** prepare a Provider from a Program with coral.workspace.createProvider ([#2351](#2351)) ([e7e56fd](e7e56fd)) * **run:** reshape the Dispatch Fence into one batched check per chunk commit ([#2550](#2550)) ([120c9d3](120c9d3)) * **run:** route approval decisions and cancellation through the Run Owner ([#2577](#2577)) ([77bcbf5](77bcbf5)) * **run:** summarize your recent activity in a Workspace ([#2573](#2573)) ([bf4c75e](bf4c75e)) * **search:** show a Saved Function's result fields in its signature line ([#2522](#2522)) ([41365f6](41365f6)) * **search:** trace each reconcile pass and its steps ([#2456](#2456)) ([0594094](0594094)) * **ui:** add option to create a correctly-configured Slack app during Slack provider setup ([#2585](#2585)) ([8c3a883](8c3a883)) * **ui:** move Add provider to the providers toolbar ([#2591](#2591)) ([e1ee4cb](e1ee4cb)) ### Bug Fixes * **app:** sweep device authorization leases every 15 s ([#2452](#2452)) ([1a4e4ae](1a4e4ae)) * **auth:** drop the unverified client name note from the approval page ([#2578](#2578)) ([ddf5aa4](ddf5aa4)) * **catalog:** derive each Built-in Operation's contract from the types Coral parses ([#2582](#2582)) ([534e636](534e636)) * **catalog:** import OpenAPI Providers whose Operations request different OAuth scopes ([#2401](#2401)) ([3acfe55](3acfe55)) * **catalog:** keep keyword names in Operation Call Paths ([#2506](#2506)) ([b20795b](b20795b)) * **catalog:** never make an Operation unavailable for search material ([#2465](#2465)) ([f83c909](f83c909)) * **catalog:** stop rejecting createProvider request headers by name ([#2589](#2589)) ([39e54b9](39e54b9)) * **provider:** ask for an OAuth client ID when Coral cannot register one ([#2580](#2580)) ([baefe9e](baefe9e)) * **provider:** bind each Setup to its starter and hide Coral from Members ([#2480](#2480)) ([6011a64](6011a64)) * **reef:** align the catalog table with the first rail entry ([#2484](#2484)) ([fb59758](fb59758)) * **reef:** calm the Activity badge and keep it while the stream reconnects ([#2468](#2468)) ([1b66639](1b66639)) * **reef:** frame the providers table again and lower the top bar ([#2472](#2472)) ([87e41ee](87e41ee)) * **reef:** pin third-party recipe OpenAPI documents to a commit ([#2510](#2510)) ([b4e9280](b4e9280)) * **reef:** replace loose help text with info tips and banners ([#2461](#2461)) ([695500a](695500a)) * **reef:** round the page's corners beside the sidebar ([#2503](#2503)) ([a1fef64](a1fef64)) * **reef:** shorten OAuth scope chips that share a URL prefix ([#2455](#2455)) ([5ae67ec](5ae67ec)) * **reef:** suggest a provider's namespace from its document name, not its host ([#2565](#2565)) ([8d77893](8d77893)) * **run:** name every argument to correct in an invalid_input failure ([#2354](#2354)) ([07bd727](07bd727)) * **run:** reject a credential beside createProvider's bearer method and report unreachable sources as upstream_incomplete ([#2542](#2542)) ([590eab7](590eab7)) * **run:** report an MCP JSON-RPC rejection as invalid_input or upstream_rejected ([#2402](#2402)) ([749770b](749770b)) * **search:** load each Generation's schema graph once per search ([#2448](#2448)) ([445d945](445d945)) * **search:** retry a failed Saved Function only after one interval ([#2460](#2460)) ([a61ab3a](a61ab3a)) * **search:** return no hits for a query without a letter or digit ([#2561](#2561)) ([bb0a4f0](bb0a4f0)) * **search:** start the write limit at the lock and build rows before the write ([#2575](#2575)) ([06ad55d](06ad55d)) * **search:** stop a self-referential Promise result from overflowing the stack ([#2543](#2543)) ([1d4f257](1d4f257)) * **search:** stop retrying an index write that fails the same way every time ([#2548](#2548)) ([e3b6d15](e3b6d15)) ### Performance Improvements * **catalog:** dedup credential destinations once per build, not per Operation ([#2555](#2555)) ([0afbcf5](0afbcf5)) * **catalog:** project each named TypeScript type once per Provider ([#2443](#2443)) ([153c273](153c273)) * **search:** drop schema text from the phrase test ([#2559](#2559)) ([cfdbafa](cfdbafa)) * **search:** index each word of schema text once ([#2547](#2547)) ([d1123fb](d1123fb)) * **search:** prepare search entries in the indexer after commit ([#2467](#2467)) ([1ab6765](1ab6765)) * **search:** recount only Workspaces whose rows changed ([#2457](#2457)) ([777cfb3](777cfb3)) * **search:** skip unchanged rows when an index write runs again ([#2459](#2459)) ([43b5188](43b5188)) * **search:** write only the statistics rows that changed ([#2458](#2458)) ([8e2832c](8e2832c)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: coral-release-bot[bot] <269256696+coral-release-bot[bot]@users.noreply.github.com> Co-authored-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Slice 5 of #2378 (ADR 0018,
docs/adr/0018-run-owner-coordinates-each-run.md). Closes #2384. Starts a new gh-stack frommain, since slices 0 to 4 merged.Intent
Bound how many of one Run's Invocations are upstream at once, without hiding queued calls from Reef or history and without changing which Approval Requests become visible. The ADR's Fan-out Limit bullet: at most 32 Invocations of one Run
dispatchingat once, deployment-wide and bootstrap-configured; admission stays eager; excess ready calls wait aspendingand dispatch in readiness order; approval and account waits hold no slot.What it enables
[runtime] fan_out_limit = 32in the server configuration, resolved at process bootstrap intoRuntimeLimitsbeside the Applied cap, validated (at least 1) and handed to the Run module whole. No per-Workspace override.applyagainst the slots it holds (one per fence request it has batched whose dispatch has not ended). Below the limit a ready Invocation takes a slot and joins the open chunk's fence batch; at the limit it waits in owner memory,pending, with no Event, no required action and no chunk of its own, and joins a batch in readiness order (mailbox arrival order) when an earlier dispatch ends. The count is owner memory only; a recovered owner never dispatches (slice 9), so nothing recomputes it from rows.run_terminated.dispatching; a result the owner refuses (over the Run Output Budget) leaves the slot with the task until the row's end is recorded. Every other end (denied, superseded, parked on a refresh, a failure, the Run ending) frees the slot on drop, which tells the owner throughMessage::DispatchEnded. A decision comes only from a batch that committed and every request of a batch holds a slot, so the count is exact by construction. Once the Run takes no more Program requests (a stop, the deadline, the Program's end, a lost claim), every waiting request is answered at once, as one batch.invocations.max_dispatching_at_onceper fan-out cell (from the rows' dispatch start and end instants) andscripts/load.mjsshows it asmax dispatching.ADR contract lines kept: 3 (dispatch starts only after one authoritative Dispatch Fence; a waiting call fences when it takes its slot, never from a cached decision) and 6 (an Approval binds one exact Invocation and every Approval Request a Run needs is visible at once, since approval waits hold no slot). Lines 1, 2, 4, 5, 7 and 8 are untouched: the wait is owner memory, writes no Event, and the owner code is identical on both backends.
Usage examples
A Program that fans out past the limit sees nothing new: every call is admitted at once, every approval it needs is asked at once, and calls above the limit start their upstream send in the order they became ready as earlier ones end.
Reef and the Run's history show the waiting calls as
pendingInvocations with no dispatch start; a slot wait is never a required action, and a Run canceled while a call waits ends that call asrun_terminated.Tests
crates/coral-run/tests/domain/fan_out.rs, both backends, limit 2: three/four ready calls (two dispatch, the rest staypendingwith no Event, no fence attempt and no required action, and dispatch in readiness order as earlier ones end); a call parked on approval holds no slot (two later ready calls dispatch while its Approval Request stays visible; approved, it waits for a slot, then dispatches); a Run terminated while a call waits for a slot ends it asrun_terminatedwith no dispatch start; a refresh wait the fence parks holds no slot (limit 1).applytable (tests/domain/owner.rs): slot rows for the queue and readiness order, the result-carried handover riding the same chunk, a slot freed throughDispatchEndedwith nobody waiting, a stop, the deadline, the Program's end and a lost claim.coral-app: the configured limit reaches the shared Run module over MCP; a limit of 0, a cap below the minimum and an unknown[runtime]field fail closed.Validation
Gates on the tree as submitted:
make rust-checks(fmt, clippy, migrations, 1449 tests) andmake postgres-tests(1449 tests) pass../dev test:scripts/load.test.mjspasses;scripts/dev/fs-security.test.mjsfails in this worktree only becauseapps/reef/node_modulesis not installed (it imports Reef'svite.config.ts), unrelated to this change.Harness (release, Postgres 17 + toxiproxy,
--calls 30,101 --latency 0,5,20, and SQLite--calls 30,101), compared cell by cell against the slice 4 final run on the same Mac; zerocoral_run=warnlines in every cell:Every slice 4 cell keeps its outcome within noise (fan-out cells from the final tree; emit and concurrent cells from the run before the simplify pass, which touched none of their paths). The 101-call cells show at most 32
dispatchingat once and no change to the all-requests-visible time: at +0 ms the dispatches burst and the rows show exactly 32 at once, with dispatch p95 down to 21 ms as the freed slot's fence rides the ended row's chunk; at +5 and +20 ms the harness decides one approval per round trip, so at most 4 dispatch at once and the limit never binds. The concurrent-Runs cell at +5 and +20 ms is unchanged and still outside the readiness budget at +20 ms: each of its Runs makes one call, so the limit cannot reach it; its pressure is twenty Runs' admission chunks and fences on one 10-connection pool (#2391 item 3). Full comparison on #2384.