Installation
Packtrail is a Go module. Add it to your project and import the root packtrail package —
the single public entry point. It requires Go 1.26+ and a reachable NATS Server 2.12+ with
JetStream enabled — packtrail relies on the JetStream Message Scheduler (2.12) for every timer.
$ go get github.com/henomis/packtrail
# run a NATS server with JetStream for local development
$ nats-server -js
The Server never owns the *nats.Conn you give it — your code connects
and closes the connection.
Quick start
Build a Server against a NATS connection, register a flow and an Invoker, start an
execution, then call Run to drive the engine. A single binary can host the engine
and its workers.
nc, _ := nats.Connect(nats.DefaultURL)
defer nc.Close()
srv, err := packtrail.New(nc,
packtrail.WithFlowsDir("flows"), // load *.yaml flows from disk
packtrail.WithInvoker("agent", myInvoker), // your transport
)
if err != nil { log.Fatal(err) }
// Kick off an execution with an initial JSON payload.
id, _ := srv.Start(ctx, "research-pipeline",
[]byte(`{"topic":"nats"}`))
log.Printf("started %s", id)
// Run blocks: it drives the work consumer + visibility indexer
// until ctx is cancelled. Registered workers drain on return.
if err := srv.Run(ctx); err != nil {
log.Fatal(err)
}
Run is called. New performs
no NATS I/O: it parses and validates the flows, and every bucket and stream is provisioned lazily
by the first call that needs NATS (Start, Run, Get, …).
Call srv.Init(ctx) explicitly at startup if you want provisioning errors (JetStream
disabled, missing permissions) to fail fast instead of surfacing on first use.
Core concepts
Four invariants explain everything else in this reference.
State on disk, never only in memory
Every execution's control state — current node, status, attempt, branches, which outputs exist —
lives as one document in the packtrail-executions KV bucket; each transition is a
compare-and-swap write conditioned on the current KV revision. Every payload (the start input,
each node's output, each signal) is its own entry in a separate packtrail-payloads
bucket, written before the transition that references it commits. Each transition also
commits its follow-on work (the next work item, a retry timer, a join re-evaluation) in the same
CAS write — a transactional outbox — so state and the work that drives it can never disagree.
Crash an engine mid-flight and another instance resumes from the durable state.
One backend: NATS
JetStream KV holds execution state, streams provide the durable work queue and event log, and the Message Scheduler runs every timer (retry backoff, signal timeouts, cron starts). There is no second datastore to run, scale or back up.
Ownership leases, not locks — invocation is at-least-once
A durable pull consumer distributes work across all engine instances; a per-execution lease
(TTL, see WithLeaseTTL) is heartbeat-renewed while an
instance processes a work item. When an instance crashes, its heartbeats stop and another
instance takes the lease over on the next redelivery — no coordinator, no lock to leak.
Takeover is clock-skew-immune: a contender must observe the lease unchanged for a full TTL
on its own monotonic clock before treating it as stale, so a skewed wall clock can
never seize a lease that is still being heartbeat-renewed. The lease is not a hard
mutual-exclusion lock: the execution-doc CAS fences state, but it cannot un-fire an
external side effect, so node invocation is at-least-once. For non-idempotent
targets, enable WithResultCache or make the target idempotent.
The Invoker is the only seam
The engine never speaks a wire protocol itself. Every task node and fan-out branch
runs through an Invoker. Plug in any transport — an agent
caller, an HTTP client, the built-in NATS worker — and inherit retries, fan-in policies,
choice routing, signals and timers for free.
Anatomy of a flow
A flow describes a graph of nodes connected by edges. It can
be provided as a YAML document or constructed programmatically as a FlowDef Go
struct — both are parsed, validated, and treated identically at runtime. Top-level fields:
| Field | Description |
|---|---|
| version | Schema version string, e.g. "1.0". |
| name | Unique flow name; used by Start and the flow registry. Required. |
| nodes | List of nodes. Each has an id and a type (task, fanout, fanin, choice or signal). Required, non-empty. |
| edges | List of {from, to} static transitions. A node may have at most one outgoing edge. |
Flows are loaded with WithFlowsDir (every *.yaml
/ *.yml in a directory), WithFlow (one inline YAML document, repeatable),
or WithFlowDef (one FlowDef Go struct, repeatable). All three can be
combined freely. Duplicate flow names across any source are rejected at startup.
Validation is strict, so mistakes fail at load time rather than mid-execution:
- YAML is strict. An unknown field (a typo like
retires:) is a parse error, and a file may hold exactly one flow document — extra---documents are rejected, so none is silently ignored. - Flow names, node ids and signal names become NATS subject tokens and KV key segments, so
they must match
[A-Za-z0-9_-]{1,128}. - Every
invoker:kind must be registered. A flow naming a kind that is neither the built-innats-tasknor registered viaWithInvoker/WithAsyncInvokeris rejected byNew. - Every node must be reachable from the start node via an edge, choice rule,
fanout branch or
on_timeoutroute — dead graph configuration is almost always a typo'd target.
version: "1.0"
name: research-pipeline
nodes:
- {id: triage, type: task, invoker: agent, target: triage-agent,
timeout: 2m, retry: {max_attempts: 3, backoff: exponential}}
- {id: fan, type: fanout, branches: [tech, market]}
- {id: tech, type: task, invoker: agent, target: tech-agent}
- {id: market, type: task, invoker: agent, target: market-agent}
- {id: join, type: fanin, wait_for: [tech, market], join_policy: all}
edges:
- {from: triage, to: fan}
- {from: fan, to: join}
task
A task node dispatches a single invocation through the configured Invoker — with
the assembled context {"input": …, "results": {…}, "signals": {…}} as its payload —
and stores whatever it returns as this node's output, visible downstream as
results.<node>. It is the only node type that calls out to your code.
| Field | Description |
|---|---|
| invoker | Invoker kind to dispatch through. Defaults to nats-task when omitted. |
| target | Invoker-specific target — an agent name, a URL, a subject. Takes precedence over subject. |
| subject | Alias for target, kept for nats-task flows. One of target / subject is required. |
| timeout | Per-attempt deadline, e.g. 2m, 30s. Falls back to WithDefaultTimeout (30s). |
| retry | Optional {max_attempts, backoff} policy — see below. |
Retry policy
retry.max_attempts must be >= 0. retry.backoff is one of
exponential, linear, or fixed (the default when omitted).
Backoff delays are scheduled durably through the NATS Message Scheduler, so a waiting retry holds
no engine resources and survives restarts. A node is retried when its Invoker returns
StatusRetry or a non-nil error; StatusError fails it permanently.
Placeholders
The token {execution_id} in a target / subject is replaced
with the concrete execution id before invocation — e.g.
subject: "tasks.notify.{execution_id}".
fanout
A fanout node starts several branches in parallel. Each branch id must reference
another node in the flow; those branch nodes run concurrently, each through the Invoker, and
their results are persisted in KV so they are never recomputed on engine takeover.
| Field | Description |
|---|---|
| branches | List of node ids to launch in parallel. Required, non-empty. |
StatusPending to run asynchronously.
The fan graph is validated at load: every branch must be a task node, a node may be
a branch of at most one fanout, a fanout's single outgoing edge must lead to a fanin
(that is where the execution parks and the join is evaluated), and fanout/fanin nodes must not
lie on a cycle. Inside a fan, choice rules can also read branches — the current
fan's outputs.
fanin
A fanin node joins parallel branches and proceeds according to its join policy.
| Field | Description |
|---|---|
| wait_for | List of branch node ids to join on — branches of this fanin's own fanout; a subset is fine (join on the critical branches, let the rest settle in the background). Required, non-empty. |
| join_policy | How many branches must finish — see below. Defaults to all. |
Join policies
all— wait for every branch inwait_for(the default).any— proceed as soon as one branch completes.quorum:N— proceed onceNbranches complete. Validation requires0 < N <= len(wait_for).
The join is re-evaluated whenever a branch settles, including async branches.
choice
A choice node routes to the first matching rule. Rules are
expr-lang
expressions evaluated against the assembled context, compiled at startup (not per execution).
| Field | Description |
|---|---|
| rules | Ordered list of {when, to} rules plus exactly one {default: true, to}. At least one rule and a default are required. |
| on_error | What a rule expression that errors at evaluation time does: omit (the default) to treat it as no match, or fail to fail the execution — see below. |
Each rule's to must reference a known node. A non-default rule needs a
when expression; the first rule whose when is true wins, and the
default rule is the fallback when none match. The expression environment exposes
input (the start payload), results (each visited node's output, keyed
by node id), signals (received signal payloads, keyed by signal name),
branches (the current fan's outputs) and last_node (the id of the most
recently settled output — "the previous step's result" is results[last_node]).
Reach into them with dotted paths (results.triage.risk_score,
input.user.tier, signals.approval.granted) and expr-lang's
comparison, boolean and membership operators (==, &&,
||, in, …). Rules are bounded routing predicates,
not general programs: range expressions, iteration constructs (filter,
map, …) and function calls other than len() are rejected at load
time, and evaluation runs under a fixed memory budget — a rule can never loop or blow up
over an attacker-sized payload.
A when that errors at evaluation time — most commonly because it references a
field the payload doesn't have — is treated as no match (logged at warn
level), and evaluation continues to the next rule, ultimately reaching default.
An expression that returns a non-boolean is likewise an error and counts as no match. Order
rules from most to least specific, since the first match wins. For safety-relevant routing
where silently falling through to default would be wrong, set
on_error: fail on the node — an erroring rule then fails the execution instead.
- id: route
type: choice
rules:
- {when: 'results.triage.risk_score > 80', to: escalation}
- {when: 'input.region in ["EU","UK"]', to: gdpr_review}
- {default: true, to: synthesis}
signal
A signal node pauses the execution until a named external signal arrives. It is the
primitive for human approvals and waiting on external events.
| Field | Description |
|---|---|
| signal_name | The name the external caller delivers via Server.Signal. Required. |
| timeout | How long to wait before taking the timeout route, e.g. 24h. Evaluated by the Message Scheduler at roughly one-second granularity. |
| on_timeout | Node id to route to when the deadline passes. Must reference a known node, and requires a positive timeout — a route that could never fire is rejected at load. |
Signal consumption is durable and idempotent — signals dedupe by JetStream sequence, so
redeliveries are safe; a publisher that retries the publish itself should use
SignalWithID (below) so retries dedupe too. The timeout is scheduled through the Message Scheduler and
survives restarts. Ordering is forgiving: a signal sent before the execution reaches its signal
node is stored and consumed on arrival, and one sent just before the execution is created is
redelivered until the execution exists. A genuinely orphaned signal (e.g. a typo'd execution id)
is dead-lettered after the delivery cap instead of vanishing silently. The received payload is
visible to downstream nodes and choice rules as signals.<name>. Deliver a
signal with:
srv.Signal(ctx, execID, "approval", []byte(`{"by":"alice"}`))
// retry-safe publish: reusing the idempotency key collapses
// ambiguous publish retries into a single stream entry.
srv.SignalWithID(ctx, execID, "approval", "req-42", []byte(`{"by":"alice"}`))
If your publisher retries on ambiguous errors (a timeout where the publish may or may not
have landed), use SignalWithID with a caller-supplied idempotency key —
retries within the stream's duplicate window collapse into one entry instead of creating
duplicate signals.
Edges & the start node
Edges define static transitions between nodes as {from, to} pairs. Validation
enforces a few rules that make the graph unambiguous:
- Both endpoints of an edge must reference existing nodes.
- A node may have at most one outgoing edge — branching is expressed by
choice/fanout, not by multiple edges. - A node with no outgoing edge is terminal; reaching it completes that path.
- Branch targets (fan-out branches, choice
to, signalon_timeout, fan-inwait_for) count as having an inbound transition. - There must be exactly one start node — the single node with no inbound transition. Zero or multiple start nodes is a validation error.
The Invoker
An Invoker executes a single node invocation. Implement one method and your services inherit all
of packtrail's durability machinery. The contract is re-exported from the root packtrail
package so embedding apps depend only on it.
type Invoker interface {
Invoke(ctx context.Context, req packtrail.Request) (packtrail.Result, error)
}
// InvokerFunc adapts a plain function to the Invoker interface.
agent := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
out, err := callAgent(ctx, req.Target, req.Payload)
if err != nil {
return packtrail.Result{}, err // transient → retried per node policy
}
return packtrail.Result{Status: packtrail.StatusOK, Payload: out}, nil
})
packtrail.WithInvoker("agent", agent) // register under a kind
Request
The engine hands the Invoker everything it needs, transport-agnostic:
| Field | Description |
|---|---|
| Invoker | The invoker kind selected for this node. |
| Target | Invoker-specific target, with {execution_id} already resolved. |
| ExecutionID | The owning execution id. |
| NodeID | The node being executed. |
| Payload | The assembled execution context as raw JSON: {"input": …, "results": {…}, "signals": {…}}. |
| Attempt | 0-based attempt counter for retries. |
| Generation | Execution-scoped visit generation for this node — distinguishes re-visits from legal cycles or Resume from retries of the same visit. |
| Deadline | Hard deadline for this attempt. |
Result & statuses
A Result carries a Status, an optional Payload (this
node's output) and an optional Error string. A non-nil error from Invoke
is treated as a transient transport failure (equivalent to StatusRetry) and is never
cached. A StatusOK payload is stored as its own data-plane entry and is visible to
every downstream node as results.<node> — outputs never merge into a shared
document, so any JSON shape is legal (only the start input must be an object).
| Status | Meaning |
|---|---|
| StatusOK | Node succeeded; Payload is stored as the node's output and the flow advances. |
| StatusError | Node failed permanently; the engine does not retry. |
| StatusRetry | Retry per the node's retry policy. |
| StatusPending | Dispatched asynchronously; the engine parks the execution and frees its slot. See async activities. |
Idempotent invocations
Enable WithResultCache() to memoise results by
(execution, node, visit generation, attempt) in a KV bucket. A work item redelivered
after a crash returns the cached result instead of re-invoking; a genuine retry (a new attempt)
still calls the Invoker, and a legal cycle or Resume that revisits the node (a new
generation) never replays a stale cached result. Concurrent redeliveries of the same attempt are
collapsed too: the first caller claims the invocation and the others wait for its result. Turn it
on whenever an invocation has side effects that must not run twice. The cache
covers both invocation paths — the engine-side dispatch and the async worker's execution of your
Invoker — and entries expire after a TTL (default 24h, tunable via
WithResultCacheTTL).
Built-in nats-task
The nats-task invoker is always registered — a task node that sets a
subject (and no invoker) uses it. It does a NATS request/reply on that
subject using the pkg/protocol request/reply wire format.
You can host a worker in the same process with Server.Handle:
// subject may contain wildcards; workers join queue group "packtrail-workers".
srv.Handle(ctx, "tasks.notify.*", func(ctx context.Context, req packtrail.TaskRequest) (packtrail.TaskResponse, error) {
notify(req.Payload)
return packtrail.TaskResponse{Status: packtrail.TaskOK}, nil
})
A TaskRequest carries ExecutionID, NodeID,
Payload, Attempt and Deadline. The handler returns a
TaskResponse with a status of TaskOK, TaskError or
TaskRetry; returning a non-nil error is reported to the engine as a retry.
Async activities
Long-running work (an agent call, a human task, an external job) should not hold a work slot. An
Invoker that returns StatusPending parks the execution in the waiting
state and frees the slot immediately — a single instance can have thousands of inflight async
activities. The activity is settled later with CompleteActivity.
You rarely wire that by hand. The built-in invoker/asyncqueue package turns any
ordinary synchronous Invoker into a durable async one: register it with
WithAsyncInvoker and packtrail dispatches matching nodes to a JetStream work-queue,
runs your Invoker on a hosted worker pool, and settles the activity for you — with
at-least-once delivery, dispatch dedup, generation-aware completion and ack-extending
heartbeats handled automatically. The work-queue itself is bounded (job-count and byte
limits, tunable with asyncqueue.WithMaxQueuedJobs /
WithMaxQueuedBytes): when workers fall behind past the limit, new dispatches
are shed with an error and retried per the node's policy instead of growing the stream
until the cluster hurts.
// Your slow work is just a normal Invoker — no queue or ack code.
exec := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
out, err := callSlowService(ctx, req.Target, req.Payload)
if err != nil {
return packtrail.Result{}, err // transient → retried per node policy
}
return packtrail.Result{Status: packtrail.StatusOK, Payload: out}, nil
})
srv, _ := packtrail.New(nc,
packtrail.WithAsyncInvoker("agent", exec, asyncqueue.WithConcurrency(64)),
)
The mechanism the package is built on — implement it yourself only for a bespoke transport:
// 1) dispatch: enqueue durable work, park the execution.
dispatch := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
enqueueJob(req.ExecutionID, req.NodeID, req.Attempt, req.Payload)
return packtrail.Result{Status: packtrail.StatusPending}, nil
})
// 2) later, from the worker that finished the job:
srv.CompleteActivity(ctx, execID, nodeID, attempt,
packtrail.Result{Status: packtrail.StatusOK, Payload: out})
CompleteActivity is keyed by
(execution, node, attempt). A duplicate completion, or one for the wrong attempt, is
a silent no-op — so an at-least-once worker can call it freely. Use Request.NodeID
and Request.Attempt to identify the dispatched work. This works for plain task nodes
and fan-out branches alike. When the flow can revisit the node — a legal cycle, or
Resume after a failure — prefer CompleteActivityWithGeneration with
Request.Generation from the original dispatch, so a stale completion from an
earlier visit can never settle a later one that reuses the same node and attempt.
Signals & resuming
A signal node waits for an external event. Deliver one
with Server.Signal(ctx, execID, name, payload); consumption is idempotent, and
SignalWithID makes the publish itself retry-safe via an idempotency key. Received
signal payloads are visible on the execution snapshot under Signals, and the name a
node is currently waiting on appears as WaitSignal.
Resuming a failed execution
Server.Resume(ctx, execID) revives a failed execution, re-running the
node it failed on with a fresh retry budget. The durable state and every stored output are
preserved, and only failed executions can be resumed. It is durable — any running engine in the
namespace picks up the resumed work.
Cancelling an execution
Server.Cancel(ctx, execID, reason) transitions a running or waiting execution to the
distinct terminal cancelled status — separate from failed, so it is
observably operator-driven and Resume (failed-only) won't revive it. Cancellation is
abandon, not interrupt: in-flight work is not stopped, but pending work items, join evaluations,
signal timeouts and a late CompleteActivity all no-op once the execution is
non-active. It is idempotent — a no-op on already-terminal or missing executions.
Scheduling & cron
Recurring flow starts, retry backoff and signal timeouts all run through the JetStream Message
Scheduler — the engine never keeps an in-process timer, so every schedule survives restarts.
Install a recurring start with a 6-field cron expression (sec min hour dom mon dow):
// run "research-pipeline" every day at 02:00; reusing the name replaces it.
srv.ScheduleFlow(ctx, "nightly", "research-pipeline", "0 0 2 * * *", nil)
Periodic reconciliation of the visibility indexes uses the same cron format via
WithReconcileActive (in-flight executions) and
WithReconcileFull (full scan; also runs the archive sweep).
Statuses & lifecycle
An execution moves between five durable statuses:
| Status | Meaning |
|---|---|
| running | The engine is actively progressing the execution (ExecRunning). |
| waiting | Parked on a signal or an async activity; no work slot held (ExecWaiting). |
| completed | A terminal node was reached successfully (ExecCompleted). |
| failed | A node failed permanently; can be revived with Resume (ExecFailed). |
| cancelled | Terminated by an operator via Cancel; terminal and not resumable (ExecCancelled). |
A read-only Execution snapshot (from Get) exposes the control state:
ID, Flow, Status, CurrentNode,
Attempt, Outputs (which node outputs exist), per-branch state
(Branches), received Signals, the active WaitSignal, any
Error and UpdatedAt. Payloads live in the data plane and are not
carried on the snapshot — read the assembled {input, results, signals} view with
Server.Results(ctx, id). The execution KV is the source of truth — read it (not
the indexes) for correctness decisions.
Server API
The methods on *packtrail.Server:
| Method | Description |
|---|---|
| New(nc, …opts) | Build a server: parse and validate flows, register invokers. No NATS I/O — resources are provisioned lazily on first use. |
| Init(ctx) | Provision every bucket and stream eagerly, so setup errors fail fast instead of surfacing on first use. Optional. |
| Run(ctx) | Start the engine, indexer and (if configured) the reconcile and archival schedules; blocks until ctx is cancelled. Drains in-flight work on return (see WithDrainTimeout). |
| Start(ctx, flow, payload) | Create a new execution with a random id and return it. |
| StartWithID(ctx, execID, flow, payload) | Idempotent start with a caller-supplied id (idempotency key, [A-Za-z0-9_-]{1,128}); a retry returns the existing id. |
| ScheduleFlow(ctx, name, flow, cron, payload) | Install/replace a recurring schedule that starts a flow. |
| Signal(ctx, execID, name, payload) | Deliver an external signal to an execution. |
| SignalWithID(ctx, execID, name, key, payload) | Deliver a signal with a caller-supplied idempotency key — ambiguous publish retries collapse into one stream entry. |
| CompleteActivity(ctx, execID, node, attempt, res) | Settle an async activity previously reported as StatusPending. Idempotent. |
| CompleteActivityWithGeneration(ctx, execID, node, gen, attempt, res) | Generation-fenced settlement using Request.Generation — preferred when flows have legal cycles or use Resume. |
| Resume(ctx, execID) | Revive a failed execution with a fresh retry budget. |
| Cancel(ctx, execID, reason) | Transition a running/waiting execution to the terminal cancelled status. Abandon, not interrupt; idempotent. |
| Get(ctx, execID) | Return an Execution control-state snapshot, or ErrNotFound. |
| Results(ctx, execID) | The assembled {input, results, signals} data-plane view — what invokers and choice rules see. |
| History(ctx, execID, limit) | Ordered per-execution transition trace (requires WithHistory). |
| ByStatus(ctx, status) | Execution ids indexed under a status (eventually consistent). |
| ByFlow(ctx, flow) | Execution ids belonging to a flow. |
| ByStatusEventsLimit(ctx, status, n) | Up to n summary events under a status (guardrail cap; ByFlowEventsLimit for flows). |
| List(ctx) / ListFunc(ctx, fn) | Hot-bucket execution ids (active + recently-terminal when archival is on; every execution otherwise). ListFunc streams them. |
| ListFlows(ctx) / FlowGraph(ctx, name) | Registered flow names; a flow's full graph (nodes + edges) from the flow registry. |
| WatchEvents(ctx) | Live channel of execution transitions published after the call. |
| DeadLetterCount(ctx) / RecentDeadLetters(ctx, n) | Durable dead-letter stream depth; the most recent dead-letter records. |
| Reconcile(ctx) / ReconcileActive(ctx) | Rebuild the visibility indexes from the source of truth — full scan, or just in-flight executions. |
| RedriveStalled(ctx) | Run the stall watchdog once: re-drive active executions quiet past the threshold (see WithStallRedrive). |
| ArchiveTerminal(ctx) / GCIndex(ctx) | Sweep terminal non-resumable executions into the cold archive; prune index entries orphaned by archive expiry. No-ops unless archival is enabled. |
| Flows() | Names of the flows this server knows. |
| Handle(ctx, subject, h) | Register an in-process built-in nats-task worker. |
| Close() | Drain registered workers. Does not close the NATS connection. |
Configuration options
Options passed to packtrail.New:
WithNamespace(prefix)Prefix every bucket, stream, subject and durable (default "packtrail") — isolate deployments on a shared cluster.WithFlowsDir(dir)Load every *.yaml / *.yml flow definition in a directory at startup.WithFlow(yamlDoc)Register a single flow from an inline YAML document; repeatable.WithFlowDef(f)Register a single flow from a FlowDef Go struct; repeatable, combinable with the options above.WithInvoker(kind, inv)Register a custom Invoker under a kind; the built-in nats-task is always present and may be overridden.WithAsyncInvoker(kind, exec, opts…)Register an async Invoker: nodes of this kind dispatch to a durable work-queue and exec runs on a hosted worker pool (see async activities).WithResultCache()Cache invocation results by (execution, node, visit generation, attempt) for idempotent redeliveries — engine dispatch and async worker execution alike.WithResultCacheTTL(d)Result-cache entry TTL (default 24h; implies WithResultCache); a negative value disables expiry.WithHistory(retention)Durable per-execution transition trace in a <ns>-history stream, queryable via Server.History for retention.WithReconcileActive(cron)Schedule the cheap active-set reconcile over in-flight executions (6-field cron); each pass also runs the stall watchdog.WithStallRedrive(d)Stall-watchdog quiet-time threshold (default 5× ack wait): an active execution quiet past d — outside any retry backoff and not lease-held — gets its work re-driven. Negative disables.WithReconcileFull(cron)Schedule the authoritative full reconcile; also runs the archive sweep, index GC and low-frequency maintenance such as reclaiming consumed scheduler firings. Keep it well below the active cadence.WithArchive(retention)Sweep terminal non-resumable executions into a cold archive bucket retained for retention, bounding the hot store. Failed executions stay hot so they remain resumable. Runs on the full-reconcile schedule.WithMaxConcurrency(n)Cap concurrent work items per instance (default 64).WithDefaultTimeout(d)Invocation timeout for nodes that omit one (default 30s).WithMaxDeliver(n)Deliveries of a work item, fired schedule or signal before it is dead-lettered instead of retried forever (default 10; the cap cannot be disabled).WithDrainTimeout(d)Graceful-shutdown window for in-flight work to settle before stragglers are abandoned to redelivery (default 30s).WithMaxPayloadBytes(n)Cap on a single data-plane entry — the start input, one node's output, one signal payload (default 512 KiB; negative disables). An over-limit output fails its node with a clear reason.WithMaxDocumentBytes(n)Cap on an execution's serialized control document (default 768 KiB) — guards very wide fanouts against NATS's 1 MiB ceiling with a typed error instead of an opaque publish failure. Negative disables.WithSignalRetention(d)How long the signals stream retains messages (default 7 days) — the window an undelivered signal survives an engine outage. Negative disables the age limit.WithLeaseTTL(d)Per-execution ownership lease TTL (default 30s); a crashed instance's work frees after roughly this.WithOwnerID(id)Stable ownership-lease owner id; defaults to a random id per instance.NATS resources
Every resource is prefixed with the namespace (default packtrail), so independent
deployments can share a cluster. The buckets and streams a deployment creates:
packtrail-executionsKV · control plane, source of truthpacktrail-payloadsKV · data plane: inputs, outputs, signalspacktrail-leasesKV · ownership (TTL)packtrail-workstream · work queuepacktrail-eventsstream · domain eventspacktrail-signalsstream · external signalspacktrail-schedulescheduler · timers & cronpacktrail-flowsKV · flow registrypacktrail-result-cacheKV · idempotency (opt-in)packtrail-executions-archiveKV · cold archive (opt-in)packtrail-deadletterstream · dead letters (~30d)packtrail-historystream · per-exec trace (opt-in)packtrail-idx-statusKV · projectionpacktrail-idx-flowKV · projection
Each flow's graph is published to packtrail-flows at startup, so observability tools can
render flows without access to the source files or the engine process.
Observability · packtrail-ui
packtrail-ui is a read-only dashboard that connects to the same NATS cluster, reads
execution state and the flow registry, and tails the live event stream — no engine process or
source files required. It reads NATS_URL (default nats://localhost:4222).
$ go run ./cmd/packtrail-ui --namespace packtrail --addr :8088
| Endpoint | Returns |
|---|---|
| GET /api/flows | Names of all registered flows. |
| GET /api/flows/{name} | Full FlowGraph (nodes + edges). |
| GET /api/executions | Execution summaries; filter with ?status= or ?flow=. |
| GET /api/executions/{id} | Execution control-state snapshot. |
| GET /api/executions/{id}/results | Assembled {input, results, signals} context (data plane). |
| GET /api/executions/{id}/history | Ordered transition trace (?limit=; empty unless WithHistory). |
| GET /api/deadletters | Dead-letter count and recent dead-letter records. |
| GET /api/events | Live Server-Sent Events stream of every transition. |
The same data is available programmatically through the Server API
(ListFlows, FlowGraph, Get, ByStatus,
ByFlow, DeadLetterCount, RecentDeadLetters) and, for
events, the WatchEvents live channel. With WithHistory enabled,
Server.History returns an execution's ordered step-by-step trace.
Dead letters
No message can loop forever: work items, fired schedules, signals and async jobs are all capped
by a delivery limit (WithMaxDeliver), and a message that can
never succeed — an unknown flow, a removed node — is dead-lettered immediately. Every dead
letter is durable and observable: each consumer emits a record (kind, key, reason, deliveries)
to the packtrail-deadletter stream (~30-day retention), surfaced by
Server.DeadLetterCount, Server.RecentDeadLetters and the dashboard's
dead-letter tile.
Testing
Packtrail's own test suite runs against a real embedded nats-server — there are no
mocks. When testing flows that embed packtrail, the same approach works well: start an embedded
server, register an InvokerFunc stub for each kind, and drive executions.
$ go build ./...
$ go test -race ./... # real embedded nats-server, no external NATS needed
$ go vet ./...
WithFlow to register flows inline from a YAML literal,
or WithFlowDef to build them as Go structs — both keep tests self-contained with
no files on disk. Give each test a distinct WithNamespace so they never collide
on a shared server.