Skip to content

feat(api): let an administrator say what the queue should do first - #145

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/api-queue-priority
Aug 23, 2026
Merged

feat(api): let an administrator say what the queue should do first#145
TheMeinerLP merged 1 commit into
mainfrom
feat/api-queue-priority

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

A guild whose eight-person retrospective is stuck behind forty one-minute recordings can only wait. Migration 0013 added transcription_job.priority and 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.priorities reasons 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 done or dead means nothing will claim it again.

A drag is relative, and the console never invents a number

POST /api/sessions/{session_id}/queue/priority
{"place": "before", "session": "512"}

place is first, last, before or after; session names 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_job on 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_order releases 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 to JobQueue.complete and apply_requeue, so no two of the three can deadlock.

How a relative order becomes integers

priorities_for is 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. priority is one column shared by every guild in the deployment, 0 is 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

POST /api/guilds/{guild_id}/queue/priority
{"rule": "many-participants-first"}

many-participants-first reads session_participant; short-recordings-first reads transcription_job.audio_seconds, summed across a session's tracks. Both are pure functions in sturnus.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 in KNOWN_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 as model is; 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_seconds is 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}/queue and its /stream twin now carry each session's priority. 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_sends pins 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_guild the 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}
  ]
}

changed is 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's ORDER BY priority, id would be one forward scan of it. It is not, and it cannot be. status leads that index and the claim matches two values of it — a pending job and a running one 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 a Sort on 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 id for 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, so claim_statement, TranscriptionJob.priority, the index's own comment and §6.2.11 all now say so, and test_no_index_this_schema_has_can_order_a_claim_by_priority pins 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, id does 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", because pending sorts before running. 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.
  • A partial index, (priority, id) WHERE status IN ('pending', 'running'), gives the ordering and keeps the semantics, because the status predicate moves into the index's WHERE and 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 drop ix_job_claim_order in the same breath.

claim's statement is otherwise byte-for-byte what #134 left: the same cap as a rank, the same FOR UPDATE SKIP LOCKED, the same fencing token. It moved into claim_statement only so the EXPLAIN runs 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's SKIP LOCKED claim walks past them and takes somebody else's work: correct, and worth knowing.

Checks

2483 passed (baseline on main at e1f272e: 2400), mypy clean over 281 files, ruff check and ruff format --check clean.

Rebased onto e1f272e. Three conflicts, all "both sides added": two import blocks (adapters.py, tests/console/conftest.py) and the tail of tests/infrastructure/test_queue.py, where #142's recording-header section and this branch's priority section both append. Both sections survive intact, the ordering regression test included.

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
TheMeinerLP force-pushed the feat/api-queue-priority branch from 1ebfa7a to 16d7708 Compare August 23, 2026 14:06
@TheMeinerLP
TheMeinerLP merged commit 7f4f17d into main Aug 23, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant