Documentation / Exploitation / Référence / Run index database

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) sets final_status the first time a run is observed to have reached "completed" or "failed", and only then:
    async 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(() => {});
      }
    }
    
    If meta.finalStatus is already set, the function returns immediately — a terminal status, once cached, is never re-queried or overwritten. The setFinalStatus call is best-effort: a failure is swallowed (.catch(() => {})).
  • opListRuns() (backing GET /api/runs) uses the cached value when present, and only calls Restate's getRun for runs where finalStatus is still null:
    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) (backing GET /api/runs/:id) always calls getRun (a single-run lookup needs the live snapshot regardless), then also calls rememberFinalStatus to 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.

75 documents9 sectionssource : /docs · généré au build