Run index database
The run index is a small lookup database owned by the proxy process (apps/api). It exists because Restate holds the durable state of every workflow execution (journal, suspension, resumption) but does not expose a list of workflows to the ingress. The proxy therefore keeps its own index of the runs it has started, keyed by eventId (the same string used as the Restate workflow key).
Source: apps/api/src/server/run-store.ts. Only the proxy process opens this store; the endpoint process never reads RUN_DB_URL.
Losing or rebuilding the run index never loses a run: the authoritative state stays in Restate. What is lost is the list surfaced through GET /api/runs. See explanation/why-durable-execution.md for the durable-execution model this sits in front of.
For provisioning steps, see how-to/use-postgres-run-index.md and how-to/back-up-and-restore.md. For the full environment-variable list, see reference/environment-variables.md.
Backend selection
The backend is chosen at boot by openRunStore(url), called with no argument from apps/api/src/server/api.ts (const runStore = await openRunStore();), which defaults to process.env.RUN_DB_URL.
RUN_DB_URL prefix |
Backend | Module loaded |
|---|---|---|
sqlite:<path> |
SQLite file at <path> |
better-sqlite3 |
postgres://… |
PostgreSQL | pg |
postgresql://… |
PostgreSQL | pg |
| (unset) | SQLite, path .data/runs.sqlite |
better-sqlite3 |
| anything else | — | throws RUN_DB_URL non reconnu : <url> (attendu sqlite:<chemin> ou postgres://…) |
The default constant is DEFAULT_URL = "sqlite:.data/runs.sqlite", resolved relative to the process working directory. Both backend modules are loaded with a dynamic import(), so only one of better-sqlite3 / pg is actually required at runtime depending on the selected URL.
In the bundled docker-compose.yml, the proxy service sets RUN_DB_URL: ${RUN_DB_URL:-sqlite:/data/runs.sqlite}, with /data mapped to the proxy-data volume. The pg compose profile adds a postgres service and a pg-data volume.
Schema
Both backends create the same single table, runs, with the identical DDL text (DDL_POSTGRES is literally DDL_SQLITE, since every column type used is valid in both engines):
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
service TEXT NOT NULL,
workflow_name TEXT NOT NULL,
event TEXT NOT NULL,
created_at TEXT NOT NULL,
final_status TEXT
)
Columns
| Column | Type | Nullable | Contents |
|---|---|---|---|
id |
TEXT |
no (PK) | The triggering event's eventId; also the Restate workflow key. |
service |
TEXT |
no | The Restate service name the run was dispatched to. |
workflow_name |
TEXT |
no | spec.name of the workflow that handled the event. |
event |
TEXT |
no | The full triggering event, JSON-serialized (JSON.stringify / JSON.parse); typed as EventLike from @meridian/engine-core/engine/interpreter. |
created_at |
TEXT |
no | ISO-8601 timestamp, set once at insert. |
updated_at |
TEXT |
yes | Last activity: insert, then the freeze of a terminal status. Read back as created_at for rows predating the column. |
workflow_file |
TEXT |
yes | Spec file that started the run — the stable identity (a workflow can be renamed, and two specs can share a name), and what the run's graph view resolves against. |
workflow_slug |
TEXT |
yes | Stable slug of the workflow definition (run ↔ version traceability). |
spec_version |
INTEGER |
yes | Definition-store version active when the run started. |
spec_hash |
TEXT |
yes | Content hash of that spec (matches the journaled copy). |
subjects |
TEXT |
yes | JSON map of the run's subjects, one entry per registered context kind whose id field is present in the event ({"patient": "p-42"}). The store treats it as opaque. |
final_status |
TEXT |
yes | "completed", "failed", or NULL. See Final-status cache. |
Schema migrations
An index created before the subjects column (it carried a NOT NULL patient column)
is not migrated: drop it (rm ~/.meridian/runs.sqlite, or DROP TABLE runs) and it is
recreated at boot — within Restate's retention window the journal is the source of truth,
so a dev index is disposable. Columns added after the current baseline are applied
additively every time the store is opened, before any query is served — one MIGRATIONS
list shared by both backends (subjects, final_status, workflow_file, updated_at, workflow_slug, spec_version,
spec_hash):
| Backend | Migration statement | Failure handling |
|---|---|---|
| SQLite | ALTER TABLE runs ADD COLUMN <col> <type> |
Wrapped in try { … } catch { /* colonne déjà présente */ } — SQLite has no IF NOT EXISTS form for ADD COLUMN, so the error from a second run is caught and discarded. |
| PostgreSQL | the same statement with ADD COLUMN IF NOT EXISTS |
Idempotent natively, and run inside the locked init below. |
There is no other migration mechanism — the table is otherwise stable, and rows created
before a column existed read back as NULL (or the documented fallback above).
Concurrent schema init (PostgreSQL)
CREATE TABLE IF NOT EXISTS is not atomic on PostgreSQL: it tests for existence, then
creates. Two proxy replicas booting simultaneously against an empty database therefore
collide on pg_type (error 23505) — and running more than one replica is the whole
reason for choosing the Postgres backend.
So the DDL and the migrations run inside one transaction holding a transactional
advisory lock (initPgSchema, apps/api/src/server/pg-schema.ts): the second process
waits, then the statements are no-ops for it. The same helper guards the credential store
and the definition store, which share this database.
RunStore interface
openRunStore() returns an object implementing RunStore (apps/api/src/server/run-store.ts), identical across both backends:
| Method | Signature | Semantics |
|---|---|---|
insert |
(meta: RunMeta) => Promise<void> |
Insert-if-absent, keyed by id (INSERT OR IGNORE / ON CONFLICT (id) DO NOTHING). A second insert for an existing id is a silent no-op — the first row wins, later fields (including a different workflowName) are discarded. |
get |
(id: string) => Promise<RunMeta | null> |
Single row by id, or null if absent. |
list |
(limit?: number) => Promise<RunMeta[]> |
All rows ordered by created_at descending, capped at limit (default DEFAULT_LIST_LIMIT = 200). |
setFinalStatus |
(id: string, status: FinalRunStatus) => Promise<void> |
Unconditional UPDATE … SET final_status = ? WHERE id = ?. Idempotent to call repeatedly with the same value; nothing prevents overwriting a different value at the storage layer — the guard against that lives in the caller (see below). |
close |
() => Promise<void> |
SQLite: db.close(). PostgreSQL: pool.end(). |
RunMeta and FinalRunStatus are exported from the same module:
export type FinalRunStatus = "completed" | "failed";
export interface RunMeta {
id: string;
service: string;
workflowName: string;
patientId: string;
event: EventLike;
createdAt: string;
finalStatus?: FinalRunStatus | null;
}
Row-to-object mapping (fromRow) accepts only the literal strings "completed" or "failed" for final_status; any other stored value (including absent/NULL) maps to finalStatus: null.
Final-status cache
final_status exists to avoid re-querying Restate's getRun for every run on every list refresh. The caching logic lives in the proxy, not in the store itself (apps/api/src/server/api.ts):
rememberFinalStatus(meta, snap)setsfinal_statusthe first time a run is observed to have reached"completed"or"failed", and only then:
Ifasync function rememberFinalStatus(meta: RunMeta, snap: RunSnapshot | null): Promise<void> { if (!snap || meta.finalStatus) return; if (snap.status === "completed" || snap.status === "failed") { await runStore.setFinalStatus(meta.id, snap.status).catch(() => {}); } }meta.finalStatusis already set, the function returns immediately — a terminal status, once cached, is never re-queried or overwritten. ThesetFinalStatuscall is best-effort: a failure is swallowed (.catch(() => {})).opListRuns()(backingGET /api/runs) uses the cached value when present, and only calls Restate'sgetRunfor runs wherefinalStatusis stillnull:let status: string | null = m.finalStatus ?? null; if (!status) { const snap = await restateGetRun(m).catch(() => null); status = snap?.status ?? "running"; await rememberFinalStatus(m, snap); }opGetRun(id)(backingGET /api/runs/:id) always callsgetRun(a single-run lookup needs the live snapshot regardless), then also callsrememberFinalStatusto populate the cache as a side effect.
Without this cache, listing runs would issue one Restate getRun call per run on every poll of the monitoring view; with it, only runs still in flight ("running" or unknown) incur that call.
Callers and operational surface
| Consumer | Location | Store method(s) used |
|---|---|---|
GET /api/runs |
opListRuns() in apps/api/src/server/api.ts |
list, conditionally setFinalStatus via rememberFinalStatus |
GET /api/runs/:id |
opGetRun(id) |
get, setFinalStatus via rememberFinalStatus |
| Event ingestion (workflow start) | opSendEvent(event, wf) |
get (idempotency check by eventId), insert |
GET /readyz |
liveness/readiness probe | list(1) — failure marks checks.runStore = false, yielding HTTP 503 with { ready, checks } |
The /readyz probe treats the run store as one of two conditions for readiness, the other being the Restate ingress (${RESTATE}/restate/health). Both must succeed for a 200 response. See reference/observability-signals.md for the full probe and signal inventory.
Related documents
- how-to/use-postgres-run-index.md — switching
RUN_DB_URLfrom SQLite to PostgreSQL for multi-replica proxy deployments. - how-to/back-up-and-restore.md — backing up and restoring the run index file or database.
- reference/environment-variables.md —
RUN_DB_URLalongside every other configuration variable. - reference/topology.md — where the proxy process sits relative to the endpoint and Restate.
- explanation/why-durable-execution.md — why Restate, not this index, is the source of truth for run state.
- explanation/sizing.md — when running multiple proxy replicas (and hence needing PostgreSQL) is warranted.