deployment specification - noetl/worker GitHub Wiki
NATS is fully removed from the command path (2026-08-01). The worker claims commands from the EHDB bus (
NOETL_COMMAND_BUS=ehdb+NOETL_COMMAND_BUS_CLAIM_ADDR) and its pool comes fromNOETL_FEED_FILTER_SUBJECTβ the legacyNATS_FILTER_SUBJECTname is no longer read. A worker that cannot resolve a pool now refuses to start rather than silently joiningshared, which previously stalled every execution with nothing in the logs (noetl/ai-meta#218).NatsSubscriber,NatsCommandSourceand the NATS lag poller are gone;CommandNotification,segment_from_filterandclaim_outcomemoved tosrc/dispatch.rsbecause they were NATS-named but on the live EHDB path. This is about the internal bus only.natsis also a user-facing tool kind: a playbook step can point it at the user's own broker to store or publish business data, the same waypostgres,duckdband the object-store kinds reach user-supplied services. That tool lives in the separatenoetl-toolscrate with its ownasync-nats, takes its endpoint from the step'surlor a keychain credential, and is fully supported β verified on v5.91.2 bytests/user_nats_tool_dispatch.rs. See nats-mcp-tool-kinds. EHDB is internal transient infrastructure and is never exposed as a user store.The worker's own
async-natsdependency still ships forstate_builder's WAL-rehydrate path (live undershard_read_verify, readsNATS_URL), the materializers' NATS arms andspool_runtime'snats_objectbackend. noetl/ai-meta#219 once proposed dropping the dependency wholesale; it is closed as won't-remove β doing so would have taken the user-facing tool and the rehydrate path with it. Recovery path: the DR IaC inplaybooks/211-t3-events-migration/nats-dr/. See noetl/ai-meta#212 and #220.
This page is the durable reference for deploying noetl-worker into any environment. It covers the runtime contract the binary expects, the resources it consumes, the network surface it exposes, and β critically β every environment variable it reads, with the why behind each one.
This page is the single source of truth for the deployment shape.
Any code change that adds, renames, removes, or shifts the
meaning of an env var MUST update the
Environment Variables section in the
same change set. Same rule for ports, dependencies, and
runtime requirements. See
agents/rules/wiki-maintenance.md.
The matching deployment manifests live in noetl/ops (Helm chart + kind overlays). This wiki page describes what the manifests need to provide; the manifests are the implementation.
| Field | Value |
|---|---|
| Repo | noetl/worker |
| Binary | noetl-worker |
| Container image |
noetl-worker (built from the repo's Dockerfile) |
| Image versioning | crates.io version pinned in Cargo.toml; semver releases tagged vX.Y.Z
|
| Current version | see Cargo.toml package.version
|
| Language / runtime | Rust 1.91+; Tokio multi-threaded |
| Process model | Single binary, single process per pod |
| Role | EHDB command-bus claimer + tool dispatch. Stateless atomic-compute block per agents/rules/execution-model.md. |
What the binary expects from its environment to start cleanly:
-
EHDB command-bus coordinator reachable at
${NOETL_COMMAND_BUS_CLAIM_ADDR}(the writer's claim face,:9101), withNOETL_COMMAND_BUS=ehdb. Hard requirement. 1b.NOETL_FEED_FILTER_SUBJECTset (e.g.noetl.commands.system.>). It is the input the worker's EHDB pool is derived from. A worker that cannot resolve a pool refuses to start rather than defaulting tosharedβ a crashloop an operator sees in seconds beats a cluster that looks healthy while nothing progresses (noetl/ai-meta#218). -
noetl-server reachable at
${NOETL_SERVER_URL}(HTTP). The worker's per-commandserver_urloverride (set by the publishing server on every command notification per noetl/ai-meta#53) takes precedence at runtime; the env var is the initial default for one-server deployments. -
Worker pool slot:
WORKER_POOL_NAMEidentifies which pool this worker belongs to. Server-side runtime registration is keyed by(kind, name); multiple workers in one pool share work. -
Worker identity:
WORKER_IDdistinguishes this individual worker. When unset, the worker generates a uuid (fine for ephemeral pods; for StatefulSets prefer setting it explicitly from the pod ordinal so logs + metrics stay readable across restarts). -
KEDA-managed scaling: the worker is scaled by KEDA on EHDB per-subject
lag, scraped from the writer's
:9102/metrics(ehdb_feed_subject_lag{subject="commands.<pool>.shard.<n>"}). The oldnats-jetstreamtrigger is gone. See Scaling.
| Port | Protocol | Purpose | Bind |
|---|---|---|---|
9090 |
HTTP | Prometheus scrape endpoint at /metrics + /healthz + /readyz + /livez, and the read-only EHDB tier-query route GET /ehdb/tiers/{tier}
|
${WORKER_METRICS_BIND} (default 0.0.0.0:9090) |
9100 |
TCP | Command-bus writer host only β feed ingest; the server publishes commands here | ${NOETL_COMMAND_BUS_INGEST_BIND} |
9101 |
TCP | Command-bus writer host only β claim coordinator; worker replicas compete for commands here | ${NOETL_COMMAND_BUS_CLAIM_BIND} |
9102 |
HTTP |
Command-bus writer host only β /metrics carrying ehdb_feed_total_lag / ehdb_feed_shard_lag{shard} / ehdb_feed_subject_lag{subject} (the KEDA scale signal). β Not a cheap read β it also samples the durability window and takes the engine lock, so a slow tier op blocks it; see cmdbus-writer-tier-service
|
${NOETL_COMMAND_BUS_METRICS_BIND} |
The scrape port also carries the EHDB Data Query Interface
data-plane read handler (noetl/ai-meta#178):
GET /ehdb/tiers/{tier} where tier β eventlog|kv|object|vector.
The noetl-server relays raw-tier queries here over a direct
synchronous HTTP read (not the NATS drive) via
NOETL_EHDB_WORKER_QUERY_URL. The handler is read-only, guarded
(worker/src/ehdb/guard.rs refuses control-plane roles),
disabled-by-default (a strict outcome=disabled no-op unless
NOETL_EHDB_ENABLED is truthy β no new env var of its own), bounded
(limit/top_k β€ 1000), and secret-free (it relays the tier
*Outcome view structs verbatim). It reads the same
local_reference log / durable per-shard segment stores the tier
mirrors write through; the raw eventlog scan k-way-merges across the
durable shards this replica owns. Because the mirror is pod-local
and this Service is headless, point the server's query URL at a
single-replica pool for coherent reads. Apart from this read route
the worker exposes no API β all other traffic flows out (NATS pull,
HTTP to server).
| Target | Protocol | Why |
|---|---|---|
| NATS JetStream | TCP 4222 (default) | Pull commands from noetl.commands.* subjects. Receive notifications with the publishing server's URL. |
| noetl-server | HTTP (default port 8082) | Fetch command details (GET /api/commands/{event_id}), claim atomically (POST /api/commands/{event_id}/claim), emit lifecycle events (POST /api/events), put result blobs (POST /api/result_store/...). Per-command server_url from the NATS notification overrides this default. |
| External APIs (Auth0 / Duffel / OpenAI / ...) | HTTPS | Whatever the executing playbook tool calls. Credentials resolved via NoETL keychain or NOETL_KEYCHAIN_ENV_VARS allowlist (see Keychain credentials). |
The worker does NOT call Postgres directly. Per
agents/rules/data-access-boundary.md,
NoETL platform data is accessible via server API only.
Recommended starting point for production. Workers scale on EHDB per-subject backlog (KEDA), so each replica should be sized for one concurrent playbook dispatch.
| Resource | Request | Limit | Notes |
|---|---|---|---|
| CPU | 100m | 500m | Per-replica; CPU-bound only during tool execution (Python eval, JSON transforms). Idle workers consume <5m. |
| Memory | 128Mi | 768Mi | The limit must cover the memory-backed /dev/shm tmpfs (charged to the pod cgroup, sized to the Arrow IPC cache budget β see Shared memory below) plus the worker RSS. With the 256 MB cache budget the /dev/shm tmpfs is 320Mi, so the limit is 768Mi (320Mi shm + worker RSS headroom). |
| Ephemeral storage | 100Mi | 500Mi | Tool execution scratch (Python eval temp dirs). |
WORKER_MAX_CONCURRENT (default 1) governs how many commands a
single worker pod processes in parallel. Stay at 1 unless the
pool's tools are I/O-bound (HTTP fetches dominated by external
latency); CPU-bound tools should scale out via more replicas, not
more concurrency per pod.
Required: a memory-backed /dev/shm sized to exceed the Arrow IPC
cache budget.
The worker allocates one Arrow IPC shared-memory cache per process at
init (ArrowIpcSharedMemoryCache::new(), budget
NOETL_IPC_CACHE_BUDGET_BYTES, default 256 MB). The cache stages
call.done results that exceed the broker's 100 KB inline budget into
POSIX shared-memory regions backed by /dev/shm (via the
shared_memory crate's shm_open+ftruncate+mmap).
The Kubernetes container-runtime default for /dev/shm is a 64 MiB
tmpfs. When the cache writes past 64 MiB the store page-faults against
the full tmpfs and the kernel delivers SIGBUS β the worker dies
with exit code 135 and crash-loops. Mount a memory-backed
emptyDir at /dev/shm sized above the cache budget:
spec:
containers:
- name: noetl-worker
env:
- name: NOETL_IPC_CACHE_BUDGET_BYTES
value: "268435456" # 256 MiB β pin next to sizeLimit below
volumeMounts:
- name: dshm
mountPath: /dev/shm
resources:
limits:
memory: "768Mi" # >= sizeLimit + worker RSS
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 320Mi # >= NOETL_IPC_CACHE_BUDGET_BYTES + headroomThree values move together and must stay coherent:
| Value | Constraint |
|---|---|
NOETL_IPC_CACHE_BUDGET_BYTES |
the cache budget (256 MiB default) |
/dev/shm emptyDir sizeLimit
|
> the budget (320Mi = budget + 64Mi headroom for tmpfs page rounding) |
| container memory limit |
> sizeLimit + worker RSS (768Mi); the tmpfs is charged to the pod cgroup |
If you raise the cache budget, raise the sizeLimit and the memory
limit in lockstep. The committed manifests wiring this up live in
noetl/ops ci/manifests/noetl/worker-*.yaml
(fixed in ops#193).
| Probe | Path | Initial delay | Period | Failure threshold | Effect |
|---|---|---|---|---|---|
| Liveness | /healthz |
10s | 10s | 3 | Pod restart |
| Readiness | /readyz |
3s | 3s | 20 | Removed from Service endpoints / rollout blocked until warm |
/healthz returns 200 whenever the process is responding (it does not check
upstream NATS / control plane β those have their own failure modes the
heartbeat covers), so a slow warmup never trips a liveness restart.
/readyz (noetl/ai-meta#130) returns 503 warming during boot and 200
ready once boot warmup has completed β on the drive pool that means the
system/orchestrate wasm module is Cranelift-compiled + cached (the
noetl_worker_ready gauge flips 0β1). Gating readiness on warmup hides the
one-time ~1.5s compile (the cold-start, ~99% Module::new) from the first real
request: Kubernetes only routes / completes a rollout once worker_ready=1.
The warmup also completes before the pull loop starts claiming, so the first
orchestration is warm even where the probe is not wired (e.g. the metrics port
is not fronted by a Service).
The table above describes the HTTP probes the binary serves on :9090. The
prod noetl-cmdbus-writer StatefulSet does not use them β it probes
cmdbus-claim (:9101) over TCP, for liveness as well as readiness:
livenessProbe: { tcpSocket: { port: cmdbus-claim }, timeoutSeconds: 1,
periodSeconds: 20, failureThreshold: 3 }
readinessProbe: { tcpSocket: { port: cmdbus-claim }, timeoutSeconds: 1,
periodSeconds: 5, failureThreshold: 3 }A listening socket passes a TCP probe whatever the process is doing behind it,
so the writer reports Ready with Restart Count: 0 through a stall of any
length. On 2026-09-22 that hid an 18-hour degradation in which the tier
service's read_execution ran to a ~36s tail and took the whole product with
it (noetl/ai-meta#351). Same
class as noetl/ai-meta#322, and
worse: there only readiness was TCP.
Full detail: cmdbus-writer-tier-service.
-
Stream:
noetl_commands(default; override viaNATS_STREAM). Subjects:noetl.commands.>. Historical β the NATS layout below is retained for the DR path only. -
Consumer: pull-based, durable. Default name
worker-pool(override viaNATS_CONSUMER). Filter subject:noetl.commands(override viaNATS_FILTER_SUBJECT). -
Subjects the worker reads: per-pool filter shapes vary; the
server publishes commands to
noetl.commands.{system|shared}.<execution_id>per the Phase F sharding-design. A "shared" pool worker filters onnoetl.commands.shared.*; a "system" pool worker filters onnoetl.commands.system.*.
The worker is the scale-out unit per
agents/rules/execution-model.md.
KEDA reads NATS consumer lag and scales the Deployment between
minReplicas and maxReplicas.
Standard KEDA ScaledObject for the worker pool:
triggers:
- type: nats-jetstream
metadata:
account: $G
natsServerMonitoringEndpoint: "nats.noetl.svc:8222"
stream: noetl_commands
consumer: worker-pool
lagThreshold: "5"Workers can be added, removed, or restarted freely β state lives in the server's event log + cache; the worker is stateless.
The worker mints its own snowflake IDs via
src/snowflake.rs
for client-side event IDs (per
agents/rules/observability.md
Principle 3). The 10-bit node id comes from either:
-
NOETL_SNOWFLAKE_NODE_ID(preferred β set per pod by the deployment manifest). -
NOETL_SHARD_ID(back-compat alias for the same idea). - Derived from
NOETL_NODE_ID/NODE_NAME/ pod hostname via FNV-1a hash to 10 bits.
For multi-replica deployments, set NOETL_SNOWFLAKE_NODE_ID
explicitly to avoid hash collisions producing duplicate IDs.
The epoch comes from NOETL_SNOWFLAKE_EPOCH_MS if set, otherwise
defaults to a build-time constant. Match the server's epoch
(2024-01-01T00:00:00Z UTC) to keep IDs mutually orderable across
producers.
All env vars the binary reads at startup or runtime, with the why behind each one.
| Variable | Default | Required | Why |
|---|---|---|---|
WORKER_ID |
(uuid v4 generated at startup) | recommended | Unique identifier for this worker pod. Embedded in event metadata + runtime registration so the server can track which worker handled which command. For StatefulSet pods, derive from the pod ordinal so logs/metrics stay stable across restarts. |
WORKER_POOL_NAME |
default |
yes for non-default pools | Pool the worker registers under. Drives the NATS subject filter (system pool reads noetl.commands.system.*, shared pool reads noetl.commands.shared.*). Must match the pool's runtime row name on the server side. |
WORKER_HEARTBEAT_INTERVAL |
(see code; ~10s) | no | Seconds between heartbeat POSTs to /api/worker/pool/heartbeat. Lower = faster failover detection; higher = less HTTP load. Tune to match the server's NOETL_RUNTIME_OFFLINE_SECONDS. |
WORKER_MAX_CONCURRENT |
1 |
no | Number of commands this pod processes concurrently. See Resources. |
WORKER_METRICS_BIND |
0.0.0.0:9090 |
no | Bind address for the metrics + health HTTP server. Override only when you don't want to accept scrape traffic on all interfaces. |
WORKER_NATS_LAG_POLL_INTERVAL |
(see code) | no | Seconds between consumer-lag polls used by the worker's own lag-aware logic (independent of KEDA's external poll). Tune only when the default is shown to be too lazy or too aggressive. |
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_SERVER_URL |
(built-in fallback, typically http://noetl.noetl.svc:8082) |
yes | Initial default URL for the noetl-server HTTP API. Note: as of noetl/worker#41 the per-command server_url from the NATS notification overrides this at runtime, so a worker that picks up a command published by a different server replica (or by the Rust server vs Python server) will correctly route lifecycle events back to the publishing server. Use an https:// URL when the server's TLS listener is on (see Transport security below). |
Secrets Wallet Phase 4b (noetl/ai-meta#61,
noetl/worker#56) β the worker half of
the mTLS transport the server opts into in Phase 4a
(noetl/server#103, NOETL_TLS_*).
The worker's control-plane HTTP client (credential fetch, command claims, event
posts) speaks plain HTTP by default; the env below makes it present a client
certificate so the workerβserver credential channel is authenticated +
encrypted (the resolved secret no longer travels plaintext on the wire).
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_TLS_CLIENT_CERT |
β | with NOETL_TLS_CLIENT_KEY
|
PEM client-certificate-chain path (the mTLS identity the worker presents). Both cert + key together, or neither (setting one is a fail-fast misconfig). |
NOETL_TLS_CLIENT_KEY |
β | with NOETL_TLS_CLIENT_CERT
|
PEM private-key path for the identity cert. Never logged. |
NOETL_TLS_CA |
β | for a private-CA server | PEM CA-bundle path the worker adds as a trust root when verifying the server's certificate. Needed when the server cert is signed by an internal CA rather than a public root. Independent of the identity (a publicly-trusted server needs none). |
Built on the rustls-tls reqwest backend the worker already uses
(reqwest::Identity::from_pem + Certificate::from_pem_bundle). Set
NOETL_SERVER_URL to the server's https:// URL when these are on. The
cert/key/CA are mounted from a K8s Secret; the manifests live in
noetl/ops.
Init-container caveat (Phase 4c): the wait-for-api init container curls
/api/health over plain HTTP β against an mTLS server it can't complete and
blocks the pod in Init. It needs the client cert (mTLS curl) or a non-mTLS
health path (parallels the server's mTLS httpGet-probe caveat).
Secrets Wallet Phase 5c (noetl/worker#58, tracks noetl/ai-meta#61) β defense in depth on top of the Phase-4 mTLS transport. mTLS encrypts the wire; sealing encrypts the credential payload to a key only this worker holds. The cleartext exists only briefly inside the worker process after unseal; never on the wire, never in the server's HTTP response body.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_SEALED_CREDENTIALS |
false |
when sealing | Master toggle. When true / 1 / yes and WORKER_ID is set, the worker calls GET /api/credentials/{alias}/sealed?worker_id=<WORKER_ID> instead of the plaintext path. The server (v2.32.0 + the Phase-5b endpoint, server#109) seals the response to the X25519 public key this worker registered on POST /api/worker/pool/register. |
Wire shape. The worker generates a long-lived X25519 keypair once at
startup, includes the base64 public half in its register payload's runtime
JSON blob:
{
"name": "<WORKER_ID>",
"component_type": "worker_pool",
"runtime": { "kind": "rust", "worker_public_key": "<base64-32-byte-x25519-pub>" },
"status": "ready",
...
}The private half stays in-process for the worker's lifetime β only the public
half ever leaves. The server-side
deployment-specification Β§ Sealed credential delivery
describes the endpoint shape; the wire envelope is
{alg, v, eph_pub (b64), ciphertext (b64)} carried by the same noetl_sealed
algorithm constant on both sides
(x25519-hkdf-sha256-chacha20-poly1305, v=1).
Zeroize. After the auth-alias resolver has injected the resolved fields
into the tool config, the worker zeroizes the string values in the
intermediate credential.data map so an OOM core dump or a debugger snoop
on the worker pod can't recover the secret from the post-dispatch heap.
Sealing requires a worker name on the server side. The server looks up
worker_public_key by the name (kind=worker_pool) of the runtime row.
Workers identify themselves at register time with WORKER_ID, so that env
var is required for sealing to work; absent β the wrapper logs a WARN and
falls back to the plaintext path.
Command bus β NATS or EHDB (noetl/ai-meta#194 L1 T4)
This is live in production. Since 2026-07-27 the shastaratech prod cluster runs
NOETL_COMMAND_BUS=ehdbβ commands reach workers over the EHDB feed, not NATS. NATS is still installed as the rollback path; deleting it (T5) is a separate, irreversible approval.
The worker plays two roles here, and they are configured separately.
As a consumer (every worker pool) it claims commands from the shard coordinator over the network instead of subscribing to NATS. As a writer host (one designated deployment per command shard) it owns the durable command log and serves the ingest / claim / metrics faces that the server and all other workers talk to.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_COMMAND_BUS |
nats |
no | Transport selector: nats (today's default) / ehdb (claim from the shard coordinator, no NATS subscriber is created) / shadow (NATS authoritative, EHDB mirrored for parity comparison). Read once at process start. |
NOETL_COMMAND_SHARD_COUNT |
1 |
no | Shard modulus. Must match the server's value β a mismatch routes commands to a shard nobody claims. Prod runs 1 (the #202 Option-2 decision); this is a different axis from NOETL_SHARD_COUNT (#166 state sharding), which is unaffected. |
NOETL_COMMAND_BUS_CLAIM_ADDR |
β | in ehdb mode |
host:port of the shard coordinator to claim from. A writer host in ehdb mode also needs this β it is a consumer of its own shard too. Omitting it on the writer was one of three config bugs the kind rig caught before prod. |
NOETL_COMMAND_BUS_HOST |
false |
on the writer |
true on the deployment that hosts this shard's writer. |
NOETL_COMMAND_BUS_SHARD |
0 |
on the writer | Which shard this writer owns. |
NOETL_COMMAND_BUS_WRITER_DIR |
β | on the writer | Durable command-log directory. MUST be a PVC β a pod-local path loses the command log on reschedule. Prod uses premium-rwo (PD-SSD) 20Gi. |
NOETL_COMMAND_BUS_INGEST_BIND |
β | on the writer | Ingest listen address; the server publishes here. |
NOETL_COMMAND_BUS_CLAIM_BIND |
β | on the writer | Claim listen address; consumers compete here. |
NOETL_COMMAND_BUS_METRICS_BIND |
β | on the writer |
/metrics listen address (ehdb_feed_total_lag, ehdb_feed_shard_lag{shard} β the KEDA signal). |
NOETL_COMMAND_BUS_ACK_WAIT_SECS |
30 |
no | Redelivery window. A claim unacked past this is redelivered to another consumer β the mechanism behind the 0-loss pod-kill result. |
Pool isolation is not a separate flag: each consumer subscribes with a
subject filter (commands.<pool>.>) derived from its NATS_FILTER_SUBJECT
segment, and the coordinator only ever hands a member a matching command. A
user-pool worker therefore cannot claim a system command β proven on prod
(0 system claims during the canary).
Rollback is a single env flip back to nats on the server and every
worker pool, valid for as long as NATS remains installed.
Full cutover procedure, the three-round prod execution log, and the T5 checklist: ehdb wiki β Runbook: L1 Command-Bus Cutover.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_FEED_FILTER_SUBJECT |
β | yes | The routing filter subject the worker's EHDB pool is derived from (noetl.commands.system.> β pool system). Replaces the legacy NATS_FILTER_SUBJECT, whose fallback was removed. Unset / unresolvable β the worker refuses to start (noetl/ai-meta#218). |
NOETL_COMMAND_BUS |
nats |
yes β set to ehdb |
Any value other than ehdb is a startup error: the NATS transport no longer exists. |
NOETL_COMMAND_BUS_CLAIM_ADDR |
β | yes | The writer's claim face, e.g. noetl-cmdbus-writer-0.noetl.svc.cluster.local:9101. |
NATS_USER |
β | when NATS auth | NATS user. |
NATS_PASSWORD |
β | when NATS auth | NATS password. Should come from a K8s Secret. |
NATS_STREAM |
noetl_commands |
no | JetStream stream name. Override only when running multiple NoETL deployments on one NATS cluster. |
NATS_CONSUMER |
worker-pool |
no | Durable consumer name. All workers in one pool share the same consumer name. |
NATS_SUBJECT |
noetl.commands |
no | Base subject prefix. Pool-specific filter (noetl.commands.system.* vs noetl.commands.shared.*) appends below this. |
NATS_FILTER_SUBJECT |
(derived from pool) | no | Explicit override for the consumer's filter subject. Use only when the pool routing scheme needs a custom filter. |
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_SNOWFLAKE_NODE_ID |
β | per-replica in prod | 10-bit node id (0β1023) for the worker's snowflake generator. Each pod in a deployment MUST set a distinct value to avoid id collisions producing duplicate event IDs. Same shape as NOETL_SERVER_MACHINE_ID on the server side. |
NOETL_SHARD_ID |
β | (alias) | Back-compat alias for NOETL_SNOWFLAKE_NODE_ID; same semantics. |
NOETL_NODE_ID |
(HOSTNAME) | β | Fallback identifier hashed to derive the 10-bit node id when neither NOETL_SNOWFLAKE_NODE_ID nor NOETL_SHARD_ID is set. Read at startup. |
NODE_NAME |
(set by container runtime via downward API) | β | Same fallback chain as NOETL_NODE_ID; standard K8s downward-API value. |
NOETL_SNOWFLAKE_EPOCH_MS |
(build-time default: 2024-01-01T00Z) | no | Epoch the snowflake timestamps count from. Override only to match an alternate epoch elsewhere in the system β but the noetl-server uses 2024-01-01, so changing this on the worker breaks cross-component ordering. |
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_KEYCHAIN_ENV_VARS |
β | when env-aliased credentials used | Comma-separated allowlist of env var names that hold credentials playbook steps can reference by alias. Example: NOETL_KEYCHAIN_ENV_VARS=NOETL_FLIGHT_BEARER_TOKEN,OPENAI_API_KEY permits a playbook to write bearer_token: NOETL_FLIGHT_BEARER_TOKEN and the worker resolves it from the env at dispatch time. The values themselves are loaded from the same env (the variable name is the alias and the var holds the secret). See src/executor/command.rs::KEYCHAIN_ENV_ALLOWLIST_VAR. When unset, env aliasing is disabled and all credentials must go through the NoETL keychain API. |
The mechanism is a bridge for credentials that already exist
as env vars in the worker's environment (because they come from
GKE Workload Identity, an existing K8s Secret mount, or similar
already-in-place trust per
agents/rules/execution-model.md).
Net-new business-logic credentials belong in the NoETL keychain,
not in this allowlist.
| Variable | Default | Required | Why |
|---|---|---|---|
HOSTNAME |
(set by container runtime) | β | Fallback for snowflake node-id derivation. |
RUST_LOG |
(build default) | no | Standard tracing-subscriber filter. Set to noetl_worker=debug for targeted debugging. |
NOETL_IPC_CACHE_BUDGET_BYTES |
268435456 (256 MB) |
no | Arrow IPC shared-memory cache budget for same-node zero-copy reads. The pod's /dev/shm must be a memory-backed tmpfs sized above this value, or the worker SIGBUSes (exit 135) when the cache fills past the 64 MiB k8s default β see Shared memory. Tune up only when working sets exceed 256 MB; raise the /dev/shm sizeLimit and the pod memory limit in lockstep. |
NOETL_PLAYBOOK_AWAIT_CHILD_RESULT |
true |
no |
Sub-playbook result return (noetl/ai-meta#136, noetl-tools 3.18.0). Kill-switch for the playbook tool's opt-in return_result: true mode. When a kind: playbook step sets return_result: true (the travel itinerary-planner's `mcp/google-places |
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_EVENT_RESULT_CONTEXT_MAX_BYTES |
102400 (100 KB) |
no | Inline budget for a tool result's serialised context on the call.done event. A result within budget goes inline ({status, context}); a result over budget is staged in the durable result store (noetl.result_store) + the shm cache, and the event carries a {data:{_ref}} placeholder + a reference block instead β keeping the event log lean. The server orchestrator resolves those references when driving state (hydrate_result_references, noetl/server#197). Lower it to push more results through the reference path (ops tuning, or forcing the path in kind validation β =256 was used to validate every PFT result by reference); raise it to keep more results inline. Read per-result in src/executor/command.rs::inline_context_max_bytes. |
NOETL_RESULT_MATERIALIZER_ENABLED |
false |
no |
Result materializer β shadow Feather tier (noetl/ai-meta#104 Phase B, system pool only). Truthy spawns a SEPARATE noetl_events consume-loop (consumer noetl_result_materializer, own ack cursor) that, for an over-budget result, fetches the authoritative payload (read-only), tiers it (tabular β Arrow Feather, non-tabular β JSON, small β no write) and writes the body to the derived Β§7 physical_key via the server's PUT /api/internal/objects/{key} β shadow, alongside noetl.result_store; nothing reads it until the resolve path is enabled. Never alters the authoritative result, never fails an event. Default off β not spawned (true no-op). |
NOETL_RESULT_URI_RESOLVE |
false |
no |
Resolve-by-URN read path (noetl/ai-meta#104 Phase C, consume pool). Truthy makes a step that binds the bulk of an over-budget upstream result resolve it by canonical logical URI β cell placement (server registry) β Β§7 key β object bytes (GET /api/internal/objects/{key}) instead of the legacy noetl.result_store fetch. On any registry miss / object miss / decode error it falls back fail-safe to the authoritative resolve_ref (noetl_worker_result_resolve_total{outcome} records resolved_* vs fallback_*). Default off β byte-identical legacy read path. |
NOETL_RESULT_MINT_AUTHORITATIVE |
false |
no |
Phase D minting flip (noetl/ai-meta#104 Phase D). ONE flag that makes the URN β Feather/GCS tier the authoritative result store: on the system pool it makes the result materializer the authoritative tier writer (implies NOETL_RESULT_MATERIALIZER_ENABLED); on the consume pool it makes resolve-by-URN the primary read path (implies NOETL_RESULT_URI_RESOLVE). A tier miss / parse failure still falls back fail-safe to the dual-written noetl.result_store (rollback safety), recorded on noetl_worker_result_mint_authoritative_total{path} (tier = authoritative tier served; legacy_fallback = reversible fallback served). Default off β byte-identical to Phase AβC (true no-op). The dual-write to result_store continues until the OQ5-gated retirement decision (NOT Phase D), so flag-off rolls back cleanly. |
NOETL_SIDE_EFFECT_BARRIER |
false |
no |
Side-effect durability barrier (noetl/ai-meta#104 Phase E, consume pool). Before the worker (re-)dispatches a side-effecting tool (per the registry classifier noetl_tools::registry::kind_is_side_effecting, noetl-tools 3.17.0), it checks whether the cycle's derived result URN already resolves to a durable result (the Phase C resolve-by-URN read path). If it does β the cycle already ran to a durable completion on a prior drive β the worker skips re-execution and adopts the recorded result, so an external side effect (HTTP POST, DB write, payment) fires exactly once across a crash-resume / re-drive. Non-side-effecting cycles are never blocked (idempotent recompute is fine); a side-effecting cycle whose result is not durable re-executes normally. Adopt-only: resolve_by_urn returns Some only on a durable tier hit, so the barrier can only ever turn a duplicate side effect into a single one β never drop work. Outcomes on noetl_worker_side_effect_barrier_total{outcome} (skipped = adopted durable result; executed = re-executed). Default off β byte-identical to today (the barrier block is short-circuited by the cheap flag read; dispatch unchanged). |
NOETL_RESULT_TIER_DR |
false |
no |
Result-tier DR re-derive (noetl/ai-meta#104 Phase F, system pool only). The Feather/JSON result tier is derivable from the WAL β an object's bytes are the deterministic encode of the authoritative payload and its location is computed from the logical URI β so a missing or corrupt tier object can be rebuilt from its source by re-running the materialization for its URN. Truthy puts the result materializer in verify-and-repair mode: for each over-budget result it re-derives the object + Β§7 key and rewrites it only when the durable object is missing or byte-divergent (corrupt); a healthy object is left untouched. The flag alone spawns the materializer (DR-only mode β no need for NOETL_RESULT_MATERIALIZER_ENABLED). Never alters the authoritative result_store source; the rewrite is byte-identical to a fresh materialization. A WAL event re-delivery is therefore a targeted DR repair. Outcomes on noetl_worker_result_tier_dr_total{outcome} (present / rederived / source_gone / error). Default off β byte-identical to Phase B/D (normal write path; this branch never runs). |
System worker pool only. The off-server state builder reconstructs orchestrator
WorkflowState from the noetl_events WAL (not the materialized
noetl.event table), walking the one-level prev_event_id chain and caching the
built spine keyed by the immutable chain head
(noetl/ai-meta#115 Phase 4). The
shadow loop is observation-only β it proves on the running cluster that the
builder reads the WAL with zero noetl.event scans and that the chain-walk +
pool-side cache (hit / incremental tail-advance / cold-rebuild) behave, without
touching the drive. The drive cutover that makes the drive consume this
builder's state is staged behind the server's NOETL_STATE_BUILDER=offserver.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_STATE_BUILDER_SHADOW |
unset (off) | no | Truthy (1/true/yes/on) spawns the off-server state-builder shadow loop (system pool). It opens its own ephemeral DeliverAll/AckNone pull consumer on noetl_events (never the materializer's durable noetl_materializer consumer), replays the retained WAL into a pool-side per-execution chain index, and exercises the chain walk + cache, emitting the state_builder metrics below. Observation-only β no drive impact; safe to leave on for monitoring. Default off β every other worker unaffected. |
NOETL_STATE_BUILDER_STREAM |
noetl_events |
no | The JetStream WAL stream the shadow drains. Mirror of the materializer's NOETL_MATERIALIZER_STREAM; only override when the stream is renamed. |
NOETL_STATE_BUILDER_BATCH |
200 |
no | Bounded pull batch (clamped 1β1000). |
NOETL_STATE_BUILDER_TIMEOUT_MS |
2000 |
no | Pull-batch expiry wait. |
NOETL_STATE_BUILDER_IDLE_SLEEP_MS |
500 |
no | Sleep when a drain comes back empty (keeps the idle loop off the CPU). |
WAL chain-index bounds (noetl/ai-meta#166 Phase 1)
Live in production on both system-pool shards and undocumented here until 2026-08-05. Found by diffing
env::var("NOETL_*")call sites inworker@mainagainst this page.
The chain index the builder caches is unbounded by default. That is what took the system pool to ~748 Mi steady and then OOM-killed it at a 768 Mi limit, wedging the off-server drive and stopping Muno turns and login (noetl/ai-meta#163). These three are the cure, and prod runs all of them.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_STATE_INDEX_TTL_SECS |
unset β no TTL | recommended | Evicts an execution's cached spine this many seconds after last use. Parsed as u64; 0 or a non-numeric value is discarded and treated as unset, i.e. unbounded β the failure is silent. Prod: 900.
|
NOETL_STATE_INDEX_MAX_BYTES |
unset β no ceiling | recommended | Byte ceiling for the whole index; eviction is by least-recently-used above it. Same parse rule, same silent fallback to unbounded. Prod: 268435456 (256 MiB). |
NOETL_STATE_INDEX_REHYDRATE_ON_MISS |
unset (off) | yes, with the two above | Truthy rebuilds a spine evicted by TTL/ceiling on the next reference. Without it, bounding the index turns a memory problem into a correctness one β an evicted execution simply misses. Set this whenever either bound is set. Prod: true.
|
The interaction is the part worth remembering: TTL and ceiling are safe only in combination with rehydrate-on-miss. Setting either bound alone trades an OOM for silent state loss.
NOETL_STATE_INDEX_MAX_EXECUTIONS and NOETL_STATE_INDEX_REHYDRATE_CONCURRENCY
exist in the same code path and are not set in prod.
Documented 2026-08-05. These were invisible to three earlier sweeps because their names live in constants (
pub const EHDB_KV_MODE_ENV: &str = "NOETL_EHDB_KV") and are read asenv::var(CONST)β a pattern matching a literal inside the call sees nothing.
EHDB's storage tiers are selected per tier, not by one switch. Every one defaults to off.
Updated 2026-08-10 β the "none in prod" statement above was true until this date and is no longer.
NOETL_EHDB_{ENABLED,MODE,CLIENT_ROLE,LOCAL_REFERENCE_LOG}plusEVENTLOG/PROJECTION/KV/OBJECT=shadoware now set on the three prod data-plane worker Deployments (noetl-worker-rust,noetl-worker-system-pool,noetl-worker-system-pool-shard1).NOETL_EHDB_VECTORremains off β it has no live call site.Shadow only. No tier serves anything: it is a dual-write plus parity compare and never touches the authoritative path. Prod parity is holding (
mirrored/materialized,last_degraded 0, zeroparity_mismatch).Shadow runs on the worker Deployments, not the writer, because the mirror seams fire per-worker on that worker's own activity; the writer claims only
commands.cmdbus.>and would mirror nothing. It needs no PVC β the live path passesauthoritative_sequence: None, so parity rests on the engine's own count and monotonic-order invariant, and a fresh log after a restart is correct. Durable storage is aprimaryconcern, andprimaryis where the writer's PVC comes in.β Do not set any tier to
primaryβ but the reason changed on 2026-08-11, and the old reason is no longer true.Superseded (was true until ai-meta#247):
runtime_hook_envarmed the mirror only inshadow, so selectingprimarysilently disarmed verification β measured in kind on one binary under identical load,shadowmirrored 30 events andprimarymirrored 0.Now:
runtime_hook_envarms the mirror for bothshadowandprimary(arming is monotonic β it can only add mirroring), and the event-log tier has a real authoritative serve path gated byprimary_serve::decide. Serving requires three conditions, not one: the tier isprimary, a durable tier service is reachable, and parity held on this operation. Any missing condition demotes to the incumbent and recordsprimary_unavailableβ never a silent wrong answer, never a failed caller.β Which append path carries the decision depends on how the tier is configured β and the serve-ready configuration is not the default one.
decidehas three call sites, and no single one of them covers every configuration:
call site reached when eventlog::mirror_live_event(this worker's own hook)NOETL_EHDB_EVENTLOG_MIRROR_SOURCE=workerβ the default.serverdisarms the hook before the tier mode is consulted, so this site never runs theremetrics_server::ehdb_tier_append_handler,Resolution::LocalbranchNOETL_EHDB_TIER_QUERY_SOURCE=localβ the defaulteventlog::serve_service_append, theResolution::Servicebranch of the same handlerNOETL_EHDB_TIER_QUERY_SOURCE=serviceSuperseded (was true until worker
8d46b33, worker#273, 2026-08-12): earlier text here described the serve path as riding the append path without naming which one, which read as "anyprimaryflip serves". OnMIRROR_SOURCE=server+TIER_QUERY_SOURCE=serviceβ the only configuration on which the tier is both complete (#258) and not per-pod fragments (#257 PR 4) β the flip reached neither of the first two sites. It producedserved_primary = 0, in fact the wholenoetl_ehdb_eventlog_ops_totalfamily absent, and no log line, while events flowed and landed correctly. The third site closes that; a property test walks all eight input combinations and asserts its verdict equalsdecide's, so the sites cannot drift apart about whatprimarymeans.π’ FLIPPED 2026-08-13 β the event-log tier is
primaryand SERVING in prod on worker v5.118.1 / server v3.81.0. The paragraph that stood here said no tier wasprimaryanywhere, the tier-service address was set nowhere andNOETL_EHDB_TIER_QUERY_SOURCEwas unset everywhere; all three are now false. Live prod values are in the Live prod configuration table below. Serving evidence:served_primary=232 with every demote outcome 0,serve_state: served_primaryin the endpoint reply, and the IS SERVING log line. The pre-flip gate ran 20 executions on a fresh store: 0 divergence, 20/20 full-set match, tier-service append errors 0, store parses 580/580, and all five replicas returned the same 29 records. The full precondition list isplaybooks/257-serve-readiness/GO-LIVE.md. See Umbrella: EHDB Primary Serve Path (ai-meta#257).Live prod configuration (2026-08-13,
shastaratech-noetl-prodnsnoetl):
where variable value writer ( noetl-cmdbus-writer)NOETL_EHDB_TIER_SERVICE_BIND0.0.0.0:9110writer NOETL_EHDB_TIER_SERVICE_DIR/data/eventbus/ehdb-tierβ on the eventbus RWO PVC, not pod-localworker pools (Γ3) NOETL_EHDB_EVENTLOGprimaryworker pools NOETL_EHDB_TIER_QUERY_SOURCEserviceworker pools NOETL_EHDB_TIER_SERVICE_ADDRnoetl-cmdbus-writer-0.noetl.svc.cluster.local:9110worker pools NOETL_EHDB_EVENTLOG_MIRROR_SOURCEserver(worker hook disarmed)server NOETL_EHDB_EVENTLOG_MIRROR_SOURCEserverserver NOETL_EHDB_WORKER_QUERY_URLhttp://noetl-worker-rust-metrics.noetl.svc.cluster.local:9090server NOETL_EHDB_CROSSSTORE_PARITY_ENABLEDtrueβ
served_primaryis per-pod. The server's mirror relay targets thenoetl-worker-rust-metricsService, so appends land on whicheverapp=noetl-worker-rustpod it reaches, and the system pools receive none. Sum across replicas when alerting β a single pod reading 0 is not a demotion.β
NOETL_EHDB_TIER_QUERY_SOURCE=servicewithoutNOETL_EHDB_TIER_SERVICE_ADDRfalls back tolocalwith only a WARN. Setting the query source alone givesdowngraded_localβ a read that looks correct and answers from a pod-local fragment. Always set the pair.β Only the event-log tier can be flipped today.
kv,object,projectionandvectorhave aserve_primary_cycle, but its only caller isbin/ehdb-selfcheck, so setting themprimarycannot change what any caller receives β it is a no-op with a warning. The RFC's suggested cutover order (KV first, event-log last) describes a desired blast-radius order, not a runnable one: the only wired tier is the one it names last. Wiring the other four is unstarted work, tracked on ai-meta#257.The flip-time signal says which case a tier is in β one line and one
operation="runtime_hook"counter increment per tier per process (ai-meta#259 corrected these; the old text told operators a serving tier was inert, at the exact moment they were running a cutover):β Not yet released. The three-outcome table below is worker#263, which is open and unmerged. A binary built from
maintoday records only the singleprimary_not_wiredoutcome. Checknoetl_worker_build_info{version}against the release that carries #263 before reading these outcomes as current.β And it fires from
runtime_hook_env, whichNOETL_EHDB_EVENTLOG_MIRROR_SOURCE=serverdisarms. On the serve-ready configuration this signal is unreachable β so it cannot mislead there, but it also says nothing there, and its silence is not evidence of anything. It is the signal for theMIRROR_SOURCE=workerconfiguration. On the serve-ready one, readserve_stateand the transition log line instead (below). Landing #263 would not have made aprimaryflip serve: the same disarm routes around both the warning and the first serve call site.
outcomelevel meaning operator response primary_not_wiredWARN tier has no runtime serve path ( kv/object/projection/vector)nothing was promoted; flipping again will not help primary_no_tier_serviceWARN serve path exists, NOETL_EHDB_TIER_SERVICE_ADDRunsetset the address; until then every op demotes ( no_durable_service)primary_armedINFO serve path armed and a tier service is configured watch served_primaryagainstparity_diverged/no_durable_service
primary_armedis not a claim that the tier is serving β reachability and per-op parity are only knowable per operation. It says the flip was accepted.β Reachability is measured, never assumed, and never inferred from a clock. It is derived from real tier-service operations β the append is the probe β and a demotion stays in force until a real operation succeeds. A lapsed cooldown permits a retry; it does not restore belief. (Before worker#262 it did, so five seconds after a tier service died the tier decided it was authoritative again with nothing having contacted it.) A service that answers but rejects a record is reachable β that is a data problem, and demoting there would let one poisoned payload disable authoritative serving platform-wide.
β Prod workers run
ephemeral-storage: 1Gi(request = limit) andlocal_referenceis an unbounded pod-local JSONL log β the 8 MiB segment rotation belongs todurable_segment, not this backend. Watchdf -h /tmp.
The bus has been live in prod since T5; the storage tiers are shadow-only (ehdb#241).
| Variable | Purpose |
|---|---|
NOETL_EHDB_ENABLED |
master switch for the EHDB client surface |
NOETL_EHDB_SEAL_MAX_AGE_MS |
(unset) |
NOETL_EHDB_SEAL_SWEEP_INTERVAL_MS |
1000 |
NOETL_EHDB_FENCING |
off |
NOETL_EHDB_ELECTION |
false |
NOETL_EHDB_ELECTION_LEASE_SECS |
15 |
NOETL_EHDB_ELECTION_RENEW_SECS |
5 |
NOETL_EHDB_TIER_APPEND_BATCH |
false |
NOETL_CLAIM_LOOP_FAILURE_STREAK |
3 |
NOETL_CLAIM_LOOP_UNHEALTHY_SECS |
180 |
NOETL_EHDB_CATALOG_MIRROR_SOURCE |
(unset) |
NOETL_EHDB_MODE, NOETL_EHDB_CLIENT_ROLE, NOETL_EHDB_CAPABILITIES
|
client posture and declared capabilities |
NOETL_RUN_MODE |
run posture the contract checks against |
NOETL_EHDB_EVENTLOG |
driver for the event-log tier |
NOETL_EHDB_KV |
driver for the KV tier |
NOETL_EHDB_OBJECT |
driver for the object/blob tier |
NOETL_EHDB_PROJECTION |
driver for the projection tier β see the note below; its meaning WIDENED in ai-meta#265 |
NOETL_EHDB_VECTOR |
driver for the vector tier |
NOETL_EHDB_LOCAL_REFERENCE_LOG |
local reference-log mode |
NOETL_EHDB_TENANT, NOETL_EHDB_NAMESPACE
|
scoping for the Flight SQL surface |
The projection tier's shadow means more since ai-meta#265, and prod already
sets it.
β Two corrections worth stating plainly, because both were recorded wrongly before:
-
Prod has set
NOETL_EHDB_PROJECTION=shadowon all three worker deployments for some time. The 2026-08-14 kind-gate exit note claiming "noNOETL_EHDB_PROJECTION*variable exists on any prod workload" was wrong. A gate's exit assertion is itself a representation. -
What that value reaches changed. Before #265 it selected a mode whose
only caller was
bin/ehdb-selfcheck. Since A1/A2 the same value additionally reaches the tier-generic store (projection.jsonl, its own file and its own lock) and the write-path serve decision throughprimary_serve::decide.shadowstill resolves to "the incumbent is authoritative" and the tier receives nothing at all unless the server carriesNOETL_EHDB_PROJECTION_MIRROR_SOURCE=server.
The worker's half of the projection tier is deliberately small:
-
No pod-local write path, by design. A
localresolution is refused 503 with a reason. The event log has one only because it predates the tier service; giving the projection tier one would build N disjoint per-pod fragments that aprimaryflip would promote while the incumbent holds everything. - Therefore
NOETL_EHDB_TIER_QUERY_SOURCE=serviceand a reachableNOETL_EHDB_TIER_SERVICE_ADDRare hard prerequisites for the projection tier, not tuning. - The worker cannot read
noetl.projection_snapshotat all (data-access-boundary.md), so it has noworkermirror mode β the mirror is server-authored or absent. -
worker/src/ehdb/projection.rsstill carriesshadow_project/fold_window_authoritative, which compare EHDB to a second copy of EHDB's own logic. That is a useful engine-conformance drive and it is not serve-readiness evidence; the evidence is the server-side cross-store comparator against the incumbent's rows.
Server-side variables (NOETL_EHDB_PROJECTION_MIRROR_SOURCE,
..._PARITY_ENABLED, ..._READ_SOURCE, ..._MIRROR_ASYNC,
..._PARITY_LAG_TOLERANCE_SECS) are documented on the
noetl/server deployment-specification
page. Cutover order and gating:
playbooks/265-projection-tier/CUTOVER.md.
Writer-fronted tier service (ai-meta#257).
Unset everywhere today; the client is a strict no-op without an address, which is
what keeps primary demoting and the flag-off path byte-identical.
| Variable | Default | Purpose |
|---|---|---|
NOETL_EHDB_TIER_SERVICE_BIND |
unset |
Server side (the writer). host:port to serve the tier protocol on. Unset β no listener. |
NOETL_EHDB_TIER_SERVICE_DIR |
unset |
Server side. Directory backing the tier store. β Pod-local unless a PVC is mounted β the same caveat as the durable event log, and it matters more here because primary serves from this store. |
NOETL_EHDB_TIER_SERVICE_ADDR |
unset |
Client side (workers). host:port of the writer's tier service; accepts DNS names, not only IP literals (worker#257). Unparseable β fail closed, never a default address. |
NOETL_EHDB_TIER_SERVICE_TIMEOUT_MS |
2000 |
Client connect + read timeout. Also the cost of the one-operation lag: the first read after the service dies pays this before demoting. That request is slower, not wrong β the incumbent still answers it. Kept short because the event-log tier serves primary, so a blocked read is latency a user feels. Appends no longer use this bound β see the next row. |
NOETL_EHDB_TIER_SERVICE_APPEND_TIMEOUT_MS |
4000 |
Append bound, separate from the read bound (ai-meta#324). The two have opposite risk: a slow read should give up quickly, but a slow append that gives up early means the record was not written β and under the pre-#320 mirror that was an event the tier would never hold. Sharing one 2 s bound made the safe choice for reads the unsafe one for writes. β Measured, not guessed: under load the writer's tier append exceeded 2 s and returned 502 β¦ "timed out after 2s"; the server's mirror retried, which is why #320 reports zero loss but a visible recovered count. 4 s sits under the server's own 5 s per-attempt APPEND_TIMEOUT, so a slow append finishes inside one attempt instead of consuming a retry β a guard asserts that ordering, because a worker budget above the server's is unreachable by construction. Junk or 0 falls back to the default. |
NOETL_EHDB_TIER_QUERY_SOURCE |
local |
Which store this worker's tier route reads from and appends to (ai-meta#257 PR 4; introduced by worker#272, server half server#347). local (default) uses this pod's own tier store. service resolves through the writer's tier service at NOETL_EHDB_TIER_SERVICE_ADDR. β This IS flip precondition P3 β see the note below. Unrecognised values resolve to local. |
β local at more than one replica is not one tier β it is N disjoint
fragments, and this is the single most consequential thing on this page. The
server's mirror hop resolves the pool's ClusterIP Service, so each event lands
on whichever pod that hop reached. Measured at three replicas: one execution's
13 events read back as 0 / 13 / 0 records, and all three replicas labelled
their answer local. The cross-store comparator, pinned at each pod in turn,
returned divergent / match / divergent for the same execution. Two
thirds of reads report the tier as empty while one third reports a clean
full-set match β so a single-replica sample has a 1-in-3 chance of reading the
full set and declaring success. Prod runs multiple replicas.
Setting service on every replica makes the tier one store by construction:
the same three replicas then read 13 / 13 / 13 with match from each, and the
pod-local stores stay empty because nothing is written to them.
Failure behaviour is deliberately asymmetric, so a misconfiguration cannot degrade quietly into the fragmented state:
| situation | behaviour |
|---|---|
service, address set, service unusable |
fails loud β 503. It does not fall back to local |
service, no address set |
downgrades to local with a WARN, recorded as downgraded_local
|
service, address malformed |
disables the read path (misconfigured) rather than being ignored |
Point NOETL_EHDB_TIER_SERVICE_ADDR at the writer's stable per-pod DNS
(noetl-cmdbus-writer-0.noetl.svc.cluster.local:9110), never the headless
Service β that round-robins, and the premise is one process.
Attribution rides
noetl_worker_ehdb_query_ops_total{operation="tier_query_source.read"} with
outcomes service / downgraded_local / misconfigured.
β The label alone is not evidence. A defect can keep tier_query_source: service truthful-looking while serving the pod-local store β one mutation did
exactly that, passing the label check and failing only the data checks.
Compare counts, not labels.
Tier-route reply fields (tier_query_source from worker#272; serve_state from worker#273). GET|POST /ehdb/tiers/{tier} carries two fields
that exist so an operator can read the serve decision from something that
cannot be absent the way a metric can (Registry::gather prunes empty
families, so a labelled series does not exist until it fires):
| field | tiers | values |
|---|---|---|
tier_query_source |
all |
local / service β which store answered. Necessary for attribution, insufficient as proof; see the warning above |
serve_state |
event log only β a bare serve_state on a kv body would describe the wrong thing |
served_primary / not_primary / no_durable_service / parity_diverged / unknown
|
β unknown is a real answer and is not not_primary. It means this pod
has decided nothing β it received none of the execution's appends, because the
server's mirror hop resolved to a different replica. Expect served_primary on
the replicas that received appends and unknown on the rest; in the three-replica
rehearsal that was 1β2 of 3 per execution. Requiring all replicas to report
served_primary would be asserting a property of kube-proxy.
Do not read serve_state as parity. It is the tier's authority to answer;
cross-store parity against noetl.event is the server comparator's job.
The matching transition log line is emitted once per transition, in both directions β not per append, because an append happens 13 times per execution and a line on each is a line nobody reads, which is operationally the same as the no-line state this was added to fix:
NOETL_EHDB_EVENTLOG=primary IS SERVING: the event-log tier answered
authoritatively through the writer-fronted tier service
serve_state="served_primary" outcome="served_primary"
NOETL_EHDB_EVENTLOG=primary is NOT serving β the incumbent answers
(demoted: no_durable_service)
serve_state="no_durable_service" outcome="unavailable"
The serve series is pinned at 0 at the bind site of the route carrying
server-authored appends, gated on EHDB enabled + tier not off, on both
shadow and primary β so the flip changes values, never which series exist,
and a disabled build's /metrics stays byte-identical. Seven outcomes:
mirrored / parity_mismatch / primary_divergence / primary_unavailable /
rejected / served_primary / unavailable. served_primary 0 means "did
not serve"; the family absent means "this binary predates the serve path" β
and noetl_worker_build_info{version} answers which.
Authoritative event identity (ai-meta#258).
| Variable | Default | Purpose |
|---|---|---|
NOETL_EHDB_EVENTLOG_MIRROR_SOURCE |
worker |
Which component mirrors into the event-log tier (noetl/ai-meta#258). worker (default) keeps today's behaviour: this worker's emit chokepoint mirrors the events it emits. server disarms this worker's event-log mirror entirely β including the tier-service append that rides the same hook β because the server's write chokepoint then mirrors the complete authoritative set, this worker's own events included (they reach noetl.event through POST /api/events, so the server writes them). The disarm is not optional: with both halves mirroring, every worker-emitted event is appended twice and the server's cross-store comparator reports a count divergence on every healthy execution. server also arms POST /ehdb/tiers/eventlog on the worker's query port, which is how the server's appends arrive; without it that route answers 501 unconfigured and appends nothing, so a build carrying this change behaves exactly as the build before it. Set the same value on the server. Anything unrecognised resolves to worker, so a typo restores the old mirror rather than leaving the tier silently empty while NOETL_EHDB_EVENTLOG still reads shadow. |
NOETL_EHDB_AUTHORITATIVE_ID_STAMP |
false |
Write the server-assigned event_id into a mirrored payload the producer left without one. Five live emit paths (spool_runtime Γ2, subscription Γ2) send event_id: None and let the server assign a snowflake, so the mirrored copy of those events carries no identity and cannot be matched against noetl.event at all. Off β those records are mirrored unidentified, recorded as authoritative_id{outcome="unstamped"} rather than passing silently. A producer-supplied id is never overwritten in either state β a stamp that overwrote one would make the two stores agree by construction. |
The verdict rides the tier's existing family as
noetl_ehdb_eventlog_ops_total{operation="authoritative_id"}, with outcomes
agreed / stamped / unstamped / disagreed / not_json / not_supplied.
disagreed is a real cross-store identity divergence caught at the append site.
Count, ordering and payload parity against the authoritative log are not
checked here β they are compared server-side
(noetl/server#343), because per
data-access-boundary.md the worker may not read noetl.*.
Per-tier limits and tuning, same default-off caveat:
| Variable | Tier |
|---|---|
NOETL_EHDB_EVENTLOG_DURABLE_DIR |
event log β β pod-local unless a PVC is mounted |
NOETL_EHDB_EVENTLOG_SHARED_DIR |
event log β same PVC caveat |
NOETL_EHDB_EVENTLOG_SEGMENT_MAX_BYTES |
event log |
NOETL_EHDB_EVENTLOG_MAX_PAYLOAD_BYTES |
event log |
NOETL_EHDB_EVENTLOG_GC_INTERVAL_SECS |
event log |
NOETL_EHDB_KV_MAX_VALUE_BYTES |
KV |
NOETL_EHDB_OBJECT_MAX_BYTES |
object |
NOETL_EHDB_PROJECTION_MAX_BATCH |
projection |
NOETL_EHDB_VECTOR_MAX_DIMENSIONS |
vector |
NOETL_EHDB_DATAPLANE_MAX_PAYLOAD_BYTES |
data plane |
NOETL_EHDB_DATAPLANE_MAX_READ_LIMIT |
data plane |
NOETL_EHDB_EVENTSTREAM_MAX_PAYLOAD_BYTES |
event stream |
NOETL_EHDB_EVENTSTREAM_MAX_CONSUME_LIMIT |
event stream |
NOETL_EHDB_RAG_TOP_K |
RAG retrieval |
NOETL_EHDB_RAG_MAX_CHUNK_BYTES |
RAG retrieval |
NOETL_EHDB_RAG_TIME_BUDGET_MS |
RAG retrieval |
NOETL_EHDB_SYSTEM_MAX_MODULE_BYTES |
system/wasm store |
NOETL_EHDB_SYSTEM_MAX_CAPABILITIES |
system/wasm store |
NOETL_EHDB_READINESS_TIMEOUT_SECONDS |
readiness gate |
Flight SQL β NOETL_EHDB_FLIGHT_SQL, NOETL_EHDB_FLIGHT_SQL_BIND,
NOETL_EHDB_FLIGHT_SQL_TOKEN_ALIAS, NOETL_EHDB_FLIGHT_SQL_TOKEN_FIELD,
NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH β is documented in its own
section below.
β NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH disables authentication on the
loopback interface. Development only.
| Variable | Purpose |
|---|---|
NOETL_AUTOSINK |
Master switch. Truthy makes the auto-sink task eligible to spawn β it still needs a positive interval, so setting this alone does nothing. |
NOETL_AUTOSINK_INTERVAL_SECS |
Sweep interval; non-positive keeps the task unspawned. |
NOETL_AUTOSINK_MIN_BYTES |
Minimum size before a sink is worthwhile. |
NOETL_AUTOSINK_TARGET |
Sink destination. |
| Variable | Default | Why |
|---|---|---|
NOETL_KEYCHAIN_STRICT |
off | Truthy makes an unresolved keychain alias fail loudly instead of rendering empty, and distinguishes NotFound from FetchFailed. Shipped in v5.97.0. Deliberately default-off: a playbook naming an alias it does not strictly need would start failing, which is the right end state but a behaviour change that wants an explicit opt-in. |
NOETL_TEST_H5_SOURCE_PROBE |
β | Test probe; not deployment configuration. |
Set only on the pod that hosts the writer. NOETL_EVENT_BUS_HOST is the
master switch; the rest configure the faces it binds. None is set on the prod
worker pools β prod runs the writer as its own StatefulSet
(noetl-cmdbus-writer), so these are the in-process alternative.
| Variable | Default | Why |
|---|---|---|
NOETL_EVENT_BUS_HOST |
off | Host the events-bus writer in this process. |
NOETL_EVENT_BUS_INGEST_BIND |
unset | Ingest face (9103 in the deployed layout). |
NOETL_EVENT_BUS_CLAIM_BIND |
unset | Claim face (9104). |
NOETL_EVENT_BUS_SSE_BIND |
unset | SSE face (9105) β the gateway's NOETL_EVENT_FEED_ADDR target. |
NOETL_EVENT_BUS_METRICS_BIND |
unset | Metrics face (9106) β the events-lag port nothing currently scrapes. |
NOETL_EVENT_BUS_KV_BIND |
unset | KV face (9107) β the gateway's NOETL_KV_ADDR target. |
NOETL_EVENT_BUS_WAL_BIND |
unset | WAL fan-out face (9108) β the state builder's NOETL_EVENT_BUS_WAL_ADDR target. |
NOETL_EVENT_BUS_SHARD |
0 |
This writer's shard ordinal. |
NOETL_EVENT_SHARD_COUNT |
1 |
Total event shards. |
NOETL_EVENT_BUS_WRITER_DIR |
unset | On-disk root for the log. β pod-local unless a PVC is mounted β the same trap as the durable eventlog dir above. |
NOETL_EVENT_BUS_KV_DIR |
unset | On-disk root for KV. Same PVC caveat. |
NOETL_EVENT_BUS_CURSOR_PERSIST_SECS |
β | How often consumer cursors are flushed. A longer interval widens the redelivery window after an unclean restart. |
NOETL_EVENT_BUS_ACK_WAIT_SECS |
30 |
Redelivery window for an unacked claim. |
NOETL_EVENT_BUS_CURSOR_FALLBACK |
unset | Where a consumer with no persisted cursor starts. |
Two independent drain loops with an identical knob set and identical defaults. Neither is set in prod, so both run on the defaults below.
| Variable | Default | Why |
|---|---|---|
NOETL_MATERIALIZER_BATCH |
200 |
Pull-batch size, clamped 1β1000 β an out-of-range value is silently clamped, not rejected. |
NOETL_RESULT_MATERIALIZER_BATCH |
200 |
As above, result loop. |
NOETL_MATERIALIZER_TIMEOUT_MS |
2000 |
Pull-batch expiry wait. |
NOETL_RESULT_MATERIALIZER_TIMEOUT_MS |
2000 |
As above, result loop. |
NOETL_MATERIALIZER_IDLE_SLEEP_MS |
500 |
Sleep after an empty drain. |
NOETL_RESULT_MATERIALIZER_IDLE_SLEEP_MS |
500 |
As above, result loop. |
NOETL_MATERIALIZER_ERROR_BACKOFF_MS |
2000 |
Sleep after a failed drain. |
NOETL_RESULT_MATERIALIZER_ERROR_BACKOFF_MS |
2000 |
As above, result loop. |
NOETL_MATERIALIZER_CONSUMER |
per-loop constant | Durable consumer name. β Changing it starts a new consumer at the fallback position rather than resuming β the #166 Phase 5 shards depend on these names. |
NOETL_RESULT_MATERIALIZER_CONSUMER |
per-loop constant | As above, result loop. |
NOETL_RESULT_MATERIALIZER_STREAM |
constant | Stream the result loop drains. |
NOETL_MATERIALIZER_FAULT_FAIL_FIRST |
0 |
Fault injection β fails the first N drains deliberately. Non-zero in production would be a mistake. |
| Variable | Default | Why |
|---|---|---|
NOETL_STATE_BUILDER_CONSUMER |
constant | Durable consumer name; same rename caveat as above. |
NOETL_STATE_BUILDER_UNHEALTHY_SECS |
45 |
How long the drain may be continuously unable to serve before /livez fails. Sized longer than the in-process self-heal window so a transient consumer loss recovers without a restart while a wedge the self-heal cannot clear trips the k8s backstop (~45s + periodSecondsΓfailureThreshold β 135s). Referenced by the liveness probe documented above. |
NOETL_STATE_BUILDER_REBUILD_SECS |
5 |
Cold-rebuild retry interval. |
NOETL_STATE_BUILDER_DURABLE |
off | Durable-consumer mode for the builder. |
NOETL_STATE_BUILDER_DRIVE_RETRIES |
unset | Retry count when the drive asks for state the index has not caught up to β the Incomplete outcome, which is "not yet knowable", not "the chain is broken". |
NOETL_STATE_BUILDER_DRIVE_RETRY_MS |
unset | Delay between those retries. |
NOETL_OFFSERVER_SPINE_ORDER |
off | Spine causal-ordering mode (#117). |
NOETL_SINK_GATE_EVICTION |
off |
SinkGate eviction primitive (#199); default-off, not in prod. |
Companions to the TTL / byte-ceiling bounds above β these govern the refill after an eviction, which is what makes bounding the index safe.
| Variable | Default | Why |
|---|---|---|
NOETL_STATE_INDEX_REHYDRATE_BATCH |
500 |
Messages per refill pull, clamped 1β2000. |
NOETL_STATE_INDEX_REHYDRATE_POLL_MS |
300 |
Poll interval, clamped 50β5000. |
NOETL_STATE_INDEX_REHYDRATE_DEADLINE_MS |
3000 |
Wall-clock budget for one rehydrate. β Exceeding it yields an incomplete spine, so this trades latency against a ColdRebuild. |
NOETL_STATE_INDEX_REHYDRATE_MAX_MESSAGES |
100000 |
Hard ceiling per rehydrate. |
NOETL_STATE_INDEX_MAX_EXECUTIONS |
unset | Cap on executions held, alongside the byte ceiling. |
NOETL_STATE_INDEX_REHYDRATE_CONCURRENCY |
unset | Parallel rehydrates. |
| Variable | Default | Why |
|---|---|---|
NOETL_CONTAINER_COMPLETION_POLL |
off | Poll for container-step completion instead of waiting on the callback. |
NOETL_SUBSCRIPTION_PATH |
unset | Subscription playbook path for the subscription runtime. |
NOETL_CLAIM_DEAD_LETTER |
off | Park a command the claim path can never resolve (server returns 404) instead of retrying it forever. Off = the pre-fix behaviour: an unresolvable command is retried indefinitely and, in enough quantity, starves the pool of claim slots β the ai-meta#249 wedge, reproduced with 6 poison commands (+13 failures / 0 successes while healthy work sat RUNNING). On = the command is acked and counted on noetl_worker_claim_dead_lettered_total. Never a silent drop β parking is recorded at ERROR with the command id. |
NOETL_PROVIDER_ERROR_TERMINATES |
off | Treat an MCP provider result carrying isError as a terminal step failure (call.error + command.failed) instead of call.done + command.completed(success). Off = the ai-meta#246 behaviour, where a provider that failed on credentials or an API error still advanced the DAG down its success arc. β Enabling this is a real semantics change: playbooks that today continue past a failed provider hop will begin failing. Releases a pending sink-gate entry (released_provider_error) so the terminal path cannot leak an eviction block. |
NOETL_STATE_AFFINITY_MAX_REDIRECTS, NOETL_STATE_AFFINITY_NAK_DELAY_MS
|
β | Sub-knobs of the affinity policy. β Read only by the unreachable AffinityConfig::from_env β see the decorative-flag note above. |
Found 2026-08-05 by a second pass. The first pass matched only
env::var("NOETL_*")and missed everything read through the crate'senv_bool/env_u32/env_u64/env_addrhelpers β 55 variables in this binary. All eight below are set in prod.
Each consumer picks its transport independently. Post-T5 they are all ehdb,
and there is nothing else to point them at.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_MATERIALIZER_SOURCE |
unset | yes, in prod | Transport the event materializer drains. Prod: ehdb.
|
NOETL_RESULT_MATERIALIZER_SOURCE |
unset | yes, in prod | Same for the result materializer. Prod: ehdb.
|
NOETL_STATE_MATERIALIZER_SOURCE |
unset | yes, in prod | Same for the state materializer. Prod: ehdb.
|
NOETL_STATE_BUILDER_SOURCE |
unset | yes, in prod | Same for the off-server state builder. Prod: ehdb.
|
β They are four independent selectors, not one switch. A cutover that misses one leaves that single consumer pointed elsewhere while the other three move, and nothing cross-checks them.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_SHARD_INDEX |
0 |
yes, per shard | This pod's shard ordinal. Prod: 0 on noetl-worker-system-pool, 1 on -shard1 β the #166 Phase 5 two-shard system pool. |
NOETL_STATE_INDEX_SWEEP_SECS |
15 |
no | How often the chain-index eviction sweep runs. Completes the bounds documented above: TTL and byte-ceiling decide what is evicted, this decides how often anything is. Prod: 30.
|
| Variable | Prod value | Effect |
|---|---|---|
NOETL_STATE_AFFINITY_ROUTE |
true |
none |
Its only reader is sharding::AffinityConfig::from_env, which has no caller.
AffinityConfig::decide() is exercised only by sharding.rs's own unit tests,
and record_affinity_decision β the counter for those decisions β has no caller
either, so the metric is absent from a live pod.
The steering block that used all three was deleted by 14a3f1f chore(nats): remove the NATS command path during T5 and was not reinstated on the EHDB path.
The likely reading is that it is superseded rather than lost β Phase 5 put
system workers on per-shard durable consumers and L1 T4 made pool isolation and
shard routing subject dimensions, so the writer routes and worker-side steering
is redundant. That is inference, not proof; the code cannot tell the two apart.
Until it is settled, read this variable as decorative: setting it true
does not enable anything. Tracked on
noetl/ai-meta#242 and
#166.
Also live on both system-pool shards and previously undocumented. Both are read
as trimmed strings and an empty value is filtered to None, so a blank
setting is indistinguishable from unset.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_EVENT_BUS_CLAIM_ADDR |
unset | yes, in prod | Materializer's claim face on the writer. Prod: noetl-cmdbus-writer-0.noetl.svc.cluster.local:9104.
|
NOETL_EVENT_BUS_WAL_ADDR |
unset | yes, in prod | WAL fan-out the state builder drains. Prod: β¦:9108.
|
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_RESULT_CELL_REGION |
local |
no | Region label on minted result references. Defaulting silently to local in a real region makes references look local-tier. Prod: usc1 on the server and both system pools. |
NOETL_RESULT_PRODUCER_STAGE |
unset (off) | no | Truthy enables the producer-stage result path. Prod: true on noetl-worker-rust and both system pools. |
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_INTERNAL_API_TOKEN |
unset | yes, for system pools | Bearer credential for the server's /api/internal/* routes, which is how workers reach noetl.* at all (agents/rules/data-access-boundary.md). Set on both system pools in prod. Supply from a K8s Secret; value deliberately not recorded here. |
State-builder metrics (:9090/metrics):
noetl_worker_state_builder_wal_events_total (events consumed from the WAL β the
WAL-read proof, RFC tenet 5) Β· noetl_worker_state_builder_event_scans_total
(noetl.event scans the builder issued β the no-scan proof, RFC tenet 3;
stays 0) Β· noetl_worker_state_builder_builds_total{outcome=cache_hit|incremental|cold_rebuild|incomplete}
(cache effectiveness + correctness) Β· noetl_worker_state_builder_chain_hops
(chain-walk depth per cold rebuild) Β· noetl_worker_state_builder_build_duration_seconds{outcome=β¦} β build
latency by the same four outcomes, new in v5.96.0. Until it shipped the
counter said which path a build took and nothing said what it cost, which is
why #156 could quantify the
per-hop floor in kind and not on prod.
Object-store state-shard writer (noetl/ai-meta#166 Phase 2 β shadow)
The write-side sibling of the #104 result materializer: a third read-model
projector off the noetl_events WAL (durable consumer noetl_state_materializer,
self-ensured at startup) that projects each execution's slim event-chain
(event_id, prev_event_id, event_type, node_name, status, the #104
result_ref URN, a bounded extracted block, and a payload column β the
verbatim shard_chain_payload (slim projection + prev_event_id) the Phase-3
cold-load reader reconstructs a byte-equivalent chain from) into a per-execution
Arrow Feather shard on object store at
β¦/shard=sNNNN/.../execution=<eid>/state/<open|sealed>.feather (co-located with
the same execution's result bytes via the shared FNV-1a shard_key).
Append-while-live, seal-on-terminal. Written by the shadow writer; read by
the Phase-3 cold-load path (below) when NOETL_STATE_SHARD_READ is on β otherwise
the drive serves from the Phase-1 bounded in-memory index and the writer is
output-neutral.
READ-ONLY w.r.t. noetl.* (object PUT only β preserves the #103 sole-writer);
never fails an event (errors counted + WARN-logged with execution_id, batch acked,
idempotent key β safe redelivery); bounded by terminal eviction + idle-TTL sweep +
a max-open ceiling so it never reproduces the unbounded Phase-1 index it complements.
Default off β not spawned (true no-op). Instant rollback = unset NOETL_STATE_SHARD_WRITE.
| Variable | Default | Required | Purpose |
|---|---|---|---|
NOETL_STATE_SHARD_WRITE |
false |
no | Master toggle (system pool only). Truthy (1/true/yes/on) spawns the shadow state-shard writer. Default off β not spawned. |
NOETL_STATE_SHARD_CONSUMER |
noetl_state_materializer |
no | Durable pull-consumer name on noetl_events. Self-ensured (idempotent create_consumer) so no server release is required. |
NOETL_STATE_SHARD_STREAM |
noetl_events |
no | The WAL stream drained. Override only if the stream is renamed. |
NOETL_STATE_SHARD_BATCH |
200 |
no | Bounded pull batch (clamped 1β1000). |
NOETL_STATE_SHARD_TIMEOUT_MS |
2000 |
no | Pull-batch expiry wait. |
NOETL_STATE_SHARD_IDLE_SLEEP_MS |
500 |
no | Sleep when a drain returns empty (also runs the idle sweep). |
NOETL_STATE_SHARD_ERROR_BACKOFF_MS |
2000 |
no | Backoff after a drain error. |
NOETL_STATE_SHARD_EXTRACTED_BUDGET |
4096 |
no | Byte cap on the inline extracted snippet kept per event (the slim-chain memory lever). The authoritative body is always re-fetchable by the result_ref URN. |
NOETL_STATE_SHARD_MAX_OPEN |
8192 |
no | Resident open-shard ceiling β evict least-recently-active open shards beyond this. The abandoned-execution backstop; the writer must never itself OOM. |
NOETL_STATE_SHARD_OPEN_TTL_SECS |
3600 |
no | Idle sweep: an open shard untouched this long (wedged / never-terminal execution) is evicted. |
NOETL_STATE_SHARD_OPEN_MIN_INTERVAL_SECS |
30 |
no | Min interval between open-shard rewrites per execution (RFC Β§4.3 cadence / write-amplification throttle). The open shard rewrites to the same object key, which object stores rate-limit (~1 mutation/s/object); without this an execution spanning many drain cycles amplifies into HTTP 429s. The terminal sealed write (distinct key) is never throttled. |
Cell placement reuses the result tier's NOETL_RESULT_CELL_ENV / _REGION /
NOETL_RESULT_CELL / NOETL_RESULT_SHARD_COUNT so a state shard lands in the same
cell + s{fnv:04} folder as that execution's result bytes.
State-materializer metrics (:9090/metrics):
noetl_worker_state_materializer_drained_total Β·
β¦_rows_total (slim rows projected) Β·
β¦_shards_written_total{seal=open|sealed} Β·
β¦_shard_bytes_total Β·
β¦_errors_total (encode/write failures β never fails an event) Β·
β¦_evicted_total{reason=idle|max_open} Β·
β¦_open_shards (resident working set, O(live executions)) Β·
β¦_cycle_duration_seconds. Writer lag rides
ehdb_events_group_lag{group="noetl_state_materializer"} β it used to ride a
worker-side JetStream gauge, which T5 left with nothing writing it
(noetl/ai-meta#242).
Object-store state-shard cold-load (noetl/ai-meta#166 Phase 3)
The read side that makes the Phase-1 bounded index cheap: on an off-server
drive cache miss (the execution's chain was evicted by TTL/byte pressure, or
this pod never indexed it), the drive cold-loads the execution's state by reading
the Phase-2 Feather state shard from object store β one keyed
GET /api/internal/objects/{key} (~tens of ms) β instead of replaying the retained
noetl_events WAL (a scan of up to the whole 24h window, bounded to the
rehydrate deadline). The shard's payload column carries the verbatim
shard_chain_payload per event, so the reconstructed WalEventIndex chain is
byte-equivalent to the WAL-replay / in-memory path by construction (same
payload β same apply β same spine β same drive commands). The drive makes
routing decisions (next.arcs, when, loop counters, fan-in) from this spine, so
byte-equivalence is the correctness bar.
Correctness never depends on the shard alone (belt-and-suspenders):
-
No shard (never written / GC'd) β
object_get404 β fall back to the WAL replay (unchanged). -
Stale open shard (writer cadence lags the WAL tip) β the reconstructed chain
can't reach
expected_headβ theadvance_tostaleness guard returnsIncompleteβ fall back to the WAL replay, which supplies the tail. -
NOETL_STATE_SHARD_READ_VERIFYdual-build guard β after a shard build serves, also run the WAL replay and byte-compare the two spines; on any divergence incrementstate_equivalence_mismatch_totaland serve the WAL build. Never serves divergent state.
Read-only w.r.t. object store + noetl.*. Byte-equivalence assumes the slim index
(NOETL_STATE_INDEX_SLIM=on, the prod default): with slim off the shard stores the
slim projection while the WAL stores the full envelope β still output-equivalent to
the drive, but not byte-identical. Default off β the miss path is byte-identical to
today's WAL replay. Instant rollback = unset NOETL_STATE_SHARD_READ.
| Variable | Default | Required | Purpose |
|---|---|---|---|
NOETL_STATE_SHARD_READ |
false |
no | Master toggle (system pool only). Truthy spawns nothing β it gates the cold-load on the drive miss path. On β try the shard before the WAL replay; off β WAL replay only (byte-identical to today). |
NOETL_STATE_SHARD_READ_VERIFY |
false |
no | Equivalence dual-build guard β after a shard build serves, also run the WAL replay and byte-compare. A validation/canary knob: it pays BOTH reads (no latency win), used to prove state_equivalence_mismatch_total == 0 on kind + a prod canary, then turned off. |
The reader resolves the shard object key deterministically β tenant/project default
to default/default (the worker mints result URNs with no tenant/project), the
folder shard + cell come from the same NOETL_RESULT_CELL_* env the writer read,
and the date partition derives from execution_id β so no carried metadata is
needed.
State-reader metrics (:9090/metrics):
noetl_worker_state_shard_reads_total{outcome=hit|miss|fallback} (the payoff
distribution β hit = shard served, one object read replacing a WAL scan) Β·
noetl_worker_state_shard_read_duration_seconds (cold-load latency vs the
WAL-replay miss cost) Β· noetl_worker_state_equivalence_mismatch_total (the
VERIFY divergence tripwire β MUST stay 0). The served-outcome also shows on
noetl_worker_state_builder_drive_builds_total{outcome=served_shard} (vs
served_rehydrated for the WAL path).
Cold-start plug-in warmup (noetl/ai-meta#130)
The off-server orchestrate drive runs the system/orchestrate wasm module via
wasmtime. On the first dispatch after a fresh pod start the module is
fetched from the server catalog and Cranelift-JIT-compiled (Module::new)
before it is cached by digest β a one-time ~1.5s cost (β99% compile, β1% fetch
of the ~1.6MB module) that the subsequent warm hops do not pay. Boot warmup
compiles the module once at startup, behind the /readyz gate, so the first
real drive is a cache hit.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_WARM_ORCHESTRATE_PLUGIN |
auto (drive pool) | no | Force boot-time compile of system/orchestrate@1 on/off (1/true/yes/on vs 0/false/no/off). Unset it defaults to the pool that actually drives orchestrate β the system pool, detected by running the off-server state-builder drain (NOETL_STATE_BUILDER set) or the CQRS materializer (NOETL_MATERIALIZER_ENABLED). A leaf tool pool never receives system/orchestrate, so it does not warm (no wasted startup/RAM). The warmup runs before the pull loop claims, so the first orchestration is warm even when /readyz is not wired; failure is non-fatal (the first dispatch lazy-loads). |
Warmup metrics (:9090/metrics):
noetl_worker_plugin_load_seconds{phase="fetch"|"compile"} (the cold-start
attribution β compile is the dominant phase) Β·
noetl_worker_plugin_warm_total{outcome="warmed"|"skipped"|"error"} (boot
warmup outcome) Β· noetl_worker_ready (0 while warming, 1 once warm β the
/readyz source of truth).
EHDB command bus (noetl/ai-meta#194 L1 T4)
NOETL_COMMAND_BUS selects which transport carries commands:
nats (default, unchanged), shadow (the writer exists and receives
the server's publishes, but NATS stays authoritative and the worker
keeps consuming NATS), or ehdb (the worker claims from the EHDB
bus and creates no NATS subscriber β the shape T5 needs).
One worker per shard is the writer host (NOETL_COMMAND_BUS_HOST).
It opens the shard's durable command log and serves three faces:
ingest (the server publishes here), claim (worker replicas compete
here), and a Prometheus lag endpoint (the KEDA signal). Every other
worker is a claimer and needs only NOETL_COMMAND_BUS_CLAIM_ADDR.
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_COMMAND_BUS |
nats |
no | Transport selector (nats / shadow / ehdb). Anything unrecognised is nats, so a typo degrades to the default rather than to a dark bus. |
NOETL_COMMAND_BUS_HOST |
false |
no | This worker hosts its shard's writer. Exactly one pod per shard; a second host on the same volume is not supported (single-writer-per-shard is what makes the shard log ascending). |
NOETL_COMMAND_BUS_SHARD |
0 |
for a host | Which shard this writer owns. |
NOETL_COMMAND_SHARD_COUNT |
1 |
no | Routing modulus. Must match the server's β it is the divisor in the commands.<pool>.shard.<n> subject both sides derive. |
NOETL_COMMAND_BUS_WRITER_DIR |
β | for a host | The command log's directory. Mount a PVC here. It holds the log and the committed cursor, so a pod-local emptyDir throws away the writer's durable state on every restart. |
NOETL_COMMAND_BUS_INGEST_BIND |
β (never binds) | for a host | Where the server publishes commands (0.0.0.0:9100). |
NOETL_COMMAND_BUS_CLAIM_BIND |
β (never binds) | for a host | Where worker replicas compete for commands (0.0.0.0:9101). |
NOETL_COMMAND_BUS_METRICS_BIND |
β (never binds) | for a host | The lag / committed-cursor endpoint (0.0.0.0:9102); see the metrics note below. |
NOETL_COMMAND_BUS_CLAIM_ADDR |
β | in ehdb mode |
The claim coordinator as host:port. A DNS service name works and is preferred (noetl-cmdbus-writer.noetl.svc.cluster.local:9101) β it is resolved at connect time, so a restarted writer's new pod IP is picked up on redial. |
NOETL_COMMAND_BUS_ACK_WAIT_SECS |
30 |
no | How long a claimed-but-unacked command waits before it is redelivered to another replica (the crash-recovery window). Too low redelivers work still in progress; too high delays recovery from a killed pod. |
NOETL_COMMAND_BUS_CURSOR_PERSIST_MS |
1000 |
no | How often the host persists its committed cursor to NOETL_COMMAND_BUS_WRITER_DIR (noetl/ai-meta#208). This is what a restart resumes from; before it existed, every restart replayed the whole shard log. The cursor may lag reality by one interval, which is the safe direction (a few already-acked commands are redelivered and answered "already claimed"). 0 disables the ticker β the shutdown persist still runs, so a graceful restart is still exact. |
NOETL_COMMAND_BUS_CURSOR_FALLBACK |
tail |
no | Where the host starts when no cursor has been persisted yet β the first boot after #208 shipped, or a wiped volume. tail serves only commands appended after the restart; beginning replays the whole log (the pre-#208 behaviour, kept as a rollback lever). tail leans on the control plane's orphaned-command guardrail (#171) to re-issue anything skipped by that one-time gap, which is cheaper than stalling dispatch behind a full replay. |
Restart behaviour (#208). A writer restart is a routine event β node drain, image bump, OOM, HPA move β and both halves of surviving it are now in the binary, shipped in v5.81.3 and prod-validated 2026-07-30: the writer pod was deleted mid-stream under continuous load and 38 executions β 38 COMPLETED, 114 = 38 Γ 3 commands, 0 duplicates / 0 loss, redial in ~2.7 s, resuming from the committed cursor rather than replaying the shard.
command.issued",
check the claimers' last log line against the writer's start time
before anything else.
- Claimers detect a writer that stopped answering (TCP keepalive plus
a coordinator heartbeat on the claim connection) and redial on their
own, so restarting the writer alone no longer requires restarting
the workers. A redial logs
EHDB claim_next failed; reconnecting to the claim coordinatorwith the claim address, member and filter. - The host resumes from its committed cursor instead of replaying,
and logs the decision once at startup:
EHDB command-bus claim coordinator resumed shard=0 from_cursor=N origin=persisted|fallback_tail|fallback_beginning.origin=fallback_*on a pod that should have had a cursor means the volume did not survive β check that the PVC is really mounted. - On SIGTERM the host persists the cursor and seals the log. The
seal matters: the storage engine reopens from its durable manifest,
so records still in an unsealed part are invisible to the restarted
process even though every append was
fsynced. Give the pod enoughterminationGracePeriodSecondsfor the seal to finish β prod runs 90 s (raised from the 30 s default for exactly this).β οΈ The seal covers graceful stops. An ungraceful stop (SIGKILL, node loss) can still lose the unsealed tail of the active part, and the seal itself races in-flight ingest β noetl/ai-meta#209, open. On a command bus that is a loss class, so it should land before T5 removes the NATS fallback. - The persisted cursor is clamped to the reopened log's tip. This is not a micro-optimisation: the engine resumes from its durable manifest, so a log that lost an unsealed part can reopen behind the persisted cursor β and because the writer then re-issues keys from its recovered sequence, a cursor no future key can exceed would filter out every record and take the bus permanently dark. Clamping costs at most a small redelivery.
Shutdown sequence + its knobs (#209, #226). A process may host more than one writer β the command bus and the events feed β and the phases run across every host at once rather than finishing one host before starting the next: stop all ingest β quiesce once β persist all cursors β seal all engines. Host-at-a-time left the second host accepting and appending for the whole of the first host's sequence and then spent its own deadline from a standing start, out of the same shared budget.
| Variable | Default | Required | Meaning |
|---|---|---|---|
NOETL_EHDB_SHUTDOWN_TIMEOUT_MS |
15000 |
no | Total budget for the whole shutdown sequence, across every host. Bounded so a wedged uploader cannot hold the pod past terminationGracePeriodSeconds and turn a graceful stop into the SIGKILL that loses the tail. Must stay comfortably below the grace period. |
NOETL_EHDB_SHUTDOWN_QUIESCE_MS |
250 |
no | How long already-accepted publishers get to land and be acked after the listeners close, before the seal. Paid once for all hosts, not per host. Connections already accepted are owned by tasks inside ehdb-feed and cannot be cancelled from outside, so this window is what lets their appends reach the pre-seal part; anything that misses it is refused by the closed writer and republished to the replacement. |
NOETL_EHDB_SHUTDOWN_STOP_TIMEOUT_MS |
2000 |
no | How long to wait for each host's faces to confirm their listener is actually closed before sealing anyway. A face that will not stop is a bug, but sealing late is worse than sealing with a listener open β the seal is what prevents loss. A timeout is logged, not fatal. |
Verify a graceful stop from the terminating pod's own log: it should
end with EHDB writer hosts all sealed β shutdown complete and
Worker stopped. An ERROR line saying the hosts did not all
seal β or the absence of both lines β means an unsealed tail, and the
next incarnation will resume clamped=true over a log that came back
below its own persisted cursor. The same fact is on /metrics as
noetl_worker_shutdown_hosts_sealed vs _total; see Observability.
Metrics (:9102, host only):
ehdb_feed_shard_lag{shard} + ehdb_feed_total_lag (the KEDA trigger β
undelivered + delivered-but-unacked) Β·
ehdb_feed_shard_committed{shard} (the acked-through cursor, i.e. what a
restart would resume from). Β·
ehdb_feed_shard_inflight{shard} + ehdb_feed_total_inflight
(delivered-but-unacked depth, from ehdb v0.7.0). Across a writer restart the committed
cursor should pick up roughly where it left off; a jump back toward 0
with a lag spike to the size of the log is the replay regression #208
fixed.
origin=persisted) yet logged from_cursor=0, and
ehdb_feed_shard_committed reset 408 β 165 across the restart β
the counter is segment-relative, not a global monotonic offset. No
replay happened (proven by exact command accounting), but the line
cannot distinguish "resumed" from "replayed from 0". Until
noetl/ehdb#304 lands, confirm
a resume by counting delivered commands, not by reading the cursor.
inflight must be sampled, never defaulted. render_snapshot
publishes this family unconditionally, and per its own HELP text high
lag with zero in-flight is the stalled-consumer reading. So a writer
that fills the field with a literal 0 does not report "unknown" β it
reports an outage, on every scrape, for a bus that is working. The pair
is what separates two opposite conditions that lag alone renders
identically:
lag |
inflight |
what is happening |
|---|---|---|
| high | 0 | nothing is being worked on β consumers absent, stalled, or not polling |
| high | at cap | consumers saturated and making progress |
| 0 | 0 | drained |
tests/cmdbus_writer_metrics.rs claims three records without
acking and asserts the scrape reads inflight=3 while lag stays at 6 β
lag counts undelivered plus unacked, so a poll-without-ack moves it
by zero. Before ehdb v0.7.0 the coordinator had no accessor for the
value, so 0 was the only thing a writer could compile
(noetl/ehdb#402).
ehdb_feed_total_lag counts every subject past one committed cursor,
so on a single-shard deployment carrying both
commands.shared.shard.0 and commands.system.shard.0 it over-reports
either pool's own queue. Safe for scaling up; the hazard is that a
stuck system-pool command pins the global cursor and therefore pins lag
high indefinitely. Per-subject lag is
noetl/ehdb#303, open. See the
ops wiki KEDA page
for how this feeds the user pool's autoscaler.
The writer Deployment is not yet a checked-in noetl/ops manifest β
it lives with the cutover automation in
noetl/ai-meta
(cmdbus-writer-shard0.yaml, its PVC, and the staged
shadow β canary β flip β rollback scripts). The claimer side is the
NOETL_COMMAND_BUS* env on the ordinary worker deployments in
noetl/ops.
EHDB Data Query Interface + Flight SQL external driver (noetl/ai-meta#178 + #184)
Two read-only EHDB surfaces the data-plane worker can expose, both
disabled by default and gated behind NOETL_EHDB_ENABLED:
-
In-cluster tier query (#178) β the
GET /ehdb/tiers/{tier}route on the existing metrics/query port:9090the noetl-server relays to (see Ports). Adds no new worker env var of its own β it ridesNOETL_EHDB_ENABLED+ the tier mirror config. - External Flight SQL driver (#184) β a dedicated Arrow Flight SQL listener on port 8092 serving the projection tier read-only to apps outside the platform. Inert unless opted in; fail-closed (a non-loopback bind with no token alias is refused at startup, so it never serves open).
| Variable | Default | Required | Why |
|---|---|---|---|
NOETL_EHDB_FLIGHT_SQL |
β (listener never binds) | to enable the driver | Opt-in for the external Flight SQL projection endpoint. Truthy and a data-plane role and a satisfiable auth mode are all required before the :8092 listener binds; otherwise the worker is byte-identical to one without the driver. |
NOETL_EHDB_FLIGHT_SQL_BIND |
0.0.0.0:8092 |
no | Listener socket address. An unparseable value logs an error and leaves the endpoint unstarted (fail-closed, not fail-open). |
NOETL_EHDB_FLIGHT_SQL_TOKEN_ALIAS |
β | for any non-loopback bind | Keychain alias of the scoped read token the driver requires on every Flight SQL request. A non-loopback bind without this alias is refused β the driver never serves an unauthenticated external listener. The token is resolved from the keychain, never an env literal. |
NOETL_EHDB_FLIGHT_SQL_TOKEN_FIELD |
(see code) | no | Field within the keychain entry to read the token value from, when the alias resolves to a multi-field secret. |
NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH |
β (off) | no | Harness escape hatch: run without a token only when the bind is loopback. Never set on a cluster/GKE deployment β it is for local validation against 127.0.0.1 only. |
The driver is read-only and serves the projection tier through
the same ProjectionDriver read contract as #178 (built on it, not a
fork); it opens no write path and relays no secrets. The kind
deployment + a pyarrow validation client live in
noetl/ops
(ci/manifests/noetl/ehdb-flightsql-deployment.yaml,
ci/validation/ehdb_flightsql_client.py).
Not armed anywhere. NOETL_PROJECTOR_ENABLED is unset on every prod workload,
so no loop runs and the orchestrator self-writes the snapshot exactly as today.
Blast radius is zero until two flags are flipped β this one, and the server-side
NOETL_PROJECTOR_OWNS_SNAPSHOT, which has its own default of false.
Added here because drift-audit.sh env-docs reported them absent (Rule 2a).
| Variable | Default | Required | Notes |
|---|---|---|---|
NOETL_PROJECTOR_ENABLED |
unset (off) | no |
Arms the projector loop, which drains the EHDB events feed and advances noetl.projection_snapshot via POST /api/internal/projection/advance. Truthy set is 1/true/yes/on, case-insensitive. β Setting it fails startup rather than degrading if NOETL_INTERNAL_API_TOKEN or NOETL_EVENT_BUS_CLAIM_ADDR is empty β a projector with nothing to connect to would otherwise look armed and do nothing. |
NOETL_PROJECTOR_GROUP |
DEFAULT_PROJECTOR_GROUP |
no | Consumer group for the feed. β β Startup refuses the materializer's group outright. Sharing a group splits the feed between the two consumers, so each sees roughly half the events and neither reports an error β the projector would advance half the executions and look healthy. That is a silent-wrong state, so it is rejected loudly at config time rather than detected later. |
NOETL_PROJECTOR_BATCH |
64 |
no | Events claimed per poll, clamped to 1..=1000. |
NOETL_PROJECTOR_IDLE_SLEEP_MS |
500 |
no | Sleep when the feed is empty. |
NOETL_PROJECTOR_ERROR_BACKOFF_MS |
2000 |
no | Sleep after a failed pass. |
NOETL_PROJECTOR_TIMEOUT_MS |
1000 |
no | Per-call HTTP timeout for advance. β Bounded on purpose and generous on purpose: advance recomputes a snapshot per execution so a large batch is legitimately slow, and a timeout below that turns a working-but-slow projector into a failing one β but it must be bounded, because reqwest::Client::new() has no timeout at all, the defect noetl/worker#324 fixed in this crate. |
β All six are read through env_u32/env_u64/env::var helpers rather than a
single config struct, so a read-set scan that greps only env::var("NOETL_β¦")
undercounts this module.
Both change what a run does, not what it reports, which is why both default off.
| Variable | Default | Required | Notes |
|---|---|---|---|
NOETL_EXECUTION_FAIL_ON_STEP_ERROR |
off | no |
Does a failing step fail its execution? (noetl/server#434.) Truthy 1/true/yes/on. Off β byte-identical to the long-standing behaviour. Same posture and reason as NOETL_EXECUTION_STATUS_FROM_STEPS (noetl/ai-meta#251): any playbook relying on an erroring step to continue begins failing when this is on, so it must be a deliberate flip rather than a side effect of deploying an image. β β LIVE as true on all four prod workloads β noetl-worker-rust, noetl-worker-system-pool, noetl-worker-system-pool-shard1 and noetl-cmdbus-writer β and it had no row on this page. |
NOETL_MATERIALIZER_DEAD_LETTER |
off | no |
Park an un-insertable event instead of redelivering it for ever. Truthy 1/true/yes/on. Off β today's delivery guarantee. On, an event the server will never accept is acked and parked in noetl.event_dead_letter instead of holding the head of the ordered drain while every other execution's command.completed queues behind it. That blockage is measured: prod 2026-09-27 had five playbook.failed events from 09-24 still looping three days later, 120,545 project errors, 271s projection lag, 1,283 executions abandoned. β Enabling it changes delivery guarantees, so it is a deliberate flip for the same reason poison-command dead-lettering is (noetl/ai-meta#249). Not set on prod. β The landing table is provisioned by the server at startup; it was also missing from the DDL the deploy applies until noetl/noetl#709. |
| Variable | Default | Required | Notes |
|---|---|---|---|
NOETL_EHDB_TIER_SEAL_MAX_BYTES |
unset β never seal | no | Byte threshold at which the tier store seals the active segment and starts a new one. Unset, unparsable or 0 β no sealing β byte-identical to an unsealed store, which has exactly one segment: the file that exists now. β Fail-safe direction is deliberately off β a typo must not start rotating a primary-serving tier's store behind the operator's back. Exists because an unbounded store OOM-killed noetl-cmdbus-writer-0 repeatedly on 2026-09-20, at a 4 GiB limit and again at 8 GiB: raising the limit is a delay, bounding the store is the fix. β β LIVE on noetl-cmdbus-writer as 268435456 (256 MiB), undocumented here until now. |
NOETL_EHDB_TIER_SHED_AFTER_MS |
1000 |
no | How long an accepted tier request waits for a concurrency permit before it is shed with an explicit busy error. Chosen against the client budget, not picked round: the prod tier client times out at 2s, so a request that has not started work within 1s is better answered than left to expire silently. Waiters are additionally capped at TIER_MAX_INFLIGHT Γ TIER_MAX_WAITERS_MULTIPLE (8), because accepting unconditionally must not turn a memory spike into an unbounded task list. |
Shadow-tier mirror sources (noetl/ai-meta#348) β documented 2026-10-04
| Variable | Default | Required | Notes |
|---|---|---|---|
NOETL_EHDB_KV_MIRROR_SOURCE |
worker |
no | Which component mirrors into the KV shadow tier. worker (default) = the worker's emit chokepoint; server = the server's write chokepoint, and the worker's own mirror disarms. β Anything unrecognised β including an empty value β resolves to worker, so a typo restores the pre-change mirror rather than disarming both halves and leaving the tier silently empty while NOETL_EHDB_* still reads shadow. Not set on prod. |
NOETL_EHDB_OBJECT_MIRROR_SOURCE |
worker |
no | The same, for the object shadow tier. Each tier gets its own variable rather than borrowing the event log's, because a tier whose producer is configured by another tier's variable is a coupling nobody would predict from reading either one. Not set on prod. |
Same shape as the server:
| Secret | Storage | Mount as |
|---|---|---|
NATS_PASSWORD |
K8s Secret | valueFrom.secretKeyRef |
Any value listed in NOETL_KEYCHAIN_ENV_VARS
|
K8s Secret (when used) |
valueFrom.secretKeyRef per allowlisted name |
Per agents/rules/execution-model.md
"Secrets and credentials rule": business-logic credentials
(third-party API tokens, tenant database DSNs) belong in the
NoETL keychain via the server's /api/keychain/* routes. The
allowlist mechanism above is a bridge only for credentials
that already live in the pod env via existing trust (GKE Workload
Identity, etc.).
-
noetl_worker_build_info{version}β always1; theversionlabel identifies the running binary.Read this first when a metric you expect is missing.
Registry::gatherprunes metric families with no children, so every labelled metric is absent from/metricsuntil something increments it β registering it is not enough. That makes absence ambiguous between "this has never happened" and "this binary predates the metric", and the two want opposite responses.Where the label set is closed the series are pinned at
0at startup so a scrape reads0rather than nothing. Where it is open β as onnoetl_worker_event_emit_failed_total{event_type}, which takes a free-form string β pinning cannot work at all, and this gauge is the only way to tell the two cases apart. Added in noetl/ai-meta#238; before it, nothing on/metricscarried the version, so the question was answered from the Deployment's image tag β a different representation, which can disagree with what is actually running. -
Silent-failure signals β counters added 2026-08-05 closing the warn/error-without-metric triage (#238). Each covers a path that previously logged and counted nothing; all labelled sets are pinned at 0 at startup.
metric labels what a non-zero value means noetl_worker_materializer_skipped_totalβ Data loss. A drained message had no event_id, so it was never projected β and was acked anyway. UnderNOETL_EVENT_INGEST_PUBLISH_ONLYthe materializer is the sole writer ofnoetl.event, so the event is permanently absent from the durable log. The materializer's six other counters do not move on this path.noetl_worker_materializer_ack_failed_totalstagenon_event_batchis the serious one: without that ack the batch poison-loops forever, so a sustained rate is a stalled materializer.after_projectcosts a redelivery, never a row.per_handleis a partial ack.noetl_worker_materializer_drain_failed_totalβ Drain polls failing outright. A materializer that cannot drain is a durable log that stops being written. noetl_worker_ehdb_claim_reconnect_totalfeed,reasonClaim-coordinator reconnects. feed=commandsstalls dispatch;feed=eventsstalls the materializer. One production pod logged 85 of these in 24 h with no metric at all before this.noetl_worker_claim_failed_totalβ Commands whose claim failed and were nacked. β This path retries with no backoff (#244), so a persistently failing command spins at full speed β measured at ~1000 log lines/second. The rate is the signal; the log flood is the symptom. noetl_worker_state_builder_replay_end_totalreasonWhy a cold-rebuild replay stopped. Three of the four ( complete,deadline,max_messages) are ordinary termination;feed_errormeans the rebuilt state is INCOMPLETE. Read the ratio, not the count.noetl_worker_event_emit_failed_totalevent_typeAn event was abandoned after its retry window. On the container-poll resume path a lost call.donemeans the execution never continues (#227).noetl_ehdb_dataplane_ops_total{operation="reachability"}outcomeWhether the EHDB tier service was reached on a real operation. unreachableis a transport failure and demotes the tier;reached/rejectedpromote it (a service that answers, even to refuse a record, has been reached). This is the input to the serve decision, so it is the series that explains aprimary_unavailable. β Deliberately not pinned at 0 β it is only reachable from a configured tier client, which keeps the flag-off path's/metricsbyte-identical; absence here correctly means "no tier op has ever run".noetl_worker_state_builder_drive_builds_totaloutcomeSeven outcomes, not the three an older doc comment claimed. fallback_incompleteandserved_shard_mismatchare the interesting ones.Unlike the server, this crate builds its metrics eagerly in
WorkerMetrics::new, so an unlabelled metric is always present at 0 and never absent-because-untouched. Verified against a released image: v5.105.0 declares 77 metrics and serves 45, with zero unlabelled-absent. -
Metrics: Prometheus surface at
:9090/metricsperagents/rules/observability.md. Surface includes NATS pull rate, command claim outcomes, tool dispatch durations per tool kind, result-store PUT latency. -
EHDB tier queries (noetl/ai-meta#178):
noetl_worker_ehdb_query_ops_total{tier,operation,outcome}(counter) +noetl_worker_ehdb_query_duration_seconds(gauge, last op) β recorded by theGET /ehdb/tiers/{tier}read handler. Like the other EHDB families, adisabledoutcome records nothing, so a worker with EHDB off (the default) emits a byte-identical/metrics. Each query also rides anehdb.querytracing span carryingexecution_id. -
EHDB tier service β the server half (noetl/ai-meta#260, introduced by worker#270): the writer-fronted face on
:9110thatprimarypromotes to authoritative. Until #260 it recorded nothing β its whole observability was fourtracinglines, three on failure paths β so the only prod-side signal was a demotion counter recorded by the client, which cannot tell an unhealthy service from an unhealthy network.metric labels what it says noetl_ehdb_dataplane_ops_total{operation="tier_service.<op>"}outcomeOne per handled request. <op>βhealth/append/read_execution/scan/unsupported/conn. Deliberately the same family astier_client.<op>, so a prefix match puts both halves of one request side by side.noetl_ehdb_tier_service_duration_secondsoperationRequest latency histogram. Incremented by the same function as the counter, so the two cannot drift apart. Measures decode β store β encode, excluding the frame read and write, which are dominated by how fast the caller is. noetl_ehdb_tier_service_store_appends_totalβ Records this process has written. A counter, so a restart reads as a reset rather than a plateau. noetl_ehdb_tier_service_store_sequenceβ Highest global sequence the store holds β "does the tier I am about to promote actually contain anything", answerable without reading it. Sampled at startup, so a restart in front of a populated store does not read as empty. noetl_ehdb_tier_service_store_bytesβ Store size on disk, sampled at startup and after each append. Outcomes, and the one distinction that matters for alerting:
ok/hit/miss/invalid/unavailable/error/unsupported, plusaccepted/closed/protocol_error/write_error/accept_erroronconn.degradedis not!okβ a malformed request isok=false, degraded=false(the caller got a correct refusal; the service is fine), whileunavailableanderrorare degraded because a tier promoted here could not answer. Alert ondegraded, never on!ok, or a client sending bad frames pages someone.On reads,
hitvsmissis the load-bearing pair: an all-missread stream means the store is serving, and empty.β These ARE pinned at 0 β but only once the listener binds. All 20 series are created at 0 from a single call site immediately after the bind in
event_bus.rs, and below that bind the pin is unconditional: not gated on a store being configured, on tier mode, or on anyNOETL_EHDB_*. (A pin inside a config branch is not a pin β server#315 shipped that mistake and left its skip reasons absent on exactly the configuration worth debugging.) WithNOETL_EHDB_TIER_SERVICE_BINDunset nothing is pinned and/metricsis byte-identical to a build without the module, which is correct: absence then means this process has no tier service, andnoetl_worker_build_info{version}above answers "is this binary too old to have the metric".Hosted by the process that sets
NOETL_EVENT_BUS_HOSTβ the writer, not an ordinary worker pod β because that is the process holding the durable RWO volumes. The writer's Service already publishes:9090, so this needs no PodMonitoring change; noetl/ops#255 only has to be applied. -
Materializer / consumer lag β
ehdb_events_group_lag{group}, exposed by the EHDB writer, not by the worker.This replaced a pair of worker gauges (
noetl_worker_nats_consumer_pending+_ack_pending, labelled{stream, consumer}) written by a periodic JetStream lag poller. T5 deleted JetStream and the poller with it, so from that point the gauges were never written again β and because a labelled metric is absent from/metricsuntil something increments it, they simply stopped appearing rather than reading zero. The recording rule and the three backlog alerts built on them went permanently inert with no error anywhere. Both gauges are removed outright in noetl/ai-meta#242.The signal itself still matters and is unchanged in meaning: under the server's
NOETL_EVENT_INGEST_PUBLISH_ONLYgate it is the earliest indication that published events are piling up un-materialized, and it climbs even when the materializer loop has stalled or died β the loop cannot report its own lag, so the measurement has to come from outside it. That is now the writer rather than a per-pod poller, which also removes the need to dedup across system-pool pods. It is what the CQRS flip guardrail alerts read (see noetl/ai-meta#103 and the ops runbooknoetl-cqrs-publish-only-flip.md). -
NATS resilience / self-heal (noetl/ai-meta#163): two independent in-process reconnect paths keep a NATS bounce from taking the worker down. (1) The state-builder drain (system pool) recovers a lost pull consumer β
noetl_worker_state_builder_consumer_recreate_total{reason}- the
noetl_worker_state_builder_healthygauge the/livezprobe reads. (2) The main command loop (every pool) survives a hard NATS disconnect (nats-0pod delete, full client disconnect) in-process instead ofexit(1)+ k8s crash-restart + full WAL replay: on a pull/ack/nack disconnect it rebuilds theNatsSubscriberwith bounded exponential backoff (250 msβ10 s) and re-binds the same durable consumer (server-side cursor preserved;claim_commandatomicity keeps exactly-once), recorded onnoetl_worker_command_loop_reconnect_total{reason}βreason=pull|ack|nack(a disconnect surfaced on that op and the subscriber was rebuilt) /connect_error(a rebuild attempt failed, retrying). A risingpull/ack/nackrate with the pod'srestart_countstaying flat is the fix working. Like the sibling counter it is a labelled family, so a worker that never hit a disconnect emits no series for it (byte-identical/metrics).
- the
-
Graceful-shutdown seal (noetl/ai-meta#226):
noetl_worker_shutdown_hosts_sealed+noetl_worker_shutdown_hosts_total, written once, immediately before exit.sealed < totalon a terminating pod means an unsealed tail, and the next incarnation's resume over that log will clamp. A pod that terminates without ever writing them never reached the seal at all.This exists because a partial seal used to be observable only on the next boot, as a
clamped=trueresume over a log that had come back below its own persisted cursor β i.e. after the records were already gone. In prod the terminating pod loggedsealing EHDB writer hosts before exit hosts=2, sealed the command bus, and stopped: no events seal, noWorker stopped, and no expiry line from the 15 s shutdown budget either, because the starving seal had also stopped tokio's timer.seal_allnow logs at ERROR when any host is left unsealed, in the terminating pod's own log, while an operator is still watching. -
Events-consumer reattach (noetl/ai-meta#225):
noetl_worker_events_consumer_redials_total{face}βfaceisgroup_claim(the:9104named-group claim path) orwal(the:9108fan-out the off-server state builder subscribes to). A rising count around a writer restart is the consumers reattaching; a flat count while a group cursor is not advancing is the wedge.Before #225 this counter could not have moved even while the consumers were wedged: neither events face carried the TCP keepalive
- negotiated heartbeat the command claim path got in
#208, so a half-open
socket left the parked read neither yielding nor erroring β and the
redial paths downstream were unreachable for want of an
Err.noetl.eventtook no writes for 3h24m with/readyzready.
- negotiated heartbeat the command claim path got in
#208, so a half-open
socket left the parked read neither yielding nor erroring β and the
redial paths downstream were unreachable for want of an
-
Materializer counters:
noetl_worker_materializer_drained_total/_projected_total/_duplicates_total/_acked_total/_project_errors_total+_cycle_duration_secondsβ the ack-after-materialize loop's throughput + the no-loss redelivery surface (project_errors= a batch left un-acked to redeliver). -
Scrape path (the metrics above only guard the flip if something scrapes them): the
:9090/metricsport is exposed by a headless*-metricsService per pool. On the kind dev cluster a VictoriaMetricsVMServiceScrapeselects them; on prod (GKE) the cluster runs Google Managed Prometheus, which does NOT honorprometheus.io/scrapeannotations β the worker pods are scraped by a GMPPodMonitoring(ci/manifests/noetl/gmp/podmonitoring-noetl.yaml, ops wiki Production monitoring (GMP)). Without that object the lag gauge + materializer counters never reach Managed Prometheus and the CQRS flip guardrail is blind. -
Tracing:
tracingspans on every NATS message, every tool dispatch.execution_idis a span field. -
Logs: structured JSON via
tracing-subscriber.
When changing any of the above, validate per
agents/rules/deployment-validation.md:
cargo build --release --binscargo test --quiet- Build image locally + load into kind:
kind load docker-image β¦ - Apply the manifests against
kubectl --context kind-noetl - Smoke-test:
- Submit a playbook execution via the noetl CLI.
- Confirm the worker claims the command, runs the tool, and POSTs lifecycle events back.
- For env-var changes: verify
kubectl exec β¦ env | grep <VAR>shows the value AND the worker's startup log line confirms it was honored.
Only after kind passes does the change roll forward to Cloud Build + GKE.
- noetl-executor adoption β the shared execution core the worker pulls from crates.io.
- release-pipeline β Cargo + Docker image publish flow.
- worker-credentials β credential resolution in tool dispatch.
- nats-mcp-tool-kinds β tool kinds the worker dispatches.
-
noetl/server
sharding-designβ the cross-component routing context. -
noetl/server
deployment-specificationβ the server-side counterpart of this page. - noetl/ops Helm chart + manifests β the deployment-time implementation.
-
agents/rules/wiki-maintenance.mdβ the rule that requires this page to update in lockstep with code.