Skip to content

Extraction runs: lifecycle machine and store contract - #98

Merged
jimador merged 7 commits into
jimador/feat/extraction-run-modelfrom
jimador/feat/extraction-run-store-contract
Sep 10, 2026
Merged

jimador merged 7 commits into
jimador/feat/extraction-run-modelfrom
jimador/feat/extraction-run-store-contract

Conversation

@jimador

@jimador jimador commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

PR 7 of the extraction-integrity train (refs #67), stacked on #95. The run lifecycle and store contract, in dice core. The state machine lives in one place: save accepts only RUNNING runs, and ExtractionRunTransition.applyTo under store compare-and-set is the only way a run reaches COMPLETED/FAILED/CANCELLED — nothing can manufacture a terminal run without a store. Idempotency has teeth: replaying the same terminal payload succeeds; an incompatible rewrite is rejected by insert-or-compare on a SHA-256 fingerprint of the transition payload, deterministic and golden-literal-locked. ExtractionRunStore gives tenant-scoped bounded pages (scope before limit), bounded cycle-safe chain walks, whole-lineage lookup in one read off the denormalized root, and invocation persistence that survives header saves. The cross-backend contract suite carries the discriminating cases — interleaved replay, lineage disagreement, the terminal-by-terminal matrix, a transition race, invocation preservation — so the Drivine store inherits the semantics.

Changed in this review round:

  • The terminal fingerprint hashes transition identity only — (status, finishedAt). Counts and failures travel as data on the transition and still participate in its equality, so Return typed product outcomes for batched and linked extraction passes #69's typed product outcomes change no persisted fingerprint format. TERMINAL_VERSION moved to xrun-terminal:v2 once, before any release, and the design doc states the choice.
  • Applied transitions are announced. ExtractionRunTransitioned is a new DiceEvent carrying the terminal run; the in-memory store takes a DiceEventListener collaborator and emits after the write lands, gated on the result being applied. Nothing is wired automatically — the audit projection and host listeners have their subscription point.
  • Header writes carry a version under compare-and-set. A stale save used to rebuild every header field, so it could erase revisions, counts, failures, profile or fingerprints another writer had stored; it is now rejected. Terminal invocation records are locked the same way: an identical replay succeeds, an incompatible rewrite is refused on the fingerprint.
  • ProtectedContentRef is deferred out: DICE had no writer, reader, or retention behavior for it; it returns with the first runtime path that uses it. The doc states the reference as it now stands.

Breaking changes: none. New types only; the header-version parameter's binary consequences are enumerated in the CHANGELOG (which descriptors it preserves, which it moves, and what happens to a caller that never passes one).

Opt-in and status: EXPERIMENTAL, @ApiStatus.Experimental on every type. Nothing calls the store yet; the coordinator is the first real caller and is sequenced next.

stateDiagram-v2
    [*] --> RUNNING: save accepts RUNNING only
    RUNNING --> RUNNING: save under versioned compare-and-set
    RUNNING --> SUCCEEDED: transition
    RUNNING --> FAILED: transition
    RUNNING --> CANCELLED: transition
    SUCCEEDED --> SUCCEEDED: identical replay succeeds
    FAILED --> FAILED: identical replay succeeds
    CANCELLED --> CANCELLED: identical replay succeeds
Loading

Two write doors, and what each promises:

flowchart TD
    A[save: whole header] --> V{version matches stored?}
    V -- no --> R[rejected, stored row untouched]
    V -- yes --> W[header replaced, invocations merged by identity]
    B[recordInvocation: one child row] --> T{record already terminal?}
    T -- yes --> F{payload fingerprint identical?}
    F -- yes --> K[accepted as replay, nothing moves]
    F -- no --> R2[rejected]
    T -- no --> M[child row inserted or compared]
    W --> E[applied terminal transition emits ExtractionRunTransitioned]
Loading

Closed value sets are exercised by iterating their own entries, so a newly declared constant is covered the day it is added.

  • ExtractionRunStore.save states the returned version for each kind of save. A first save returns version 0 (the version the caller named, now confirmed by the store), an accepted update the stored version plus one, and a replay the stored version unchanged. The KDoc used to say a first save returned the caller's version plus one. The contract test pins all three.

@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from b6aeb70 to b53964b Compare September 1, 2026 04:20
@jimador
jimador marked this pull request as ready for review September 1, 2026 04:20
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from b53964b to f41b38f Compare September 1, 2026 14:07
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from f41b38f to a6dd906 Compare September 2, 2026 10:51
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from a6dd906 to 8104365 Compare September 2, 2026 14:11
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 8104365 to 955445c Compare September 2, 2026 15:07
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 955445c to 9259552 Compare September 2, 2026 15:24
@jimador
jimador requested a review from igordayen September 2, 2026 20:41
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 9568f77 to 549dc26 Compare September 2, 2026 20:50
Comment thread dice/src/main/kotlin/com/embabel/dice/common/DiceEvent.kt
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch 2 times, most recently from d644394 to e96f40c Compare September 8, 2026 20:30
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from e96f40c to 0c5b866 Compare September 8, 2026 20:46
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 0c5b866 to 739efc7 Compare September 8, 2026 21:07
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 739efc7 to d097c95 Compare September 8, 2026 21:14
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from d097c95 to 1328018 Compare September 9, 2026 00:02
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 1328018 to 5f6e5eb Compare September 9, 2026 01:13
@igordayen

Copy link
Copy Markdown

from CODEX:

Findings:

  • P2 InMemoryExtractionRunStore retains every run forever
    dice/src/main/kotlin/com/embabel/dice/proposition/extraction/InMemoryExtractionRunStore.kt:79-82 keeps all run headers and terminal
    fingerprints in maps, and ExtractionRunStore exposes no delete/prune/retention API. Bounded reads only bound the returned page, not
    stored memory.
    Example: a host using the shipped in-memory store for 50k extractions/day keeps every completed run and fingerprint until JVM
    restart. Either add explicit retention/pruning support, cap the in-memory implementation, or make it clearly dev/test-only so a
    long-running host cannot accidentally choose an unbounded audit store.

  • P2 reads become global stop-the-world scans as the store grows
    InMemoryExtractionRunStore.page() filters and sorts runs.values while holding the single monitor (:260-267). runsInContext,
    childrenOf, and runsOfRoot all use that path. The API returns at most limit, but the work is still O(total stored runs), across all
    tenants, under the same lock used by save, recordInvocation, and transition.
    Example: after millions of historical runs, runsInContext("tenant-a", 10, since = null) scans/sorts the whole map and blocks all
    writers while it does so. Tenant-indexed maps or per-query indexes would keep the reference store’s thread-safety claim from
    degrading under retention.

  • P3 listener failures can make a committed transition look failed to the caller
    transition() commits the terminal run, releases the lock, then calls listener.onEvent(...) without catching
    (InMemoryExtractionRunStore.kt:178-203). If the listener throws, the run is already terminal, but the caller sees an exception;
    retrying the same transition returns REPLAYED and emits no event.
    SafeDiceEventListener exists, but the store does not wrap the collaborator itself. If “transition succeeded” must not depend on
    listener quality, wrap internally or document this as a hard contract on callers.

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - would you please see few inquiries from myself and CODEX. Do we need to consider eviction policy to control memory growth?
Thanks

@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 5f6e5eb to 84cbd60 Compare September 9, 2026 21:57
@jimador

jimador commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

All three addressed.

  • Retention: InMemoryExtractionRunStore takes maxRuns (default 10_000) and evicts the oldest ended runs past it, never a running one; the class doc says what a host running the reference store in production is accepting. e5be31b.
  • Reads: each tenant's runs sit in their own index, so a page reads that tenant only; filter, order, limit is unchanged. b11703a.
  • Listener: a throwing listener is logged at error and the transition stays committed and reports APPLIED. 84cbd60.

On your eviction question: yes for the reference store, and the cap above is it. For the Drivine store, retention past that is the durable store's own policy, kept for as long as an operator decides, and a sweep on finishedAt would be a separate slice; the design doc now says so.

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - looks good subject to rebasing, thanks

@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 84cbd60 to 9828921 Compare September 10, 2026 02:02
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 9828921 to 1060a1c Compare September 10, 2026 02:10
The state machine lives in one place: save accepts only RUNNING runs, and
ExtractionRunTransition applied by the store under compare-and-set is the
only way a run reaches a terminal status. Replaying the same terminal
payload succeeds; an incompatible rewrite is rejected by insert-or-compare
on a fingerprint of the transition payload alone, so a replay survives an
interleaved invocation record while a materially different retry cannot
pass as one. Every scoped read is bounded and scopes before it limits;
lineage walks are bounded and cycle-safe, and all runs of a root resolve in
one read off the denormalized ref.

Each kind of state has exactly one owner. Header writes carry a version and
take it under compare-and-set, and they write header fields only: whatever
invocation list a caller hands to save is carried through untouched, and an
insert starts with no invocation rows at all. recordInvocation is the only
door onto invocation state, insert-or-compare on the invocation's own key.
A header save therefore cannot create, update or delete an invocation row,
and a stale header carrying an old snapshot leaves a newer stored record
alone. Run state accumulates from independent writers, the way a lineage
event stream does; one writer's row is never another writer's to rewrite.

The cross-backend contract suite carries the discriminating cases, so the
Drivine store inherits these semantics with nothing left to remember:
interleaved replay, lineage disagreement, the terminal matrix, transition
races that prove which write landed, ownership of invocation rows across
both doors, and concurrent writers on separate attempts both landing. Every
field of a run and of an invocation record is pinned through the door that
writes it, including clearing a stored value back to null. Terminal records
are locked alike whatever outcome they carry, through recordInvocation.
Closed value sets are exercised by iterating their own entries, so a newly
declared constant is covered the day it is added.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The terminal fingerprint covered counts and failures, so any change to
their shape would have changed a persisted format; it now hashes status
and finishedAt alone under xrun-terminal:v2, and counts and failures
ride as data. A typed product outcome in a later slice changes no
stored byte. The store announces an applied transition through
ExtractionRunTransitioned, emitted once where the transition is
accepted: a replay announces nothing and a rejected write announces
nothing, pinned by contract. The suite carries the reshaped failure
vocabulary throughout.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The absent-by-decision list still said no reference type exists, while
the sections above it describe the shipped specification. The bullet
now records both facts: the first cut was removed for having no runtime
path, and the interface returned as a written contract the host
implements.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The KDoc on ExtractionRunStore.save said a first save returned the version
the caller named plus one. It returns 0: the version the caller named, now
confirmed by the store. An accepted update returns the stored version plus
one, and a replay returns the stored version unchanged. The contract test now
pins all three.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
InMemoryExtractionRunStore kept every run and every terminal fingerprint
forever, which is unbounded retention for a store meant as a reference and
sometimes a bridge before a host has a database.

Add a maxRuns constructor parameter, defaulting to 10,000, as the last
parameter so the existing Java descriptors survive. When an insert would
push the store past the cap, evict the oldest ended runs by startedAt
until it fits again, dropping each evicted run's terminal fingerprint with
it. A run still RUNNING is never evicted, so a store where every run
happens to be running can grow past the cap; that breach logs once at
warn, not on every insert.

Say plainly in the class KDoc, and in the ExtractionRunStore contract
KDoc, that retention past the cap is the reference store's own policy,
not something the contract promises, and that a host running it in
production is accepting that runs older than the cap are gone.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
page() filtered and sorted runs.values while holding the monitor, so every
scoped read was O(all tenants' runs), and a busy neighbour tenant paid the
cost of every other tenant's page too.

Keep a per-tenant index, contextId to that tenant's own run keys in
insertion order, maintained in exactly two places: an insert adds a key,
and eviction removes one. A run's value is always looked up fresh in the
main map, so nothing has to touch the index when a header is saved again,
a run transitions, or an invocation is recorded against it.

runsInContext, childrenOf and runsOfRoot now iterate only their own
tenant's candidates before filtering, ordering and limiting; the rule
that scope comes before the limit is unchanged, only the candidate set
a page starts from shrinks.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
transition announced the terminal run outside the monitor and let the
listener's exception reach the caller after the write had landed. A
caller retrying on that exception got REPLAYED and no event either time.
The announcement is now caught: the failure is logged at error with the
run's key and the result is returned as applied, because the store's job
ended when the write did. The design note and the changelog say so.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
@jimador
jimador force-pushed the jimador/feat/extraction-run-store-contract branch from 1060a1c to 68c5f8a Compare September 10, 2026 02:17
@jimador
jimador merged commit 0dc362d into main Sep 10, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants