Umbrella Request Latency - noetl/ai-meta GitHub Wiki
Umbrella: Request latency on POST /api/execute
Issue: noetl/ai-meta#319 Opened: 2026-09-02 ยท Status: in progress
The owner's target for NoETL's internal system transactions is microseconds. Measured latency was seconds. This umbrella is the decomposition of that gap into layers that can be fixed independently, and the record of which ones have been.
The decomposition
| layer | cost | verdict |
|---|---|---|
| P1 โ raw EHDB append | ~4 ms, stable across every shape | FlushPolicy::EveryAppend takes sync_data() per record. A durability posture, not an inefficiency. Parked behind ehdb#322. |
| P2 โ Postgres round-trips | ~45โ60 ms of a ~150 ms request | The same catalog row was read four times. Fixed, server#394. |
| P3 โ command publish retry | 0 | โ Diagnosed wrongly. See below. |
| P0 (found later) โ event-log mirror | 1.68 s mean, on the request path | The actual tail. Fixed, server#395. |
| delivery hop | 0โ2 ยตs | already microsecond-scale |
| publisher-mutex wait | 0 ยตs | already won; ~35ร vs the before shape |
| reconcile / sweep cadences | 1โ300 s | cadence, not transaction latency โ conflating the two makes "the system takes seconds" unfalsifiable |
โ The correction that matters most
ยง4 of the issue named the 10 s command-publish retry budget as the p95 tail. It was wrong, and the way it was wrong is the reusable lesson.
The diagnosis was assembled from constants read in source โ a 10 s deadline, 32 attempts, an exponential backoff โ which supports a claim about what could happen. It was promoted to what was happening with no instrument that could say no.
server#393 shipped the fix for it, and the counter it added read:
noetl_ehdb_command_publish_deferred_total 0
Zero, across a full load run. The publish had always succeeded inside 500 ms. p95 stayed at 14.9 s.
A hazard visible in the source is not the same as the cost visible in production. The instrument is what separates them, and it should be built before the conclusion is published, not after.
server#393 remains a correct defensive change โ it just fixes nothing that was happening.
What the tail actually was
emit_mirror measured 1.68 s mean on the request path, while every other
phase was noise (emit_publish 50 ms, emit_should_publish 2 ยตs).
NOETL_EHDB_EVENTLOG_MIRROR_ASYNC=true was set and the queue was armed. Not
enough:
queue_total{outcome="enqueued"} 2463
queue_total{outcome="enqueued_after_wait"} 327
queue_total{outcome="queue_full_inline"} 908 <- 24.6%
A quarter of all batches went back onto the request path. An inline batch waits out a 5 s enqueue timeout then POSTs with a 5 s timeout โ up to 10 s โ and a request emits three events.
The queue was not marginally behind: mean lag 170 s, only 56 of 2,790 batches draining within 10 s.
Cause: deliver_pass coalesced per execution and then POSTed strictly one
at a time, so drain throughput was 1 / relay-round-trip regardless of how
many independent executions waited โ while the function's own comment already
said different executions are independent.
It also explains the run-to-run bistability better than the issue's ยง5 did: queue fills โ submits wait then go inline โ requests slow โ more in flight โ queue stays full. A run stays under the knee or falls through it.
Recent activity
| date | what | where |
|---|---|---|
| 2026-09-02 | Decomposition filed; P1/P2/P3 prioritised | #319 |
| 2026-09-03 | P3 shipped (v3.100.3) โ bounded on-path publish to 500 ms with a background continuation carrying the remaining deadline | server#393 |
| 2026-09-03 | P3 measured and refuted; publish_deferred_total = 0, p95 unchanged |
#319 comment |
| 2026-09-03 | P2 shipped โ four catalog reads โ one, no cache introduced | server#394 |
| 2026-09-03 | P0 shipped โ concurrent mirror drain (v3.100.4, live) | server#395 |
The validated curve โ and what it says
Clean run on v3.100.4, 1/2/4/6/8/10 ร 2 reps, nothing probing the server.
rep conc submit ok err p50ms p95ms sched
1 1 46 46 0 120 2820 5s
1 2 46 46 0 140 11010 6s
1 4 20 8 12 30040 30080 6s
1 6 0 0 0 - - 64s <- SKIP, no generator scheduled
1 8 91 81 10 3430 30040 49s
1 10 36 7 29 30040 30050 54s
2 1 19 19 0 150 12050 6s
2 2 35 34 1 150 8000 6s
2 4 32 20 12 7550 30150 6s
2 6 18 0 18 30030 30110 6s
2 8 24 0 24 30040 30210 5s
2 10 102 84 18 7230 30030 49s
โ โ Verdict: the bistability is NOT gone
c=8 goes 81 ok โ 0 ok and c=10 goes 7 ok โ 84 ok between two runs of the same configuration. That is the original cliff, unchanged in character. Only cโค2 is now consistently fast and clean.
โ โ And the first "after" p95 was one rep
p95 14,910 โ 3,070 ms at c=1 was published from a single sample; the repeat read 12,050 ms. One sample of a bimodal distribution reported as the distribution โ the same mistake as ยง4, one level down. p50 is what is genuinely reproducible: 510 โ 120/150 ms at c=1, 5,730 โ 140/150 ms at c=2.
Run-wide deltas: inline fallback 24.6% โ 8.3%, emit_mirror
1.68 โ 0.78 s, lag 170 โ 67 s. Everything ~2โ3ร better; nothing
eliminated. The queue stayed pinned at depth 512 ten minutes after the load
stopped, so the backlog outlives the traffic that made it.
The tail is now cleanly P1-bound. More drain concurrency cannot help: 8
parallel POSTs still land behind one writer taking ~4 ms fsync per append.
Next concrete steps
- Fix two rig defects before the next capacity measurement. A SKIP row
(
0 0 0with-latencies) reads like a total collapse at a glance; and levels run back-to-back with no drain interval, so each inherits the previous one's backlog โ the two best rows both had ~50 s of Autopilot scheduling in front of them, which is free drain time. Neither number can be read as steady-state capacity until the queue settles between levels. - Tune the drain against the writer.
DRAIN_MAX_BATCHES(more records coalesced per POST, hence fewerfsyncs) is the likelier lever than more concurrency, because the writer is the ceiling โ but it risks the tier's page cap and needs measuring first. - P1 stays parked behind ehdb#322.
The drain cannot outrun the writer it feeds, so ~4 ms/append bounds
everything above it. Group-commit already exists in the engine
(
append_batch, onefsyncper batch); adopting it is a durability trade, and that is the owner's decision, not an optimisation.
Decisions taken
- The 10 s publish deadline was not shortened. It exists to span a writer
pod restart (~2.7 s measured, [#208]) and a dropped publish is lost โ
command.issuedis durable but nothing reaches the bus, and there is no NATS after T5. Only the waiter moved. - The mirror drain's concurrency is safe because of the queue's shape, not
because ordering is unimportant: a pass holds at most one batch per
execution_id, and pass N+1 does not start until pass N completes.NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_CONCURRENCY=1is the rollback. - P2 introduced no cache. The row is read fresh every request; only the repeats were removed. A negative control fails the suite if content is ever retained, because a content cache would be faster still and serve stale playbooks.
Related
- ehdb#322 โ the quorum-ack / durability decision P1 belongs with
- Umbrella: EHDB Primary Serve
playbooks/load-test/innoetl/ai-metaโ the measurement rig, and the four harness artifacts it was built to remove