Repository navigation
event log retention archival
A completed execution older than a configurable window is copied to an object store, indexed for discovery by date, and only then removed from the hot EHDB volume. The hot volume becomes a window rather than an unbounded accumulator.
Program: noetl/ai-meta#459 · closes
#457 · spec
noetl/ehdb design/retention-archival-tier.md
Source: src/services/event_archive.rs,
src/services/archive_store.rs,
src/handlers/archive_read.rs.
The store this prunes — /data/ehdb-embedded, 2.4 GiB on a 9.7 GiB PVC — is a
shadow. Verified 2026-10-09 with a control grep: the only readers of engine() are the
shadow writer itself and the parity verifier, and GET /api/executions/{id} serves events
from Postgres noetl.event, which is append-only and never purged.
So pruning it cannot make API history unreadable — the API never read it. The archive is the only place EHDB-format history survives a prune, which is what the read routes are for.
⚠ Not the same store as the serving tier. NOETL_EHDB_EVENTLOG=primary on the worker
pools points at noetl-cmdbus-writer-0:9110 — a different process, a different volume, and
that one is primary-serving. Pruning that would need the fall-through helper wired
into its read path.
Catalogued on deployment-specification; summarised here:
| variable | default | meaning |
|---|---|---|
NOETL_EHDB_RETENTION_HOURS |
48 | a completed execution older than this is archivable; 24 supported |
NOETL_EHDB_ARCHIVE_ENABLED |
false | run the archive pass — additive, deletes nothing |
NOETL_EHDB_PRUNE_ENABLED |
false | allow the floor to advance and parts to drop — deletes hot data |
NOETL_EHDB_ARCHIVE_BUCKET |
unset | archive target; unset ⇒ archiving is off regardless of the flag |
NOETL_EHDB_ARCHIVE_ENDPOINT |
falls back to NOETL_OBJECT_STORE_GCS_ENDPOINT
|
GCS or an emulator |
NOETL_EHDB_ARCHIVE_INTERVAL_SECS |
900 | pass cadence |
NOETL_EHDB_ARCHIVE_MAX_PER_PASS |
200 | work bound per pass |
⭐⭐ Archive and prune are two independent flags, deliberately. Archiving is additive; pruning destroys the only other copy. There has to be a reachable state where the archive is provably complete and nothing has been deleted — a single flag makes "verify before deleting" impossible to express. Pruning with archiving off is refused, not obeyed.
⚠ A 0 window is refused, not clamped, and an unparseable flag is an error, not a
silent false: a typo in a data-deleting flag must not look like "off".
gs://<bucket>/execution_id=<id>/events-<min>-<max>.jsonl zero-padded: lexicographic = sequence order
gs://<bucket>/execution_id=<id>/manifest.json digest, count, seq range, status, archived_at
gs://<bucket>/date=YYYY-MM-DD/execution_id=<id>.json the execution's START date
⭐⭐ One object per execution under date=, not one appended per-day manifest. GCS has
no append, so a per-day object would be read-modify-write and two concurrent archives would
lose an entry with nothing reporting it. One object per execution makes each write
independent and idempotent, and discovery becomes list(prefix). A rolled-up daily index
may be derived later; it must never be the write path.
Dataset::partition for D1 is shard_for_execution(execution_id, shard_count) with
shard_count = 1 in prod, and index_key is the execution id. A part therefore holds
many executions interleaved by append sequence. Consequences:
- an execution owns no part → archival is an extraction, not a file move;
-
PartMeta.execution_bloomanswers "might X be here" and cannot enumerate; - ⚠⚠ dropping an old part can destroy the early events of a still-running execution.
Resolution: archive per execution (exact), prune per part (coarse), with a
floor as the bridge — min(global_sequence) over every execution not yet safe to
prune. A part entirely below the floor holds only archived executions.
ehdb_l0::plan_retention never splits a part; one straddling the floor is kept whole.
upload → read-back verify (digest + record count) → record durably → then prune. A crash anywhere leaves the hot copy intact, because the prune's floor derives only from durably recorded, verified archives. There is no window in which the only copy is in flight.
The prune gate (archive_state) requires all three: a verified data object, a matching
record count, and the date reference. An execution archived but unindexed is data that
exists and cannot be found, so it blocks the prune.
Found on prod 2026-10-09, from a log line, after every published gauge had read healthy for hours:
archive pass complete examined=6511 archivable=6259
archived=0 already=3680 out_of_coverage=100 failed=0
out_of_coverage=100 is exactly max_per_pass. The budget is decremented before the
empty-records check:
budget -= 1;
let Some(records) = read_records(*id) else { ... };
if records.is_empty() { rep.out_of_coverage += 1; continue; } // budget already spent…and the answer was not retained. So every pass spent its entire budget re-deciding the
same unarchivable executions, archived sat at 0, and everything further down the list was
never reached. It is server#514's shape by a
different route: work repeated because its result was not retained.
⚠⚠ And it was worse than a stall. An out-of-coverage execution is archivable by age and
never becomes verified_archived, so compute_prune_floor counted it as backlog and returned
AwaitingBacklog permanently. Prune could never have run, on any timescale.
Fixed in server#522:
-
known_out_of_coveragepersists across passes, so a remembered id is skipped for free. Monotonic by construction — the engine only holds records appended since it opened, so an execution with none now will never acquire any. - The floor's satisfied set is archived ∪ out-of-coverage. Safe, and this is the load-bearing half: the floor exists to stop a part being dropped while it still holds an unarchived execution's records, and an execution the engine has no records for is in no part. Nothing to copy, nothing to protect.
-
out_of_coverageis now a pinned metric outcome (10 → 11). It had been aPassReportfield reported only in a log line — which is exactly why the block was invisible on/metrics. A count that can halt the system does not belong only in prose.
The honest completion criterion is:
already_archived + out_of_coverage >= archivable
Measured on prod: already=3686 + out_of_coverage=2578 = 6264 = archivable. 41% of
"archivable" executions had no records in this store at all — they predate the volume, so
there is nothing to copy. Reporting progress as "X of 6,264" overstates the remaining work,
and waiting for archived to reach archivable waits forever.
Nothing is lost by it: those executions' events live in Postgres noetl.event, which is
append-only and never purged. They simply do not need archiving from a store that never held
them.
Both known_archived and known_out_of_coverage are in-memory. A pod restart forces
re-establishment: one pass of archive_state GETs for the archived set (free of budget, but
~77 ms each, so minutes), then max_per_pass out-of-coverage per pass until re-enumerated.
Batch configuration changes rather than making several small ones.
One stuck non-terminal execution blocks all reclamation while every other counter reads healthy — archiving keeps succeeding, verification keeps passing, and zero bytes come back. This is the drift hazard of the whole design.
noetl_ehdb_retention_oldest_non_archivable_age_seconds 0 = nothing blocking
noetl_ehdb_retention_floor_blocked_by_execution the id (0 = none)
noetl_ehdb_retention_floor_sequence
noetl_ehdb_archive_scan_examined / _archivable the selection WITH its population
noetl_ehdb_prune_bytes_reclaimed_total
noetl_ehdb_archive_total{outcome} 10 pinned outcomes
⭐ The alert that matters pairs two series: archiving succeeding while reclaimed bytes stay 0. It is invisible in either series alone.
All pinned at 0 unconditionally at startup — a pin inside an if archive_enabled
branch is not a pin, it leaves the series absent on exactly the configuration whose value
someone would be reading (the server#315 mistake). Both floor gauges are reset when the
block clears, because a stale id names an execution that is no longer blocking anything.
The prune path is prune_pass (src/services/event_archive.rs), deliberately separate from
archive_pass: archival behaves identically whether pruning is on, and the floor refuses by
itself. It is the only code in the server that deletes from the event log.
prune_pass(cfg, &CandidateSet, now, verified_archived, footprint_fn, prune_fn)
└─ floor_relevant_ids -> compute_prune_floor -> ehdb_embedded::prune_below
└─ L0Engine::apply_retention
The footprint comes from the manifest, not from reading records. The obvious
implementation reads each constraining execution's records and takes min/max
global_sequence; at prod scale that is tens of MB read under the append lock, every
pass, on the one process hosting both buses. PartMeta already carries the part's sequence
range and a bloom over its execution ids, so hot_footprint tests the bloom instead and
holds the lock only long enough to clone the manifest.
⚠ The bloom's error direction is what makes this sound. A false positive attributes a
part to an execution not in it, which lowers that execution's min_sequence, lowers the
floor, and prunes less. There is no false negative. A part with no bloom at all is
treated as possibly holding everything, for the same reason. Records in the unsealed tail are
not in the manifest, which is also fine: the retention plan only drops parts the manifest
lists.
| refusal | what it prevents |
|---|---|
AwaitingBacklog |
any archivable execution not yet verified-archived — a part may hold it |
CandidateSet.complete == false |
a floor computed from a partial population. collect_candidates caps at 200 pages x 100 and used to return Ok on a truncated list; an execution the listing never returned is invisible to the floor, so its records can lie below it |
absent footprint (None) |
the engine not being open. Absence of evidence is not evidence of absence — it must never read as "nothing constrains" |
MAX_FOOTPRINT_IDS (20,000) refuses rather than truncating, because a truncated footprint
places the floor above live data. There is no safe partial footprint.
An empty footprint and a footprint whose every member is archived are opposites, and conflating them cost the whole feature its purpose:
- empty ⇒ we measured nothing ⇒ refuse;
- non-empty, all archived ⇒ we measured, and all of it is safe ⇒ the floor advances past the highest sequence those executions occupy.
The first implementation returned NoFootprint for both. That is exactly the steady state an
idle log reaches once the backlog drains, so the entire prune path would have reclaimed zero
while every call returned Ok. It is also why HotExecution::max_sequence exists — added
for this case, then never read.
The floor must clear the highest sequence, not the highest first sequence; the latter is far
below the log tip and reclaims nothing. Both errors return Ok, which is why the tests assert
on surviving records and reclaimed bytes, never on a return code.
Guards: tests/prune_wiring.rs (8 tests, 5/5 mutation-caught) and tests/prune_floor.rs.
The prune callback in the former models a real part layout and the engine's actual
droppability rule (part.max_sequence < keep_from_sequence).
Two mistakes were made here and both are worth not repeating.
Time the primitive, not a caller. The put histogram was added to ObjectBackend::put, the
wrapper. The archive tier's ArchiveStore impl calls GcsBackend::put directly, so ~300
real objects reached prod GCS while the metric read its seed. The timing now lives inside
GcsBackend::put, which makes the bypass unexpressible — there is nowhere else to put the
bytes. The Postgres arm keeps its timing in the wrapper (it has no primitive here), so the
wrapper's GCS arm must stay a bare delegation or every GCS put is counted twice.
tests/object_put_instrumented.rs guards all three facts.
Never pin a histogram by observing. The pin used observe(0.0) per label.
inc_by(0) on a counter is harmless; observe(0.0) on a histogram records a measurement
that was never taken — _count starts at 1 so "has it fired?" is unanswerable, and the
distribution is biased toward zero, worst when n is small. In the first prod reading the only
sample below 50 ms was that seed. Absent-vs-zero is now carried by
noetl_object_store_put_total{backend,outcome}, a counter, which can be pinned truthfully;
the histogram is left to be pruned until it fires, with noetl_server_build_info
discriminating "this version has no such metric".
Measured in prod, n=300, same-region: p50 77 ms, p90 98 ms, p95 139 ms, p99 234 ms. Roughly 19x a ~4 ms local fsync — the figure that settles whether a remote put can sit on the append path. It cannot.
GET /api/archive/executions/{execution_id}
GET /api/archive/executions?date=YYYY-MM-DD[&limit=N]
Both are gated (the by-date route enumerates, which is a capability distinct from
reporting on an id the caller already holds) and read-only by construction — the
ArchiveStore trait has no delete, so once the hot part is pruned the layer that wrote
the archive cannot remove it.
ReadSource is three states, never two: hot, archive, not_found.
- ⚠⚠ An empty hot result is a MISS that falls through, never an answer. After a prune the hot store legitimately holds zero records, and treating that as "no events" would turn every pruned execution into a silent empty success.
- ⚠⚠ A broken archive is an error, never a
not_found. A present manifest means the execution was archived, so a missing or corrupt data object is data loss — reporting it as absence would hide that behind a clean 404. - 503 with a reason when the archive is unconfigured. "Not configured" and "nothing archived" are different facts and must not share a response.
- A malformed date is refused, not turned into a prefix that matches nothing and returns a clean empty — a quiet day that never existed.
Every listing carries its denominator: keys_seen, unrecognised, truncated,
limit_applied.
-
IAM — the server SA needs
roles/storage.objectAdminon the archive bucket. It has no binding there and no project-level roles; archiving fails closed until granted. - Deploy.
-
NOETL_EHDB_ARCHIVE_BUCKET+NOETL_EHDB_ARCHIVE_ENABLED=true— archive-only, deletes nothing. Leave it until the output is checked. -
Wait for the backlog to drain. ⚠⚠ Flipping
NOETL_EHDB_PRUNE_ENABLED=truebefore that reclaims nothing, correctly: the floor returnsAwaitingBacklogwhile any archivable execution is neither verified-archived nor out-of-coverage. ⚠⚠⚠ Do NOT wait forarchivedto reacharchivable— that never happens. The criterion isalready_archived + out_of_coverage >= archivable; on prod 41% of the archivable set was out of coverage. Watch both outcomes, and note the pass is bounded byMAX_PER_PASSperINTERVAL_SECS. An operator who flips the flag early and reads zero bytes will reasonably conclude the feature is broken. - Only then
NOETL_EHDB_PRUNE_ENABLED=true, and measure the volume before and after.noetl_ehdb_retention_floor_sequencebecoming non-zero is the signal the floor advanced; reclaimed bytes are reported per pass in theretention pruned parts below the proven floorlog line.
The object client already supports an open emulator (GcsAuth::None for any non-Google
endpoint), so the whole tier runs against fake-gcs-server:
podman run -d --name fake-gcs -p 4443:4443 docker.io/fsouza/fake-gcs-server:latest \
-scheme http -port 4443 -external-url http://127.0.0.1:4443 -public-host 127.0.0.1:4443
curl -X POST localhost:4443/storage/v1/b -H 'Content-Type: application/json' -d '{"name":"ehdb-archive-test"}'
NOETL_TEST_GCS_ENDPOINT=http://127.0.0.1:4443 NOETL_TEST_GCS_BUCKET=ehdb-archive-test \
cargo test --test archive_execution --test archive_date_index --test archive_retrieval \
emulator -- --ignored --nocapture⚠ GcsBackend::open_unauthenticated refuses a googleapis.com host: an unauthenticated
client against real GCS fails every request, and the first guess would be the credential
rather than the constructor.
- deployment-specification — env-var source of truth.
- runtime-registry — the sibling #455 work.
- ehdb#345 — a full volume is silent; why this matters.
- Event envelope
- Event-sourced execution
- API surface
- Runtime shape (compiled + plug-in ring)
- Runtime registry (D8)
- Event-log retention + archival
- Off-box replication + the unsealed tail
- Catalog refuses secret material — topology, discovery, leases
- Cursor / claim loop mode
- Static guards
- noetl/cli wiki
- noetl/worker wiki
- noetl/tools wiki
- noetl/noetl wiki — Python implementation (twin during migration)
- noetl/ops wiki