offserver state builder cost - noetl/worker GitHub Wiki

Off-server state builder — where the drive-build cost actually is

Measured on prod 2026-10-01. Recorded because a design decision rests on it: see noetl/ai-meta#367 and noetl/ai-meta#366.

Which pool indexes, and why the user pool's zeros are correct

NOETL_STATE_BUILDER=offserver is set on noetl-worker-system-pool only. noetl-worker-rust does not set it, so builder_mode() returns BuilderMode::Off — and WalDrain::from_env returns Ok(None) for Off, so the WAL drain is never started there. No drain means no indexing, which means every state-builder series on the user pool reads zero correctly. That is by design: Off means the server-built state is used.

So all-zero state-builder metrics on noetl-worker-rust are not a misconfiguration. Settled in noetl/ai-meta#368 after it looked like one.

Which pod runs it

NOETL_STATE_BUILDER=offserver is set on noetl-worker-system-pool, not on noetl-worker-rust. The user pool's state builder has never run — over 2d15h of uptime every noetl_worker_state_builder_builds_total outcome read 0 and index_events was 0. Look at the system pool when investigating drive builds.

The distribution (live system pool, 2d19h uptime)

outcome builds share mean total
cold_rebuild 740 9.8% 0.23 ms 0.17 s
cache_hit 1096 14.6% 2.84 ms 3.12 s
incremental 3380 45.0% 3.88 ms 13.13 s
incomplete 2297 30.6% 49.1 ms 112.83 s

chain_hops_sum / chain_hops_count = 8245 / 740 = 11.1 hops per cold rebuild.

⚠ An earlier version of this page cited "index_events 156 resident" as a live measurement. That was wrong. index_events was a stale gauge: finish_batch refreshed indexed_executions and index_bytes but not index_events, so after a terminal eviction the first two dropped to 0 while the third kept its old value. Prod read indexed_executions 0, index_bytes 0, index_events 1 — zero executions holding one event, which is what exposed it. Fixed in noetl/worker#345; see noetl/ai-meta#368. Do not read residency from index_events on any build older than that.

Series to read (note the noetl_worker_ prefix — not noetl_state_builder_):

noetl_worker_state_builder_builds_total{outcome}
noetl_worker_state_builder_build_duration_seconds_{sum,count}{outcome}
noetl_worker_state_builder_chain_hops_{sum,count}
noetl_worker_state_builder_drive_builds_total{outcome}
noetl_worker_state_builder_index_events

⚠ The incomplete total is HISTORICAL, not steady-state

The 2297 incomplete builds and their 112.83 s accumulated in a burst and are not recurring. Measured 2026-10-01 by driving 18 executions on live prod:

counter delta over 18 executions
builds_total{incremental} +53
builds_total{cold_rebuild} +34
builds_total{incomplete} +0
drive_wait{woken} / {timeout} +0 / +0
drive_builds{fallback_incomplete} +0

87 state builds, zero incomplete, zero retry waits, zero fallbacks. The counters were also frozen across a 3-minute idle window, and incomplete took +0 across ~40 minutes of mixed activity while incremental took +16.

Likeliest cause of the burst: pod start with a cold WAL index — every drive finds the tip unindexed while the drain backfills, and the drain holds the mutex the build is waiting on, which also explains the 49 ms. The pod has 2d19h uptime, so a start-time burst is consistent with the totals.

Consequence: do not read incomplete's share of cumulative time as an ongoing inefficiency. It is a cold-start cost. Read the rate, not the total.

MEASURED: the split, on worker v6.2.1

noetl_worker_state_builder_index_lock_wait_seconds_total{outcome} shipped in worker v6.2.1 (noetl/worker#344). Measured on noetl-worker-system-pool, ~45 min after a fresh roll:

outcome builds mean build mean lock-wait mean work wait share
incomplete 4107 381.6 µs 376.6 µs 4.9 µs 98.7%
incremental 72 1592.9 µs 1.3 µs 1591.6 µs 0.1%
cold_rebuild 31 174.2 µs 1.3 µs 172.9 µs 0.7%

incomplete is 98.7% lock wait, and its work is 4.9 µs — the HashMap miss that path performs. The other two outcomes are the control: 1.3 µs of wait, so the mutex is not generally contended. It is contended specifically when the outcome is incomplete, because the drain holds it while applying the very tip the build is waiting for.

⚠ The 49.1 ms figure quoted below was a 2d19h aggregate and does NOT reproduce per-build. The real per-build wait on a fresh pod is ~377 µs — 119x smaller. The aggregate was dominated by a startup burst with a far larger WAL backlog than a normal roll faces. Reading the rate, not the total, applies to this page's own earlier numbers.

How to read these series:

mean lock wait = index_lock_wait_seconds_total{outcome} / builds_total{outcome}
mean work      = build_duration_seconds_sum{outcome}/count - mean lock wait

Confirmed empirically: the burst is cold-start

4107 incomplete builds in ~45 minutes on a freshly-rolled pod, versus 2297 accumulated over 2d19h on the previous one. Dispatch showed it from the outside too: the first two executions after the roll took 79 s and 36 s, then settled to 10 s — better than the 12–16 s pre-roll baseline.

So a worker roll costs a brief dispatch blip while the index backfills, and then runs at or better than before. Expect that; it is not a regression.

Why incomplete reads 49 ms — it is lock wait

build_duration_seconds includes acquiring the index mutex (build_started_at is set before index.lock().await), and the contention is causally tied to the outcome: a build returns incomplete precisely because the WAL drain has not applied the tip, and the drain holds that same mutex while applying it. apply_indexed, finish_batch and rehydrate_execution_from_wal all take it.

noetl_worker_state_builder_index_lock_wait_seconds_total{outcome} (noetl/worker#344) splits the two: build_duration − lock_wait is the work, and lock_wait / builds_total{outcome} is the mean wait. Merged but not yet deployed, and the ~45 ms wait / ~4 ms work prediction is therefore an untested hypothesis, not a result.

The retry loop is already event-driven

drive_wait{woken}=1884 vs {timeout}=280 — 87% woken (noetl/ai-meta#130), and 129 of 5216 drives (2.4%) reach the fallback. There is no fixed polling interval to shorten here.

Two things this overturns

1. cold_rebuild is the cheapest outcome, not the expensive one. 0.23 ms and 11 hops. The name invites the opposite assumption. The chain walk is not a bottleneck at this chain length, and advance_to already short-circuits an unchanged chain to CacheHit by comparing head_event_id — an O(1) change detector that already exists.

2. 87% of state-builder time is incomplete. That outcome means the worker's WAL index has not yet seen the server's expected_head, so the build correctly refuses and the drive retries (stateless_retry 129, fallback_incomplete 129 over the same window). It costs ~49 ms per occurrence — 20x any productive outcome — and it is a waiting problem, not a folding problem. Anything that optimises folding leaves it untouched.

Consequence for chain-certified projections (ai-meta#366)

A chain certificate here would be a redundant and more expensive version of the head_event_id comparison advance_to already does, and the total work it could remove is 16.4 s over 2d19h (cache_hit + cold_rebuild + incremental combined). The worker-side consumer was therefore not built — see ai-meta#367 for the decision and the conditions that would reopen it.

Related

  • Chain-certified fold skip — the server-side consumer, merged and flag-off.
  • worker/src/state_builder.rs — ExecutionChain::advance_to, the incremental walk and its cache.