Send tier2 jobs in the order the client reads them - #915
Merged
Conversation
sduchesneau
force-pushed
the
tier2-launch-queue
branch
from
September 1, 2026 19:01
078868f to
9ee7f0a
Compare
Jobs used to race each other for whatever tier2 instance had room, so the segment the client reads first was no more likely to land than one it would only read minutes later, and a whole request could idle behind one unlucky low segment. A job now asks a per-request launch queue for its turn before every request it sends, first attempt included. Only the first 20% of the jobs waiting for capacity may dial at all, lowest segment first; the ones behind them send nothing until a job ahead gets in.
A client reading slowly held the workers further back than it needed to.
Every reason to dial a tier2 again now goes through the same ordered queue instead of a Fibonacci backoff of its own, so a retry keeps the request's reading order. A job that could not get in waits 100ms; one that got in and then failed waits 5 seconds once, then is back on the short delay. The failure and timeout counters that end a hopeless request are unchanged. A job also leaves the queue as soon as a tier2 takes it, instead of when it finishes: it was holding a window slot for its whole run and keeping the jobs behind it from dialing.
sduchesneau
force-pushed
the
tier2-launch-queue
branch
from
September 1, 2026 19:55
7a074cb to
e44d540
Compare
sduchesneau
marked this pull request as ready for review
September 1, 2026 20:03
sduchesneau
force-pushed
the
tier2-launch-queue
branch
3 times, most recently
from
September 1, 2026 20:39
f24074a to
967fb73
Compare
Past a few tens of workers a wider window only spreads the fleet's free slots over more jobs, without getting the client its first segments sooner.
sduchesneau
force-pushed
the
tier2-launch-queue
branch
from
September 1, 2026 20:40
967fb73 to
e380c7c
Compare
billettc
approved these changes
Sep 2, 2026
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.
Jobs of a tier1 request race each other for whatever tier2 instance has room. A tier2 at its concurrent-request limit refuses the job without doing any work, the job redials and can land anywhere, so the segment the client reads first is no more likely to land than one it will only read minutes later. When the low segment loses that race a few times, the execout walker stalls on it and the whole request idles behind it.
This adds a per-request
LaunchQueue(orchestrator/work/launchqueue.go). A job asks it for a turn before every request it sends to a tier2, first attempt and retries alike.The queue holds the jobs waiting to get in, ordered by what the client reads first: lowest segment, then highest stage within a segment (that job also produces the partials of the stages under it). A job leaves the queue the moment a tier2 takes it — not when it finishes — so the jobs behind it move up while it runs.
The window. Only the first 20% of the queue may dial at all, at least two jobs and at most 15. The ones behind them send nothing — they are not retrying slowly, they are not dialing. Each admission moves the window up by one job.
No queue, no wait. A job with nothing queued ahead of it dials immediately and never joins, so a fleet with room is paced exactly as before.
One retry path, two delays
derr.RetryContextand its growing 1s-to-5s backoff are gone from the worker. It was only ever contributing the sleep:maxRetriesandmaxExecutionTimeoutsare hand-rolled locals in the loop, and derr was passedmath.MaxUint64retries. Every reason to dial again now goes through the queue, which means retries keep the request's reading order instead of jumping ahead of it. Two delays replace the curve:no healthy upstream. Another instance may have room right now. Not charged to any counter, as before.The counters that end a hopeless request are unchanged: five failures, or two execution timeouts.
What it looks like with 10 workers, fleet full
Jobs for segments 1-10 are dispatched. Segment 1 dials, is refused, joins the queue; segments 2-10 join behind it. Segments 1 and 2 redial every 100ms; segments 3-10 send nothing. A tier2 frees a slot and it goes to segment 1 or 2 — it cannot go to segment 7, which isn't dialing. Segment 1 gets in and leaves, and segment 3 starts dialing.
At 50 workers the window is 10 jobs. At 200 it is 15, not 40: past a few tens of workers a wider window only spreads the fleet's free slots over more jobs without getting the client its first segments any sooner.
Because the order is the segment and not the job's age, a job the scheduler re-creates late for a low segment (the re-run after a missing execout file) goes to the front of the queue and dials on its next tick.
Also here
orchestrator/scheduler/scheduler.gonow schedules jobs up to 2x the request's worker count ahead of the blocks the client is reading, instead of 1.5x.Trade-offs worth knowing
SUBSTREAMS_WORKER_LAUNCH_WINDOW_PERCENT(default 20) andSUBSTREAMS_WORKER_LAUNCH_WINDOW_MAX(default 15) are the levers.Tests
launchqueue_test.gocovers the window sizing and its cap, the launch order, no wait while nothing is queued, a job outside the window staying silent until the window reaches it, a late low segment going to the front, the head's redial cadence, and cancellation.worker_overloaded_test.goadds two worker-level tests: a job leaves the queue while it is still running, and a failure waits once without putting the capacity refusals that follow it on the long delay.go test -race ./orchestrator/...is clean.