save

open override fun save(run: ExtractionRun): ExtractionRun

Records a running run's header, inserting it under ExtractionRun.key or updating the one already there. This writes header fields only — it is not a door onto invocation state. recordInvocation is the only method that creates, updates or locks an invocation row; whatever ExtractionRun.invocations holds on run is not written anywhere and does not affect what a save accepts, rejects or replays as a no-op. A caller building run from a previous read does not need to strip that field, but nothing is lost either way if it does.

The lineage and the start time are fixed at insert. A later save that disagrees with either names a different run wearing the same id and is rejected. Everything else the header owns — the profile, the source revisions the run has read so far, the digests, the counts, the failures it has accumulated — may be filled in as the run proceeds, and an accepted update replaces the previous value for every one of those fields at once.

A header update is compare-and-set on ExtractionRun.version. Two callers can hold a run at once — one updating counts, one recording a source revision it just read — and whichever saves second must not silently put the header back the way it looked before the first save. save accepts an update only when run's version names the version currently stored; a first save must name 0, the version a run nobody has saved yet carries. When the named version does not match what is stored, the store has moved since run was read, and the write is rejected. A caller meeting that rejection reads the run again with findRun and rebuilds its update on the version that read returns. A save whose header content is already exactly what is stored replays as a no-op regardless of the version it names, so a retry that never learned its first attempt landed is never told it conflicted.

This was tried first as a field-by-field merge that let two saves each keep the parts of the header the other did not touch. A review round found the merge could not be made both correct and simple: independent count contributions from two writers cannot be recovered by keeping the larger number, because each may have counted disjoint work the other did not see; a union of source revisions loses the order they were read in, which is the field's own documented meaning; and a merge that combines fields from two different writers' saves can produce a header no writer ever actually held — a fingerprint from one save paired with a replay fidelity from another that the fingerprint does not support. A rejected stale write, retried against fresh state by a caller who has the domain knowledge to combine its own new work with what it reads, avoids all three.

Why invocation state moved to its own door entirely. An earlier version of this contract had save merge the invocation records run carried into the ones already stored, so a header update built on a run read before an attempt was recorded would not silently drop that attempt. That merge shared the header's version fence for its own conflict checks but not for its own staleness: recording an attempt never moved the header's version, so a save built on a header read well before a recordInvocation call landed could still name the current version and be accepted — carrying a stale, non-empty invocation snapshot in on the same write, silently replacing dispatch details that call had already filled in. Two independent writers each updating counts on a header read before the other's attempt was recorded could race the same way against each other. Versioning the header does not cover invocation rows, so the lost update survived the version check — which was the defect. Run state here follows the model lineage systems such as OpenLineage use: independent writers contribute rows, and no write rewrites a row another writer owns. recordInvocation insert-or-compares on the invocation's own key, so two attempts never contend on the header's version because neither is writing the header, and a header save can never be the stale write that erases one.

Return

The stored run, including any invocation records already recorded against it through recordInvocation. Its ExtractionRun.version is 0 after a first save (the version the caller named, now confirmed by the store), the stored version plus one after an accepted update, and the stored version unchanged when the save replayed as a no-op.

Parameters

run

The run to record. Must be ExtractionRunStatus.RUNNING and carry no ExtractionRun.finishedAt.

Throws

if run is not running, if it carries a finish time, or if it is a first save naming a version other than 0. A terminal status reaches the store only through transition, which is what keeps COMPLETED behind its precondition; a running run that has already finished is a record every page and audit meeting it has to guess about; ExtractionRun leaves status-and-timing pairing to this state machine, so this is where both checks live, alongside the version a run's first save must carry.

if a run is already stored under the same key and has ended, if it disagrees on lineage or start time (the tenant cannot disagree — it is half of ExtractionRun.key), or if run's version does not name the version currently stored.