Skip to content

Run the lake writer

Applies to OML v0.6.x · Status: Stable · Last verified 2026-11-06

The lake writer, oml-lakewriter, copies what PostgreSQL commits into Delta tables for analysis. No client reads the lake. Recall, search and get are served from PostgreSQL. The lake answers your questions about how the system is used. The tables are on Delta tables.

The lake writer is a separate process from the server. The Helm chart does not deploy it, and oml quickstart does not start it. Run it on the server's host, or as your own workload on a cluster, with a role of its own.

Start it

You need a running server, a directory or object-store path for the lake, and a PostgreSQL role for the writer.

oml-lakewriter keygen --out <kek-file>          # once; owner-only, back it up with the database
export OML_LAKE_URL=<lake-dir>
export OML_LAKE_DATABASE_URL=postgres://oml_lake:…@127.0.0.1/oml   # its own role
export OML_LAKE_OWNER_URL=postgres://oml_owner:…@127.0.0.1/oml     # grants that role at start
export OML_LAKE_KEK_FILE=<kek-file>
oml-lakewriter run --metrics-addr 127.0.0.1:9466

The role in OML_LAKE_DATABASE_URL must be neither the owner nor the serving role. It must not be a superuser or bypass row security. The writer refuses one that is. One writer runs at a time, and a second waits for the first's lease.

Losing the key-encryption key (KEK) file makes every sealed body in the lake unreadable, as erase does. The file must be owner-only (mode 600). run and drain-once refuse to start on one that group or others can read. Rotate it with the writer stopped:

oml-lakewriter keygen --rotate --kek-file <kek-file>   # takes the lease, re-wraps, retires the old KEK

If it stops part way, the file keeps both versions. Run it again. Back up the new file with the database before you restart the writer.

Where the lake lives

Each tenant has its own prefix, <lake>/<tenant id>/. The provider prefix, <lake>/_provider/, holds lake/batches (the frontier) and bench/results. Load benchmark numbers with oml-lakewriter load-bench eval/bench/results.

Run the named queries

oml-lakewriter query --list lists them. oml-lakewriter query <name> runs one.

Name Answers
writes-per-kind-per-day claims written each day, by kind
review-latency seconds from write to promotion, per promotion day
recall-volume-per-pipeline recalls and empty recalls per pipeline per day
recall-outcomes what readers did after a recall, per pipeline
stale-share share of promoted versions no longer the served tip
top-workareas workareas by journalled activity, with names
bench-latest each suite's methods on their overall metrics

DuckDB, with its delta extension, answers the same questions against one tenant:

INSTALL delta; LOAD delta;
SET VARIABLE lake = '<lake-dir>/<tenant id>';
-- writes per kind per day
SELECT event_date, coalesce(kind, 'memory') AS kind, count(*) AS writes
FROM delta_scan(getvariable('lake') || '/bronze/events')
WHERE event_type IN ('submission.remember', 'submission.learn')
GROUP BY ALL ORDER BY ALL;
-- review latency, seconds
SELECT promoted_at::date AS day, count(*) AS promoted,
       median(epoch(promoted_at) - epoch(created_at)) AS median_s
FROM delta_scan(getvariable('lake') || '/silver/claims') GROUP BY ALL ORDER BY ALL;
-- recall volume per pipeline
SELECT day, pipeline, sum(recalls) AS recalls, sum(empty_recalls) AS empty
FROM delta_scan(getvariable('lake') || '/telemetry/recall_daily') GROUP BY ALL ORDER BY ALL;
-- stale share
SELECT kind, avg(CASE WHEN state = 'promoted' THEN 0 ELSE 1 END) AS stale_share
FROM delta_scan(getvariable('lake') || '/silver/claims') GROUP BY ALL;

Spark reads the same paths with spark.read.format("delta").load(path).

Check its health

oml-lakewriter doctor prints one line per check, ok, warn or fail, and exits 1 when one fails. The checks are lake.writer (no writer holds the lease), lake.lag, lake.lag_seconds, lake.heartbeat, lake.kek, lake.commits, lake.schema, lake.dead_letter and lake.maintenance. Pass --kek-file for lake.kek, which checks permissions and warns past 90 days. The metrics listener serves the oml_lake_* families (metrics).

What happens on erase

On lifecycle.erase, the writer destroys the version's key in artifact_keys and rewrites its silver.claims row as erased with no body. Every older table version that still holds the sealed body then opens under no key. The daily VACUUM removes those files once they are older than the erasure bound, 7 days by default (--retention-hours 168). Bronze holds digests only and needs no change.