Engine internals
The engine (internal/engine) executes one compiled pipeline against one
store: source admission through a durable spool, in-memory DAG execution with
commit tracking, per-edge delivery retries, dead lettering, checkpointing and
backpressure. The package is five files plus tests: engine.go (lifecycle,
persistence), admission.go (the inbound path), nodes.go (transform/sink
workers, dead letters), commit.go (the commit frontier), plus
sourcecommit.go (the per-source watermark committers). Every reliability
property is phrased as one of the eight invariants (see
Architecture), each with a dedicated test in
internal/engine/invariants_test.go.
Startup sequence
engine.New resolves plugins and allocates structures; Run starts them
(internal/engine/engine.go):
- IR in, plugins instantiated. For each node in
ir.Pipeline.Order: sources and sinks instantiate through the registry (reg.NewSource/reg.NewSink) or, forgrpc:blocks, throughrpcplugin.SpawnSource/SpawnSink; codecs resolve by name (namedcodecs:declarations come pre-instantiated on the IR, bare names instantiate config-less). Transforms instantiate throughreg.NewTransformand are immediatelyInit-ed with aTransformEnv— constants, parameters, a node-scoped logger and the slow-call advisory — before workers exist. A transformInitfailure closes the instance and aborts startup. - Commit tracker.
newCommitTrackergets onesrcTrackerper source node and the two callbacks:onCommit(per-message bookkeeping) andonAdvance(checkpoint persistence). - Admission and committers.
newAdmissionbuilds the inbound path (below); onesourceCommitteris allocated per source node. Run(ctx)phases, in this order (candidate 01; the ordering is an invariant, not a preference):- committers + workers — one goroutine per source committer, then the transform and sink workers;
- crash replay — read the checkpoint and re-dispatch every spool row
beyond it through admission (
replaymode). Replay dispatches into node channels, so the consumers must already exist: a recovery with more uncommitted rows than a channel holds used to block forever on a channel nobody read. A cancelled context stops the scan without an error (a voluntary stop, v1.24); - sources last — a fresh source restores its persisted state via
Init(state)when one exists, thenRuns (orPulls for pull sources). Sources last so replayed rows are already registered before new emissions race them.replayDoneflips only after the replay scan finishes;WaitCommitrefuses to read “all committed” before that (an unregistered replay row momentarily looks likeoutstanding == 0).
The admission path
internal/engine/admission.go owns how a message enters the DAG: the
backpressure gate, the acquired-slot ledger, stamping, the spool append,
commit registration and the dispatch step. One entry (admit) has three
modes that differ only in request fields:
| Mode | Entry | Append | Stamps | Source ref | Codec |
|---|---|---|---|---|---|
| live (source emission) | out of the source node | yes | message_id, ingest_time, source, MetaStamps | commit.arrived(seq, node, srcSeq) | node decoder (json default) |
| replay (crash recovery) | as the spooled row demands (injected_at → into the node, else out of its source) | no (existing seq) | original, kept | arrived(seq, "", 0) | message (spooled), json default |
| inject (operator/test) | into the target node — out of it when the target is a source, which has no inbound processing | yes | live stamps + injected_at (internal target only) | arrived(seq, "", 0) | message, json default |
Live admissions also record an accept time (commit latency) and a sampled
span; replay and inject do not. Every mode takes admission quota — one
slot per uncommitted message. The uncommitted set is ≤ HighWatermark by
construction, so replay cannot wedge on the gate, and the quota is uniform
across entries. The gate can be a shared pool (Options.Admission) so the
jobs manager aggregates limits.max_in_flight across concurrent
overlap: all runs instead of multiplying per run.
The verdict admit returns is the source contract’s emit error: nil =
durably accepted; a ctx error = engine shutdown; anything else = refusal
(not durable, never visible, safe to re-emit — see
Plugin system). A refused live emission returns the slot it
took; a refused injection returns the error to InjectAt/InjectReplay.
The accept path
accept is the thin live shell over admission (engine.go):
- Admission gate. The caller blocks on the gate — one slot per
uncommitted message. When the high watermark is reached, sources stop
being served (backpressure;
eventboat_backpressure_events_total). A blocked accept that races shutdown returnsctx.Err()and the message is not accepted — the source may safely re-emit it (at-least-once). - Stamping.
message_id(preserved if the caller supplied one, so replays keep identity),ingest_time,source, plus anyOptions.MetaStamps(e.g.job_run_id). The source node’s decoder names the message’s codec (jsondefault). - Durable spool append.
Store.AppendSpoolreturns the spool sequence. On failure the admission slot is returned and the message is refused — never delivered (invariant 1). - Registration.
acceptedAt[seq]records the accept time (commit latency), the acquired-slot ledger remembers the backpressure slot, andcommit.arrived(seq, sourceNode, raw.SrcSeq)pre-registers exactly one outstanding branch and records the source emission for watermark tracking. ThendispatchFromdecodes at the source entry (decode failure = dead letter) and fans out.
Injection
InjectAt(node, msg) and InjectReplay(node, msg) take a
registry.Message (Raw/Meta/Codec/ID); the message’s identity and codec
travel with it. InjectReplay stamps meta.is_replay=true and
meta.original_message_id from msg.ID, and preserves the ID — a csv dead
letter replays as csv, never as json. InjectAt at a source node fans out of
it (spooled and stamped like a live emission, but not attributable to the
source’s watermark); at an internal node it is spooled and enters INTO that
node (transform script, sink batch), which is what makes operator replay
re-execute the failing step.
Per-edge delivery
Edges are resolved by the IR with defaults from edge_defaults and
overrides per edge (internal/ir/ir.go): Required (default true),
Retries (default 3), Backoff (exponential | constant, base
Options.BackoffBase = 100ms, exponential doubling capped at 30s),
TimeoutMs (per-attempt, Options.DefaultTimeout = 30s when unset),
BufferMax (per-edge channel surge capacity, default 128).
- Sinks batch engine-side (
runSink,nodes.go):batch.sizeandbatch.timeout_mscontrol flush;writeBatchencodes each message, evaluatesorder_key, takes the strictest retry policy of the edges in a mixed batch, and on success commits every instance. After retries are exhausted, arequired: falseedge drops the message (counted,eventboat_optional_drops_total) while a required edge dead letters. - Transforms retry per the incoming edge’s policy (review R6), then
dead letter with the plugin’s backtrace if any. A transform failure never
fails the node. Zero outputs filter the message (commit-as-filtered +
NoMatch); N outputs expand the commit accounting by N-1 extra branches — thesplitplugin’s 1→N contract. - Dead lettering (
deadLetterMsg→writeDeadLetter) retries the store write forever (backoffOptions.DLBackoff= 500ms) until it succeeds or the engine shuts down: a dead letter that cannot be written blocks the commit (invariant 4) — degraded, not lossy.Abandoncalls the same writer with a caller-bounded ctx (candidate 02): a failed write stops the attempt and the message stays uncommitted for the next run’s replay. The record carries the full original message, node/edge attribution, reason, the dead-letter class recorded where the failure was produced (store.DeadLetter.Class: decode/codec/encode/delivery/canceled or the transform failure kind — never re-derived from the reason text), backtrace and run id. - Delivery on shutdown is deliberately not committed: an instance that
could not be queued before
ctx.Done()stays uncommitted and is replayed from the spool on restart (invariant 3).
The commit frontier
internal/engine/commit.go implements the heart of the reliability model:
outstanding map[int64]intcounts open execution branches per spool seq;openBranchesis a running sum of positive values sosnapshot()is O(1) (it is polled from the hot path —WaitCommit’s 2ms loop, ops status).arrived(seq)pre-registers one unit; fan-outadd(seq, n-1)grows it; each terminal event callsdone(seq)(i.e.add(seq, -1)).advanceLockedscans the contiguous prefix: fromcommittedPtrup while entries are zero/closed; the scanned range is committed, and the just-committed seqs plus the per-source frontier snapshot become the callback payload. Callbacks run outside the tracker lock (they do store I/O; the lock must not convoy on fsync).- The hole barrier (adversarial review 2026-09-26). A seq with no
outstanding entry stops the scan unless it carries a
removedmark — the recordforceTerminalleaves when the abandon path deliberately terminates a message (its durable dead letter is already written). An unmarked absence is theAppendSpool → arrivedwindow: the row is durable but not yet registered, so it is not committed and the checkpoint must not cross it. Treating that absence as committed let a crash replay from beyond a row that was never delivered, which no-cursor sources (cron, http_server) cannot re-emit. Marks are consumed by the sweep and bounded by the in-flight window. The straggler branch (aforceTerminalracing an in-flightarrived) still delivers the late terminal event and now also sweeps the source refs under the cursor. - Restart seeding.
resumeCheckpointmoves the cursor and the persistence barriers to the recovered checkpoint: rows at or below it are committed by definition and are never registered in the new run, so the barrier must start past them (otherwise the prefix would stall on the first pre-checkpoint seq andWaitCommit/Quiescedwould wait for a flush that already happened). A fresh tracker starts its cursor at seq 1. - Ordered
srcRefsFIFO. Each source emission is recorded as(spool seq, node, srcSeq). Arrival is near-ordered (each source goroutine appends and registers back-to-back), but two sources racing throughacceptcan invert;addSrcRefsplices those into sorted position, so the sweep can pop a contiguous prefix off the head instead of scanning the whole in-flight window per message (the old map scan was O(high watermark) per message). - Per-source frontiers.
srcTrackerkeepsarrivedAt/committedAtsets per source seq and advancesfrontover the contiguous prefix of emissions committed this run. Replayed spool rows are not attributable to a live source emission and never reach the tracker — sources re-pull their uncommitted tail instead, which is exactly the duplicate-delivery contract. Swept ranges are deleted so the maps stay bounded by the in-flight window. Commit(ctx, throughSrcSeq)contract (registry.Source): the engine calls it when the contiguous committed frontier advances, with the highest committed srcSeq for that source; the source returns its new durable state (Kafka offsets, file offsets, SQL watermarks), which the source committer persists viaStore.SetSourceState.- Source committers (
sourcecommit.go, candidate 01). One worker per source receives coalesced maximum frontier advances frompersistCheckpointand callsSource.Commitoff the committing goroutine — so a source may hold an internal lock acrossemit(the file source does) without deadlocking the pipeline. A failed commit or state write keeps its pending value and is retried on the next advance; thesrcPersistedmonotonic guard lives in the committer. Afterdrain(),Runwaits (bounded) for the workers to exit and flushes each pending frontier synchronously withcontext.WithTimeout(context.Background(), DrainTimeout)— the engine ctx is already cancelled, and a cancelledCommitwould discard the last frontier. A source state that lags the checkpoint only widens the crash replay window: duplicate delivery, never loss.
Persistence
persistCheckpoint (engine.go) runs on the goroutine that advanced the
prefix, outside the tracker lock. Concurrent advances can flush out of
order, so monotonic guards under persistMu keep everything from
regressing:
persistedThrough— the highest checkpoint actually written (Store.SetCheckpoint);CheckpointPtrmirrors it for status. A failed write only widens the replay window; the next advance retries. The flush positionflushAttemptedadvances even on failure so observers waiting on the durability barrier are not wedged by a permanently broken store.durableThrough()reportsflushAttempted— the visibility barrier.WaitCommitandQuiescedrequiredurableThrough >= committedThrough, so “committed” still implies “persistence attempted” now that flushing is asynchronous to the tracker lock.Quiescedadditionally refuses to read “no work” while the crash-replay scan is in flight (replayDone) or while an admission holds a gate slot but has not registered yet (admitting) — both windows would otherwise look like an empty pipeline (candidate 02).- Spool retention. One batched trim per window of durable progress:
when
persistedThrough >= retentionDue, rows at or belowpersistedThrough - SpoolRetentionare deleted (Store.DeleteSpoolThrough) and the next due point moves a full window. The cutoff is anchored atpersistedThrough, not the in-memory frontier — trimming ahead of what a restart would replay from would turn at-least-once into loss while checkpoint writes fail.DefaultSpoolRetentionis 10_000 rows;storage.spool_retentionin the Runtime config overrides it (internal/runtimecfg). - Dead-letter retention (opt-in). Rides the same window: a pipeline that
sets
dlq.retentionsweeps dead letters older thanClock() - DLQRetentiononce per retention window (Store.DeleteDeadLettersBefore, bounded 10,000-row batches, driven by thedead_letter(pipeline, created_at)index so a large backlog stays a range scan). Unset (the default) keeps everything forever — dead letters are operator data forreplay, so the engine never deletes what the pipeline did not ask to delete; removed rows are gone fromreplayfor good. A failed sweep is logged and retried by the next window, exactly like the spool trim — it never blocks the commit path (deleting terminal artifacts cannot affect the invariants). - Per-source
Commitstates persist through the committers (above), with the monotonic guard inside each committer; the checkpoint stays the durable barrier. - Single writer (candidate 08). A pipeline store is written by exactly one
process at a time. A running engine holds an exclusive OS file lock on a
sidecar next to the canonical database —
<store>.lock— viainternal/store/lease.go(flockon Unix,LockFileExon Windows, both non-blocking); the kernel releases the lock when the process dies, so there is no TTL heartbeat and a crash leaves no stale lease. The daemon acquires the lease per deployed pipeline and releases it when the instance stops;run --config,triggerand a livereplayacquire it for the duration of their run and refuse with a message pointing at the admin/MCP surface when they cannot (a second engine on the same spool would race the checkpoint). On a target without a file-lock implementation the run refuses loudly instead of silently skipping the single-writer guarantee. The lock is an advisory lock: the filesystem must honor it (local disk or a block-backed volume); on NFS/RWX it may not protect across nodes, so keep the k8s deployment onRecreatewith one replica. The lock is a sidecar so SQLite’s own locking is untouched, and the file is never deleted — an unlocked leftover is expected and harmless.
Group-commit write path (P1, design 2026-09-24 §2.2)
Every store mutation runs on one writer goroutine over one dedicated
connection (internal/store/writer.go); reads (Checkpoint, SourceState,
replay, dead-letter and job-run queries) stay on the pool. There is exactly
one write transaction at a time, so writer-writer SQLITE_BUSY contention is
gone by construction. The pragma contract (WAL, synchronous=NORMAL,
busy_timeout) is unchanged.
AppendSpool is a group commit:
- The writer drains the queued writes and commits one transaction per group —
a single multi-row
INSERTfor the spool, capped atstorage.write_batch.max_rows(default 256) spool rows per transaction.spool.seqisINTEGER PRIMARY KEY AUTOINCREMENT, so one statement assigns contiguous rowids: the group’s seqs come back aslast_insert_rowid - n + 1 .. last_insert_rowid, in queue (admission) order. - Every caller blocks until its own group has committed and returns the
seq of its own row. A failed transaction fails every waiter it carried
with the same error — the refusal contract is unchanged (nothing durable,
the source re-emits).
AppendSpooltakes noctxand has no “give up halfway” path: a source cancelled while its append is in flight still gets the row committed and registered, so a committed row can never be invisible to the commit tracker and pin the contiguous prefix (the orphan-row hazard,TestAppendCancelDuringAppendRegistersRow). SetCheckpointandSetSourceStatecoalesce: one upsert per pipeline (the maximum seq) and one per(pipeline, source)(the request with the highestsrcSeq, carrying its paired state — never a field-level merge) per group. Monotonic high-water marks make a later transaction unable to regress an earlier value; a caller whose value was superseded still succeeds, with its value or a larger one durable.- Close stops the writer, refuses everything still queued with
ErrClosed, refuses a transaction that was in flight (rollback — nothing landed), joins the writer goroutine and releases the dedicated connection: a caller blocked inAppendSpoolis always woken, never left blocking.
No artificial linger. storage.write_batch.max_wait_ms (default 2) does
not make the writer sleep: a timer’s floor is the OS quantum (>500 µs on
Windows), which would cap a lone producer at ~1K rows/s while gaining nothing
— a lone producer cannot enqueue a companion until its own append returns.
Instead the value maps to a bounded probe budget (up to 16 scheduler yields,
companionProbes): after a group commits, the writer yields to let the
callers it just released re-enqueue, then commits whatever arrived. 0
disables companion collection (write-through). Observed batch sizes are
available through the optional WriteOptions.OnBatch(rows, waited) hook; the
store stays a leaf and never imports telemetry (§2.6.3 is the tuning
surface).
Bounds and fairness. storage.write_batch.max_rows is capped at 2000
(a Runtime load error above it; the store clamps defensively): 2000 rows is
18000 SQL bindings, and a larger group would exceed the driver’s parameter
limit, fail the whole transaction and refuse every waiter in a loop. A
checkpoint or source-state request whose value is already durable (a later
group carried a higher one) is resolved before its group’s transaction, so a
failing sibling statement cannot refuse a caller whose value is safe.
Recovery
Crash recovery is Run’s second phase: read the checkpoint, replay every
spool row beyond it (Store.ReplayFrom) through admission, re-dispatching
each into the DAG — with the workers already running, so a backlog larger
than a node channel cannot wedge. Rows whose source node no longer exists in
the IR are committed immediately rather than wedging the contiguous prefix
forever. Pull sources resume from their persisted watermark, so the
uncommitted tail may arrive twice: once via spool replay, once via
re-emission — duplicate delivery, never loss (invariant 3;
TestInvariant_Kill9ReplayReplaysAllUncommitted).
Job pipelines resume runs found in pending/running on startup;
internal/jobs drives each run through Engine.Wait and maps the Outcome
onto the run status (below). Run admission — the overlap policy, the run
record and the goroutine registration — is one critical section, and
Manager.Stop waits until every run persisted its terminal state, so
ops.Drain/Deploy really wait for drained runs and a restarted manager
cannot pick up a run the previous instance was still driving (candidate 08).
A canceled run that must stop immediately
dead-letters its outstanding set through the bounded Abandon(ctx, reason):
the durable record is written first and only then is the tracker cleared
(force-terminate, which also releases the admission slot the old path
leaked — candidate 01), so the checkpoint prefix never wedges (review R2). A
ctx cancellation or store failure stops the abandon attempt, leaves every
unrecorded message uncommitted and returns an error — the next run replays
them (never loss, candidate 02). The normal dead-letter path keeps invariant
4: it retries forever on the engine ctx; only Abandon is bounded.
Run outcome
One decision point for “how did the run end” (candidate 02): every runner
calls Engine.Wait(ctx, runDone, WaitOptions) and maps the returned
Outcome onto its own process contract — none of them re-derives the
terminal state.
- Statuses.
RunStatusiscompleted | partial | failed | interrupted: completed = quiesced cleanly; partial = quiesced with dead letters; failed = the engine stopped itself (worker-fatal or a source failure); interrupted = the caller’s ctx was canceled (never the engine’s internal cancel, so an engine self-stop is never misreported as interrupted). - Classification priority (one
classifyOutcomeswitch): worker-fatal → source errors → caller cancellation → dead letters > 0 → completed. Outcomecarries status, rows read, committed, dead-lettered, theSourceErrorssnapshot, the worker-fatal error, and the abandon count / error.FailureText()renders the operator-facing cause.WaitOptions:AbandonOnCancel(jobs: R2 semantics; batch does not — uncommitted rows replay),AbandonReason,AbandonTimeout(bounded, and the caller ctx is already canceled on that path, so Wait derives a fresh context) andDrainTimeout(the post-cancel wait bound, defaulting to the engine’sDrainTimeout + 5s). Past that bound a non-cancellable writer may outlive the run by design; Wait then folds in the engine’s published fatal (failNoderecords it before it cancels) instead of waiting onRun.WaitQuiescedstays the low-level primitive (tests use it);Waitbuilds on the same loop.
A source failure (v1.24: Run/Pull returned a non-nil error) stops the
engine in every mode: the error is recorded first, then the engine cancels
— a continuous pipeline must not keep running with a dead source. Restart
resumes from the source watermarks (duplicate delivery, never loss). An
error returned under an already-cancelled engine ctx is a shutdown artifact
and never a source failure. Options.OnSourceError was deleted; the
SourceErrors snapshot carried by the outcome is the only channel.
Shutdown
Every pipeline runner registers signal.NotifyContext for SIGINT and
SIGTERM (the run, config-dir daemon, mcp, replay and trigger verbs —
commit a9250c8 closed the docker-stop gap). On ctx cancellation the engine:
- stops accepting (
acceptreturns ctx.Err()), - drains: waits up to
Options.DrainTimeout(default 10s, overridable bylimits.drain_timeout) for worker and source goroutines, then hard-cancels, - flushes pending sink batches (the sink worker’s ctx branch flushes before returning),
- closes sinks and transform masters (clones close themselves),
- stops the source committers and flushes their pending frontiers with a bounded, non-cancelled context (candidate 01).
Run then returns the fatal error, if any. The ops layer prints the final
status line (the “settle status” report: counts + checkpoint).
The fatal path
Per-message failures dead-letter. A node that cannot run at all is fatal:
failNode records the first error (first error wins), cancels the context,
and Run returns it after draining — the run outcome is failed. The
motivating case is a transform whose Clone fails: the master instance is
precisely what the plugin declared unsafe to share, so the pipeline fails
instead of racing workers on it (nodes.go, runTransform). A failed
source stops the engine the same way without a worker-fatal error: the
error lands in SourceErrors, the outcome is failed, and Run returns nil
(candidate 02).
Transform workers
workers (default 1) spawns that many goroutines per transform node, all
reading the node’s channel. A plugin implementing TransformCloner is
cloned once per worker (Clone() after Init); stateless plugins share
one instance — script programs are immutable and per-message state lives in
copy-on-write bindings, split has no state. A failed Clone is
worker-fatal (see above). TransformFlavor (“script”, “wasm”) selects the
per-flavor duration histogram and the budget/timeout counters, which are
driven by the typed failure kind (registry.FailureKind); any other flavor
takes the generic branch (run accounting + dead-letter class, no per-flavor
instrument).
Options and defaults
| Option | Default | Meaning |
|---|---|---|
HighWatermark | DefaultHighWatermark = 10_000 | in-flight (uncommitted) cap; limits.max_in_flight overrides |
SpoolRetention | DefaultSpoolRetention = 10_000 | spool rows kept behind the checkpoint; storage.spool_retention overrides |
ChannelSize | 128 | per-node channel capacity (per-edge buffer.max_events raises it) |
BackoffBase | 100ms | delivery retry base; exponential doubling, 30s cap |
DLBackoff | 500ms | dead-letter write retry interval |
BatchFlush | 1s | default sink batch flush interval (batch.timeout_ms overrides) |
DefaultTimeout | 30s | per sink-write attempt when the edge sets no timeout_ms |
DrainTimeout | 10s | graceful drain bound (limits.drain_timeout overrides) |
WasmSlowCallWarnMs | 5000 | zero-interference slow-call watchdog threshold |
SpanSampleRate | 0 | per-message spans, opt-in (telemetry.span_sample_rate) |