ADR-064 — A project's deadline: claims, leases and per-repository markers¶
Status¶
Accepted (2026-10-02; the per-repository deadline, the best-effort commit,
the archive rule and the reopen's un-archiving settled by the product owner
in conversation, the claims and leases by the orchestrator of the merge).
Implemented by merge task M3-05a: apps/api/src/modules/project/deadline.ts
(the rules, the reopen, a repository's own deadline, the staff's lock) and
modules/project/jobs.ts (the ticker's claims, the project.deadline
job); migration 0059_project_deadline. Tested by
modules/project/deadline.db.test.ts. Completes ADR-006 (as its merge
addendum carries it to projects), ADR-012 and D13 (amended the same day,
docs/merge/08-decisions.md).
Context¶
heig-classroom applied a deadline with a deadline.apply job per due
assignment, sent by its ticker as a pg-boss singleton, which re-read
locked_at and bot_commits per repository. In Quiz a singletonKey
without singletonSeconds dedupes nothing on a standard queue, and the
in-process queue of development did not dedupe the same way (#273): a
dedupe that holds in one of the two is worse than none. Quiz's ticker
also runs every second for the live evaluations; nothing in a tick may wait
for GitHub (invariant 5).
Three more facts shape the port. A deadline is no longer the project's alone: the staff may give one repository its own (D13 amended). A job may crash, or exhaust its retries, halfway through a hundred repositories, and must be resumable without doing anything twice (N-RES-08). And a deadline may move while its job runs: a reopen, an extension, a staff's unlock must win over a pass already under way.
Decision¶
- Everything is per repository, on its effective deadline
(
coalesce(project_repos.deadline_at, projects.deadline_at), andeffectiveDeadlineof@quiz/domain):deadline_applied_at(the provisional freeze written, auditedproject_repo.deadline_applied),frozen_at(definitive at the effective deadline + the grace, auditedproject_repo.frozen),deadline_committed_at(strategycommit). The project keeps only its owndeadline_applied_at, for its state (lockedat its deadline); itsfrozen_atis dropped (migration0059), the freeze being a repository's. A repository takes deadline work when it is provisioned, not deleted, and its project is not archived (LIVEand its twinisLive; #476). - The ticker claims, the job acts. The task
project.deadlines(every 20 s) runs conditional writes on the server's clock: publish the scheduled drafts (throughpublishProject, whose refusals keep an incomplete group project a draft), lock the projects due, write each repository's provisional and definitive freeze — all database-only, so they happen in the tick itself — and, with a queue only, take the lease of each project with GitHub work left and send its job. Without a queue the tick claims nothing: a job never runs in the ticker's process; a staff action then runs it in its request. - A lease, not a singleton.
projects.deadline_job_atis taken by oneUPDATE … WHERE deadline_job_at IS NULL OR deadline_job_at < now − 10 min … RETURNING, and the job carries it: a job whose lease is no longer the row's does nothing. The job renews it after each repository it settles (UPDATE … WHERE deadline_job_at = <held>), so a slow job is never taken over while it works, and stops as soon as a renewal finds the row holding another lease. It gives the lease back when every repository is settled. A failure backdates it (still conditional on holding it) tonow − 10 min + 30 s, so the next tick claims the work again some 30 s on — inside N-PERF-07's five minutes; a crash keeps it until it expires, ten minutes after its last renewal. The queue does not retry the job (a retry would carry a stale lease). A staff action (a reopen, an extension, a lock) asks for the same lease; when a job holds it, the next claim does the work. - Desired state against recorded fact. What GitHub should hold is the
staff's hand (
staff_lock, true or false) or else the deadline's lock (deadline_applied_atset, strategylock) —deadlineWantsLockof@quiz/domain,WANTS_LOCKits SQL twin for the scans; what it holds islocked_at(andarchived_at), written after each call as the fact it is, never conditioned on the desire. Work is any difference, or a deadline commit due. The job re-reads a repository's row before each step, so a reopen or an unlock landing while GitHub was locking is simply the next step: the lock just made is lifted. - Recorded before the move. A deadline commit is written to
bot_commits(deadline)before its ref moves, like the restore's (ADR-062 addendum); a branch whose head already is the deadline's commit gets none again. - A 404 is terminal (
markRepoDeleted,via: deadline). The archive stands for the lock only where rulesets cannot exist — a repository provisioned without its protection ruleset, or GitHub refusing one for the plan — never on a 5xx or a rate limit; it is audited once per repository (project_repo.archived) and counted as a lock in the job'sproject.deadline_enforced. - Moving a deadline requalifies the runs of the repositories it moves
(
receivedLate, the ingestion's rule) and reselects the score of those whose runs flipped or that reopened (reopensof@quiz/domain: applied and now ahead) — their markers, their frozen and review slots, theirdeadlinedispatches and the staff's hand cleared — in the moving transaction; the locks are lifted by the next job, never a hundred GitHub calls inside the request. A run being ingested reads both deadlines under a share lock, so a deadline moving at the same time either comes first or requalifies it.
Consequences¶
- No queue policy is relied upon: pg-boss and the in-process queue behave alike, and two jobs never settle one project at once.
- A crashed pass resumes on its own within ten minutes and repeats nothing.
- The staff's unlock after the deadline is never undone by a resumed pass, until the repository's deadline moves (F-PROJ-09).
- The tick stays database-only, with or without a queue; a test asserts that it makes no request.
- Without a queue (
JOBS_DISABLED=1), GitHub follows a deadline only when a staff action asks for the work.
Rejected alternatives¶
- One job per repository (heig-classroom's ADR-006 point 2): a hundred jobs per deadline for one lease each, and no single place to bound the concurrency GitHub's secondary limits ask for.
- Conditional marks only (record
locked_atonly if the lock is still wanted): a reopen between the call and the mark leaves GitHub locked and the row saying otherwise, which nothing would ever repair. - The reopen unlocking inside the PATCH (heig-classroom): a hundred calls in a request, and a failed unlock swallowed.
- A lease without renewal: a job longer than the lease would be overlapped by a second one.
Addendum (2026-10-02, merge task M3-05b): the review dispatches¶
The final review and the review checkpoints take the same shape, decided by the product owner (points 2 to 5) and the orchestrator (points 1, 6 and the review round's tightening of 2, 4 and 6):
- A lease of their own, one frame.
projects.dispatch_job_at(migration0060), taken, renewed, backdated and given back exactly asdeadline_job_at; both jobs run in ONE frame,runLeasedofmodules/project/lease.ts(the lease compared, the installation's client, the repositories four at a time with a renewal after each, a lost lease → stop, a failure → backdated lease and throw, else released). The ticker claims the dispatch lease, WITH A QUEUE ONLY, for each project with a final review or a checkpoint due, and sends oneproject.dispatchjob (retryLimit: 0); nothing in a tick calls GitHub, and without a queue no review is ever dispatched. - At most once. Each repository's dispatch is claimed in the
grade_dispatchesledger (ON CONFLICT DO NOTHING, the sha it sends recorded) in a transaction that re-reads the repository under its row lock — a reopen landing meanwhile wins, and forgets the repository'sdeadlinerows. A ledger row is NEVER sent again: one left withoutdispatched_at— a crash between the claim and the call, a request that got no response, or a 5xx, which GitHub may have acted on — is "not confirmed" for the staff (M3-08). Only a 4xx gives the claim back for the next pass (the lease backdated); a 404 marks the repository deleted. Octokit's retries are turned off for this one call. heig-classroom retried an unconfirmed row (at least once): a review costs the organization an LLM call and may overwrite the slot, so a duplicate is worse than a missing one, which the staff see. - Per repository, at its definitive freeze, at the frozen run's
commit with the repository's EFFECTIVE deadline in the payload; never
for a project graded
none, a repository without a frozen run, one archived as its lock (H8; never un-archived for a review) nor one whose protection is suspended (F-PROJ-08; its teacher's score settles it) — both auditedproject_repo.review_skippedwith theirreason, by the ticker at the freeze or by the deadline job archiving a repository already frozen.projects.review_dispatched_atis dropped: the ledger says it. - Only Quiz's final review counts: the review slot takes a
reviewrun only when itstriggering_actoris Quiz's App (none: a person's, failing closed), it is notto_verify, and it STARTED (run_started_at) at or after the repository'sfrozen_atand after itsdeadlineledger row was claimed — so neither a checkpoint's run nor one from before a reopen, delivered late, is taken for it. Theworkflow_runevent does not carry the dispatch'sclient_payload: a checkpoint's run started after the final review was asked would still pass. (5) leaves that only to a GitHub delay longer than the gap from the checkpoint to the deadline, plus the grace, plus one tick — an Actions incident, not a student's doing. The slot also compares GitHub's clock (run_started_at) with ours (frozen_at, the ledger'screated_at): a clock of ours running ahead of GitHub's by more than the dispatch's round trip would leave a real final review as a trace (negligible under NTP, visible as "no review" to the staff). The rules of this point and of the at-most-once 5xx case were decided by the orchestrator during the review of M3-05b (2026-10-02), within the product owner's choices: only Quiz's review counts, never twice, and a review on a tampered workflow (protection suspended) counts for nothing. - Checkpoints fire at their date while they lie before the project's
deadline (else void: never, but deletable;
checkpointFiresandcheckpointRefusalof@quiz/domain, their SQL twin only in the claim), to every live repository whose effective deadline has not come (CHECKPOINT_TARGET), on the last receipt before the date that no bot pushed; a checkpoint is marked dispatched once a pass met no refusal. A repository already at its deadline (even in its grace) gets none. Deleting one is refused once any ledger row names it; the deletion and the claim are ordered by the checkpoint's row lock.