-- The storage half of the export door (canon §createExport/§getExport). -- -- Every read joins `books.owner_id` rather than carrying an owner of its own: an export belongs to -- whoever the book belongs to, and one carrier of that fact is what keeps an authorization check -- from disagreeing with itself (API1 BOLA). -- name: InsertExport :exec insert into exports (id, book_id, format, state, requested_at) values (sqlc.arg(id), sqlc.arg(book_id), sqlc.arg(format), 'pending', sqlc.arg(requested_at)); -- name: ReadExportForOwner :one -- The book's revision travels with the row because the canon stamps every book-scoped response -- with it, and an export read is one — read in the SAME statement so the two cannot be a -- transaction apart. select e.id, e.book_id, e.format, e.state, e.path, e.size_bytes, e.failure_code, e.complete, e.requested_at, e.finished_at, e.expires_at, b.revision from exports e join books b on b.id = e.book_id where e.id = sqlc.arg(id) and e.book_id = sqlc.arg(book_id) and b.owner_id = sqlc.arg(owner_id); -- name: StampExportStart :execrows -- The worker says it has the build, and this is also what makes the claim EXCLUSIVE. -- -- Two guards, and each answers a different way of being wrong. `state = 'pending'` refuses a row the -- stale sweep has already ended — zero rows means the verdict is in and this worker must not build. -- `started_at is null` refuses a SECOND worker on a row the first is still building: without it both -- would build to the SAME path (it is derived from the export's id), and the loser's cleanup would -- delete the file the winner had just published. Unreachable while River works one job of a kind at -- a time; the day a second replica is deployed it is the whole difference between a duplicate build -- and a deleted artifact. update exports set started_at = sqlc.arg(now) where id = sqlc.arg(id) and state = 'pending' and started_at is null; -- name: ReadExportForBuild :one -- The worker's read: no owner, because the worker is not a caller — it was handed an id by the -- door that already checked ownership, and a job the queue re-drives after a restart has no -- session behind it at all. -- -- ⚠ It asks whether the book HAS A CHAPTER TREE, and the question moved here from the door on -- 04.09 by ratification: the canon forbids the door to refuse by a book's state («Nothing about a -- book's state conflicts with exporting it»), so the request is always accepted and the answer is -- given as the export's own outcome. The worker needs the fact because a book nobody has cut has -- nothing to build from and must NOT be sent to the engine — see exports.Service.Build. select e.id, e.book_id, e.format, e.state, b.workdir, exists (select 1 from chapters c where c.book_id = b.id) as has_tree from exports e join books b on b.id = e.book_id where e.id = sqlc.arg(id); -- name: FinishExport :execrows -- `state = 'pending'` in the WHERE is the whole of the worker's write safety: a build that comes -- back after its export was already failed by the stale sweep must not resurrect the row and -- publish a link the poll has already reported as finished. update exports set state = 'ready', path = sqlc.arg(path), size_bytes = sqlc.arg(size_bytes), complete = sqlc.arg(complete), finished_at = sqlc.arg(finished_at), expires_at = sqlc.arg(expires_at) where id = sqlc.arg(id) and state = 'pending'; -- name: FailExport :execrows update exports set state = 'failed', failure_code = sqlc.arg(failure_code), finished_at = sqlc.arg(finished_at) where id = sqlc.arg(id) and state = 'pending'; -- name: ExpireReadyExports :many -- The GC's first question: which links have lapsed. The ROW moves here and nothing else does — a -- link must stop working at the moment it was promised to, and that is a statement the row makes. -- -- ⚠ `path` is KEPT, not cleared, and that is the difference between a leak and a retry. Clearing it -- in the same breath as the state made the unlink a ONE-SHOT act: a removal that failed — a full -- disk, a read-only mount, a crash between the two — left a file that nothing could ever name -- again, because every other read of this table selects `ready`. The path now outlives the state -- change and `ForgetExportPath` takes it away once the bytes are actually gone (`UnlinkedExports`). update exports set state = 'expired' where id in (select e.id from exports e where e.state = 'ready' and e.expires_at <= sqlc.arg(now) order by e.expires_at limit sqlc.arg(lim)) returning id; -- name: UnlinkedExports :many -- Artifacts whose row has lapsed and whose bytes are still there. The retry of an unlink that did -- not happen — see ExpireReadyExports for why the path outlives the state. select id, path from exports where state = 'expired' and path is not null order by finished_at limit sqlc.arg(lim); -- name: ForgetExportPath :exec -- The bytes are gone; the row stops naming them. Only after the unlink, or the retry above would -- have nothing to find. update exports set path = null where id = sqlc.arg(id); -- name: FailStalePendingExports :many -- The GC's second question: which builds nobody is coming back for. The queue does not retry this -- kind (jobs.ExportArgs), so nothing else would ever end these polls — and a poll that never ends is -- the one thing the canon forbids of this resource. -- -- ⚠ TWO CLOCKS, not one, and the second one was an adversarial pass's finding. A row a worker HAS -- picked up is judged from `started_at`: silence longer than one job's whole life means its process -- is gone. A row still IN THE QUEUE is judged from `requested_at` against a much longer grace, -- because one queue serves spawns, parses and builds — four parses ahead of it are a wait, not a -- fault, and burying such a build tells its user it was interrupted when it had not begun. The long -- grace still ends the poll, which is what the canon actually requires. update exports set state = 'failed', failure_code = sqlc.arg(failure_code), finished_at = sqlc.arg(now) where id in (select e.id from exports e where e.state = 'pending' and ((e.started_at is not null and e.started_at <= sqlc.arg(started_cutoff)) or (e.started_at is null and e.requested_at <= sqlc.arg(queued_cutoff))) order by e.requested_at limit sqlc.arg(lim)) returning id;