PgStore: implement JobStore with FOR UPDATE SKIP LOCKED #3
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #1. Depends on: Postgres schema and migrations
Goal
Implement
tireless_core::port::JobStoreforPgStore. This is the persistencehalf of stage 1 and the foundation of the claim protocol:
PgStorecurrentlyholds a pool and nothing else.
Claiming is a row transition under
SELECT … FOR UPDATE SKIP LOCKED(design.md §4.2), which is what makes it atomic across any number of runners.
Labels mirror this for humans but are never consulted to decide whether a job is
taken — that is invariant 6 in
CLAUDE.md.Only the trait methods are in scope. The behaviour that uses them (a runner
loop, lease-expiry timers, label mirroring) is stage 2.
Files
crates/tireless-data/src/store.rs— theJobStoreimpl, and the SQL behindit
crates/tireless-data/src/lib.rs— exports, if new types are neededcrates/tireless-data/Cargo.toml— a dev-dependency for integration tests ifone is chosen
Steps
tracked_repos.enqueueas an upsert on(forge, owner, repo, number)that skipsissues already having a non-terminal job, returning the count newly enqueued.
claim_nextwithFOR UPDATE SKIP LOCKED, filtered to the lanesthe caller allows, setting
claimed_byandclaim_expires_atin the sametransaction as the state transition.
transition, rejecting transitions out of a terminal state —JobState::is_terminalalready says which those are.expire_stale_claims, returning expired claims toPendingandreturning how many moved.
winner and one
None, never the same job twice. This is the property thewhole design rests on and the one most likely to be subtly wrong.
Acceptance
cargo test --workspacecargo clippy --all-targets --all-features -- -D warningsclaim_nextcalls against a singlepending job return it exactly once.
by
expire_stale_claims, and that a job with a live lease is not.Out of scope
schedule. Stage 2.
tireless_core::budgetandis already implemented; do not reimplement any part of it here.
Done in
98f193d, together with #4.Two spec gaps, both only visible on contact
enqueue(&[DiscoveredIssue])cannot know aJobKind.DiscoveredIssuecarries labels but nothing says which mode they imply, and the trait takes no
protocol. The store now carries the
LabelProtocoland core gainedrouting::job_kind_for.That forced a decision the spec did not mention: what happens when an operator
applies several mode labels, which is easy to do by accident. Precedence is
discover, then plan, then implement — planning beats implementing because a plan
produces the implementation children, so running it first loses nothing, while
the reverse silently discards the decomposition that was also requested.
claim_next(worker, allowed_lanes)had no lane to filter on. The #2 schemastores kind and parent but not the routing result, and the labels that carry the
tireless/agent:*override are not stored at all — so deriving a lane at claimtime would mean a forge request per claim. Migration
0002_job_lane.sqladds alanecolumn, recorded at enqueue by a newrouting::lane_for, whichroutenow delegates to so the two cannot drift. There is a test asserting they agree
across every combination.
The consequence is that the lane is a cache of operator intent, so
refresh_laneexists for the case where someone addstireless/agent:octosomething already queued. Without it the override would only work if applied
before the poller first saw the issue.
The claim itself
One statement: a CTE takes the row lock with
FOR UPDATE SKIP LOCKED, theupdate writes the claim. Select and update share a transaction without anyone
managing one by hand.
Returning a job to
pendingclears the claim — not because the code remembersto, but because
pending_holds_no_claimfrom #2 refuses the half-done row. Thatis what makes lease expiry safe: a buggy release path fails loudly instead of
stranding an issue with a claim nobody holds.
renew_claimis extra, and needed: a run can outlast the 10-minute lease, andwithout renewal the sweeper would hand a still-running job to a second worker.
It is guarded on
claimed_by, so a worker cannot renew a claim it already lost.Verified against Postgres 18
Fifteen database tests, covering the things that are the database's to get right:
label without opt-in — the state a discovery proposal sits in); ignores closed
issues; and is a no-op on repeated polls, which matters because a repo polled
every five minutes would otherwise accumulate a job every five minutes;
agent:ocoverride, andreturns nothing when every lane is held;
rather than at completion;
visible in the attempt count;
history stays true (§4.3);
Enum values round-trip through serde rather than a hand-written
match, so theschema's check constraints and the Rust types are provably the same vocabulary.
One test from #2 replaced
exactly_one_migration_ships_todayfailed the moment a second migration waslegitimately added. A test that fails on correct behaviour teaches people to
edit the assertion rather than think, so it now asserts what it was reaching
for: versions unique and ascending, starting at 1.