This is the multi-page printable view of this section. .
Documentation
- 1: Introduction
- 1.1: What is Eventboat?
- 1.2: Architecture
- 2: Get Started
- 2.1: Installation
- 2.2: Your First Pipeline
- 3: Reference
- 3.1: CLI Reference
- 3.2: Configuration
- 4: Developer guide
1 - Introduction
1.1 - What is Eventboat?
Eventboat routes events through directed acyclic graphs (DAGs) of transforms — from sources (Kafka, HTTP, cron, SQL, files) to sinks (Kafka, HTTP, files), with at-least-once delivery, dead-lettering, and replay.
It is agent-native: every capability is accessible through MCP (Model Context Protocol), CLI, and a language server — so AI agents can write, verify, deploy, and operate pipelines autonomously.
What makes it different
| Dimension | Approach |
|---|---|
| Predicates | CEL (Kubernetes standard) — zero custom DSL, huge training corpus |
| Transforms | Starlark (Python dialect, sandboxed, deterministic) |
| Verification | Four machine gates: verify, test, explain, operate |
| Reliability | Seven invariant tests, spool/settle/checkpoint engine on SQLite |
| Jobs | Cron scheduling, catchup windows, typed parameters, backfill |
| Extension | CEL → Starlark → WASM → gRPC out-of-process plugins |
| Interop | CESQL dialect (CloudEvents), official TCK 100% |
The one-line pitch
Eventboat lets AI agents build and run event pipelines that don’t lose messages — because machines verify every step before it goes live.
1.2 - Architecture
Three-layer model
- Config layer: YAML parsing, strict schema validation, variable substitution
- Static IR: validated DAG + precompiled CEL programs + Starlark programs + schema
- Runtime: spool + settle + checkpoint engine, consuming only the IR
Reliability model
- Spool: every message hits SQLite before the DAG sees it (invariant 1)
- Settle: each message settles when all branches reach terminal state
- Checkpoint: advances only over settled prefix (invariant 2)
- Crash recovery: kill -9 → restart → replay from checkpoint, never lose (invariant 3)
- Dead letters: exhausted retries → DLQ store with query + replay CLI
Seven invariant tests
Each has a dedicated test that must pass in CI:
- Spool before visible
- Checkpoint advances only after settle
- Kill -9 replay covers all unsettled
- Dead-letter write failure blocks settle
required: falseedges don’t block siblings- Redelivery keeps message ID stable
- Cursor watermark never exceeds settled
Four machine gates
| Gate | Command | What it does |
|---|---|---|
| verify | eventboat verify | Schema, topology, CEL+Starlark compile, lint — static, zero side effects |
| test | eventboat test | Contract tests against the real engine — fixture in, assertions out |
| explain | eventboat explain --message sample.json | Deterministic path walkthrough with real CEL evaluation and Starlark dry-run |
| operate | eventboat mcp | MCP server: 15 tools covering the full agent lifecycle |
2 - Get Started
2.1 - Installation
Prerequisites
- Go 1.25+ (for building from source)
- No runtime dependencies — everything is compiled into one binary
Install
Verify:
You should see the help screen with 11 commands listed.
What’s in the binary
| Component | Included |
|---|---|
| Engine (spool/settle/checkpoint) | ✅ |
| CLI (verify/test/run/trigger/jobs/explain/replay/repl/lsp/plugin/mcp) | ✅ |
| Built-in sources (kafka/http_server/cron/file/sql) | ✅ |
| Built-in sinks (kafka/http/file/drop) | ✅ |
| Built-in codecs (json/raw/csv/avro/protobuf) | ✅ |
| MCP server (15 tools) | ✅ |
| LSP (diagnostics/completion/hover) | ✅ |
| Admin REST + SSE + read-only UI | ✅ |
| OpenTelemetry (OTLP + Prometheus) | ✅ |
| SQLite storage (pure Go, no CGO) | ✅ |
Next steps
2.2 - Your First Pipeline
The three-section format
Every Eventboat pipeline is a single YAML file with three top-level sections:
sources, transforms, and sinks — connected by from edges.
Verify (gate 1)
Output: pipeline.yaml: 0 error(s), 0 warning(s)
Run
The pipeline starts; every 5 minutes the cron source fires, the Starlark script enriches the message, and the sink receives it.
Add branching (CEL predicates)
Agent mode
Next steps
3 - Reference
3.1 - CLI Reference
Overview
verify
Statically validate a pipeline. Checks schema, topology invariants (no cycles, no orphans, source has no in-edges, sink has no out-edges, at least one source→sink path), CEL predicate compilation, Starlark script compilation, job configuration, and semantic lint.
| Flag | Default | Description |
|---|---|---|
--config | (required) | Pipeline YAML file |
--strict | false | Upgrade warnings to errors |
test
Run contract test suites against the real in-process engine. Test files declare injection points, expected captures, and DLQ assertions.
run
Execute a pipeline. Job pipelines (with run.mode: job) run under the
jobs manager with scheduling and catchup. --config-dir starts a
multi-pipeline daemon with the admin surface.
| Flag | Default | Description |
|---|---|---|
--config | — | Single pipeline file |
--config-dir | — | Directory of pipeline files (daemon mode) |
--runtime | ./eventboat.yaml | Runtime config (telemetry endpoints) |
--data-dir | data | SQLite storage directory |
--ephemeral | false | In-memory store (nothing persists) |
trigger
Manually fire a job pipeline once, optionally with parameters (backfill).
explain
Deterministic walkthrough of a pipeline. With --message, performs real
CEL evaluation and Starlark dry-run on the sample. With --topology,
renders the DAG (mermaid + ASCII).
replay
Re-inject dead letters (--dlq), a spool window (--spool --from N),
or one job run’s dead letters (--job <run-id>) into a live pipeline.
repl
Evaluate CEL predicates and Starlark scripts against one sample message without running a pipeline.
mcp
Start the MCP server for AI agents.
| Flag | Description |
|---|---|
--stdio | Speak MCP over stdin/stdout (for agent hosts) |
--http | Serve MCP over HTTP with Admin REST + SSE + UI |
--config-dir | Deploy pipelines at startup |
3.2 - Configuration
Top-level sections
| Section | Required | Purpose |
|---|---|---|
apiVersion / kind / metadata | ✅ | Resource identity (K8s convention) |
sources | ✅ (≥1) | Topology: where events come from |
transforms | optional | Topology: what happens to events |
sinks | ✅ (≥1) | Topology: where events go |
run | job pipelines only | Job scheduling (mode/schedule/overlap/catchup_window) |
parameters | job optional | Typed job parameters with defaults |
constants | optional | Read-only values visible to scripts and predicates |
hooks | optional | Lifecycle hooks (failure/success → inline sink) |
limits | optional | Per-pipeline resource limits |
edge_defaults | optional | Default edge attributes |
codecs | optional | Named codec declarations |
dlq | optional | Dead-letter policy |
Three-section topology
Nodes are organized by section; edges declared on the downstream side via from:
Edge attributes
Attributes on from elements:
| Attribute | Type | Description |
|---|---|---|
when | string or object | CEL predicate (or {lang: cesql, expr: ...}) |
delivery | object | {retries, backoff, timeout_ms} |
required | bool | false = best-effort (failure doesn’t block siblings) |
buffer | object | {max_events, strategy} |
Variable substitution
${VAR}— environment variable (unset = error)${?VAR}— optional (unset = omit key)${constants.name}— pipeline constant- Applies to all string values
Built-in plugins
Sources
| Name | Key config |
|---|---|
kafka | brokers, topics, group_id |
http_server | listen, max_body_bytes |
cron | expression (5-field) |
file | path (tail) |
sql | driver (mysql/postgres/sqlite), query, cursor, pagination |
Sinks
| Name | Key config |
|---|---|
kafka | brokers, topic |
http | url, timeout_ms |
file | path (JSON lines) |
drop | (none — discards) |
Codecs
| Name | Key config |
|---|---|
json | (none) |
raw | (none) |
csv | columns or header |
avro | schema (inline or file) |
protobuf | descriptor_set (file path) |
Job pipeline example
4 - Developer guide
These guides are rendered at build time from the eventboat/eventboat repository; edit them there, under docs/developer/.
4.1 - Architecture & package map
Eventboat is a single Go binary that routes events: messages arrive from sources (Kafka, HTTP, cron, file, SQL), flow through an explicit DAG of transforms and filtered edges, and land at sinks — at-least-once, verifiable before anything runs, and replayable after a crash. Pipelines are declared in YAML, statically verified against plugin JSON Schemas and topology rules, and then executed by an engine whose entire reliability model is pinned by eight dedicated invariant tests. Predicates are CEL, transforms are Starlark (or WASM), so there is no custom language to learn.
The processing model
A pipeline is a three-section YAML document — sources, transforms,
sinks — joined by depends_on edges into a DAG. The runtime model is:
- Admission: every inbound message is durably spooled (SQLite, or
in-memory with
--ephemeral) before it becomes visible to the DAG (internal/engine/admission.go,admit: live / replay / inject modes). - Execution: the message fans out along matched edges into transforms and sinks. Each execution branch must reach a terminal state: sink ack, dead letter, filtered (zero matching edges or zero transform outputs), or an optional-edge drop.
- Commit: a commit tracker counts outstanding branches per message; the
checkpoint only advances over the contiguous committed prefix
(
internal/engine/commit.go). Per-source watermarks are persisted by the async source committers (internal/engine/sourcecommit.go). - Recovery: on restart the engine starts its workers first, replays the spool beyond the checkpoint through admission, then starts sources — pull sources resume from their committed watermarks: duplicate delivery, never loss.
Sources, transforms and sinks are plugins in the registry (Plugin system); edges carry predicates (CEL or CESQL) and delivery policies (retries, backoff, timeout, required/optional) (Configuration).
Event lifecycle through the engine
Package map
Every directory, what it owns, and what it must not reach into. The layering
is enforced by Go imports — each “must not” below is checkable with
go list -deps.
| Package | Owns | Must not reach into |
|---|---|---|
internal/config | Typed pipeline config, the strict loader (LoadBytes/LoadBytesIn), ${VAR}/${?VAR}/${constants.x} substitution, metadata.name validation, sections/edges/hooks/parameters parsing, declaration order and materialized defaults | The registry or IR; plugin validity is decided later. It imports only the leaf packages schema/fsname |
internal/schema | The single source of the framework vocabulary (candidate 06; renamed from internal/framework): per-section node fields, top-level keys, edge attributes, reserved plugin names, node-level default constants. config, registry and the LSP read it | Pipelines, plugins or parsing — a leaf with static lists only |
internal/ir | The static IR: DAG nodes/edges, compiled CEL/CESQL predicates and Starlark programs, topology checks, plugin + codec resolution, lint, job semantics. ir.Build is the build stage (with the retaining BuildForExplain for explain, released through Pipeline.Close — candidate 09) — reached only through internal/verify in production code | engine, store, anything runtime. IR is built before an engine exists |
internal/verify | The one verify-first composition (candidate 05): File/Bytes → a Result carrying the built IR, the merged diagnostics and the strict verdict; the two-stage LoadBytes → (parameters) → Build form; the explicit baseDir rule for relative paths | Running anything — it stops at the built IR (ops, cli, lsp, jobs, testrun enter it) |
internal/engine | Spool admission, DAG execution, commit tracking, per-edge delivery retries, dead lettering, checkpointing, backpressure, transform/sink workers | Config parsing or expression compilation — it consumes a built ir.Pipeline only. It also never decodes payloads itself (codecs do) |
internal/registry | The plugin registration model: four kinds, JSON Schema validation (santhosh-tekuri), version pins, the typed struct→schema generator, Catalog, and the reserved-name gate shared with config through internal/schema | Any host package. A leaf — plugins register into it; it imports only internal/schema |
internal/registry/builtin | All compiled-in plugins: file/cron/http_server/kafka/sql sources; script/split/wasm transforms; file/http/kafka/drop sinks; json/raw/csv/avro/protobuf codecs | Anything beyond registry and wasmhost (for the wasm transform config) |
internal/lang/celhost | CEL predicate host: env binding, compile, cost-limited eval | Nothing internal (leaf). It does not know about pipelines |
internal/lang/cesqlhost | The CESQL edge dialect (official CloudEvents parser + the data.* rewrite) | Nothing internal (leaf) |
internal/lang/starhost | The Starlark sandbox host: compile, frozen constants, lazy COW message bindings, step budget, backtraces | Nothing internal (leaf) |
internal/wasmhost | wazero runtime host: wasip1 reactor compile (compiled modules cached by file identity + compile-affecting config, candidate 09), per-invoke budgets through the one ResolveMode resolution (fast mode default), guest ABI check, invokers | Nothing internal (leaf). registry/builtin adapts it |
internal/rpcplugin | Out-of-process gRPC source/sink plugins: spawn, JSON handshake, auth metadata, restart supervision | ir/engine. It adapts config.GrpcConfig + pkg/pluginproto into registry-shaped sources/sinks |
internal/fsname | The shared file-name rules: Sanitize (conservative base-name charset) and WindowsReservedName — used by config’s metadata.name validation and the store owner’s layout | Nothing internal — a leaf. One reserved-name list, no copies |
internal/store | The durable spine, split into facets — SpoolStore (spool/checkpoint/source states), DeadLetterStore, JobRunStore, combined as Store — backed by SQLite (modernc.org/sqlite) and an in-memory implementation, plus the Owner that decides where a pipeline’s store lives (<data-dir>/stores/<sanitized name>.db) and owns its handle lifetime (one cached handle per pipeline, closed together; memory owner for tests/--ephemeral) | Parsing or execution. It persists registry.Message but never interprets payloads |
internal/jobs | Job pipelines: cron scheduling, catchup, overlap, run lifecycle, typed parameters (cursor/now), hooks, run history | Being a caller of the engine only — it must not reimplement admission or commit logic |
internal/ops | The operations service: verify/test/explain/deploy/status/jobs/trigger/tail/dlq_query/dlq_replay/drain/pause/resume. The single implementation behind MCP and Admin REST | HTTP or protocol concerns. admin/mcpserver/CLI are thin shells over it |
internal/admin | Admin REST + SSE + the embedded read-only UI + the security middleware (token, Host allowlist) | Business logic — everything delegates to ops |
internal/obs | OpenTelemetry: one MeterProvider with Prometheus + OTLP readers, the instrument set, span helpers | Nothing internal (leaf); callers pass in pipeline/node names |
internal/explain | Deterministic walkthroughs: symbolic and message-level traces (resolved semantics — shared wasm mode, per-edge delivery + batch rule, optional drop, decoder-aware sample), mermaid/ASCII topology | engine — explain runs compiled IR, never a live engine; it dry-runs the retained ForExplain instances, and the caller closes them (Pipeline.Close, candidate 09) |
internal/lsp | The language server: minimal hand-written JSON-RPC 2.0, diagnostics from the real verify path (the document’s directory is the relative-path base), completion, hover | Execution — it validates documents through internal/verify, never runs pipelines |
internal/mcpserver | The 14 MCP tools over stdio or Streamable HTTP (official Go SDK) | Business logic — thin shells over ops |
internal/dlq | The dead-letter strategy surface (candidate 05): where-filter compilation with the pipeline’s constants, selection order (select before delete), the codec-carrying replay request | Transports — ops injects into a live engine, the CLI replay builds a local engine; both assemble the shared Request |
internal/runtimecfg | Deployment-level kind: Runtime config: storage.*, admin.*, mcp.*, telemetry.* keys | Pipeline concerns; it never loads pipeline files |
internal/testkit | Injection/capture/fault-injection primitives: ManualSource, CaptureSink, FlakySink, StoreWrapper, RegisterFakeTransform | Production behavior — nothing in internal/ (outside tests) may import it |
internal/testrun | The §3.2 contract-test runner: suite YAML → in-process real-engine runs with capture sinks | Only the public engine/testkit surfaces; it is itself consumed by ops and the CLI test verb |
internal/inttests | Env-gated integration suites: kafka/ (real broker via testcontainers) and soak/ (long-run stability) | — test-only; skipped without their env vars |
pkg/pluginproto | Generated gRPC stubs for eventboat.plugin.v1 | Generated code — regenerate, never hand-edit. It lives in pkg/ (not internal/) so third-party plugin modules can import it |
pkg/plugin | The compile-time plugin ABI: aliases of the engine’s plugin interfaces plus typed RegisterSource/RegisterTransform/RegisterSink/RegisterCodec into the process-wide registry | Importing the engine — plugin packages import only this, so their own test binaries stay light |
root package eventboat | RunCLI — the library entry point for custom builds: blank-import plugin packages, then run the full CLI under your own main (Benthos’s service.RunCLI role) | Business logic — it delegates to internal/cli |
internal/cli | The CLI verb table and cmdX executors (verify/test/run/trigger/jobs/explain/replay/repl/lsp/plugin/mcp), dispatch on lynx-go/commands, help goldens; shared by cmd/eventboat and the root package’s RunCLI | New business logic — verbs delegate to ops, testrun, explain; and no os.Args/os.Exit — Run returns the exit code, the caller owns exit |
cmd/eventboat | The shipped binary: a thin main over internal/cli | Anything else |
tools/ | Support tools. tools/sitegen (in progress) is the documentation site generator: a separate Go module, run with go run ./tools/sitegen, no CDN dependencies | Importing internal/ — it is a separate module |
examples/ | Shipped pipelines (linear, branching, fan-in, job-sync, codecs), the third-party gRPC plugin example (examples/plugins/ticker-source, its own Go module), the VS Code launcher, the k8s manifest | — covered by the CI examples gate: all examples are verified and contract-tested |
Design invariants
These are the properties the architecture is built around. Items 1–8 each
have a dedicated test (TestInvariant_* in internal/engine/invariants_test.go);
breaking any of them is a review blocker.
- Spool before visible. A message must not become visible to the DAG
until its spool append has succeeded. A failing append refuses the message;
it never reaches any sink, and the refusal reaches the source through
emit’s error (the source may safely re-emit it). - Checkpoint only over committed. The durable checkpoint may advance only across the contiguous prefix of messages whose every execution branch reached a terminal state.
- Crash = replay, never loss. After a kill -9, the set replayed from the checkpoint is a superset of the uncommitted set. Duplicate delivery is the contract; loss is not.
- Dead-letter write failure blocks commit. A dead letter that cannot be written is retried; the message stays uncommitted. Dead-letter unavailability slows the pipeline instead of losing messages.
- Optional-edge failure isolates. A
required: falseedge failure terminates only its own branch and never blocks sibling branches from committing. - Duplicate delivery is safe by design. Re-delivery to an idempotent sink
must be harmless;
meta.message_idexists for dedup keys. This part is the user’s documented responsibility. - Cursor watermark follows commit. Pull sources’ watermarks advance only to the max cursor column of committed messages — job-pipeline resume correctness.
- Fan-out branches are isolated. Sibling branches share the underlying
msg.Decoded/msg.Metamaps (delivery shallow-copies the struct), so transforms must replace those values wholesale and never mutate them in place — an in-place write races a sibling branch’s encode and can crash the process with an unrecoverable concurrent-map fault. This is also the message-ownership contract documented onregistry.Messageandpkg/plugin.
Project-level invariants that live outside the engine:
- Verify-first for every write path.
deploy, the admin REST write surface, the MCPdeploytool andeventboat runall verify through the same composition,internal/verify(load → build → judge).ops.Verifyreturns the same diagnostic sequence and strict verdict for the same input;ops.Deployfails when verification fails. There is no bypass channel — the LSP, jobs, testrun and the CLI enter the same composition (internal/verify, candidate 05). - The engine does not parse payloads. Raw bytes plus a codec marker are
the spooled truth (
internal/registry/registry.go,Message.Raw); decoding and encoding are codec plugins. Replay stays compatible with codec upgrades because the spool never stores the decoded form. - Plugin isolation. Out-of-process plugins are separate processes speaking
gRPC on loopback with a one-shot token (
internal/rpcplugin); WASM guests run in a capability sandbox with memory caps (internal/wasmhost); Starlark scripts run without while/recursion/I/O under a step budget (internal/lang/starhost). A wedged plugin degrades one worker or one pipeline, not the process. - Single binary. Everything — engine, store, language hosts, admin UI,
MCP, LSP — compiles into one static binary with CGO disabled (every driver
is pure Go; SQLite via
modernc.org/sqlite), see theDockerfile.
How a config becomes a running pipeline
The same path serves every entry point:
Entry points on top of this: eventboat run --config (one pipeline,
OTLP-only telemetry), eventboat run --config-dir (the multi-pipeline
daemon; every YAML in the directory loads, the admin surface serves on
127.0.0.1:7788 by default), ops.Deploy (the admin/MCP write path —
verify-first, then drain-and-swap), and the one-shot verbs (verify,
test, explain, replay, trigger, jobs). Job pipelines
(run.mode: job) put internal/jobs between the config and the engine:
one engine per run, sharing one admission pool across overlap: all runs.
Every runner reads one run outcome (candidate 02): the engine’s
Wait/Outcome classifies the run as completed, partial, failed or
interrupted, and the caller maps that onto its own process contract —
job runs to success/partial/failed/canceled, the batch verb to
exit 0/1, the long-lived continuous verb to exit 0 on a graceful stop and 1
on a failed run, the daemon to completed/failed (see
Observability & operations). A source failure stops
the engine in every mode; restart resumes from the source watermarks
(duplicate delivery, never loss).
Every entry point opens the pipeline’s store through one store.Owner
(internal/store/owner.go, candidate 04): the canonical file is
<data-dir>/stores/<sanitized name>.db for the one-shot verbs and the daemon
alike, one handle per pipeline per process, closed on shutdown. --ephemeral
substitutes a cached in-memory owner, so all surfaces of one process see the
same data. The old eventboat.db / stores/pipeline.db layouts are retired
without migration (beta ruling). Modules depend on the store facets they use
— engine.New takes the spool + dead-letter facets, jobs.New run history
plus the engine’s facets, ops the combined Store.
Where to read next
- Engine internals — the accept path, commit frontier, persistence and recovery in detail.
- Plugin system & registry — how to register, write and test plugins.
- Configuration & diagnostics — the load pipeline and every diagnostic code.
- Testing guide — where each guarantee is pinned.
4.2 - 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) |
4.3 - Plugin system & registry
Every source, sink, codec and transform in eventboat is a plugin registered
in a registry.Registry (internal/registry/registry.go). Registration
carries a JSON Schema; configuration blocks are validated strictly against it
(unknown fields are errors), which is the mechanism that keeps agents from
inventing plugins or fields that do not exist (redesign-v3.md §5.6). The
registry is a leaf package: it imports nothing internal, so any Go program —
including your own build of eventboat — can register plugins into it.
The four kinds
| Kind | Interface | Factory signature |
|---|---|---|
sources | Source (+ optional PullSource) | func(cfg map[string]any) (Source, error) |
transforms | Transform (+ optional TransformCloner, TransformFlavor) | func(cfg any, dir string) (Transform, error) |
sinks | Sink | func(cfg map[string]any) (Sink, error) |
codecs | Codec | func(cfg map[string]any, dir string) (Codec, error) |
Registration also pins an integer ABI version (builtins are version 1)
and optional capabilities (e.g. "pull" for sources, "explain-safe" for
transforms).
Plugin-name-as-key
In YAML, the plugin name is the key of a node’s block; everything else at
node level must be a framework field from the per-section whitelist
(internal/schema, the single source config, the registry and the LSP
all read — candidate 06):
Exactly one plugin key per node (cfg_missing_plugin /
cfg_multiple_plugins otherwise). Names colliding with framework fields
(depends_on, decoder, encoder, workers, order_key, batch, grpc,
version, when, route, buffer, delivery, required, plus from for
the rename migration) are rejected at registration (registry review R5,
candidate 06: the reserved set is the union of the framework fields, so
grpc/version can no longer register a plugin that could never load).
script, split and wasm are ordinary registrable names. A version: pin
on the node is checked against the registered/manifest version at verify
(plugin_version_mismatch).
The typed schema system
Hand-writing a schema string and a map[string]any factory invites drift.
The typed layer (internal/registry/typed.go) derives both from one struct:
jsontags name the config keys;schematags carry the constraint grammar:optional,default=,enum=a|b|c,min=,max=,minLen=,minItems=,maxItems=,desc=(must come last so descriptions may contain commas).- Fields are required by default;
additionalProperties: falseis emitted always; properties render in declaration order. - Defaults are injected, not just documented: the same
default=tag is applied to zero-valued fields after decode, recursing into nested structs and slices of structs — schema-documented defaults are the runtime behavior. - Generated schemas compile through the same santhosh-tekuri pipeline as
hand-written ones, so
plugin_schemadiagnostics and the external gRPC manifest path are byte-compatible. - Golden tests pin every builtin schema:
internal/registry/builtin/testdata/schemas/*.json; regenerate withgo test ./internal/registry/builtin -update-schemasafter changing a config struct. Every schema change becomes a reviewable diff. - The string-based
RegisterSource/RegisterSink/RegisterCodec/RegisterTransformAPI remains as the escape hatch (it is whattestkit.RegisterFakeTransformuses).
The SCALAR ROOT asymmetry for transforms
RegisterTransformT[T, C] allows C to be a scalar, not only a struct:
transform plugin blocks are not forced to mappings. The one deliberate
asymmetry exists because the script plugin’s config is the Starlark
source text itself (script: | — the block value is a string). A null
script: is therefore a type error against the string schema, not an empty
config (registry.NewTransform does not normalize nil to an empty object for
transforms). The factory also receives dir — the pipeline file’s directory
— so configs carrying file paths (wasm module:) resolve against it.
Lifecycle contracts per kind
Source
Initreceives the persisted state the source previously returned fromCommit— the engine calls it only when state exists. Restore your offset here.emit’s error is the admission verdict (candidate 01):nilmeans the message was durably accepted — it is spooled before it becomes visible to the DAG (invariant 1);- an error satisfying
errors.Is(err, context.Canceled)/context.DeadlineExceededmeans the engine is shutting down: the message was not accepted, andRun/Pullmust returnnil(a voluntary stop, never a failure); - any other error is a refusal: the message is not durable and never
became visible, so it is safe to re-emit. The source owns its input
semantics (Kafka offset, file offset, HTTP response) and decides whether
to retry internally or return the error. The builtin default is to report
the refusal as a failed source — the source watermark is the no-loss
safety net, and a failed run is louder than a silent zero-output run.
(
http_serveranswers 503 instead: one client’s retry is the source policy there.) The engine retries nothing on its own. emitblocks under backpressure (the admission gate), so no buffering is needed. SetMessage.SrcSeqto a per-source monotonic sequence — it advances the commit watermark.
Runreturns the source’s completion signal (v1.24):nil= exhausted (a finite source read to its end — batch runs exit, job runs commit) or a voluntary stop; a non-nil error is a failed source: the engine records it inSourceErrorsand stops in every run mode (candidate 02), so the run outcome isfailed(a failed job, a non-zero exit) instead of a live pipeline with a dead source. ctx cancellation is a voluntary stop — return nil, never ctx.Err(). An infinite source (tailer, broker consumer) simply never returns until ctx is cancelled.Commit(ctx, throughSrcSeq)is called whenever the contiguous committed frontier advances; commit your offsets here (Kafka offsets, file offsets, SQL watermarks). The builtin sources use a watermark-bounded scan: the scan starts at the last folded watermark and deletes every pending entry it visits, keeping each call O(new work) — seekafkaSource.Commit/fileSource.Commitininternal/registry/builtin/.Cursoron emitted messages is what job pipelines persist as the sql watermark. Commit runs on the engine’s per-source committer goroutine, not on the committing goroutine — a source may hold an internal lock acrossemitwithout deadlocking the pipeline; watermark persistence merely lags the checkpoint (duplicate delivery on crash, never loss).PullSourceaddsPull(ctx, emit) errorfor job pipelines: emit rows synchronously, return nil on exhaustion (the run commits) or an error (the run fails — distinct from per-message dead letters). Sources declaring the"pull"capability SHOULD implement it; a source withoutPullSourceis still job-eligible whenRunitself terminates (v1.24: the file source withon_eof: stopworks exactly this way — the job run drivesRunand its nil return IS the exhaustion signal). The"finite"capability declares “Run can exhaust”; the batch mode’sbatch_no_finite_sourceverify warning keys off it.
Sink
Batching is engine-owned (§6.4): the sink receives one batch, writes it,
returns nil or an error; the engine handles retries, optional drops and
dead letters. Success = commit of every message in the batch. There is no
Init on sinks — out-of-process sink adapters self-init with their config
for that reason (docs/plugins.md).
Codec
Decode(raw []byte) (any, error) and Encode(v any) ([]byte, error).
Codecs referenced by bare name (decoder: json) instantiate config-less;
named codecs: declarations validate and instantiate at verify with the
pipeline directory for relative paths. Declaration names and registered
codec names are disjoint namespaces (cfg_codec_shadow otherwise).
Transform
Init(TransformEnv)runs once before workers start: the pipeline’s frozenConstantsand the run’sParameters, a node-scopedLogf, andSlowCallWarn— the advisory slow-call threshold for plugins hosting heavyweight engines (the wasm watchdog reads it).Applyturns one message into zero or more messages:- zero outputs = the message is committed as filtered and counted
(
eventboat_fanout_no_match_total) — the same semantics as an edge predicate with no matching edge; - one or more outputs fan out downstream; the engine expands the commit accounting for the extra branches (the split contract);
- a non-nil error retries per the incoming edge’s delivery policy, then dead letters — it never fails the node.
- zero outputs = the message is committed as filtered and counted
(
- Message ownership (invariant 8). Fan-out sibling branches share the
underlying
msg.Decoded/msg.Metamaps (the engine shallow-copies the struct per edge), soApplymust never mutate those maps in place — replace the fields wholesale: assign a fresh map/value tomsg.Decoded/msg.Meta(the script plugin’s copy-on-write binding does exactly this) or return fresh messages. An in-place write races a sibling branch’s encode and can crash the process with an unrecoverable concurrent-map fault (TestInvariant_BranchIsolation). The same contract is documented onregistry.Messageandpkg/plugin. TransformCloner— implement it when your execution state is not goroutine-safe (wasm module instances die on traps). The engine clones once per worker goroutine and clones must be independent. IfClonefails, the engine treats it as node-fatal:failNodecancels the pipeline andRunreturns the error — the master instance is exactly what the plugin declared unsafe to share, so it must not degrade into being shared across workers (internal/engine/nodes.go,runTransform).TransformFlavor— return"script"or"wasm"to feed the per-flavor duration histograms and budget/timeout counters; any other flavor (including split’s"split") takes the engine’s generic branch: the execution is still counted by the run accounting and a failure’s kind still becomes the dead-letter class, but no per-flavor instrument exists for it.- Declare the
"explain-safe"capability (script, split) ifexplainmay dry-run your transform on scratch messages; wasm deliberately does not (explain never executes guest code). The instance lifecycle is the build’s choice: a verify-only build (verify, LSP, jobs, testrun) callsInitfor validation and closes the instance immediately — failure paths included — while an explain build (verify.Options.ForExplain) retains it and closes it throughir.Pipeline.Closewhen the walkthrough is done. Explain-safe plugins must therefore tolerateInit/Closeonce per build and aCloseafter any validation outcome; the pipeline’sCloseis idempotent.
Error classification across plugin boundaries
A plugin cannot raise diagnostics itself, so failures carry classification
in registry.TransformError:
| Field | Meaning |
|---|---|
Err | the underlying error |
DiagCode | at verify time, routes a factory failure to a specific diagnostic code (empty falls back to plugin_schema) — expr_starlark_compile and expr_wasm_compile survive this way, with a Hint |
Backtrace | at run time, stored verbatim in the dead letter (Starlark backtraces; positions render as script:L:C) |
Kind | the typed failure kind — steps, timeout, guest, compile, runtime or other (registry.FailureKind). It becomes the dead-letter class and drives the budget/timeout counters; classification is never derived from the message text |
The hosts define their own kinds where the failure is created —
starhost.ScriptError.Kind (compile/steps/runtime; budget exhaustion is set
by the Starlark step-limit hook, not sniffed from "too many steps") and
wasmhost.Error.Kind (compile/timeout/guest/trap) — and the built-in
adapters map them onto the registry enum: script → compile/steps/runtime,
wasm → compile/timeout/guest/runtime (a trap is the guest’s runtime failure).
A custom in-process transform just sets Kind (or leaves it empty for
other).
internal/ir.addFactoryDiags unwraps TransformError at verify; the engine
unwraps Kind/Backtrace at run time (internal/engine/nodes.go). Flavor
is not carried on errors: it is an instance property read through
TransformFlavor.
Writing a custom in-process transform
For tests and third-party compile-in transforms, internal/testkit provides
the fastest path:
TransformFunc adapts a plain function to registry.Transform (identity
Init/Close); returning a nil slice filters, an error dead-letters after the
edge’s delivery retries — both engine contracts, exercised by tests. For the
full out-of-process story — gRPC sources/sinks in any language, the JSON
handshake, manifests, restart policies — see docs/plugins.md; note that
out-of-process gRPC transforms are future work (transforms are
registered plugins, but compiled-in only).
Where the builtins live
internal/registry/builtin/register.go wires everything:
source-file/cron/http_server/kafka/sql,
transform-script/split/fields/wasm,
sink-victorialogs/file/http/kafka/drop/debug,
codec-json/raw/csv/avro/protobuf. Each registration is a ~30-line
typed function — the best templates for writing your own.
4.4 - Configuration & diagnostics
A pipeline is one YAML file loaded by internal/config and compiled by
internal/ir. Loading is deliberately strict: unknown keys, unknown plugins
and un-compilable expressions are errors, not warnings — the config is a
contract with agents as much as with humans. Every finding surfaces as a
config.Diagnostic with a stable code, severity, file/line, message and
hint (internal/config/config.go); --json output, the LSP, the MCP verify
tool and the admin UI all render the same structs.
The load pipeline
LoadBytes(file, data) (internal/config/loader.go) runs four passes:
- Parse + env substitution. The document parses into a
yaml.Node; every string scalar goes through${VAR}/${?VAR}substitution (unset${VAR}is acfg_env_unseterror; unset${?VAR}omits the key; substituted values are never re-scanned — single-pass, ruling D1). - Structural decode into generic maps.
- Constants substitution.
${constants.x}expands (unknown constant = error);${parameters.x}passes through untouched only in job pipelines (run.mode: job) — anywhere else it is acfg_scope_unknownerror. The optional marker?is only legal for plain environment variables. - Structural validation with whitelists: top-level keys,
metadata.namevalidation (metadata is a strictly checked mapping: unknown keys and a non-mapping value are errors), thelimits/run/parameters/hooks/telemetry/codecs/dlqsections, the three node sections with their framework-field whitelists, and finally manifest reads for every external (grpc:) node. The whitelists, the top-level keys, the edge attributes, the reserved plugin names and the node-level default constants all come from the single leaf packageinternal/schema(candidate 06) — config, registry and the LSP read one vocabulary, so a name likegrpcorversioncannot register as a plugin that config would parse as a framework field.
metadata.name is a conservative identifier (cfg_name_invalid): 1–64
characters of [a-zA-Z0-9._-], starting alphanumeric, no .. (path
traversal), and no Windows reserved device name (CON, PRN, AUX, NUL,
COM1-9, LPT1-9) — the name becomes the deployed file name and the store key.
Pipeline.Order is the YAML document order of node declarations
(candidate 06), preserved from the node stream: jobs binds cursor to the
first source in that order, explain picks its default entry node from it and
the diagnostics iterate it, so a shuffled document still behaves like the one
a human reads.
The three-section topology
sources, transforms, sinks — each node is name: {plugin block, ...framework fields}. depends_on edges join them. Sources and sinks are required; the
transforms section is optional. Exact node-level whitelists
(internal/schema, read by internal/config/sections.go) —
| Section | Allowed framework fields |
|---|---|
sources | decoder (default materialized to json), grpc, version (never depends_on) |
transforms | depends_on, workers, version |
sinks | depends_on, encoder (default json), order_key, batch, grpc, version |
— everything else at node level must be exactly one plugin key. workers is
transform-only: sinks.workers was accepted and ignored before and is now
rejected with cfg_sink_workers (sink concurrency is engine-owned; implement
or refuse). Edge attributes on a depends_on element: when, route,
buffer, delivery, required. when accepts a string (CEL) or
{lang: cel|cesql, expr: "..."}; route is sugar compiled to
meta.route == "<name>" and is mutually exclusive with when.
edge_defaults accepts only delivery, required and buffer — a global
default predicate is a footgun and a default route is meaningless, so
when/route there are cfg_edge_defaults_field errors (candidate 06).
Pipeline-level defaults (decoder/encoder json, delivery retries: 3 /
backoff: exponential, required: true, buffer max_events: 128, workers 1)
are materialized into the typed config at load: downstream layers read the
typed field instead of re-applying a default (the json fallback used to be
copied in ten places).
Runtime configuration
Deployment-level settings live in a separate kind: Runtime document
(internal/runtimecfg/runtimecfg.go), resolved from --runtime, then
./eventboat.yaml, then defaults; CLI flags override. The decode is typed
and strict (candidate 06): unknown keys AND type errors are errors
(data_dir: 123, enable: "yes", sample_ratio: -1 used to fall through to
the defaults), and sample_ratio must be in [0, 1] (0 = tracing off).
| Key | Default | Meaning |
|---|---|---|
storage.data_dir | data | SQLite storage directory; each pipeline’s store is <dir>/stores/<sanitized name>.db (candidate 04) |
storage.ephemeral | false | in-memory store, nothing persists |
storage.spool_retention | 10000 | spool rows kept behind the checkpoint |
admin.listen | 127.0.0.1:7788 | admin listener bind address |
admin.enable | true | serve the admin surface (config-dir daemon mode) |
admin.token | "" | bearer token (mandatory for non-loopback binds) |
mcp.enable | true | register /mcp next to the admin surface; false means the daemon does not serve MCP at all (the explicit eventboat mcp --http command always does) |
telemetry.otlp_endpoint | "" | OTLP/HTTP push endpoint (empty = off) |
telemetry.sample_ratio | 0.1 | trace sampler ratio |
telemetry.prometheus | true | serve Prometheus exposition at /metrics |
Verify and IR resolution
Production code builds pipelines only through internal/verify (candidate
05): verify.File/verify.Bytes load, build and judge in one call and
return a Result with the built *ir.Pipeline, the merged diagnostics and
the strict verdict; verify.LoadBytes + verify.Build is the two-stage form
for consumers with a middle step (jobs substitutes parameters between the
stages). ir.Build(cfg, reg, starOpts, parameters) is the build stage it
wraps. Per node, in order:
- Plugin lookup — the plugin key must resolve in the registry
(
plugin_unknown) or, for external nodes, match its manifest (grpc_manifest_name,grpc_builtin_conflictfor compiled-in names). - Version pin check — a node
version:must equal the registered or manifest version (plugin_version_mismatch); this is the §6.5 rule that makes “documented but absent” impossible. - JSON Schema validation — the plugin block validates against the
generated/manifest schema (
plugin_schema). - Factory instantiation — the registry factory builds a real instance
at verify time: the script plugin compiles the Starlark program
(
expr_starlark_compile), the wasm plugin compiles the module and checks ABI exports (expr_wasm_compile), sources/sinks run their typed build functions (cross-field rules JSON Schema cannot express live here).
Transform instances declaring explain-safe are retained on the IR for
explain dry-runs — but only in the retaining build (verify.Options.ForExplain,
used by the CLI/ops explain entries and replay --dry-run); the default
verify-only build validates every instance and closes it immediately, failure
paths included, so nothing outlives a verify. An explain caller owns the
pipeline and closes the retained instances with ir.Pipeline.Close when the
walkthrough is done. Others (wasm — explain never executes guest code) are
always closed immediately. Then: codec resolution
(codec_unknown, codec_config), order-key compilation, topological sort,
job semantics, telemetry pattern checks and lint.
explain renders the resolved semantics (candidate 09), never a
per-plugin re-derivation: the sample message is decoded with the entry
source’s declared decoder (the same codec instance the engine resolves — a
raw/csv source is not fed JSON), each edge predicate is evaluated once per
walk, every inbound edge of a sink is rendered with its own delivery policy
plus the engine’s mixed-batch rule (max retries, max explicit timeout; the
default timeout applies only when no edge in the batch sets one), and a
required: false edge shows its terminal drop — the engine drops and commits
the message instead of dead-lettering it. A wasm symbolic line shows the
resolved per-invoke mode (fast mode (no per-invoke kill switch) when
timeout_ms is unset).
The diagnostics table
Every diagnostic code that exists in the code, by emitting layer. Severity
error fails verify; warning is lint (upgraded to error by
verify --strict). Example messages are abbreviated.
Loader: file, syntax, document shape
| Code | Sev | Fires when | Example |
|---|---|---|---|
io_read | error | the config file cannot be read | open p.yaml: The system cannot find the file specified. |
yaml_parse | error | YAML syntax error, top level not a mapping, or decode failure | top level must be a mapping |
empty_config | error | the document is empty | configuration is empty |
cfg_unknown_top_section | error | unknown top-level key | unknown top-level key "codecs2" |
cfg_api_version | error | apiVersion is not eventboat/v1 | apiVersion must be "eventboat/v1" |
cfg_kind | error | kind is not Pipeline | kind must be "Pipeline" |
cfg_metadata_name | error | metadata.name missing/blank | metadata.name is required |
cfg_metadata_type | error | metadata is not a mapping | metadata must be a mapping |
cfg_name_invalid | error | name violates charset/../reserved-name rules | metadata.name "con" must be 1-64 characters of [a-zA-Z0-9._-]... |
cfg_env_unset | error | ${VAR} references an unset variable | environment variable TOKEN is not set |
cfg_constant_unknown | error | ${constants.x} names an undeclared constant | unknown constant "vip" |
cfg_scope_unknown | error | unknown scope, ${parameters.*} outside a job pipeline, or ? on a scoped reference | unknown scoped reference ${parameters.x}: parameters are only available in job pipelines |
Loader: sections
| Code | Sev | Fires when |
|---|---|---|
cfg_limits_type | error | limits not a mapping; drain_timeout not a duration string |
cfg_limits_range | error | max_in_flight < 1; drain_timeout not positive |
cfg_run_type | error | run not a mapping |
cfg_run_mode | error | run.mode not continuous/job/batch |
cfg_run_schedule | error | empty schedule, or schedule without mode: job |
cfg_run_overlap | error | run.overlap not `skip |
cfg_run_catchup | error | catchup_window not a valid duration |
cfg_run_skip | error | skip_if_successful not a boolean |
cfg_run_retention | error | retention shape/type/range errors |
cfg_dlq_type | error | dlq not a mapping |
cfg_dlq_retention | error | dlq.retention missing/not a duration/not positive |
cfg_telemetry_type | error | telemetry not a mapping |
cfg_telemetry_redact | error | redact not a list of non-empty strings |
cfg_telemetry_span_rate | error | span_sample_rate not a number in [0,1] |
cfg_codecs_type | error | codecs or a declaration not a mapping |
cfg_codec_type | error | codec declaration missing type |
cfg_parameters_type | error | parameters/a declaration not a mapping |
cfg_parameters_not_job | error | parameters: present without run.mode: job |
cfg_parameters_decl | error | any parameter-declaration rule: type mismatch, bad enum/pattern/min/max, default violating constraints, required + default |
cfg_hooks_type | error | hooks not a mapping |
cfg_hooks_sink | error | unknown hook name, or hook not exactly one inline sink block |
cfg_unknown_field | error | unknown field inside metadata, limits, run, retention, dlq, parameters, grpc, batch, edge attribute blocks, when objects, buffer, delivery |
Loader/sections: nodes and edges
| Code | Sev | Fires when |
|---|---|---|
cfg_missing_section | error | sources: or sinks: absent |
cfg_section_type | error | a section is not a mapping |
cfg_empty_section | error | a section present but empty |
cfg_node_type | error | a node is not a mapping |
cfg_source_with_depends_on | error | a source declares depends_on (sources have no in-edges) |
cfg_sink_workers | error | a sink declares workers (not implemented; sink concurrency is engine-owned) |
cfg_from_renamed | error | node still uses the old from key, renamed to depends_on (migration diagnostic) |
cfg_missing_plugin | error | node has no plugin block |
cfg_multiple_plugins | error | node has more than one plugin block |
cfg_plugin_block_type | error | source/sink plugin block is not a mapping |
cfg_missing_depends_on | error | transform/sink without depends_on |
cfg_bad_depends_on | error | depends_on not a name/list/single-key mapping; empty name; multi-key element; non-mapping attrs |
cfg_when_type | error | when empty, not a string/object, or expr empty |
cfg_when_lang | error | when.lang not cel/cesql |
cfg_when_route_exclusive | error | when and route on one edge |
cfg_edge_defaults_field | error | edge_defaults is not a mapping, or declares when/route (only delivery/required/buffer are allowed) |
cfg_route_type | error | route not a non-empty name |
cfg_required_type | error | required not a boolean |
cfg_buffer_type | error | buffer not a mapping; type not memory |
cfg_buffer_range | error | buffer.max_events < 1 |
cfg_delivery_type | error | delivery not a mapping |
cfg_delivery_range | error | retries < 0; timeout_ms < 1 |
cfg_delivery_backoff | error | backoff not exponential/constant |
cfg_decoder_type | error | source decoder not a codec name string |
cfg_encoder_type | error | sink encoder not a codec name string |
cfg_order_key_type | error | sink order_key not a CEL string |
cfg_batch_type | error | batch not a mapping; unknown batch fields |
cfg_batch_range | error | batch.size < 1; timeout_ms < 1 |
cfg_workers_range | error | workers < 1 |
cfg_version_range | error | node version: < 1 |
cfg_grpc_type | error | grpc not a mapping |
cfg_grpc_command | error | grpc.command not a non-empty string argv |
cfg_grpc_env | error | grpc.env value not a string |
cfg_grpc_schema | error | grpc.schema not a manifest path |
cfg_grpc_restart | error | grpc.restart not fast-fail/restart |
topo_dup_name | error | duplicate node name within a section (loader) or across all three sections (ir) |
grpc_manifest_read | error | manifest file unreadable, or path references job parameters |
grpc_manifest_parse | error | manifest is not valid JSON |
grpc_manifest_kind | error | manifest kind mismatches the node’s section |
grpc_manifest_version | error | manifest version < 1 |
grpc_manifest_schema | error | manifest has no config_schema (loader) or schema marshal fails (ir) |
IR: topology, plugins, expressions, jobs, lint
| Code | Sev | Fires when |
|---|---|---|
topo_missing_ref | error | depends_on references an unknown node |
topo_sink_as_upstream | error | depends_on references a sink (sinks have no out-edges) |
topo_cycle | error | the DAG has a cycle |
topo_no_path | error | no source-to-sink path exists |
topo_orphan | error | source with no downstream; non-source with no in-edges |
plugin_unknown | error | plugin key not in the registry (all kinds; transform hint notes gRPC transforms are future work) |
plugin_version_mismatch | error | node version: ≠ registered/manifest version |
plugin_schema | error | plugin block fails its JSON Schema (also hook sink validation) |
grpc_builtin_conflict | error | grpc: block on a compiled-in plugin name |
grpc_manifest_name | error | manifest name ≠ plugin block key |
codec_unknown | error | decoder/encoder names an unknown codec |
codec_config | error | a codec needing configuration is referenced bare instead of via codecs: |
cfg_codec_shadow | error | codecs: declaration name shadows a registered codec |
expr_cel_env | error | the CEL environment cannot be built |
expr_cel_compile | error | a CEL predicate or order key fails to compile |
expr_route_compile | error | a route:-sugared edge’s compiled CEL fails |
expr_route_dangling | error | route: edge whose upstream transform never assigns meta.route |
expr_cesql_compile | error | a CESQL when fails to parse |
expr_starlark_compile | error | script plugin: Starlark resolve failed (via TransformError.DiagCode, with backtrace context) |
expr_wasm_compile | error | wasm plugin: module unreadable, not a reactor, or missing ABI exports (via TransformError.DiagCode) |
wasm_no_kill_switch | warning | wasm transform without timeout_ms: no kill switch (escalated by --strict) |
run_retention_unset | warning | job pipeline without run.retention.history: run history keeps forever (escalated by --strict) |
batch_no_finite_source | warning | run.mode: batch but no source declares finite exhaustion: the run hangs until cancelled (escalated by --strict) |
job_file_source_no_eof | warning | job pipeline file source without on_eof: stop: the run never completes (escalated by --strict) |
job_bad_schedule | error | run.schedule is not a valid 5-field cron |
job_source_not_pull | error | job pipeline source lacks the pull capability |
job_multiple_pull_sources | warning | job pipeline with >1 pull source (cursor binds the first) |
job_parameters_in_continuous | error | parameters.* referenced in a continuous pipeline |
job_parameter_unknown | error | ${parameters.x} names an undeclared parameter (verify; also the runtime backstop in SubstituteParameters) |
telemetry_redact_pattern | error | a telemetry.redact pattern is not a valid field-path glob |
lint_when_literal | warning | edge condition is literally true/false |
lint_constant_unused | warning | a declared constant is never referenced |
lint_sql_continuous | warning | a pull source (sql / pull-capable plugin) used in a continuous pipeline |
lint_line_bytes_over_vl | warning | file source with max_line_bytes > 262144 in a pipeline with a victorialogs sink: VL’s -insert.maxLineSizeBytes default would skip longer lines server-side |
lint_multiline_no_timeout | warning | file source with multiline timeout_ms explicitly 0: no time-based flush, so an isolated trailing group waits for the next group-starting line |
lint_collector_batch_one | warning | file source plus a non-drop/debug sink whose effective batch.size is 1 (unset, or explicit 1): the collection shape should batch |
Retired codes
Spec v1.19 retired the hand-parsed transform diagnostics —
cfg_transform_main_field, cfg_script_type, cfg_split_type,
cfg_wasm_type, cfg_wasm_module, cfg_wasm_range, cfg_wasm_allow —
their checks now surface as cfg_missing_plugin/cfg_multiple_plugins or
plugin_schema issues like every other kind. expr_starlark_compile,
expr_wasm_compile and wasm_no_kill_switch survive via error
classification (see Plugins).
Where diagnostics are consumed
verify/run/testCLI verbs printseverity[code] file:line: messageplus hints;--jsonemits the structs.- The LSP converts the same diagnostics into editor squiggles
(
internal/lsp/diagnostics.go). - The MCP
verifytool returns them with anokverdict so agents iterate;deployfails the tool call when verification fails — verify-first has no bypass.
4.5 - Expressions & scripting sandbox
Eventboat hosts three languages, each with one job: CEL is the default
edge predicate dialect, CESQL is an opt-in CloudEvents-flavored
alternative for edges, and Starlark is the scripting language of the
built-in script transform. All three are sandboxed, deterministic and
side-effect free — a property explain relies on to dry-run pipelines, and
the reason replay produces the same answers as production. The WASM tier
covers compute-heavy transforms that outgrow Starlark.
Edge predicates
An edge condition is declared on depends_on:
The string form is CEL. The object form (internal/config/sections.go)
takes lang: cel (default) or lang: cesql plus a non-empty expr. Both
hosts implement one interface (internal/ir/ir.go, WhenPredicate) with
one error contract: an evaluation error means the condition does not
pass, plus a counter (eventboat_cel_eval_errors_total) — never a panic,
never a silent pass. A fan-out where no edge matches commits the message as
filtered.
CEL host
internal/lang/celhost hosts CEL exactly as-is: no custom functions, no
syntax extensions (§4.2). The activation binds four dyn-typed variables
(celhost.NewEnv / Predicate.evalValue):
| Variable | Contents |
|---|---|
payload | the decoded message payload |
meta | engine-stamped + source metadata (message_id, ingest_time, source, route, …) |
constants | the pipeline’s constants: map |
parameters | job parameters (empty in continuous pipelines — references are rejected at verify) |
The same env compiles sink order_key expressions (result must be a
string).
The cost limit. Every predicate program is built with
cel.CostLimit(1_000_000) (maxEvalCostUnits). The rationale from the code:
the runtime cost model charges ~1 per arithmetic/logical step, per element
for comprehensions, and input length × 0.1 for size-driven operations
(equality/regex/contains), so a realistic predicate over a KB-scale message
lands in the low hundreds; 1e6 leaves an order of magnitude of headroom
while cancelling payload-driven blowups. Exceeding the limit surfaces as an
ordinary EvalError (“operation cancelled: actual cost limit exceeded”) —
so a too-expensive predicate makes the edge not match and increments
eventboat_cel_eval_errors_total; it never aborts the message. Compile
errors (CompileError) are verify findings instead.
CESQL dialect
internal/lang/cesqlhost hosts the opt-in CESQL dialect (§4.7) by reusing
the official CloudEvents SDK parser (cloudevents/sdk-go/sql/v2); the
official TCK is vendored (275 cases) and must stay green in CI. Dialect
rules, per the code:
- Identifiers are alphanumeric only — CESQL’s lexer has no
.token and no underscore, mirroring CloudEvents attribute names. Meta keys containing underscores (kafka_offset,message_id) are therefore unreachable in this dialect; use CEL for those. data.*payload extension is a string-literal-aware pre-parse rewrite to synthetic camelCase identifiers:data.amount→dataAmount,data.a.b→dataAB(rewriteDataPaths). Nested objects flatten up to depth 4; identifiers starting withdata(case-insensitive) are reserved for the extension. Nulls are not injected (they read as missing attributes); arrays and non-scalar leaves become JSON strings.- Meta maps to context attributes:
id,source,type,subject,timeroute through the event writer (required context attributes); everything else becomes an extension. Values map string→string, bool→bool, integral numbers→int32, other numbers→decimal string, arrays/objects→JSON string.
The extension mode is “CESQL subset + documented extension” — never advertised as full CESQL compatibility.
Starlark sandbox
internal/lang/starhost runs transforms.<node>.script — a statement
sequence bound against payload, meta, constants, parameters.
Guarantees, all visible in starhost.go:
- Syntax restrictions (
fileOptions()):syntax.FileOptionsis built withSet: true,TopLevelControl: true(scripts are statement sequences, not function bodies — review R2), andWhile: false,Recursion: false,GlobalReassign: false. Nowhileloops, no recursion, no global reassignment. - Step budget:
thread.SetMaxExecutionSteps(100_000)(DefaultOptions). Exhaustion kills the execution; the host types it at the step-limit hook (starhost.KindSteps— never sniffed from the"too many steps"text) and the engine counts it (eventboat_script_step_budget_exhausted_total). A script that exceeds the budget dead letters after the incoming edge’s retries, with thestepsfailure kind as its dead-letter class. - Load allowlist: exactly
jsonandmath(allowedModules); there is no loadablestringsmodule in go-starlark — string methods are built into the string type. No time, no I/O, no entropy: evaluation is deterministic and replayable. - Predeclared bindings:
payload,meta,constants,parameters(frozen), plussafe_json_decode(s, default)— the one sanctioned escape hatch, returning the default instead of aborting on bad JSON — andremove(dict, key). - Per-message Thread: each execution gets a fresh
starlark.Thread(RunWithParams); payload/meta bind lazily with copy-on-write semantics, so a failed attempt’s writes never leak into a retry. - Precise dirty tracking (beta round): mutations mark through a marker
threaded into the converted dicts, so only written subtrees materialize
back into the message. Container reads no longer dirty; list-bearing
trees dirty conservatively (native Starlark list mutators cannot be
intercepted — the boundary is locked by a dedicated test in
internal/lang/starhost, and a future list wrapper would flip exactly that test). AfterApply, only dirty states are written back to the message (internal/registry/builtin/transform_script.go). fail()behavior: Starlark has no exceptions to swallow; a runtime failure becomesScriptError{Msg, Backtrace, Line}(asScriptError— the innermost frame carries the user-visible line). The engine dead letters the message with the backtrace verbatim; positions render asscript:L:C. Compiled programs are immutable, so one program serves all workers concurrently (script registers withoutTransformCloner).
WASM transforms
The third tier (internal/wasmhost, guest ABI in docs/wasm.md) runs
wasip1 reactor modules under wazero. Guests build with the standard Go
toolchain (GOOS=wasip1 go build -buildmode=c-shared); the ABI is
_initialize, eb_alloc(len), transform(ptr, len) (plus optional
eb_last_error). Wire format: payload as JSON bytes in/out; metadata passes
through untouched (chain a script: node to touch meta).
Resource model — wazero has no instruction metering, so budgets are:
- Memory cap:
max_memory_pages, default 512 pages = 32 MiB (DefaultMaxMemoryPages), always on. - Wall-clock budget:
timeout_msis opt-in. When set, wazero’sCloseOnContextDonekills runaway guests — measured at ~5x throughput cost on loop-heavy guests. When unset (fast mode, the default) there is no kill switch: a runaway guest wedges its one worker until the pipeline restarts. This is a documented tradeoff (M3-audit J2 ruling): a tier whose reason to exist is performance cannot default to slower than Starlark; the risk is availability, not correctness — no data is lost and the eight invariants hold. Guardrails: thewasm_no_kill_switchverify warning (escalated by--strict), and the zero-interference slow-call watchdog (engineWasmSlowCallWarnMs, default 5000ms) logs a wedged invoke once. - Traps/timeouts in protected mode kill the instance, which is transparently
re-instantiated per worker (
TransformCloner— module instances are not goroutine-safe).explaindoes not dry-run wasm guests.
Which layer for which job
- Edge predicate (
when:) for routing decisions: cheap, per-edge, evaluated before delivery. CEL first; CESQL only for CloudEvents interop or teams that already speak it. script:transform for mapping, enrichment, filtering-by-computation and routing viameta.route. The default choice — the ladder’s trigger standard (§4.6) is performance or dependencies, never “complex logic”.wasm:transform only when the ladder justifies it: measured heavy per-message computation (the shipped benchmark: heavy aggregation 2.5ms WASM vs 5.9ms Starlark, ~30,000x fewer allocations in fast mode) or a dependency that only exists as a guest. For light mapping Starlark wins outright.
Scratch evaluation: repl and explain
Two tools run the same hosts outside a live pipeline:
eventboat repl --message sample.json --cel 'payload.total > 100'(or--script fix.star) evaluates one expression/script against a sample; the interactive loop re-executes the accumulated script against the original sample each line — deterministic session semantics, the §4.3 guarantee.eventboat explain --message sample.jsonreally executes scripts and evaluates each outgoing edge against the transformed payload (the sandbox is deterministic and side-effect free, review R10), so the walkthrough shows the answer production would give. A failing script renders its backtrace and the dead-letter consequence. Without--message, nothing executes (symbolic summary only).
The deterministic-replay caveat
The sandboxes are deterministic on purpose: no clocks, no randomness, no
I/O in Starlark; WASI provides fake clocks and no filesystem. This is what
lets explain --message execute scripts for real, lets repl re-execute a
session deterministically, and makes contract tests reproducible. The
caveat runs the other way too: anything nondeterministic you need must
enter through the message — stamps like ingest_time in meta, or
parameters resolved at trigger time — because the pipeline itself will
never draw entropy at run time, and a script that tried to (no such builtin
exists today) would break the replay guarantees the docs make.
4.6 - Observability & operations
Eventboat exposes three operations surfaces that share one implementation:
the MCP tools, the Admin REST endpoints (plus SSE and an embedded read-only
UI), and the --json CLI outputs are all thin shells over
internal/ops.Service — REST, MCP and CLI emit the same Go structs and JSON
shapes. Telemetry is OpenTelemetry with dual export (Prometheus pull +
OTLP/HTTP push); the metric set is fixed and enumerated in
internal/obs/obs.go.
Metrics
One MeterProvider, two readers (internal/obs/obs.go): Prometheus
exposition at /metrics and, when telemetry.otlp_endpoint is set, an
OTLP/HTTP push reader (10s interval). Fully disabled telemetry costs nothing
(noop providers; every Obs helper is nil-receiver safe). Every instrument
carries the eventboat_ prefix (the static set below, plus the per-source
counters created lazily on first use):
Counters
| Metric | Labels | Counts |
|---|---|---|
eventboat_messages_in_total | pipeline, source | messages accepted into the spool |
eventboat_messages_committed_total | pipeline | messages reaching a terminal state |
eventboat_dead_letter_total | pipeline, node, reason_class | dead-lettered messages |
eventboat_dlq_write_failures_total | pipeline | failed dead-letter writes (commit blocked) |
eventboat_cel_eval_errors_total | pipeline, edge, lang | predicate evaluation errors (treated as not-passed); lang is cel or cesql, the name keeps “cel” for M2 continuity |
eventboat_fanout_no_match_total | pipeline, node | messages filtered by zero matching edges (incl. zero transform outputs) |
eventboat_delivery_retries_total | pipeline, node | delivery retry attempts |
eventboat_optional_drops_total | pipeline, edge | drops on failed required: false edges |
eventboat_decode_errors_total | pipeline, source | decode failures at entry |
eventboat_spool_failures_total | pipeline | spool append failures (message not delivered) |
eventboat_backpressure_events_total | pipeline, source | admissions blocked by the high watermark |
eventboat_script_step_budget_exhausted_total | pipeline, node | Starlark executions hitting the 100k step budget |
eventboat_wasm_timeouts_total | pipeline, node | WASM invocations killed by timeout_ms |
eventboat_jobs_started_total | pipeline, trigger | job runs started |
eventboat_jobs_overlap_skipped_total | pipeline | triggers rejected by overlap: skip |
eventboat_jobs_catchup_skipped_total | pipeline | catch-up episodes whose missed ticks fell outside catchup_window (counted once per episode: the catch-up locates the newest in-window tick by bisection and never walks the missed list — candidate 08) |
eventboat_jobs_completed_total | pipeline, status | runs completed by terminal status |
eventboat_job_rows_read_total | pipeline | rows read by job runs |
eventboat_job_rows_delivered_total | pipeline | rows delivered by job runs |
eventboat_plugin_restarts_total | plugin | supervisor respawns of crashed gRPC plugin processes (grpc.restart: restart) |
eventboat_source_<counter>_total | pipeline, node | per-source health counters, created lazily on first use: the file source’s lines_read, lines_skipped, lines_truncated, rotations and multiline_merges (ops diffs the plugin’s monotonic totals on each status snapshot and writes only positive deltas; a source restart re-baselines) |
reason_class is the dead-letter class the engine recorded with the
message (store.DeadLetter.Class) — decode, codec, encode,
delivery, canceled, or a transform failure kind (steps, timeout,
guest, compile, runtime, other). It is never re-derived from the
reason text: candidate 07 deleted the prefix-matching obs.ReasonClass, so
the label values changed ("script" became the failure kind, "encoder"
became codec, and an "encode:" failure is now encode, never other).
Histograms (unit: seconds)
| Metric | Labels | Observes |
|---|---|---|
eventboat_script_duration_seconds | pipeline, node | Starlark script execution |
eventboat_wasm_transform_duration_seconds | pipeline, node | WASM invocations (killed calls never reach it) |
eventboat_sink_write_duration_seconds | pipeline, node | sink batch writes (each attempt) |
eventboat_job_duration_seconds | pipeline, status | job run wall-clock |
eventboat_commit_latency_seconds | pipeline | accept-to-commit latency |
eventboat_spool_append_seconds | pipeline | durable spool append latency, group-commit wait included (P1) |
Gauges
| Metric | Labels | Reports |
|---|---|---|
eventboat_in_flight_messages | pipeline | uncommitted messages in execution |
eventboat_spool_depth | pipeline | spooled messages beyond the checkpoint (arrivedMax - committedThrough) |
eventboat_pipeline_paused | pipeline | 1 when paused, else 0 |
Gauges are pushed on every ops.Status() snapshot (the SSE stream and UI
poll status; /metrics scrapes the recorded values).
Tracing
- One span per job run:
eventboat.job.run, attributespipeline/run_id/trigger, ended with the terminal status and recorded errors (internal/jobs). Script backtraces ride dead-letter records and span error events. - Per-message spans are opt-in:
telemetry.span_sample_rate(default 0 = none, zero cost; 1 = all). Roots are namedeventboat.messagewithpipeline/message_id/source/spool_seqattributes and end at a terminal state —eventboat.terminal_state=committedordead_letter(internal/engine/engine.go,startMessageSpan/finishSpan). Rationale (review R16): no per-message spans at routing rates. The span context is deliberately not propagated through the DAG — correlation rides the attributes. - Trace export goes through OTLP/HTTP when
telemetry.otlp_endpointis configured, sampled bytelemetry.sample_ratio(default 0.1); otherwise the global noop tracer.
OTel wiring
obs.Setup(ctx, cfg) builds the providers from the Runtime config
(internal/runtimecfg): telemetry.prometheus (default true) registers the
Prometheus reader served by the admin listener at /metrics;
telemetry.otlp_endpoint adds the OTLP metric reader (push every 10s) and
the trace exporter. Single-pipeline eventboat run gets OTLP only (no HTTP
surface); run --config-dir and mcp --http serve both readers.
The admin surface
internal/admin serves the whole surface on one mux — Admin REST + SSE +
UI, /metrics, /mcp — behind one security middleware. Auth: an optional
bearer token resolved as --admin-token flag > EVENTBOAT_ADMIN_TOKEN env
admin.tokenin the Runtime config (internal/cli/mcp.go,internal/runtimecfg). With a token set, every request needsAuthorization: Bearer <token>(the?token=query form exists only for the UI’s EventSource and first navigation — it then appears in browser history/proxy logs; use the header from curl/agents). A non-loopbackadmin.listenrefuses to start without a token — the write surface can deploy pipeline YAML whose grpc plugincommand:executes on the host (internal/admin/security.go). Loopback binds additionally enforce a Host allowlist (DNS-rebinding defense); wildcard binds skip it and rely on the mandatory token. JSON request bodies are capped at 8 MiB (maxBodyBytes,internal/admin/admin.go).
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/status.json | status snapshot of every deployed pipeline (mode, per-node list, in-flight, checkpoint, counters, msg/s, recent runs) |
| GET | /admin/jobs/{pipeline}?limit= | job run history |
| GET | /admin/tail/{node}?n= | recent sampled deliveries for one node |
| GET | /admin/dlq/{pipeline}?since=&where= | dead-letter query (since duration, where CEL predicate over {payload, meta} compiled with the pipeline’s constants); response is redacted |
| GET | /admin/catalog.json | the plugin catalog with versions and schemas |
| POST | /admin/deploy | body {"config": <yaml>}; verify-first, deploy rejected on any verify error |
| POST | /admin/trigger/{pipeline} | body {"parameters": {...}, "wait": bool}; fires a job run |
| POST | /admin/replay | body {"pipeline", "ids", "at"}; re-injects dead letters |
| POST | /admin/drain/{pipeline} | stop sources, wait for in-flight commit; stays deployed |
| POST | /admin/pause/{pipeline} | stop the instance (resume from persisted states) |
| POST | /admin/resume/{pipeline} | restart a paused pipeline |
| GET | /admin/sse | Server-Sent Events: deploy/job/status events plus a full status snapshot every second (one payload shape per event type: status always carries the snapshot) |
| GET | /live | liveness: 200 ok while the process serves; token-exempt for kubelet probes |
| GET | /ready | readiness: 200 ready, 503 stopping once shutdown began; token-exempt for kubelet probes |
| GET | /metrics | Prometheus exposition |
| GET/POST/DELETE | /mcp | MCP Streamable HTTP — registered only when mcp.enable is true (candidate 06); the explicit eventboat mcp --http command always serves it |
| GET | /admin/ | the embedded read-only UI (/admin redirects) |
The admin UI is a single embedded HTML page (internal/admin/ui.go):
read-only status console fed by the SSE stream; on a 401 it serves a sign-in
prompt that verifies the token against /admin/status.json and keeps it in
sessionStorage.
The same operations exist as 14 MCP tools (internal/mcpserver):
catalog, verify, test, explain, status, jobs, tail,
dlq_query, deploy, trigger, dlq_replay, drain, pause, resume —
over stdio (eventboat mcp --stdio) or HTTP (--http). verify returns the
same composition the CLI runs (candidate 05: one diagnostic sequence, one
strict verdict), and explain accepts at — the entry node behaves
identically on CLI, MCP and Admin.
Log tailing
ops.Tail samples the most recent deliveries per sink node (bounded
ring of 100 entries, payloads truncated at 512 bytes, is_replay flagged)
via a sink wrapper installed on every managed engine. Entries show the
payload document as delivered (msg.Out, falling back to Raw). Consume it
via GET /admin/tail/{node}, the MCP tail tool, or the UI.
Redaction
The pipeline-level telemetry.redact section lists dot-separated field
paths where every segment is a path.Match glob (payload.user.email,
meta.authorization). Compilation and matching live in
internal/ops/redact.go. Coverage is deliberately presentation-only:
- Tail entries are masked (
"***") before the 512-byte truncation; - DLQ display masks both roots — payload patterns against the raw
document,
meta.*patterns against the meta map — before letters cross the admin REST or MCPdlq_querysurface (ops.redactDeadLetters).
The stored rows stay raw: the spool, dead letters and deliveries are the
data path and are never altered, so DeadLetterReplay re-injects the
original bytes. Non-JSON tails pass through unmasked. A redact pattern that
cannot compile is a verify error (telemetry_redact_pattern) — a bad
pattern must not silently never match.
Engine counters (the non-OTel layer)
Beside the OTel instruments, the engine keeps atomic counters in
engine.Metrics (internal/engine/engine.go): MessagesIn,
CommittedCount (split from the checkpoint pointer, review R5),
CheckpointPtr, DeadLettered, CelEvalErrors, NoMatch, Retries,
DlqFailures, OptionalDrops, DecodeErrors, TransformRuns,
Backpressured, SpoolFailures. These are the M1 bookkeeping layer and the
source of the --json CLI/status output — the OTel instruments record the
same events for Prometheus/OTLP consumers. ops.Status() reads them per
pipeline (messages_in, committed, dead_lettered, checkpoint,
in_flight, messages_per_sec as a delta over the last snapshot window).
Run outcomes and exit codes
Every runner derives its terminal state from one engine decision point
(candidate 02): Engine.Wait classifies the run — completed (quiesced
cleanly), partial (quiesced with dead letters), failed (the engine
stopped itself: worker-fatal or a source failure) or interrupted (the
caller canceled) — and each process maps that onto its own contract:
| Runner | completed | partial | failed | interrupted |
|---|---|---|---|---|
run --config (batch) | 0 | 1 | 1 | 1 |
run --config (continuous) | 0 | 0 | 1 | 0 (SIGTERM/SIGINT) |
trigger (one-shot job) | 0 (success) | 1 (partial) | 1 (failed) | 1 (canceled) |
job scheduler (run, long-lived) | — | — | — (a failed run is history, not a process exit) | 0 (SIGTERM/SIGINT) |
| daemon status | completed | completed | failed + error | shutdown owns the status |
The exit-code split follows the process shape, not the status: a one-shot
run that did not complete exits non-zero, while a long-lived process treats
SIGTERM as a graceful stop. A failed source stops the engine in every
mode (no silent “alive but not consuming”); restart resumes from the source
watermarks — duplicate delivery, never loss. The daemon’s failed status
carries the outcome’s failure text (worker-fatal first, then the first
source error); a batch that fails no longer sits at running with an error.
SSE event types
GET /admin/sse emits hello on connect, then typed events — deploy
(pipeline deployed, replaced instance), job (job run state), status
(pause/resume/drain and a full status snapshot every second, which is also
what pushes the gauges). Slow consumers drop events; snapshots repeat.
HTTP server posture
The admin listener (admin.Serve) sets ReadHeaderTimeout 10s,
ReadTimeout 60s, IdleTimeout 120s — and a deliberately loose
WriteTimeout (15m) because SSE responses are long-lived streams; it caps
their lifetime instead of erroring mid-request (the UI’s EventSource
reconnects).
Operations runbook pointers
- Status polling:
GET /admin/status.jsonor watch/admin/sse; job runs viaGET /admin/jobs/{pipeline}(the status embeds the 5 most recent runs, surfacingrun:<status>for job pipelines). - A wedged WASM worker (fast mode) shows as one transform node stalled with a slow-call watchdog log line; restart clears it.
- Backpressure is visible as
eventboat_backpressure_events_totalclimbing witheventboat_in_flight_messagespinned at the high watermark. - Checkpoint lag is
eventboat_spool_depth; a growing lag with a failing store indicates checkpoint writes failing (replay window widening — the engine keeps committing in memory and retries durability).
4.7 - Testing guide
Eventboat’s testing philosophy: every reliability property is a named,
retrievable test; every externally visible surface (help screens, plugin
schemas) is pinned by a golden file; and anything that claims to work
against the real world (Kafka, long runs) is proven against the real thing
in CI. The full suite is go test ./...; CI runs it with -race.
The test taxonomy
| Layer | Where | What it proves |
|---|---|---|
| Unit tests | beside the code (*_test.go per package) | package-level behavior: loader rules, schema generation, host evaluation, store semantics |
| Engine invariants | internal/engine/invariants_test.go | the eight §6.2 reliability invariants, one test each |
| Engine behavior | internal/engine/*_test.go | delivery, fan-out, split, transform plugins, wasm, CESQL, admission pooling |
| Persistence/recovery | internal/engine/commit_persist_test.go, retention_test.go, internal/store | flush ordering, retention bounds, crash replay |
| Golden tests | internal/cli/testdata/help/, internal/registry/builtin/testdata/schemas/ | CLI help screens; generated plugin JSON Schemas |
| Contract tests | internal/testrun + examples/*/tests/ | pipeline behavior through the real engine via suite YAML |
| Integration | internal/inttests/kafka, internal/inttests/soak | real broker roundtrips; long-run stability (env-gated, need Docker) |
| Benchmarks | internal/engine, internal/lang/*, internal/wasmhost | performance gates and the Starlark-vs-WASM comparison |
| Acceptance | internal/cli/examples_test.go, acceptance_test.go (root), internal/rpcplugin/acceptance_test.go, internal/cli/agent_loop_test.go | every example verifies and passes its suites; the custom-build binary links pkg/plugin plugins and runs them; the third-party gRPC plugin builds and runs; the full agent loop over MCP stdio |
The eight engine invariants
internal/engine/invariants_test.go pins redesign-v3.md §6.2 — these names
are load-bearing; do not rename them:
TestInvariant_SpoolBeforeVisible— a failing spool append refuses the message; it never reaches a sink.TestInvariant_CheckpointAdvancesOnlyAfterCommit— the checkpoint trails committed messages.TestInvariant_Kill9ReplayReplaysAllUncommitted— crash recovery replays a superset of the uncommitted set.TestInvariant_DeadLetterWriteFailureBlocksCommit— a failing dead-letter write never drops the message.TestInvariant_RequiredFalseFailureDoesNotBlockSiblingBranches— optional edge failure isolates to its branch.TestInvariant_RedeliveryKeepsMessageIdStable— duplicate delivery keepsmeta.message_id, so idempotent sinks can dedup.TestInvariant_CursorWatermarkNeverExceedsCommitted— pull-source watermarks only advance over committed messages.TestInvariant_BranchIsolation— fan-out siblings share the underlyingmsg.Decoded/msg.Metamaps, so a transform mutating them in place (the script binding’s lazyremove(), pre-fix) must never be able to race a sibling branch’s encode: the copy-on-write binding isolates the branches, and the process must survive a wide-map fan-out under-race.
The -race policy: the engine and store packages must stay race-clean —
CI runs go test -race ./... on every push. If you add concurrency to either
package, run the race detector locally first; a data race here is a correctness
bug in the durability model, not a hygiene issue.
Golden tests
Three golden surfaces, each with its own regenerate flag:
- CLI help snapshots:
internal/cli/testdata/help/*.txtpin every verb’s help screen. After changing a flag or usage string:go test ./internal/cli -updateand commit the diff. - Plugin schema goldens:
internal/registry/builtin/testdata/schemas/*.jsonpin the JSON Schema generated from every builtin config struct. After changing a config struct:go test ./internal/registry/builtin -update-schemas. The test failure text tells you the flag. - Protobuf descriptor golden:
internal/registry/builtin/testdata/example.descr, regenerated with-update-descr(the protobuf codec test’s fixture; no protoc involved).
A golden diff in a PR must be reviewable line by line — that is the point.
Integration tests (need Docker)
Both suites skip silently without their env var; CI sets it:
EVENTBOAT_SOAK_DURATION bounds the soak run (CI default 25m; nightly plus
manual dispatch via .github/workflows/soak.yml). The wasm tests optionally
honor EVENTBOAT_WASM_GUEST — CI rebuilds the guest from source and points
the env at the fresh artifact; locally the committed .wasm under
internal/wasmhost/testdata/ is used, so plain go test ./... needs no
wasm toolchain.
Benchmarks
scripts/bench-gate.sh is a loose order-of-magnitude gate, not a noise
gate — limits sit ~5–15x above the reference dev machine (i5-14600KF) to
absorb shared-runner variance:
| Benchmark | Package | Gate |
|---|---|---|
BenchmarkPredicateEval | internal/lang/celhost | ≤ 5000 ns/op |
BenchmarkSimpleScript | internal/lang/starhost | ≤ 20000 ns/op |
BenchmarkContainerReadOnly | internal/lang/starhost | ≤ 20000 ns/op |
BenchmarkCommitThroughput/mem | internal/engine | ≤ 100000 ns/op (the 100µs gate) |
It covers the per-message hot paths (predicate eval, script exec, commit
throughput against the memory store) and deliberately does not cover
WASM (runner variance too high — BenchmarkHeavyTransform in
internal/wasmhost stays informational) or SQLite-backed throughput. What a
gate failure means: an algorithmic regression landed; profile before
optimizing.
testkit and testrun
internal/testkit provides the deterministic primitives engine and contract
tests build on:
ManualSource— a source whoseEmitblocks until the engine accepted the message (spooled + dispatched), making tests deterministic;Commitrecords the cursor watermark like a real pull source would.CaptureSink/Recorder— capture delivered messages;DiscardSink;FlakySink— fault-inject sink writes by attempt count.StoreWrapper— hookAppendSpool/WriteDeadLetter/SetCheckpointto inject store faults.FixedClock/CounterID— frozen clock and deterministic IDs.RegisterFakeTransform/TransformFunc— register a function as a transform plugin with an accept-anything schema (see Plugins).
internal/testrun is the contract-test runner: it builds the pipeline IR,
runs each case against a real engine with an ephemeral memory store,
fixed clock, capture-wrapped sinks (the plugin sink never touches the real
world), injects at the named node, waits for commit, and evaluates
expectations (in-order subset matching on payload.*/meta.* dotted paths,
DLQ count/reason). Job pipelines run with sources disabled so injections at
the pull node are deterministic.
The suite format
The project convention is <root>/pipeline.yaml + <root>/tests/<suite>.yaml
(+ tests/fixtures/); the suite references its pipeline as
pipeline: ../pipeline.yaml (path resolution is contained within the suite
root — an escaping path is an error):
eventboat test <path> consumes it: a file recurses (.yaml files without
a top-level suite: key are skipped and counted; unreadable/unparseable
YAML is a hard error, ruling D3). Exit codes: failing cases → 1; a directory
with no valid suites but broken files → 2 (ruling D4).
Practical recipes
Run the fast loop (seconds, no Docker):
Run everything locally before a PR:
Add a regression test for a delivery bug: reproduce with the smallest
pipeline YAML (see invYAML in invariants_test.go for the minimal
shape), drive it with testkit.ManualSource + CaptureSink +
StoreWrapper fault hooks, assert on Recorder.Captured() or the store’s
dead letters. If it touches commit/checkpoint ordering, check whether one of
the eight invariants already covers the class — extend that test’s scenario
instead of asserting something weaker.
Add a contract test for a user-visible pipeline behavior: write the
suite under examples/<pipeline>/tests/, fixtures under tests/fixtures/,
and rely on the CI examples gate (internal/cli/examples_test.go) to keep
it green.
4.8 - Building & release
Eventboat builds with the standard Go toolchain and no code generation step
in the normal path: go build ./... is the whole story for contributors.
This page covers the local build, the container image, the CI workflows,
and how releases are cut.
Prerequisites
- Go 1.25 (matches
go.mod; the CI jobs pin the same version). - Docker — only for the env-gated integration suites (the Kafka testcontainers test) and for building/running the container image.
- Nothing else: all drivers are pure Go (SQLite via
modernc.org/sqlite, Postgres via pgx, Kafka via kafka-go), so CGO is never required.
Local build and run
The example job pipeline’s sqlite source database regenerates with
go run ./examples/job-sync/seed. The WASM test guest is committed as a
built artifact (internal/wasmhost/testdata/aggregate.wasm), so plain
go test ./... needs no wasm toolchain; rebuild it with
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../aggregate.wasm .
from internal/wasmhost/testdata/guest (CI does exactly this).
The container image
The Dockerfile is two stages:
- Build:
golang:1.25-alpine,CGO_ENABLED=0 go build -trimpath— the pure-Go dependency set is what makes the static build possible. - Runtime:
gcr.io/distroless/static-debian12:nonroot, binary at/usr/local/bin/eventboat, running asnonroot(uid 65532).
/pipelines and /data are the conventional mount points (config dir and
SQLite volume — examples/k8s/deployment.yaml mounts exactly these; .keep
placeholders carry the nonroot-owned directories through COPY and keep
unmounted smoke runs working). No CMD: a bare docker run prints the help
screen and exits 0.
Development data layout
Where the entry points put things on disk (all under --data-dir, default
data; --ephemeral replaces the SQLite store with a cached in-memory one):
| Path | Written by | Contents |
|---|---|---|
data/stores/<name>.db | every entry point via store.Owner | one SQLite store per pipeline: spool, checkpoint, source states, dead letters, job history |
data/pipelines/<name>.yaml | ops.Deploy | the deployed config — persisted verbatim, reloaded on restart and per job run |
The canonical layout is data/stores/<sanitized pipeline>.db (candidate 04):
the one-shot verbs (trigger, replay, jobs, run --config) and the
daemon (run --config-dir, mcp --http) all open the SAME per-pipeline
file, so a run history written by trigger is the one jobs list and the
daemon’s Status read. The name is sanitized to a conservative charset and
checked against the Windows reserved device names (internal/fsname, the
same rules the loader applies). The pre-candidate-04 layouts —
data/eventboat.db and data/stores/pipeline.db — are retired without
migration (beta ruling): the old files are simply no longer read.
The per-pipeline name is the store file namespace, which is why
metadata.name is validated so strictly (cfg_name_invalid).
Regenerating protocol code
pkg/pluginproto is generated from proto/eventboat/plugin/v1/plugin.proto.
Regenerate it only with the pinned protoc-gen-go toolchain the existing
stubs were built with (see the git history for the toolchain version), and
expect a matching ABI bump ripple: external plugins import the generated
stubs, so a regeneration that changes the surface means external plugins
must be rebuilt — record that in the CHANGELOG entry.
GHCR publishing
.github/workflows/docker.yml builds linux/amd64 + linux/arm64 (QEMU +
buildx; CGO off makes cross-builds plain Go cross-compilation) and pushes
ghcr.io/eventboat/eventboat:
| Trigger | Tags |
|---|---|
push to main | :main and :sha-<short> |
tag v1.2.3 | :1.2.3 (semver {{version}}) |
| pull request | build validation only, nothing pushed |
There is deliberately no moving 0.x tag while pre-1.0: a floating
0.2 must not silently point at an rc of a later patch line.
CI workflows
.github/workflows/ci.yml runs on every push to main and every PR:
- test — build, vet, rebuild the WASM guest from source, then
go test -race ./...(includes the eight invariants, the examples verify/test gate, the gRPC plugin acceptance, the CESQL TCK, the wasm tests; the env-gated integration packages skip without their env). - lint — golangci-lint v2 (v2.13.2) with the repo config
(
.golangci.yml): standard set + misspell + gofmt; exclusions are justified in the config. - kafka-integration — the real-broker suite
(
EVENTBOAT_KAFKA_TEST=1, testcontainers). - bench —
scripts/bench-gate.sh(the loose threshold gate) plus the informational Starlark-vs-WASM benchmark.
.github/workflows/soak.yml — nightly (02:30 UTC) plus manual dispatch:
the soak suite under mixed load with injected faults, duration controlled by
the workflow input (default 25m).
The docs site is published by a GitHub Pages workflow (.github/workflows/pages.yml):
it builds and deploys the site from docs/ changes — content pages and the
generator — so docs edits go live without a release. The rendered result
lives at eventboat.dev/eventboat/ (the project-pages subpath under the
organization domain).
The public docs site (eventboat.dev, the eventboat/eventboat.github.io
Hugo repo) consumes this repo’s developer guides at ITS build time: its
Pages workflow checks out eventboat/eventboat and runs
tools/sitegen -hugo-out to render docs/developer/*.md into Hugo content
under content/docs/developer/, then builds Hugo as usual — plus a daily
scheduled rebuild to pick up upstream doc changes. Nothing is copied into
that repo; this repo stays the single source of truth, so a guide edit here
is live there after the next org-site build (push-triggered or daily).
The editor story
The LSP (eventboat lsp) works with any editor; a minimal VS Code launcher
lives in examples/editors/vscode — it spawns the binary over stdio and
wires diagnostics, completion and hover through. There is no marketplace
extension; the example is the integration.
Release process
- CHANGELOG discipline: every user-visible change lands under
## UnreleasedinCHANGELOG.mdat the time of the commit (Keep a Changelog format; the repo’s entries carry the rationale, not just the diff summary). Look at the git log — commits like the Settled→Commit rename ship with their full old→new mapping in the entry. - Cutting a release: consolidate
Unreleasedinto a dated version section, tagv<semver>, push the tag. The tag triggers docker.yml, which publishes:<version>to GHCR. Pre-releases use rc suffixes following semver (v0.1.0-beta,v0.2.0-rc1in the tag history); dependency bumps or contract changes that would break plugins ride an rc first (the v0.2.0-rc1 release bumped lynx-go/commands for a contract fix). - The binary is the release artifact for non-container users:
CGO_ENABLED=0 go build -trimpath -o eventboat ./cmd/eventboaton the supported targets (linux, macOS, the BSDs, Windows); the only platform-specific code paths are the store lease’s file locks (flock/LockFileEx), and a target without a lock implementation refuses to run a pipeline loudly rather than skipping the single-writer guarantee.
tools/sitegen — the docs site generator
tools/sitegen (in progress) is the documentation site generator: a
separate Go module under tools/, so the main module’s dependency tree
stays untouched. Run it with go run ./tools/sitegen from within tools/.
It renders the Markdown under docs/ into a static site with no CDN
dependencies — no external JS or fonts — because the site must render
anywhere, including air-gapped environments. Frontmatter (title, order)
drives page titles and ordering; relative links within docs/ resolve as
site links; code blocks are plain ASCII-safe fences.
4.9 - Contributing
This repo has a strong house style — it is what keeps a codebase with this many guarantees reviewable. Read this page before your first PR; most of it is enforceable by CI, but the commit message convention and the comment voice are reviewed by humans.
Dev workflow
- Branch from
main; keep one logical change per commit — the git log reads as a series of self-contained rationales, and bisectability depends on it. - Run the fast loop before pushing:
go build ./...,go test -race ./...(CI runs the race build on every push), andbash scripts/bench-gate.shif you touched a hot path. - User-visible changes get a
CHANGELOG.mdUnreleased entry at commit time, not at release time. Entries carry aChanged/Added/Fixed/Removedheading and the same paragraph voice as the commit — the Settled→Commit rename entry, for example, includes the complete old→new mapping because external consumers needed it to migrate.
The commit message convention
This repo’s commits are single, detailed, paragraph-led messages written in
the imperative that explain WHAT and WHY, with concrete file and identifier
references. No one-line subjects, no bullet laundry lists (a short list is
acceptable inside the paragraph when enumerating parallel changes). Read a
few from git log — e.g. the Rename Settled → Commit everywhere commit or
the Promote transforms to registered plugins commit — they record the
motivation, the ruling that decided contested points, the migration/compat
story, and the verification done (which tests were run). A good test: could
a reviewer reconstruct the change from the message alone in a year?
Rules of thumb distilled from the log:
- First sentence = the change, imperative (“Add …”, “Fix …”, “Remove …”, “Rename …”).
- Then the why, naming the ruling or review finding that decided it (e.g. “review R6”, “M3-audit J2 ruling”) — see Design discussion below.
- Then the mechanics, with real file paths and identifiers
(
internal/engine/commit.go,RegisterTransformT,plugin_schema). - End with what was verified (“full suite passes incl. -race on the engine”).
Code conventions
gofmt and golangci-lint clean.
.golangci.ymlis deliberately small: the standard set (errcheck, govet, ineffassign, staticcheck, unused) plus misspell and gofmt — a hygiene floor, not a style machine. Exclusions (generated stubs inpkg/pluginproto, the vendored CESQL TCK, a quickfix class) must be justified in the config; new linters join with a ruling.Comment voice: comments state constraints and rationale, never narrate the obvious. A representative example,
internal/registry/builtin/kafka.go:Commit runs on every frontier advance (~per message), so the scan must start at the watermark, not at 1: the scan itself deletes every seq it visits, which keeps each call O(new work) instead of O(all emissions).
The comment gives the constraint (called per message), the decision (scan from the watermark), and the consequence (O(new work)). “increments the counter” comments do not exist here. If a comment only restates the code, delete it; if a constraint lives only in a review document, move the essential sentence into the code.
The full-name principle for YAML vocabulary. Config keys are full words in snake_case —
max_in_flight,drain_timeout,skip_if_successful,catchup_window,span_sample_rate— never abbreviations. The single recorded exception isdlq(retained industry-wide, spec v1.6). New keys follow the same rule; if you are tempted to abbreviate, the key is probably too long.Error wrapping with
%wso classification survives: the engine unwrapsregistry.TransformErrorwitherrors.As, and store/registry errors are matched by identity, not string comparison. Wrap, never re-format, an error you pass along.
The “when you touch X you must also Y” checklist
| You touch… | You must also… |
|---|---|
| a plugin config struct (new field) | add schema tags; regenerate the goldens (go test ./internal/registry/builtin -update-schemas); check whether a runtimecfg/registry test pins the unknown-key behavior |
| a CLI flag or usage string | regenerate help goldens (go test ./internal/cli -update) |
| a metric | add the instrument in internal/obs/obs.go; record it where the event happens; this developer guide’s metrics table |
| a diagnostic code | add it to the diagnostics table in Configuration & diagnostics; codes are API — do not rename, retire with a mapping in the CHANGELOG |
| the engine’s delivery/commit paths | keep the eight invariants green and run -race; extend the relevant TestInvariant_* scenario if you changed a guarantee |
| the spool/checkpoint format or retention | the retention tests (internal/engine/retention_test.go) and the kill-9 replay invariant |
| a user-visible behavior | a CHANGELOG Unreleased entry in the same commit |
Adding a builtin plugin
The checklist for a new compiled-in plugin (source, sink, codec or transform) — every step has a test that fails until you do it:
- Define one config struct:
jsontags name the keys,schematags the constraints (Plugin system). - Write the register function (typed:
RegisterSourceT/RegisterSinkT/RegisterCodecT/RegisterTransformT) and wire it intoRegisterAll(internal/registry/builtin/register.go). Cross-field rules JSON Schema cannot express stay in the build function. - Regenerate the schema goldens (
go test ./internal/registry/builtin -update-schemas) and review the diff. - Catalog surfaces are automatic —
plugin catalog,plugin schema, the MCPcatalogtool and the LSP completion read the registry — but their tests may need the new entry. - Codecs add a conformance case (
codecs_conformance_test.go); the protobuf codec’s descriptor fixture regenerates with-update-descr. - Remember the name rules: the plugin name must not collide with framework fields (registration rejects it) and should follow the full-name principle below.
PR expectations
- Full suite green (
go test ./...), with-raceon the engine and store packages (CI does this; run it locally first). - The examples gate stays green: every pipeline under
examples/must verify and pass its contract suites (internal/cli/examples_test.goruns it in CI). - Golden diffs (help screens, plugin schemas) present and reviewed line by line — a schema golden diff that “just regenerates” without discussion is a red flag.
- New public surface (CLI verb, metric, config key, diagnostic code) follows the checklist above.
Where design discussion lives
redesign-v3.md is the spec and the single source of truth. Design
discussion has a discipline: contested points are resolved as numbered
rulings recorded in the spec’s revision log (and echoed in README
decision ledgers), citing the review round that raised them
(redesign-v3-review*.md). If your change settles a design question, add
the ruling — reviewers should be able to trace every non-obvious decision
to a dated entry. Dated review documents keep their historical wording even
after later renames (the Settled→Commit rename explicitly left them alone).
Licensing
Eventboat is Apache-2.0 (LICENSE). By contributing you agree your
contributions are licensed under the same terms. Keep the generated protocol
stubs in pkg/pluginproto regenerable from
proto/eventboat/plugin/v1/plugin.proto — do not hand-edit them.