fix: start the jobs behind a job that another user holds back - #23
Open
stephenc wants to merge 1 commit into
Open
fix: start the jobs behind a job that another user holds back#23stephenc wants to merge 1 commit into
stephenc wants to merge 1 commit into
Conversation
A job that could not start parked the whole queue, whatever the cause. Measured: one job that a peer blocked kept two small jobs in the queue for ever, and each of those jobs read "waits for the job at the front of the queue", which names no cause. The rule becomes a reservation with a bounded bypass, and the class of the holder decides it. `Admit::No` now carries a `Blocker`: `Sibling`, `Peer`, `Machine`, `OversizedWaitsForIdle` or `OversizedParked`. qex schedules the release of the capacity that its own jobs hold, so a `Sibling` or an `OversizedWaitsForIdle` job at the front keeps the capacity after `[queue] max_bypass` jobs passed it, and qex then starts nothing at all. qex does not schedule the release of the capacity that another user or a program outside qex holds, so those classes never keep capacity: an empty machine gives such a job nothing. The count of the jobs that passed is NOT reset when the class changes. A job that another user held for an hour keeps its count, and it is unpassable in the same scheduler cycle in which a job of this queue becomes the holder. Every job that waits now gets a reason from its own test. The earlier loop stopped at the first job that could not start, so a job behind it was never tested and was told the wrong thing. `qex info` and `qex top` give one line that answers "is the queue healthy". The new `Response::Info` fields are options: a defaulted `0` from an older coordinator would say "no other user holds anything", which is a lie in the place where the true answer is the most valuable. Two costs of the earlier code, and the answer to each: - `admit` read /proc at each call, and it now runs for every ready job in each cycle. One measurement of the machine and of the peers for each pass removes the storm of system calls, and it also stops two jobs in one pass from getting answers from two different moments. - A change of a reason writes the record with two operations to the disk. The count of the bypasses is therefore in a field of its own, and the sentence stays the same while the count changes. New `[queue] max_bypass`, default 2. `max_bypass = 0` gives the strict order of the earlier releases. Closes part of #10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
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.
Closes part of #10 — the head-of-line half. It implements section 1 of
design-decisions.md.The rule, in one paragraph
qex walks the queue in order. The first job that cannot start is the job at the
front, and the class of the holder of the capacity decides what happens behind
it. If the jobs of this queue hold the capacity, or if the job is larger than
the budget and waits for a quiet machine, qex schedules that release: two jobs
may pass (
[queue] max_bypass, default 2), and after that qex keeps thecapacity and starts nothing at all. If another user, or a program outside qex,
holds the capacity, qex does not schedule that release, so the job at the front
never keeps capacity and every job behind it starts — an empty machine gives
such a job nothing, and to hold the machine for it is the measured fault. The
count of the jobs that passed is not reset when the class changes, so a job
that another user held for an hour is unpassable in the same scheduler cycle in
which a job of this queue becomes the holder.
What changed
Admit::Nocarries aBlockerclass:Sibling,Peer { count },Machine,OversizedWaitsForIdle,OversizedParked.status.jsonfields:blocked_sinceandpassed_by. Both are inqex schema status.[queue] max_bypass, default 2.max_bypass = 0gives the exact strictorder of 0.7.1.
nothing. This covers every resource with no extra bookkeeping, which is what
the pool feature (question 2) leans on.
continue. A job that waits for a lock neverbecomes the job at the front and never keeps capacity.
admitcall. The earlier loopstopped at the first job that could not start.
qex infoandqex topgive one queue-health line. The newResponse::Infofields are
Option, andqex info --jsongivesnullfor unknown.The two performance traps
/procon every call.admitnow runs for every ready job in each cycle.A
Machinerecord takes the available memory, the memory pressure and thepeer claims one time for each
choosecall. This removes the per-cycleper-job storm of system calls, and it also stops two jobs in one pass from
getting answers from two different moments.
its own field and never in the reason text. The one sentence that holds a
count — the sentence that a job behind an unpassable job reads — cannot move
while a reader sees it, because no job starts while the capacity is kept.
choosealso collects the changed records and writes each one once.Decisions where the document left something open
/proconly, butpeers::claimsreads a directory of files on everyadmitcall, and that is the same fault. It is in the same one-per-passrecord.
queue_stateis the mark of a coordinator that reports the health.§4.1 asks for one machine-readable enum plus one human sentence, and §4.2
asks for options, not defaulted scalars.
last_start_at: Option<u64>aloneis ambiguous:
Nonewould mean both "no job started" and "the coordinatoris too old".
queue_stateisNoneonly in the second case, so a readertests one field. The words are
running,held,waits-for-peer,waits-for-machine,waits-for-capacity,waits-for-idleandparked.The last three are additions to the list in §4.1, which did not cover a
sibling wait or an oversized job.
qex infoandqex topshare. A second wire field for the sentence woulddrift from the fields it describes. The function gives
unknownfor eachNone.OversizedWaitsForIdlegained a text for the case in which it keeps thecapacity ("qex starts no other job before this one, so the queue becomes
empty"). §1.3 gave no wording for it.
paused,paused_atandpaused_reasonare not in this PR. Theybelong to question 3, which another branch implements, and adding them here
would give two branches that write the same fields.
Where I disagree with the document
§1.2 and §4.3 say that a job which a peer blocks "becomes unpassable in the
same tick that the peer releases". A job cannot go from
Peerto a blockedSiblingon a release alone: the sibling test uses this queue's own claimsonly, so a test that passed before the release passes after it, and the job
starts. The transition to
Siblinghappens when a job of this queue takes thecapacity, and that is where the carry-over earns its value: without it, that
change of holder would reset the count and a stream of small jobs would pass
the job at the front for ever. The release of the other user must also not
reset the count. The test asserts both, and it is named
a_job_blocked_by_a_peer_becomes_unpassable_when_the_holder_changes. Themechanism the document describes is the one that is implemented; only the
sentence about "the same tick as the release" is not reachable.
What I measured
On this machine, with
[budget] cpu = 4and another coordinator that holds 3cores:
and each of those jobs read
waits for the job <id> at the front of the queue.this job cannot fit while another user holds capacity: the job needs 4 cores, this queue holds 0 of the 4 cores in the budget, and 1 other user holds 3 cores. qex does not control that user, so this wait has no known end. ...qex infogivesqueue: waits for another user · ... · 1 other user holds 3 cores and 64MB · the job at the front is <id> (true).max_bypass = 2and a stream of six small jobs behind a large siblingjob: exactly two pass, and the queue is then held.
max_bypass = 0: none pass, andpassed_bystays 0.What I tested
New unit tests in
src/sched.rs:a_job_that_another_user_holds_back_says_so_and_never_keeps_capacitythe_queue_keeps_capacity_only_for_a_holder_that_it_schedulesa_job_that_the_config_parks_does_not_stop_the_jobs_behind_ita_job_that_waits_for_a_quiet_machine_keeps_the_capacitya_sibling_wait_says_if_another_job_can_pass_itNew end-to-end tests in
tests/e2e.rs, each of which starts two coordinatorsthat share a peer directory where it needs another user:
a_job_that_another_user_holds_back_does_not_park_the_jobs_behind_it— themeasured fault.
a_job_behind_a_job_that_keeps_no_capacity_gets_its_own_reasona_job_blocked_by_a_peer_becomes_unpassable_when_the_holder_changes— thecarry-over rule.
a_stream_of_small_jobs_does_not_pass_a_large_job_for_ever— starvation isstill prevented.
max_bypass_zero_lets_no_job_pass_the_front_of_the_queuea_job_that_the_config_parks_does_not_park_the_jobs_behind_it— the secondhead-of-line path, with
oversized = "queue".Results:
Also updated:
docs/reference.md,qex help config,qex help resources,src/schema.rs, andCargo.tomlto 0.7.2.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9