feat(items): execution lease — atomic claim/checkout with expiry (#1221) - #1238
Draft
b4rk13 wants to merge 1 commit into
Draft
feat(items): execution lease — atomic claim/checkout with expiry (#1221)#1238b4rk13 wants to merge 1 commit into
b4rk13 wants to merge 1 commit into
Conversation
…petualSoftware#1221) Two pollers that both read 'unclaimed' could both proceed; whatever they write next, the last writer wins silently. The lease makes 'someone is executing this right now' first-class and time-bounded, acquired by a conditional UPDATE whose predicate is the arbiter — the same protocol the event-outbox claim (TASK-2714) and orphan GC (BUG-2415) established. - pad item claim <ref> [--holder <id>] [--ttl <dur>]: succeeds iff unclaimed, expired, or already held by the caller (a holder re-claim refreshes expiry and keeps acquired_at — heartbeat). Contention answers 409 code=lease_held naming the live holder and expiry, the same envelope discipline as update_conflict. - pad item release <ref>: idempotent (absent/expired = no-op); releasing another holder's LIVE lease is refused. - Expiry is the reaper: an expired lease reads as absent everywhere; no sweep job. TTL default 15m, max 24h, per-call ttl_seconds. - Holder defaults to the authenticated identity (email) — the PerpetualSoftware#879 tie-in; freeform string until named profiles land. - Display: 'Lease:' line under Assigned: on item show; a cyan marker on leased list rows (no new column — it would tax every row's title budget for a usually-absent state); full lease object in JSON, key omitted when absent. - Deliberately NOT wired: updated_at bump, version entry, SSE/activity event, MCP catalog exposure — a lease is coordination state, not content, and a claim must never 409 a concurrent editor's expected_updated_at token. - NUL invariant: items.lease_holder is caller text — added to the census and the regenerated 084 triggers; the lease timestamps are server-composed RFC3339 and recorded as exclusions. The new body reader goes through decodeJSON and is accounted for in the ledger.
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.
What does this PR do?
Draft PR for #1221, built to the shape you approved and the two decisions pinned in the thread (default TTL 15 minutes;
Lease:line onshow/ glyph marker onlist/ full object in JSON). Relates to #1221 — leaving close-on-merge to you.The primitive.
POST .../items/{ref}/claimand.../release, withpad item claim <ref> [--holder <id>] [--ttl <dur>]/pad item release <ref>in front. The claim is a conditional UPDATE whose predicate is the arbiter — deliberately the same protocol as the event-outbox claim (migration 083 / TASK-2714) and BUG-2415's orphan GC, dialect-uniform, noFOR UPDATE SKIP LOCKEDspecial case. A store-level test races 8 goroutines and asserts exactly one winner.409 code=lease_heldnaming the live holder,acquired_at, andexpires_at— same envelope discipline asupdate_conflict, so a loser can log who won and skip without a second call.acquired_at(heartbeat) — so long runs extend instead of everyone inflating the default TTL.released:false, exit 0); releasing another holder's live lease is refused with the same 409.ttl_seconds, refused outside (0, 24h].Display, per the thread:
Lease:line underAssigned:inPrintItemMeta(absent when none); leasedlistrows get a cyan»on the ref/title cell via the existing pinned-*pattern — no new column, sincerenderItemTable's width budget would tax every row's title for a usually-absent state;"lease": {holder, acquired_at, expires_at}in JSON on get and list, key omitted when absent (one point-read on GET, one workspace query on list — the shared item scan's column lists are untouched).Deliberate non-wirings, each open to your call:
updated_atbump, no version entry — a lease is coordination state, not content; a claim must never 409 a concurrent editor'sexpected_updated_attoken.Itemtype gained the optionalleasefield, but no component consumes it yet.Your codebase's own guards caught and shaped three things, which was pleasing to watch:
TestNULColumnCensusdemanded a ruling on the three new columns (lease_holderis caller text → census entry + regenerated 084 triggers; the two timestamps are server-composed RFC3339 → recorded exclusions with reasons),TestEveryRequestBodyReaderIsAccountedFordemanded the newdecodeJSONreader be ledgered (it is — same shape ashandlers_watches.go), andTestScanCostFiguresMatchTheListkeptScanNUL's cost figure honest (131→132).Postgres note:
pgmigrations/062_item_lease.sqlmirrors the SQLite migration; I don't have a PG instance wired locally, so the PG path is written to the dialect-uniform pattern but only SQLite-tested here.How to test
pad item claim TASK-5 --holder runner-a→ lease with holder + expiry. Terminal B:pad item claim TASK-5 --holder runner-b→ exit non-zero, error names runner-a and the expiry.pad item show TASK-5→Lease: runner-a (expires in 14m)underAssigned:;pad item listmarks the row with»;--format jsoncarries the lease object.pad item release TASK-5 --holder runner-a→ released; re-run → "No live lease" no-op, exit 0. B claims successfully now.pad item claim TASK-5 --ttl 5s, wait 6s, claim as someone else → succeeds with no reaper.Checklist
go build ./...passesgo test ./internal/store/ ./internal/server/ ./internal/cli/ ./cmd/pad/— zero new failures by name vs a clean-mainbaseline run on the same Windows box (server and the new suites fully green; the pre-existing Windows HOME-assumption set unchanged)undefined:compile errors); the display tests were written after their code, so I verified they bite by deliberately breaking the renderer and watching them fail before revertingItemLease+ optionalItem.leasepad item claim/releaserows)