Repository navigation
Runbook Async Event Log Mirror
noetl/ai-meta#155. LIVE on prod since 2026-08-19. Last verified 2026-08-20.
kubectl -n noetl set env deploy/noetl-server-rust \
NOETL_EHDB_EVENTLOG_MIRROR_ASYNC=false \
NOETL_EHDB_CROSSSTORE_PARITY_LAG_TOLERANCE_SECS=0No image roll. Restores the inline mirror, which is what prod ran before.
⚠ Set both or neither. MIRROR_ASYNC=true with the tolerance at 0 is the
dangerous pairing: the mirror is queued while the comparator has no recency
bound, so it reports missing_event for events that are merely in flight and
the tier is judged divergent on its own liveness.
The server mirrors every authoritative event into the event-log tier. That
mirror used to run inline on the emit_events chokepoint, so every event
paid a relay round trip plus a tier append before the write returned — measured
at 110 ms/call × 76 calls ≈ 8.4 s per Muno planner turn, after
the runtime cache had already cut it from
85.6 s.
None of that work is on the critical path of anything: the authoritative write has already committed when the mirror runs. It now goes through a bounded in-process queue drained by a single task.
Measured on prod: emit_mirror 78.6–87.6 ms → 0.1 ms per call
(~6.0 s → 0.007 s per turn); median warm turn 16.9 s → 13.0 s.
⚠ The turn-level win is smaller than the mirror cost removed, because not all 76 mirror calls sit on the turn's critical path. Both numbers are real; quote both.
The cross-store comparator is the tier's correctness evidence, and it had no
recency bound — correct while the mirror was synchronous, because an event was
in the tier before emit_events returned. Async breaks that assumption.
So the change ships with a lag-tolerance window: mirror-expected events
younger than the window are treated as in flight rather than missing. It is a
latency concession, never a coverage one — a genuinely lost event is
invisible for the window and then reports missing_event forever.
The window is a prefix cut at the first event newer than it, excluded from
both sides, so a fast mirror that already delivered a recent record is not
scored as holding an extra. An execution wholly inside the window scores
pending_mirror, not match — an untaken comparison is not agreement.
lag_within_window and lag_beyond_window run on every sampler tick over one
fixture driven twice with only the horizon moved. A single control proves
nothing here: a comparator taught to ignore everything passes "clean parity under
tolerance" perfectly. The pair asserts a discrimination — the same absent event
is forgiven inside the window and still reported outside it.
Check them live:
kubectl -n noetl exec deploy/noetl-server-rust -- \
wget -qO- 127.0.0.1:8082/api/ehdb/parity/self-test \
| jq '.controls[] | select(.expected == false)'Empty output is the healthy answer. Anything listed voids every zero the comparator has published.
Do not pick the window. Turn async on behind a deliberately wide window, measure, then narrow:
- Enable async with a wide window (300 s) — both flags in one rollout, so there is never an instant with async armed and the window at 0.
- Read the real lag:
noetl_ehdb_eventlog_mirror_lag_seconds. - Set the window comfortably above the observed p99.
Prod measurement at cutover, 241 observations: mean 84 ms, p50 67 ms, p95 374 ms, p99 699 ms, max < 1 s → window set to 30 s (≈43× p99).
⚠ 30 s is deliberately below the sampler's SETTLE_SECS (120 s). That keeps
the background sampler comparing every execution exactly as before; the window
then guards only the on-demand endpoint, which has no settle filter. Keep that
inequality if you retune either.
⚠ A window at or below the real lag self-demotes a healthy tier. Driven hard in kind until mean lag hit 5.76 s against a 5 s window, the comparator correctly reported divergence.
-
try_send— room on the queue, microseconds. -
reserve()with a timeout — the queue is full, so the emit path waits. Nothing lost, order preserved. - inline delivery — the queue stayed full past the timeout; i.e. the pre-change behaviour.
Rung 3 is the only path that can deliver out of order relative to batches still
queued for the same execution, so it is metered separately
(queue_full_inline) and alerted on. At prod load rung 2 has never engaged.
⚠ reserve() rather than send() is load-bearing. send(batch) moves the batch
into the future, so a timeout cancellation takes the events with it. The first
implementation did exactly that and lost 32 of 60 records while every counter
still read correctly, because the events were counted at the moment they were
lost.
SIGTERM flushes the queue; anything still pending at the deadline is counted
shutdown_abandoned, so a later divergence is attributable to the restart.
| metric | the question only it answers |
|---|---|
..._mirror_lag_seconds |
is the real lag inside the window the comparator was told to allow |
..._mirror_pending_events |
is the queue drained or stalled — a stalled queue emits no lag observations, so its histogram reads exactly like an idle system |
..._mirror_queue_total{outcome} |
conservation: enqueued + enqueued_after_wait must equal drained
|
..._mirror_async_enabled |
is this process async, vs "flag on but the drain never started" |
..._crossstore_parity_lag_tolerance_seconds |
the configured window, published so the alert reads it rather than a copy |
..._crossstore_pending_total |
what the window cost, published rather than subtracted in silence |
..._mirror_send_error_total{kind} |
which transport failure — the discriminator attempt{unavailable} lacks (see below) |
..._mirror_repair_total{outcome} |
did a repair close the gap; ⚠ repaired alone says nothing about CONTENT — read hydrated too |
Healthy snapshot (prod, 2026-08-20): enqueued 380 = drained 380, pending 0,
queue_full_inline 0, shutdown_abandoned 0, parity match 268 /
divergent 0, served_primary 925 with every demote outcome 0.
Fixed in noetl-server v3.109.1 (server#430).
Read this before trusting any "repaired" outcome from before it.
The ai-meta#342 repair sweep
re-mirrors missing rows through mirror_rows, the same chokepoint the live path
uses — and the call site's comment claimed that made a repaired record
"byte-identical to a first-delivery one". It did not, because the two paths hand
that chokepoint different rows:
| path | source of the row |
result carries |
|---|---|---|
| live mirror | the EventRow the writer holds in memory
|
inlined content |
| repair | the persisted noetl.event row |
a reference (under NOETL_PERMANENT_LOG_LEAN=true) |
So a repair closed the count gap and left the content divergent, and repairing again could not fix it — it rewrote the same reference.
Measured on prod 2026-09-12, on the two executions the sweep had repaired:
| execution | re-mirrored | still differing on result
|
outside the re-mirrored set |
|---|---|---|---|
357138298767941632 |
39 | 3 | 0 |
357138152818745344 |
26 | 3 | 0 |
357138424311848960 (never repaired) |
— | 0 | — |
Three of each because only results over the inline budget are externalised.
⚠ Why it went unseen for a whole session: the field-level differ
(/api/ehdb/projection-fold/diff/{id}) was itself unhydrated, so it reported
result as differing on every execution with an externalised result. The three
events that really differed were indistinguishable from the artefact. Fixing
the instrument is what made the defect legible — the third unhydrated
comparator found in this area after
server#425 and
server#426.
Operationally: repair now reports hydrated — how many re-mirrored rows
carried an externalised result. A repair that closes missing_after while
leaving content divergent reads identically to a clean one from the outcome
alone, which is exactly how this hid.
An execution the tier cannot return is not a divergent one, and until
server#429 the parity surface could
not say so: digests_agree: bool has no third value, so a refused read read as
false.
On a fixed 40-execution prod sample (2026-09-12), 3 of the 19 apparent
divergences were unreadable rather than divergent — all large
muno/playbooks/itinerary-planner runs refused by the tier-service frame cap:
read: tier-service frame of 1174874 bytes exceeds the 1048576-byte cap
The verdict is three-valued now (agree / diverged / not_comparable), and
the equivalence sweep publishes accounted_for — agreed + disagreed + not_comparable + comparison_errors == examined. A scan that does not publish
its denominator is the failure one layer up: before this, unreadable
executions left agreed + disagreed silently and equivalence_holds was
computed over a population the response never stated.
The cap itself is worker#311
(not deployed — the tier service runs in noetl-cmdbus-writer-0, which hosts
both buses, so rolling it is owner-gated). The codec was asymmetric:
write_frame checked only u32 overflow while read_frame enforced 1 MiB, and
both ends shared one constant — so the service could serialise replies its own
client structurally could not read, deterministically, for every execution over
the cap.
attempt_total{outcome="unavailable"} counts a timeout, a refused connection and
a mid-body reset identically, and reqwest's Display for a timeout is the
bare error sending request for url (…) with no source chain. That is why the
APPEND_TIMEOUT hypothesis went a full session without being settled.
Since v3.109.0 the cause is a label:
| metric | the question only it answers |
|---|---|
..._mirror_send_error_total{kind} |
which transport failure — timeout / connect / body / decode / redirect / request / other
|
All seven are pinned at 0, because Registry::gather prunes empty families and
an absent series reads exactly like a zero one — and on a healthy writer most of
these legitimately never fire.
⚠ Change APPEND_TIMEOUT only if kind="timeout" is the label that moves.
Raising a timeout before the error says what it is produces a mitigation nobody
can evaluate: if it helps you do not learn why, and if it does not you have
widened a window on the event-write path for nothing.
Every mirror send failure measured on prod 2026-09-12 was a timeout —
send_error_total{kind="timeout"}, with connect, body, decode,
redirect, request and other all at 0. The cause is one layer below:
noetl_ehdb_tier_append_records_total{path="single"} 80264
noetl_ehdb_tier_append_records_total{path="batch"} 0 ← never once
The relay appends one record at a time. A 29-record mirror POST is 29
sequential tier-service round trips against an fsync-per-append writer, and the
per-record fsync measures ~118 ms at production payload size — so 29
records overrun the server's 5 s APPEND_TIMEOUT unaided.
⚠ Do not raise APPEND_TIMEOUT. It would buy time for a loop that should
not exist.
NOETL_EHDB_TIER_APPEND_BATCH=true on the relay. Two things to know:
-
It requires chunking (noetl/worker#313).
append_batch_tierputs every payload in one frame, and the tier service reads requests atMAX_FRAME_BYTES= 1 MiB — which noetl/ai-meta#343 deliberately did not raise, because it bounds an untrusted length prefix checked before any allocation. A 64-eventitinerary-plannerbatch is ~1.17 MB, so arming batching unchunked fixes the small executions and hard-fails the large ones that are already failing. The two limits are coupled. -
Verify the path actually runs.
tier_append_records_total{path="batch"}has never been non-zero. "The flag is set" and "the path runs" are independent claims, and this counter is the one that settles the second.
⚠ The chunk budget counts escaped bytes. Payloads are JSON documents
embedded as JSON strings, so every " becomes \" and the serialised cost
approaches twice the raw length. Budgeting on raw length builds frames that pass
the local check and are refused by the service.
Worth knowing before anyone designs a "re-mirror to fix it" path.
The event-log tier deduplicates by IGNORING, not by replacing — "a dedupe
returns the existing position and does not advance the count". Re-sending the
same event_id with corrected content is a no-op that reports success.
And the tier service accepts exactly five ops:
Append AppendBatch Health ReadExecution Scan
No replace, no upsert, no delete. The KV and vector engines have them; the
event log deliberately does not, because it is append-only — the same invariant
that governs noetl.event itself.
So content written in error (see the repair section above) is not repairable in place. The remedies are to let a tier refill re-mirror it through the live path, or to mark the execution known-divergent so it stops polluting the parity metric. Adding a mutation operation to an append-only log is not a fix, it is a new problem.
NOETL_EHDB_TIER_APPEND_BATCH=true is armed on the relay, with chunking
(noetl/worker#313, v5.132.0) and the frame-cap fix (v5.131.3).
The counter that had never moved:
tier_append_records_total{path="batch"} 0 -> 460 (was 0 of 80,264 records, ever)
tier_append_records_total{path="single"} 2
⚠ Always check this counter after arming the flag. "The flag is set" and "the path runs" are independent claims, and this counter is the only thing that settles the second.
What it changed for repair:
| before | after | |
|---|---|---|
| a 64-event repair |
partial, 1 of 39 landed |
repaired, 64 of 64, one pass |
| 14 executions / 378 missing events | — |
missing_after = 0, 0 partial
|
new dropped / timeout
|
climbing | 0 / 0 |
Parity on those 14: agree 14, diverged 0, not_comparable 0 — including the
three executions that had defined the whole investigation, now all
agree pg=64 tier=64.
Check node memory requests before any roll. The relay is
replicas: 1 with maxSurge: 25%, which rounds up to +1 pod at 3 Gi. With
nodes at 99% memory requests that surge cannot schedule, forces an Autopilot
scale-up, and — if quota blocks the scale-up — takes the writer down with it.
That is the 2026-09-13 outage in one sentence.
Surge-free roll (what the pools now use):
kubectl -n noetl patch deploy/<pool> --type=merge \
-p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":0,"maxUnavailable":1}}}}'spec.strategy is not part of the pod template, so this apply causes zero
churn. A correct surge-free rollout logs 0 of N updated replicas are available — terminate-then-create, capacity-neutral. Cost is a brief
single-replica gap; mirror deliveries retry across it.
noetl-cmdbus-writer mounts cmdbus-data, eventbus-data and eventbus-kv
by name from spec.template.spec.volumes. Deleting those PVCs does not
cause them to be recreated — the pod declares volumes that do not exist and is
unschedulable forever. That is the noetl/ai-meta#323 shape. If you ever delete
them, recreate them explicitly before scaling the StatefulSet back up.
⚠ Related trap: a GCE disk showing USERS=0 means its pod is not currently
scheduled, not that nothing uses it. During an outage every one of the
writer's disks reads USERS=0.
⚠ And the PVCs are named after the buses (eventbus-*), not the workload
that mounts them (cmdbus-writer) — because one writer process hosts both
buses. They look orphaned and are not.
Six policies guard this, declared as code in noetl/ops at
ci/monitoring/alertpolicies.json: lag past the window, queue stalled, inline
fallback, shutdown-abandoned, comparator comparing nothing, controls failed.
⚠ They are Cloud Monitoring alert policies, not GMP Rules. On this project
the GMP Rules → managed Alertmanager path is inert — the OperatorConfig
names a configSecret that does not exist in gmp-public — so anything that
must page belongs in alertpolicies.json. See
noetl/ai-meta#238.
| variable | default | notes |
|---|---|---|
NOETL_EHDB_EVENTLOG_MIRROR_ASYNC |
false |
arms the queue + drain task at startup |
NOETL_EHDB_CROSSSTORE_PARITY_LAG_TOLERANCE_SECS |
0 |
required whenever async is on; keep below SETTLE_SECS
|
NOETL_EHDB_EVENTLOG_MIRROR_QUEUE_CAPACITY |
512 |
depth in batches |
NOETL_EHDB_EVENTLOG_MIRROR_ENQUEUE_TIMEOUT_MS |
5000 |
wait for room before rung 3 |
NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_MAX_BATCHES |
64 |
batches coalesced per drain pass |
NOETL_EHDB_EVENTLOG_MIRROR_FLUSH_TIMEOUT_MS |
10000 |
shutdown flush deadline |
Full env-var rationale: the deployment-specification page on the
noetl/server wiki.
NOETL_EHDB_REFERENCE_RUNTIME_CACHE=true, worker v5.119.1, live on the
cmdbus-writer StatefulSet (where the tier store lives — not on the worker
Deployments, which do not need it).
tier_store::driver() built a new driver per operation, and every method called
LocalReferenceRuntime::open, which replayed the entire JSONL log — 161 MB.
The tell was a read-only probe: scan(limit=10) returning 8 KB cost the same
835 ms as a read_execution returning 92 KB. Cost independent of result size ⇒
not the read, the replay in front of it.
emit_mirror 1141 ms → 110 ms per call; tier read_execution 858 ms → 23 ms.
NOETL_EHDB_TIER_APPEND_BATCH (ehdb#317 + worker#281): one open, N writes, one
fsync.
⚠ Still OFF on prod as of 2026-08-20 —
noetl_ehdb_tier_append_records_total{path="batch"} 0 on both replicas. It was
measured inert under the synchronous mirror (prod events arrive p50 225 ms
apart, so the mirror got ~1 record per call) and a coalescing window was
deliberately not built, because it would add latency to every event and
batch nothing. The async queue is what creates multi-record batches — proven in
kind at 104 batched vs 8 single. Enabling it on prod is tracked at
noetl/ai-meta#284.
⚠ The split is per pod: the relay targets a Service, so every append lands on one replica and the other reads 0. Sum across replicas.
- noetl/ai-meta#155 — the arc
- noetl/ai-meta#238 — alerting
- Runbook: prod cutover, event log
-
noetl/opsrunbooks/noetl-ehdb-mirror-async.md— the ops-side twin
- Home
- Architecture
- Architecture — the four engines
- Architecture — resilient KV core
- Consistency Invariants (per tier)
- Roadmap
- Sessions Log
- Claude Handoff
- RFC: Completion Program
- RFC: External EHDB Driver
- L1 Command-Bus Cutover (T4/T5 — prepared, human-gated)
- Prod Cutover — Event-Log Tier (Phase 9, Tier 1)
- Runbook: Async Event-Log Mirror
- Durable Event-Log — Prod Durability Sign-off (§C, slice 6)