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 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 read completed or failed is 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 is suspended or waiting, 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 is running waits for the pass.
  • The settlement pass (source: sweep). Every RUN_SETTLE_INTERVAL_MS (60 000 by default, 0 disables it, see Environment variables), armed by the proxy's start() and stopped by its stop(), the pass walks the live rows (listLive, pages of 100): first the rows of the proxy's Restate, then the others (another restate_url, or none), oldest first, one ingress call per row. One pass runs at a time, inside one runs.settle span. 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 calls getRun only for the live rows.
  • opGetRun(id) (GET /api/runs/:id) reads getRun, 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 pending and no waiting, and its nodeStatus is 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 answers 409 « 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 id is the eventId and whose source_event_id is NULL was 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_at of 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.

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.

97 documents14 sectionssource : /docs · généré au build