feat(api): let an administrator say what the queue should do first - #145
Merged
Conversation
The claim now reads `ORDER BY priority, id`, and two endpoints write that column: a drag expressed relative to a neighbour, and two quick actions that reorder a guild's whole queue by a named rule. The unit is the session, never the job. The rows are one per speaker, so a request that took job ids could reorder four of a meeting's five speakers -- a queue that is half moved, that no page renders and that nobody would notice. A session's number goes onto every one of its outstanding jobs at once. The console never sends an integer. A drag says "before that one" and the server works out the numbers, inside the same locked transaction that writes them, so two administrators dragging at once serialise into one coherent order rather than a blend of two. A reorder only ever raises a priority. Priority is one column shared by every guild and this control is reachable by any guild administrator, so a write that could lower one would be a way for the first administrator to find it to put their whole guild ahead of everybody else's ordinary work. Going first is expressed as everything that was ahead going second. Migration 0013 said `ORDER BY priority, id` would be one forward scan of `ix_job_claim_order`. Measured against PostgreSQL 17: it is not, because `status` leads that index and a claim matches two values of it. The ordering is correct and costs what ordering by `id` already cost; the index narrows the scan but does not order it. `claim_statement` records the plan, the partial index that would fix it, and why the cheaper-looking fix must not be used.
TheMeinerLP
force-pushed
the
feat/api-queue-priority
branch
from
August 23, 2026 14:06
1ebfa7a to
16d7708
Compare
This was referenced Aug 23, 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.
A guild whose eight-person retrospective is stuck behind forty one-minute recordings can only wait. Migration 0013 added
transcription_job.priorityand its index and left both unread; this branch is both ends of it — the claim reads the column, and two endpoints write it.The unit is the session, and that is not a convenience
The rows are one per speaker. Nobody in a console drags a speaker: they drag a meeting. An API that took job ids would let a caller reorder four of a meeting's five speakers and leave the fifth wherever it was — a queue that is half moved, that no page renders, and that nobody could see was wrong. So a request names a session,
sturnus.application.prioritiesreasons in sessions, and the write puts one number onto every outstanding job of that session at once. Nothing anywhere in the change can express a partial move.A finished job is not written. Its priority describes a queue it has already left, and
doneordeadmeans nothing will claim it again.A drag is relative, and the console never invents a number
placeisfirst,last,beforeorafter;sessionnames the neighbour, is required by the last two, and is refused by the first two rather than ignored.Why an anchor rather than an index or an integer. A drag-and-drop list produces "this one goes here". Turning that into integers needs the whole queue, and the browser's copy of the queue is already out of date — worse, the other browser doing the same thing a second later has a different out-of-date copy. An index (
"put it at position 3") is a claim about a list that has moved; an anchor still means something after somebody else's move landed.What two simultaneous drags produce. Each request is decided inside the transaction that writes it, holding
FOR UPDATE OF transcription_jobon the guild's outstanding jobs, so the two serialise: the second is decided against what the first left, not against the list its browser was showing. The result is always one of the two orders those two drags could serialise into — never a blend, never a lost write.test_two_reorders_at_the_same_instant_produce_one_coherent_orderreleases two drags together against the real PostgreSQL, twenty rounds, and asserts the exact numbers match one of the two serialisations; a blend produces neither. The second administrator may well see an order they did not picture, because the queue moved under them, and their page is told within one poll — priority is part of the SSE snapshot.Rows are locked in ascending
id, identically toJobQueue.completeandapply_requeue, so no two of the three can deadlock.How a relative order becomes integers
priorities_foris one forward pass over the wanted order, carrying the sort key(priority, id)of the session before. A session that already sorts after its predecessor keeps the number it has; one that does not is raised to the smallest number that puts it there — the predecessor's own number when the id tie falls that way, one more otherwise.Two properties fall out, and both are load-bearing.
Nothing is ever lowered, and that is a safety property rather than an implementation detail.
priorityis one column shared by every guild in the deployment,0is what untouched work carries, and this arithmetic is reachable by any guild administrator through the console. A function that could write a smaller number would be the control by which the first administrator to find it puts their whole guild in front of everybody else's ordinary work — permanently, and without anyone being told. So going first is expressed as everything that was ahead going second, which says exactly the same thing about the guild's own queue and says nothing at all about anybody else's. A negative priority stays an operator's to write by hand.The cost is real and is written down rather than glossed: holding a session back holds it back globally, not merely within its guild. Only the sessions actually overtaken move, and they leave the queue when their work finishes, so it is bounded — but §6.2.11 says so plainly rather than leaving it to be discovered.
Nothing is written for an order that already holds. Re-sending the current order, applying a quick action twice, two administrators who agree: all cost one read and no writes. Without that, every look at the page would push the queue further back.
Quick actions are sort keys, and nothing else
many-participants-firstreadssession_participant;short-recordings-firstreadstranscription_job.audio_seconds, summed across a session's tracks. Both are pure functions insturnus.application.priorities, tested without a database, and each is only a key function — the third rule the owner asks for next month is one function and one line inKNOWN_RULES. It cannot get the tie-breaking, the integers or the write wrong, because it does none of them. An unknown name is a 400 that lists the ones there are, resolved at the HTTP boundary exactly asmodelis; running some other rule quietly would reorder a guild's queue in a way nobody could tell from the feature working.Null is not zero, and here it decides the whole order.
audio_secondsis written when a job completes, so a session that has never been transcribed has none. Read as nought it would be the shortest recording in the queue and would go to the front on the strength of nothing being known about it. Unmeasured sessions rank after every measured one and keep the order they already had. The honest consequence, in the docs: "shortest recording first" is useful on a queue of re-queued sessions, which keep their first pass's measurements, and does very little on fresh ones.Ties keep the order the queue already had — a stable sort over the claim order — so a quick action reorders what it has an opinion about and leaves the rest exactly where it was.
Reading it back
GET /api/guilds/{guild_id}/queueand its/streamtwin now carry each session'spriority. It is present-and-null, not zero, for a session with nothing outstanding — still recording, or listed only because a job of it died. Zero is the ordinary priority and a real place in the queue; null is a row with nothing to reorder, which is a row that must not offer a drag handle. Both come out of the grouped read that already computes the counts, so it is no extra statement and no second moment.Priority becoming part of the SSE snapshot is what makes one administrator's drag arrive on another's open page;
test_a_reorder_alone_is_a_change_the_stream_sendspins that nothing else has to change for the event to fire.Authorisation and refusals
Administrator of the guild, 404 for everybody else and for a session that does not exist alike — the queue routes' existing rule, expressed by calling the same
_administered_guildthe re-queue path uses rather than by a second copy of it. A refused caller writes nothing, which is asserted against the database rather than inferred from the response.A drag naming a session that has left the queue meanwhile is 409, not 400 and not 404: the request was well formed and the person may make it, and what changed is the state. The body carries the queue as it now is, so the page redraws instead of replaying a drag it has just been told is stale.
The exact JSON both writes answer with:
{ "accepted": true, "refusal": null, "changed": ["512"], "order": [ {"session_id": "77", "priority": 0}, {"session_id": "512", "priority": 1} ] }changedis the one thing the order itself cannot say — whether this request did anything. An administrator who dragged a session two pixels and put it back gets[]and can be told "nothing to do" rather than "done". Every id is a string, like every other id in this API.What the plan actually says, which is not what 0013 said
The instruction was to verify rather than assume, and verifying changed the answer.
Migration 0013 added
ix_job_claim_order (status, priority, id)and said the claim'sORDER BY priority, idwould be one forward scan of it. It is not, and it cannot be.statusleads that index and the claim matches two values of it — apendingjob and arunningone whose lease expired — so a scan yields the pending rows in(priority, id)order and then the running ones in(priority, id)order: two ordered runs, not one. PostgreSQL puts aSorton top. Measured on PostgreSQL 17 with sequential and bitmap scans disabled, which leaves the ordered index scan as the only plan available: it still sorts.Nothing regressed — the claim sorted by
idfor the same reason before this branch — and the index still earns the half that matters more, narrowing the scan to outstanding work rather than to a table that keeps every job the deployment has ever run. But the paragraph in 0013 is wrong, soclaim_statement,TranscriptionJob.priority, the index's own comment and §6.2.11 all now say so, andtest_no_index_this_schema_has_can_order_a_claim_by_prioritypins it. That test fails the day somebody adds the fix, which is the point.Two ways to earn the ordering were considered:
ORDER BY status, priority, iddoes produce one forward scan with no sort — measured, not assumed. It also silently means "no expired lease is ever reclaimed while any pending job is claimable", becausependingsorts beforerunning. That is Defect 4 put back: a job whose worker was killed would wait for the whole queue to drain, and its session would stay undocumented for exactly that long. A plan node is not worth that, and the test's docstring says so where somebody would otherwise reach for it.(priority, id) WHERE status IN ('pending', 'running'), gives the ordering and keeps the semantics, because the status predicate moves into the index'sWHEREand stops being a key column. It is the right answer and it is a migration, which this branch deliberately is not. Whoever writes the next one should write it, and may dropix_job_claim_orderin the same breath.claim's statement is otherwise byte-for-byte what #134 left: the same cap as a rank, the sameFOR UPDATE SKIP LOCKED, the same fencing token. It moved intoclaim_statementonly so theEXPLAINruns against the query the worker runs and not against a copy of it.Not in this branch
No console change — the JSON above is the contract. No migration. No change to the cap, the fencing token or
complete. A brief note that a reorder holds the guild's jobs for one short transaction, during which a worker'sSKIP LOCKEDclaim walks past them and takes somebody else's work: correct, and worth knowing.Checks
2483 passed(baseline onmainate1f272e: 2400),mypyclean over 281 files,ruff checkandruff format --checkclean.Rebased onto
e1f272e. Three conflicts, all "both sides added": two import blocks (adapters.py,tests/console/conftest.py) and the tail oftests/infrastructure/test_queue.py, where #142's recording-header section and this branch'sprioritysection both append. Both sections survive intact, the ordering regression test included.