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 the run id (the same string used as the Restate workflow key): <eventId>:<slug> for a run started by a domain event, one per event and receiving workflow.
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. The index is also the deduplication and anteriority record of events (see Event deduplication), and it backs GET /api/runs, GET /api/runs?event=<eventId> and the 7-day volume per event type (eventVolume). An index that is lost or rebuilt empty loses that list, and an event redelivered afterwards (a source re-reading its overlap window, a caller resending the same eventId) starts its workflows again. 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 by apps/api/src/server/api.ts with config.runDbUrl: RUN_DB_URL, read once by readApiConfig (apps/api/src/server/config.ts), or the default below when it is unset or empty.
RUN_DB_URL prefix |
Backend | Module loaded |
|---|---|---|
sqlite:<path> |
SQLite file at <path> |
better-sqlite3 |
postgres://… |
PostgreSQL | pg |
postgresql://… |
PostgreSQL | pg |
| (unset) | SQLite, path ~/.meridian/runs.sqlite |
better-sqlite3 |
| anything else | — | throws RUN_DB_URL non reconnu : <url> (attendu sqlite:<chemin> ou postgres://…) |
The default constant is DEFAULT_RUN_DB_URL (apps/api/src/server/run-store.ts), under the home directory of the process user (homedir()), outside the repository: every checkout and worktree of a machine shares one index. 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 table, runs, with the identical DDL text (DDL_POSTGRES is literally DDL_SQLITE, since every column type used is valid in both engines). The DDL runs first, then the migrations (next section), which add source_event_id, route_match, event_type and their indexes:
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,
workflow_file TEXT,
updated_at TEXT,
workflow_slug TEXT,
spec_version INTEGER,
spec_hash TEXT,
subjects TEXT
)
Columns
| Column | Type | Nullable | Contents |
|---|---|---|---|
id |
TEXT |
no (PK) | The run id, also the Restate workflow key: <eventId>:<slug> for a domain event (one run per event and workflow), the eventId for a trigger with its own address (webhook, schedule, workflow call), inbound:<message>:<hash> for a pinned EAI delivery. Runs started before fan-out routing have the eventId. |
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 event delivered to the run, JSON-serialized (JSON.stringify / JSON.parse); typed as EventLike from @meridian/engine-core/engine/interpreter. Its eventId is the run id. |
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 while the run is live. See Final status. |
source_event_id |
TEXT |
yes | The event the run was started from: the received eventId for a domain event, the run id for a trigger with its own address, inbound:<message> for a pinned EAI delivery (shared by the targets of one message). Written at insert, read at deduplication (see Event deduplication). NULL for runs started before fan-out routing. Indexed by runs_source_event_id. |
route_match |
TEXT |
yes | Why the run received the event: selector (its trigger filter kept it), unfiltered (no filter), targeted (designated by __workflowFile), pinned (trigger with its own address). NULL for older runs. |
event_type |
TEXT |
yes | type of the received event. NULL for older runs. Indexed with created_at by runs_event_type_created_at. |
restate_url |
TEXT |
yes | The Restate of the run: the ingress (RESTATE_URL) that accepted its run/send, written at insert, and stamped again by the settlement when another ingress knows the invocation (see Final status). NULL for runs started before the column; no row is filled when it is added. |
journal_format |
INTEGER |
yes | The journal format the run was started in (RUN_JOURNAL_FORMAT), written at insert and shown by the run detail and the runs list (journalFormat). NULL for runs started before the column, which are of format 1. |
| trigger_id | TEXT | yes | Selected entry id for multi-entry runs (triggerId in the API). Older and single-entry rows remain null. |
| spec_json | TEXT | yes | Immutable submitted spec for multi-entry runs, including drafts. Used by the run graph; never the current published replacement. |
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, source_event_id), then the index on source_event_id, then route_match, event_type and the index on
(event_type, created_at), then restate_url and the partial index of the live runs, then journal_format, trigger_id and spec_json:
| 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. |
The indexes (CREATE INDEX IF NOT EXISTS runs_source_event_id ON runs (source_event_id),
runs_event_type_created_at ON runs (event_type, created_at) and
runs_live_restate_url_created_at ON runs (restate_url, created_at, id) WHERE final_status IS NULL) are identical on
both backends. Each stays in MIGRATIONS, after the columns it indexes, and never in the DDL: the DDL runs before the
migrations, so on an existing database the column would not exist yet, and on PostgreSQL the failure inside the init
transaction would stop the proxy from booting. The release before fan-out routing ignores route_match and
event_type. runs_live_restate_url_created_at holds only the rows without a terminal status: the settlement pass
walks it, the runs of one Restate at a time (see Final status). On PostgreSQL each CREATE INDEX
builds inside the locked init transaction: on a large runs table it holds writes on runs while it builds (see
Upgrade the platform).
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(url) returns an object implementing RunStore (apps/api/src/server/run-store.ts), identical across both backends:
| Method | Signature | Semantics |
|---|---|---|
insert |
(meta: RunMeta) => Promise<boolean> |
Insert-if-absent, keyed by id (INSERT OR IGNORE / ON CONFLICT (id) DO NOTHING). Returns true when a row was written, false on conflict: a run inserted at the same instant by another replica is reported as not created. The first row wins, later fields are discarded. |
get |
(id: string) => Promise<RunMeta | null> |
Single row by id, or null if absent. |
eventRows |
(eventId: string, runIds?: string[]) => Promise<RunMeta[]> |
Rows whose id is one of the planned runIds, whose id is eventId, or whose source_event_id is eventId, oldest first. One query on the index, no Restate call. |
eventVolume |
(eventType: string, sinceIso: string) => Promise<number> |
Distinct events of the type received since the date: COUNT(DISTINCT COALESCE(source_event_id, id)) on event_type. Rows without event_type (older runs) do not count. |
list |
(filter?: { event?: string; limit?: number }) => Promise<RunMeta[]> |
Rows ordered by created_at descending, capped at limit (default DEFAULT_LIST_LIMIT = 200). event keeps, in SQL before the limit, the rows whose source_event_id is that event, and a row without source_event_id whose id is that event (a run started before fan-out routing). |
listLive |
(page: { restateUrl: string; others?: boolean; after?: { createdAt: string; id: string }; limit?: number }) => Promise<RunMeta[]> |
Rows without a terminal status (final_status IS NULL) whose restate_url is restateUrl, or, with others, is another URL or NULL; oldest first (created_at, then id), after the cursor after (the last row of the previous page), capped at limit (default 100). A row frozen or stamped again during a walk leaves the list without shifting the next page. |
setRestateUrl |
(id: string, restateUrl: string) => Promise<boolean> |
UPDATE … SET restate_url = ? WHERE id = ? AND final_status IS NULL AND (restate_url IS NULL OR restate_url <> ?). Returns true when the row changed; a frozen row never changes. |
setFinalStatus |
(id: string, status: FinalRunStatus, at?: string) => Promise<boolean> |
Conditional UPDATE … SET final_status = ?, updated_at = ? WHERE id = ? AND final_status IS NULL. Returns true when this call froze the row, false when the run was already frozen or is unknown: a terminal status, and its updated_at, never change, and of several proxy replicas that freeze the same run at the same time, one writes. |
lastRunByWorkflow |
() => Promise<Record<string, { at: string; status: string | null }>> |
Last run per workflow file over the whole index; status is null when that run is live. |
saveSnapshot |
(id: string, snapshot: unknown) => Promise<void> |
Archives the run's snapshot in the run_snapshots table (id, snapshot, saved_at), write-once (INSERT OR IGNORE / ON CONFLICT (id) DO NOTHING), size bounded by serializeSnapshot. |
getSnapshot |
(id: string) => Promise<unknown | null> |
The archived snapshot, or null. |
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;
workflowFile: string;
workflowName: string;
workflowSlug?: string | null;
specVersion?: number | null;
specHash?: string | null;
subjects: Record<string, string>;
event: EventLike;
sourceEventId?: string | null;
routeMatch?: RouteMatch | null; // "selector" | "unfiltered" | "targeted" | "pinned"
eventType?: string | null;
createdAt: string;
updatedAt?: string;
finalStatus?: FinalRunStatus | null;
journalFormat?: number | 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
final_status holds the terminal status of a run, frozen once. It spares Restate one getRun per run on each
refresh of the runs list, and it outlives Restate's retention (one day after the end of a workflow): past it, the
row and its archived snapshot are the only record of the run. The logic lives in the proxy
(apps/api/src/server/runs.ts, apps/api/src/server/run-settlement.ts), not in the store.
Freezing
A freeze archives the run's snapshot (saveSnapshot, write-once), then sets the status (setFinalStatus,
conditional). The archive comes first: a status frozen without an archive would never be caught up, since a frozen
status short-circuits every later read, while an archive whose freeze failed is written again at the next read. Both
writes are best-effort. A freeze that wrote the row is counted once by meridian.runs.settled (ending, source,
see Observability signals).
Two sources freeze a run:
- Reads (
source: read). A snapshot readcompletedorfailedis frozen by the runs list (GET /api/runs), the run detail (GET /api/runs/:id), the settling of an ingestion (about 6 seconds) and the settling after a decision. When the last snapshot issuspendedorwaiting, the run detail, a decision just before its signal and a resume of a wait (POST /api/resume/:runId/:nodeId) just before its signal also settle the run (next section). A run killed while its last snapshot isrunningwaits for the pass. - The settlement pass (
source: sweep). EveryRUN_SETTLE_INTERVAL_MS(60 000 by default,0disables it, see Environment variables), armed by the proxy'sstart()and stopped by itsstop(), the pass walks the live rows (listLive, pages of 100): first the rows of the proxy's Restate, then the others (anotherrestate_url, or none), oldest first, one ingress call per row. One pass runs at a time, inside oneruns.settlespan. Several proxy replicas walk the same rows; the conditional freeze writes each row once.stop()interrupts a pass between two rows, and its ingress request in flight.
Settlement
Settling a run reads the output of its run invocation on the Restate ingress,
GET /restate/workflow/meridianWorkflow/<runId>/output, then, when the invocation has ended, its snapshot
(getRun, which Restate serves for one day after the end). Each ingress request is bounded by 30 seconds
(INGRESS_TIMEOUT_MS, apps/api/src/server/ingress.ts): Restate answers getRun only once the endpoint is
reachable. A getRun that fails settles nothing; the next pass reads the run again.
/output answer |
Reading | Frozen status | Archived snapshot |
|---|---|---|---|
470 |
The invocation is alive. | none | — |
200, the handler's result ({"status":"completed"}) |
The run handler returned. |
The snapshot's status when it is terminal, otherwise the result's (« Run en échec, sans message d'erreur. » for a failure without an error). | The snapshot, with that status. |
409 with a failure code equal to the status ({"code":409,"message":"killed"}) |
The invocation ended without a terminal snapshot: it was killed. | failed, error « Invocation arrêtée dans Restate (killed). » (the failure's message in parentheses) |
The last snapshot, its pending validation included. |
Another failure whose code equals the status (500 {"code":500,…}) |
The invocation ended in another failure returned by Restate. | failed, error « Invocation en échec dans Restate (500 : |
The last snapshot. |
404, for a run of this Restate whose getRun answers the empty placeholder |
This Restate no longer knows the invocation. | failed, error « Invocation absente de Restate : issue du run inconnue. » |
No state of the run: its detail reads stateLost: true. |
404, for a run of another Restate or without one |
This Restate never ran it, or nothing says it did. | none, the run is left as it is | — |
Ingress unreachable, or any other answer: no failure code, a code that is not the status, 401, 403, 407, 429, 502–504 |
Nothing to conclude. | none, the next pass reads it again | — |
A terminal snapshot always wins: it is what the run wrote at its end. A 404 for a run whose snapshot still holds a
state (a trace, a validation) freezes nothing.
The Restate of a run is restate_url, the ingress that accepted its run/send. Only that Restate can say it
lost a run: a proxy started against another Restate (a wrong RESTATE_URL, an index shared with another stack, a
recreated Restate volume) freezes none of the runs it does not know. When the proxy's ingress knows the invocation
(470, 200, a failure with a code), the row is stamped with the proxy's URL: this adopts the rows written before
the column and follows a change of RESTATE_URL. The rows this Restate does not know and that are not its own are
counted and logged once per pass, at warn: <n> run(s) d'un autre Restate ou sans Restate d'origine, non réglé(s),
with count and restate. A row written before the column that this Restate does not know is never frozen
automatically (see Upgrade the platform).
The runs component logs each freeze: a killed run run arrêté dans Restate : statut figé en échec (info, with
failure), another failure run en échec dans Restate : statut figé en échec (warn, with code and failure), a
run unknown to its Restate run absent de Restate : statut figé en échec (warn), all with runId and workflow.
The updated_at of a run frozen by the settlement is the time of the freeze: the ingress does not give the time the
invocation ended.
A run frozen after its invocation was killed, or ended in another failure, whose last snapshot waits on a
validation, gets its validation request withdrawn before it is frozen. The settlement sends
POST /record/closeValidationRequest/send to the ingress, { kind, id, closure } with the outcome withdrawn and
the time of the settlement, for each subject of the run whose context kind has a record port, with the header
idempotency-key: withdraw:<runId>:<pendingId>:<kind>: Restate discards a repeated send (another pass, another
replica). The run is frozen once every send is accepted. When the ingress refuses one, the row stays live and the
next pass sends again. The endpoint's record service calls the record port's closeValidationRequest, retried for
up to one hour. runs logs demande de validation retirée (debug), or retrait de la demande de validation impossible : run non figé, repris au règlement suivant (warn, with kind and error) when the ingress refuses
the call.
Reading a frozen run
opListRuns()(GET /api/runs) serves the frozen status without calling Restate, and callsgetRunonly for the live rows.opGetRun(id)(GET /api/runs/:id) readsgetRun, and serves the archived snapshot when Restate's snapshot is not terminal (a killed run, the empty placeholder of a recreated volume) or when Restate refuses the read (past the retention).- A run with a terminal status serves no
pendingand nowaiting, and itsnodeStatusis computed without them: a killed run reads like a processed cancel. - A decision on a frozen run answers
409« run terminé : aucune validation en attente » without calling Restate. When the run ended without applying it (its archived snapshot still waits on that validation, or holds no state), a decision feed rejects it (résolution rejetée, « décision sur un run terminé : validation non appliquée ») rather than counting a duplicate. A resume of a wait answers409« run terminé : aucune attente en cours ».
Event deduplication
An event starts one run per workflow that receives it. Before starting any, the ingestion
(apps/api/src/server/ingest.ts) computes the run id of each delivery and reads eventRows(eventId, runIds) once.
The plan (planDeliveries, apps/api/src/server/event-target.ts) then decides, per delivery:
- duplicate — a row carries its run id: that workflow already received the event;
- old row — a row whose
idis theeventIdand whosesource_event_idisNULLwas written by a release older than fan-out routing: the event is a duplicate for every workflow, routed or designated, and only that row is reported; - anteriority — when the index already knows the event (rows started from it, or an old row), a routed delivery
whose run id is unknown starts only if its workflow's active version was published before the event's first
reception (oldest
created_atof those rows). Designated deliveries are not affected; - otherwise the run starts.
POST /api/events answers 200 when nothing was started (every entry created: false). When anteriority skips
every target, the entries are the event's rows (source_event_id = eventId), reported as duplicates. The
recent-events entry of a duplicate names the run and its workflow.
Callers and operational surface
| Consumer | Location | Store method(s) used |
|---|---|---|
GET /api/runs |
opListRuns() in apps/api/src/server/runs.ts |
list, conditionally saveSnapshot and setFinalStatus via rememberFinalStatus |
GET /api/runs/:id |
opGetRun(id) |
get, getSnapshot, saveSnapshot and setFinalStatus via rememberFinalStatus and settle |
GET /api/runs?event=<eventId> |
opListRuns({ event }) |
list({ event }) |
POST /api/runs/:id/resolve |
opResolve(id), decision in apps/api/src/server/validation-resolve.ts |
get, saveSnapshot and setFinalStatus via settle (before the signal) and rememberFinalStatus (after it) |
POST /api/resume/:runId/:nodeId |
apps/api/src/server/routes/webhooks.ts |
get, and via settle before the signal |
| Settlement pass | settleLive() in runs.ts, timer in apps/api/src/server/create-api.ts |
listLive, setRestateUrl, saveSnapshot, setFinalStatus |
| Event ingestion (deduplication) | resolveIngest in apps/api/src/server/ingest.ts |
eventRows (see Event deduplication) |
| Event ingestion (workflow start) | startDelivery in apps/api/src/server/ingest.ts |
get (idempotency check by run id), insert (with restate_url) |
POST /api/expressions/check |
the filter panel's volume over 7 days | eventVolume |
GET /readyz |
liveness/readiness probe | list({ limit: 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.
Entry filters
GET /api/runs?workflow=<slug-or-file>&trigger=<entryId> combines optional workflow, entry and source-event filters in SQL before the result limit. triggerId is also served in each run detail. Old rows without an entry id remain available in unfiltered lists.