108 lines
6.3 KiB
SQL
108 lines
6.3 KiB
SQL
-- 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;
|