Drivine Extraction Run Store
Drivine / Neo4j implementation of ExtractionRunStore.
The graph
(:ExtractionRun {contextId, runId, ...})— the run header, one node per run per tenant.(:ExtractionRun)-[:RECORDED]->(:ExtractionRunInvocation {contextId, runId, invocationIndex, attempt, ...})— one child node per attempt at one planned model call.(:ExtractionRun)-[:ENDED_BY]->(:ExtractionRunTerminalWrite {contextId, runId, fingerprint, ...})— at most one per run, ever, and the thing that makes that "at most one" true.
Every key is tenant-qualified because a run id is host-minted and DICE never assumes it is globally unique. Unlike DrivineDriftReportStore, the tenant needs no ctx:-prefixed stand-in: that store's scope is nullable, and a Cypher MERGE cannot key on a null, so a global report needed a non-null encoding that no real context id could collide with. A run's tenant is never null, so the plain value is already an injective key.
Every statement is parameterized; nothing caller-derived is interpolated into Cypher. The statements are assembled only from this class's own literals.
See ExtractionRunSchema for the constraints and indexes this depends on. They are not tuning: a MERGE on a natural key is race-free only under a uniqueness constraint on that key.
Compare-and-set: why a terminal write cannot be applied twice
The whole of transition is one Cypher statement, so it is one transaction, and it works two ways at once — a lock that makes the race rare and a constraint that makes the wrong answer impossible.
The lock. The statement's first act after matching the run is SET n.casLock = $lockToken, before it reads anything. A property write takes an exclusive lock on the node and holds it until commit, so a second transaction reaching that line blocks until the first commits. Neo4j reads at read-committed, and the read of n.status is downstream of the SET in the same statement, so the second transaction reads the status after it acquired the lock and therefore after the first transaction committed. It sees a terminal run and takes the no-op branch. This is the same write-lock-before-read idiom DrivineMetamodelVersionStore uses on its counter.
Two details make that argument hold rather than nearly hold. The lock token is a fresh UUID on every call, so the write is always a real change and never a no-op a future Neo4j might optimize away before taking the lock. And no index carries status or the terminal fingerprint — see ExtractionRunSchema — so the planner cannot serve the post-lock read from an index entry it read at MATCH time, which is the one way a lock-then-read can quietly read stale.
The constraint. The argument above is about Neo4j's behaviour, and behaviour is a thing to be wrong about. So the terminal write is also a CREATE of an (:ExtractionRunTerminalWrite) node on the run's own key, under a uniqueness constraint on (contextId, runId). If two transactions ever did both read a run as RUNNING — a Neo4j build that locks differently, a cluster, a planner that reorders in a way this KDoc did not anticipate — they would both try to create that node and the database would refuse the second one at commit. The loser's whole transaction rolls back, header included, and this store catches the violation, re-reads the recorded fingerprint in a fresh transaction, and returns the replay-or-conflict answer the contract asks for. Exactly one terminal write per run is a schema fact, not an inference.
That is the multi-process half of the guarantee. Threads in one JVM could be serialized by a monitor; two processes cannot be, and nothing in this class holds mutable state to serialize on. Both halves come entirely from the database.
The fingerprint is stored, never re-derived. The node carries the exact string ExtractionRunTransition.fingerprint computed, and a repeated terminal write is decided by comparing its fingerprint against that string. Re-deriving a digest from the stored run would reject a correct retry whenever an attempt had been recorded in between, because the run it derived from would have changed while the terminal write did not.
A header write never touches a child row
Invocation records are their own nodes on their own key, and save's Cypher names none of them: it reads and writes (:ExtractionRun) alone. recordInvocation is the only method that ever names an (:ExtractionRunInvocation) node, so it is the only one that can create, update, or lock one. Whatever run.invocations a caller hands save plays no part in what it accepts, rejects, or persists.
Validation happens before any node is created
A MERGE on a node pattern creates the node the moment it runs, whether or not the write that follows turns out to be valid — a naive MERGE immediately followed by a Kotlin check that throws leaves the created node behind if whatever catches that exception does not also abort the transaction. SAVE_RUN and RECORD_INVOCATION both avoid this: an OPTIONAL MATCH reads whatever is already there, a CASE decides in Cypher whether the write should happen, and the MERGE that can create a node sits inside a FOREACH gated on that decision. A save naming version 0 for a run already stored at a later version, or a recordInvocation call against a run that has already ended, stops before reaching any MERGE at all, so a caller that catches the resulting ExtractionRunConflictException without rolling back its own transaction finds the graph exactly as it was.
Scope is applied in the query, ahead of the limit
Each page puts its tenant in the MATCH pattern and its LIMIT after the ORDER BY. Reading a limited page and filtering it in Kotlin would apply the limit first, so a tenant whose neighbour owns the head of the index would report no runs while the store held plenty. This is DrivineDriftReportStore's rule carried over, and the cross-backend suite pins it.
Every read returns each run with its invocation records attached, pages included, because the in-memory reference does and the two are held to one suite. The cost is bounded — at most limit runs times the run model's cap of 1024 attempts — but it is a real cost on a wide page.
A run that ends announces itself once
transition hands an ExtractionRunTransitioned to listener for the call that ended the run, and for no other call: a replay, a rejected write, a save and a recordInvocation all announce nothing. Exactly one call per run reaches the applied branch, because reaching it means having created the run's terminal-write node, and the uniqueness constraint lets one transaction do that.
The announcement waits for the write to be durable. When this store owns the transaction, that point is where the template returns, and the listener runs there. When a caller's transaction is active the write is durable when that caller commits, so the announcement is registered against the commit and never happens at all if the caller rolls back — a listener told a run ended by a transaction that was thrown away would be reporting a run nothing can read.
Parameters
Drivine's handle on the neo datasource.
used to own a transaction when no caller has one, so a lost compare-and-set race can be recovered in a transaction the race did not already poison.
supplies the instant a terminal write is recorded at. Informational, and injectable so a test can pin it; nothing sorts or compares on it.
Notified when a run ends. Defaults to DiceEventListener.DEV_NULL, so a host that has nothing listening constructs the store the way it always did. Handlers run inline on whichever thread the announcement happens on, and throw isolation belongs to the listener — wrap it in SafeDiceEventListener for graceful degradation.
Constructors
Functions
Walks the parent chain upward, one keyed lookup per hop, at most limit hops.
childrenOf for Kotlin callers holding typed references.
The runs whose immediate parent is parentRunId — one hop down the parent axis, in one tenant.
The run stored under key, or null.
Every attempt recorded against the run, in plan order: call 0 before call 1, and within a call, first attempt before second.
Records one attempt against a running run, on its own child node.
One tenant's runs, newest first.
runsOfRoot for Kotlin callers holding typed references.
Every run in one lineage: those whose ExtractionRunLineage.rootRunRef is rootRunId, including the root itself.
Records a running run, inserting it or updating the one already there — in one statement, so the check against the stored run and the write that follows it cannot be interleaved by a concurrent terminal write, and the header's compare-and-set on com.embabel.dice.proposition.extraction.ExtractionRun.version cannot be interleaved by a concurrent header save either.
Ends a run under compare-and-set. See this class's KDoc for why the answer is a schema fact rather than a claim about timing.