Skip to content
NATS Infrastructure

NATS Infrastructure

DagNats provisions all its NATS resources automatically on startup via natsutil.SetupAll. In a cluster every stream and bucket below is created at the cluster’s replication factor, except the buckets listed under Buckets outside SetupAll.

JetStream Streams

Ten streams handle all durable messaging. dagnats serve creates these on first boot; distributed deployments must ensure they exist before components start.

StreamSubjectsRetentionStorageLimitsPurpose
WORKFLOW_HISTORYhistory.>LimitsFile30-day max age, 5s dedup windowImmutable event log (source of truth)
TASK_QUEUEStask.>WorkQueueFileNo max age, atomic publish, direct getWork distribution to workers
EVENTSevent.>LimitsFile14-day max ageExternal event ingestion
DEAD_LETTERSdead.>LimitsFile30-day max age, 24h dedup windowPermanent failures for inspection
TELEMETRYtelemetry.>LimitsFile7-day max age, 5s dedup windowSpans, metrics, logs
SLEEP_TIMERSsleep.>, scheduled.>LimitsFileNo max ageDurable timers (sleep, timeout, rate-retry, scheduled runs)
STICKY_TASKSsticky.>LimitsMemory30-minute max ageWorker-affinity task routing
TRIGGER_HISTORYtrigger.fire.>LimitsFile30-day max ageTrigger fire records (dagnats trigger history)
BUILD_LOGSlogs.>LimitsFileDAGNATS_BUILD_LOGS_TTL, default 7 days (1h to 8760h); 2-minute dedup windowTask log lines (logs.{run}.{step}.{attempt}.{iteration})
AGENT_JOURNALjournal.>LimitsFile30-day max age, 2-minute dedup window, direct get offAgent journal records (journal.{run}.{step}), one fenced subject per step

TASK_QUEUES and SLEEP_TIMERS have no max age because their messages are live work: an age limit would silently delete pending tasks and unfired timers. Every file-backed stream also gets a byte ceiling that is a share of the JetStream store budget (max_store_bytes): WORKFLOW_HISTORY 25%, EVENTS 15%, TASK_QUEUES 12%, TELEMETRY 10%, BUILD_LOGS 5%, DEAD_LETTERS 5%, AGENT_JOURNAL 4%, TRIGGER_HISTORY 4% and SLEEP_TIMERS 4%, plus 0.5% for the submissions KV bucket, 84.5% in all, with the rest left as headroom for the other KV buckets and JetStream’s own index. AGENT_JOURNAL is sized small on purpose: one record per claim, pause, entry and commit per step, aged out at 30 days like history. STICKY_TASKS is in memory and bounded by its age.

WORKFLOW_HISTORY

The event log. Every state change for every workflow run is an immutable event published to history.{runID}. The 5-second dedup window (via Nats-Msg-Id) prevents duplicate events during retries. The orchestrator consumes it through a durable consumer; current run state is kept as snapshots in the workflow_runs KV bucket. In a cluster, every node’s engine consumes the same history stream, so snapshots are read from the stream leader and each save is a compare-and-set on the revision that was read. A save that loses the race is handled again from a fresh read.

TASK_QUEUES

Work distribution. Uses WorkQueuePolicy so each message is delivered to exactly one consumer. Tasks are published to task.{taskType} and workers create pull consumers filtered to the task types they handle. Atomic publish is enabled for fan-out operations (map steps).

SLEEP_TIMERS

A shared timer stream using an action discriminator in the message payload:

ActionFires AfterResult
sleep_completeSleep durationPublishes step.sleep.completed event
wait_timeoutWait-for-event timeoutPublishes step.wait.timeout event
rate_retryRate limit refill delayRe-publishes task to task.>
task_concurrency_retryConcurrency slot backoffRe-publishes a task that found its concurrency limit full
debounce_fireDebounce windowFires debounced trigger
retry_afterDelay a worker requestedDispatches the next attempt
retry_backoffRetry policy backoffDispatches the next attempt
step_timeoutStep timeoutFails a still-running attempt as retriable (stale timers from an earlier attempt are ignored)
approval_timeoutApproval gate timeoutExpires the approval

All timers use NATS NakWithDelay – the message is negatively acknowledged with a delay, and NATS redelivers it after the specified duration. No external timer service needed.

KV Buckets

These KV buckets store workflow state, coordination data, and operational metadata.

BucketTTLHistoryPurpose
workflow_defs–defaultImmutable workflow definitions
workflow_runs–defaultMutable run state snapshots
scheduled_runs–defaultOne-shot scheduled workflow runs
workers60sdefaultWorker directory (heartbeat)
event_waiters–defaultWait-for-event correlation entries
rate_limits–defaultToken bucket state per task type
concurrency_runs–defaultPer-workflow sets of runs holding a concurrency slot
concurrency_tasks–1Per-task-type concurrency slot holders
approval_tokens7 days1Human approval gate tokens
debounce_state14 daysdefaultSubject trigger debounce windows
idempotency_keys24 hoursdefaultWorkflow dedup key-to-runID mapping
sticky_bindings~25 hoursdefaultRun-to-worker affinity binding
singleton_locks–defaultSingleton execution locks
checkpoints30 daysdefaultResume markers written by Pause(), and loop state for workers on an older engine
signals–defaultCross-workflow KV-based signaling
triggers–defaultTrigger definitions
trigger_state–defaultCron last-run timestamps
http_idempotency1 hourdefaultCached responses for HTTP trigger idempotency keys
trigger_types–1External trigger type schemas
services–1Registered service metadata
worker_tokens–1Minted worker tokens (revoked records kept for audit)
worker_status120sdefaultPer-worker counter snapshots for dagnats status
console_audit90 daysdefaultAudit log of console write actions
leasesper key (1s-10m)1Coordination leases with revision fencing (ADR-024); changes republished on dagnats.lease.changed.<key>
submissions7 days1Inputs submitted to running agent steps, keyed {run}.{step}.{request_id} (ADR-027); values at most 256 KiB, the one bucket with a byte ceiling (0.5% of the store budget)

History default is the JetStream KV default of 1 revision. console_audit is created by dagnats serve for the console and control-plane audit log; every other bucket comes from SetupAll, so dagnats-engine and dagnats-api create them too.

Replication

Every bucket, console_audit included, uses the cluster’s replication factor, and all of them appear in the /health/cluster report (console_audit only on a node that has created it). Through 0.0.27 triggers, trigger_state, signals, checkpoints, concurrency_runs and console_audit were single-replica in a cluster; an existing deployment is upgraded in place on restart. See Clustering.

Workers Bucket

The workers bucket has a 60-second TTL. Workers re-PUT their entry every 30 seconds, so a single missed heartbeat is tolerated. The engine never reads this bucket – it exists purely for observability (dagnats workers list). If the bucket is missing, workers function normally.

Concurrency Buckets

concurrency_runs holds, per workflow, the set of run IDs that hold a slot. concurrency_tasks uses History: 1 to minimize storage. Each key holds the set of steps (<run_id>/<step_id>) that hold a slot of one task type, written with CAS. Taking or freeing a slot twice for the same step is a no-op, so a redelivered event cannot drift the count. Counters written by an older version read as no holders, so a limit can over-admit once for tasks in flight across the upgrade. The engine checks these sets before dispatching tasks. If a limit is exhausted, the task is retried via SLEEP_TIMERS with a 1-second delay.

Subject Hierarchy

All NATS subjects follow a dot-separated hierarchy. The > wildcard matches one or more tokens.

history.{runID}                         # Workflow events
task.{taskType}.{runID}                 # Task distribution (default route)
event.{eventType}                       # External events
dead.{taskType}.{runID}.{stepID}        # Dead letter entries
telemetry.spans.{service}.{runID}       # Trace spans
telemetry.metrics.{service}.{name}      # Metrics
telemetry.logs.{service}.{severity}     # Log records
sleep.{runID}.{stepID}                  # Timer messages
scheduled.{runID}                       # Scheduled run triggers
sticky.{taskType}.{workerID}.{runID}    # Worker-affinity tasks
trigger.fire.{triggerID}                # Trigger fire records
logs.{runID}.{stepID}.{attempt}.{iter}  # Task log lines
stream.{runID}.{stepID}                 # Real-time step output (core NATS)

The subject design ensures that consumers can filter to exactly the messages they need. A worker subscribing to task.summarize only receives summarize tasks. The orchestrator subscribing to history.> receives all workflow events.

Resource Setup

On startup, natsutil.SetupAll(nc):

  1. In a cluster, waits up to 60 seconds for JetStream to answer, so a meta leader exists before anything is created.
  2. Creates WORKFLOW_HISTORY, TASK_QUEUES, EVENTS, DEAD_LETTERS and SLEEP_TIMERS.
  3. Creates all KV buckets, leases last. On a nats-server older than 2.11 the leases bucket is skipped with a warning.
  4. Creates TELEMETRY, STICKY_TASKS, TRIGGER_HISTORY and BUILD_LOGS.
  5. Enables atomic batch publish on TASK_QUEUES, which needs nats-server 2.12 or newer.
  6. Creates any extra streams and buckets the caller passed (dagnats serve passes five buckets, see above).

Every resource takes the cluster’s replication factor, except the extras in step 6. A replicated stream or bucket is created first and updated only if it already exists with a different config. Running dagnats serve multiple times against the same NATS data directory is safe.

The whole sweep shares one time budget: 30 seconds standalone, so a server that does not answer fails startup fast, and 3 minutes in a cluster, where nodes starting together wait on each other’s meta leader. Each create or update call is attempted up to 5 times, 8 seconds each, with a pause that grows from 500ms to 4s. Only “no answer in time” errors are retried: timeouts, JetStream error 10008 (system temporarily unavailable) and 10005 (no suitable peers for placement). Any other error fails startup at once, naming the stream or bucket.