textmachine/platform/internal/pgstore/queries/exports.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;