Expand description
SqliteRunStore — SQLite-backed RunStore using [rusqlite-isle].
The Connection is confined to a dedicated OS thread by AsyncIsle;
every call is a typed closure dispatched over a bounded channel.
step_entries, degradations, and result_ref are stored as JSON
blobs — the former two are pure trace/observability artifacts (not
queried relationally), the latter is caller-defined payload shape.
append_step_entry/append_degradation run as a read-modify-write
inside a single transaction so concurrent appenders don’t clobber each
other’s entries.
§Schema
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
status TEXT NOT NULL, -- JSON-encoded `RunStatus`
step_entries_json TEXT NOT NULL, -- JSON-encoded `Vec<StepEntry>`
degradations_json TEXT NOT NULL DEFAULT '[]', -- JSON-encoded `Vec<DegradationEntry>` (GH #32)
operator_sid TEXT,
current_json TEXT, -- JSON object: slot -> `Assignee`, NULL when no slot is held
next_generation INTEGER NOT NULL DEFAULT 0, -- the model's `G`
result_ref_json TEXT, -- JSON-encoded `serde_json::Value`, NULL when unset
input_json TEXT, -- opaque launch-input snapshot for resume, NULL when unset
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);degradations_json (GH #32), input_json (the resume launch-input
snapshot) and the assignment pair current_json / next_generation
were all added after the initial release; each migration is applied
idempotently on open via a PRAGMA table_info(runs) existence check
followed by the matching ALTER TABLE runs ADD COLUMN … when missing,
so pre-existing database files pick up the columns without a manual
migration step. input_json and current_json are nullable TEXT (no
default) — rows written before those features read back None
(current_json NULL = no slot held); next_generation carries
DEFAULT 0 so a back-filled row starts at the launch value of G.
current_json holds the whole slot -> Assignee map as one JSON
object, not one row per slot: the map is read and rewritten whole on
every assignment event anyway (the event has to bump the sibling
next_generation in the same transaction), and nothing queries a Run
by who holds one of its slots. A map that has gone empty is stored
back as SQL NULL, so “no slot held” has exactly one representation on
disk.
acquire_assignee/vacate_assignee bump next_generation and rewrite
current_json as a read-modify-write inside one Immediate
transaction — the same shape as append_step_entry, and for the same
reason: the increment-and-stamp spans two columns, which a conditional
UPDATE (the try_transition compare-and-set) cannot express. Two
acquires naming different slots take the same path, so the map merge
is serialized too and neither can drop the other’s entry.
The two events have separate bodies rather than one parameterized one,
because they no longer share a shape: an Assign always writes
(A8 — no precondition on the incumbent), while a Vacant first
compares the seat’s generation against the one its caller observed and
writes nothing when they differ. Putting the comparison inside the same
transaction as the removal is the whole point of the verb — see
VacateOutcome — so it cannot be hoisted into a shared prologue.
Structs§
- Sqlite
RunStore - SQLite-backed persistent
RunStore.