Documentation
¶
Overview ¶
Package engine is the dwarf workflow-orchestration engine.
The engine executes workflow graphs against a SQL database, scheduling and dispatching one task at a time per step, persisting state between steps, and driving fan-out/fan-in, retries, sleeps, subgraphs, and interrupts. It owns no transport of its own; a host wires it to the outside world through injected dependency interfaces and calls its operations.
Lifecycle ¶
Build an engine with NewEngine and the Set* methods, register a Host (see SetHost), then Startup (opens the database, runs migrations, starts workers). Shutdown drains the workers and closes the database. In tests, NewEngineUnderTest wires isolated, auto-dropped SQLite databases keyed by a test name, so a test just configures the engine, calls Startup, and defers Shutdown (see NewEngineUnderTest).
eng := engine.NewEngine()
eng.SetShard(ShardSpec{Index: 1, DSN: "postgres://user:pass@host:5432/dwarf"})
eng.SetHost(host)
err := eng.Startup(ctx)
if err != nil { ... }
defer eng.Shutdown(ctx)
Each Set* method returns an error. The live ones (SetMaxOpenConns - an expert override, SetTimeBudget, SetDefaultPriority) take effect immediately on a running engine; the construction-time-only ones (SetShard, SetWorkers, SetHost, SetLogger, SetMeterProvider, SetTracerProvider) return an error if called after Startup. Tuning derives from the facts the host declares plus what the engine observes: ShardSpec.VirtualCPUs drives each shard's connection budget and its placement weight; the budget is automatically split across the engine replicas sharing each database, counted from a shared peer registry the replicas heartbeat into (no replica count is ever declared, and nothing is sent between replicas to establish it); and the worker maximum is derived from the crash-recovery lease margin and the round-trip time measured at Startup, so it holds for any task duration (the pool grows into it only on demand, so short-task deployments never pay for the headroom). The SetWorkers/SetMaxOpenConns overrides exist for tests, benchmarks, and externally-constrained hosts.
Host ¶
The engine reaches the outside world through a single injected Host interface (see SetHost):
- LoadGraph fetches a workflow graph by name (called at Create; the graph JSON is then frozen on the flow), and on subgraph spawn.
- ExecuteTask executes one task, given the Flow carrier with its state pre-populated.
That is the whole interface: the engine sends nothing between replicas, so a host wires no inter-replica transport for it. Replicas sharing a database coordinate by reading it.
The flow's opaque baggage (host identity/tenant/context, set in workflow.FlowOptions) rides on the dispatch context of every LoadGraph and ExecuteTask call; read it with workflow.BaggageFrom(ctx).
Operations ¶
Create makes a flow and runs it; Await blocks until it stops; Run is Create+Await in one call. Snapshot/History/Step/List inspect; Resume continues a paused flow; Cancel/Continue manage lifecycle; Fork clones a terminal flow from a chosen step into a new flow for non-destructive recovery; Delete/Purge retain. See the repository's docs/ directory for guides.
Security model ¶
Flow and step keys ("{shard}-{id}-{token}") are unguessable bearer capabilities, not authorization. Holding a flow key is by itself sufficient to act on that one flow — Resume, Cancel, Fork, Continue, Delete, and every introspection call — with no further check: the sole gate is the key (the numeric id plus its random flow_token). The engine performs no authentication, authorization, or rate limiting and has no notion of caller identity; its only vantage is the flow reference and the task URL, so ownership and tenancy are invisible to it. Authorizing an operation is therefore the host's responsibility: before calling the engine, verify the authenticated principal may act on the flow — typically from the baggage the host set at Create (see workflow.FlowOptions.Baggage), or the host's own record mapping a principal to the keys it was issued.
The token defends only against reference forgery and id enumeration: flow ids are sequential, so without the token a caller cannot fabricate a key for a flow it was never handed. That is defense in depth, not access control — a leaked, logged, or shared key grants its bearer full write access to that one flow, so treat a key like a password. The engine does not emit keys to traces or logs (telemetry carries only a token-free "{shard}-{id}" correlation id), and there is deliberately no operation that resolves a correlation id back to a key, which would be a capability-minting oracle.
List returns keys, tokens included — the only key-returning operation (Purge returns a count, not keys; free-text search is a field on workflow.Query consumed by List, not a separate operation). Exposing them to a principal is equivalent to granting the write capability for every flow they return, so a host must gate them by ownership and never surface them to less-than-fully-trusted callers. Key exposure is also transitive across an execution tree: Step and History navigation resolve and return the keys of neighboring steps, crossing flow boundaries into parent and subgraph-child flows, so a single step key reaches the whole tree (each neighbor key both discloses that step's state and can seed a Fork). Authorizing introspection by one flow's ownership is therefore insufficient when the caller holds any step key in that tree; treat the tree (its root) as the authorization unit, or restrict these surfaces to fully-trusted operators. Operations on an unknown or mismatched key return a uniform not-found (no existence oracle), but that is a hardening detail, not a substitute for host authorization.
Resource limits ¶
The engine imposes no size or count limits — not on initial state, baggage, the frozen graph JSON, interrupt/resume payloads, forEach fan-out width (one step row per array element), or subgraph nesting depth. This is deliberate, the same division of labor as backpressure and time budgets: state size is workload-defined (a document-processing workflow may legitimately carry tens of megabytes per flow), and no single cap fits both that and a small control flow. Bounding resource use is therefore the host's job — it holds the caller identity and tenancy the engine cannot see. A host enforces quotas where it has that context: reject an over-large initial state or Baggage before Create, cap forEach input arrays in author space, and bound its own retention (Purge deletes at most 4096 roots per call, so a retention job loops). For a pass-through host that adds no policy of its own, this obligation flows through to the application using that host. (Deep subgraph nesting is bounded storage, not a crash vector: Fork clones the tree iteratively, so nesting depth costs no goroutine stack.)
Example ¶
Wire an engine to a host, then create, start, and await a flow.
package main
import (
"context"
"fmt"
"github.com/microbus-io/dwarf/engine"
"github.com/microbus-io/dwarf/workflow"
)
// exampleHost implements engine.Host. A real host loads graphs from a registry/file/database/RPC and
// dispatches tasks over a local call, RPC, or message bus; here an in-memory registry and a local
// function stand in. LoadGraph and ExecuteTask are required; the remaining Host methods (flow-stop
// notification and the cross-replica signals) are left as no-ops.
type exampleHost struct {
graphs map[string]*workflow.Graph
}
func (h exampleHost) LoadGraph(ctx context.Context, name string) (*workflow.Graph, error) {
return h.graphs[name], nil
}
func (h exampleHost) ExecuteTask(ctx context.Context, taskName string, f *workflow.Flow) error {
f.SetString("greeting", "hello "+f.GetString("name"))
return nil
}
func main() {
ctx := context.Background()
graphs := map[string]*workflow.Graph{}
g := workflow.NewGraph("Greet")
g.SetEndpoint("Hello", "Hello")
g.AddTransition("Hello", workflow.END)
graphs["greet"] = g
eng := engine.NewEngine()
eng.SetShard(engine.ShardSpec{Index: 1, DSN: "postgres://user:pass@localhost:5432/dwarf"})
eng.SetHost(exampleHost{graphs: graphs})
err := eng.Startup(ctx)
if err != nil {
panic(err)
}
defer eng.Shutdown(ctx)
// Run is Create + Await in one call.
_, out, err := eng.Run(ctx, "greet", map[string]any{"name": "ada"}, nil)
if err != nil {
panic(err)
}
fmt.Println(out.State.GetString("greeting"))
}
Output:
Index ¶
- Constants
- type Engine
- func (e *Engine) Await(ctx context.Context, flowKey string) (*workflow.FlowOutcome, error)
- func (e *Engine) Cancel(ctx context.Context, flowKey string, reason string) error
- func (e *Engine) Continue(ctx context.Context, threadKey string, additionalState any) (string, error)
- func (e *Engine) Create(ctx context.Context, workflowURL string, initialState any, ...) (flowKey string, err error)
- func (e *Engine) DB() *database.ShardSet
- func (e *Engine) Delete(ctx context.Context, flowKey string) error
- func (e *Engine) Fingerprint(ctx context.Context, flowKey string) (fingerprint string, status string, err error)
- func (e *Engine) FlowsStarted() int64
- func (e *Engine) FlowsTerminated() int64
- func (e *Engine) Fork(ctx context.Context, stepKey string, stateOverrides any) (string, error)
- func (e *Engine) History(ctx context.Context, flowKey string) ([]workflow.FlowStep, error)
- func (e *Engine) HistoryMermaid(ctx context.Context, flowKey string, w io.StringWriter) error
- func (e *Engine) List(ctx context.Context, query workflow.Query) ([]workflow.FlowSummary, string, error)
- func (e *Engine) Poll(ctx context.Context, flowKey string) (*workflow.FlowOutcome, error)
- func (e *Engine) Purge(ctx context.Context, query workflow.Query) (int, error)
- func (e *Engine) Resume(ctx context.Context, flowKey string, resumeData any) error
- func (e *Engine) Run(ctx context.Context, workflowURL string, initialState any, ...) (flowKey string, outcome *workflow.FlowOutcome, err error)
- func (e *Engine) Seams() *seamster.Seamster
- func (e *Engine) SetDebugLogger() error
- func (e *Engine) SetDefaultPriority(p int) error
- func (e *Engine) SetEngineID(id int64) error
- func (e *Engine) SetHost(h Host) error
- func (e *Engine) SetLogger(l *slog.Logger) error
- func (e *Engine) SetMaxOpenConns(n int) error
- func (e *Engine) SetMeterProvider(mp metric.MeterProvider) error
- func (e *Engine) SetRefillInterval(d time.Duration) error
- func (e *Engine) SetShard(spec ShardSpec) error
- func (e *Engine) SetTimeBudget(d time.Duration) error
- func (e *Engine) SetTracerProvider(tp trace.TracerProvider) error
- func (e *Engine) SetWorkers(n int) error
- func (e *Engine) ShardInfo(ctx context.Context) ([]ShardSummary, error)
- func (e *Engine) Shutdown(ctx context.Context) error
- func (e *Engine) Snapshot(ctx context.Context, flowKey string) (*workflow.FlowOutcome, error)
- func (e *Engine) Startup(ctx context.Context) error
- func (e *Engine) Step(ctx context.Context, stepKey string) (*workflow.FlowStep, error)
- type Host
- type ShardSpec
- type ShardSummary
- type TaskHandler
- type TestProxy
- func (p *TestProxy) ExecuteTask(ctx context.Context, taskURL string, flow *workflow.Flow) error
- func (p *TestProxy) HandleGraph(name string, graph *workflow.Graph)
- func (p *TestProxy) HandleTask(name string, handler TaskHandler)
- func (p *TestProxy) LoadGraph(ctx context.Context, workflowURL string) (*workflow.Graph, error)
Examples ¶
Constants ¶
const ( // Scoped by task name (the graph node name passed to ExecuteTask): FaultExecuteTask = "executeTask" // ExecuteTask returns a synthetic error (as if the task failed) FaultPanicExecuteTask = "panicExecuteTask" // ExecuteTask panics (exercises host-call panic isolation) // Scoped by workflow URL: FaultLoadGraph = "loadGraph" // LoadGraph returns a synthetic error // Scoped by task name; force the persistence/transition steps of a dispatch to fail: FaultTransitionCommit = "transitionCommit" // the post-completion transition transaction errors FaultCompleteFlowCommit = "completeFlowCommit" // the flow-completion transaction errors FaultContention = "contention" // a dispatch transaction returns a lock-contention error FaultLeaseStaleWrite = "leaseStaleWrite" // the completion write carries a stale lease_seq (zombie) FaultPersistErr = "persistErr" // the step-completion write returns a non-contention database error (consumed per attempt, so InjectN sets how many attempts fail) FaultSubgraphSpawnErr = "subgraphSpawnErr" // createSubgraphFlow errors after the caller step parked // Scoped by workflow URL of the subgraph child: FaultSubgraphReviveLost = "subgraphReviveLost" // completeSurgraphFlow skips reviving the parked caller // Process-wide, consumed per attempt (InjectN sets how many attempts fail): FaultCompleteSurgraphErr = "completeSurgraphErr" // completeSurgraphFlow returns a non-contention database error // Process-wide (no scope): FaultInterruptStaleWrite = "interruptStaleWrite" // handleInterrupt's in-tx leaf lease_seq read is forced to mismatch (zombie) FaultInterruptChainWrite = "interruptChainWrite" // handleInterrupt's combined chain UPDATE fails and applies nothing (deadlock victim) FaultDropSignalStop = "dropSignalStop" // signalStop delivers nothing (lost terminal wake) FaultDropDoorbell = "dropDoorbell" // the local work doorbell is dropped (the step waits for a refiller scan) FaultRecoveryResetErr = "recoveryResetErr" // the processStep recovery defer's own reset UPDATE errors FaultReapMidTree = "reapMidTree" // the reaper errors after deleting steps, before flows FaultReapSelectErr = "reapSelectErr" // the reaper's due-root SELECT errors FaultRefillScanErr = piston.FaultScanErr // the piston's priority-band scan errors (its name, so there is one catalogue) FaultPeerReadErr = peers.FaultReadErr // a shard's peer-registry reading fails: this replica goes BLIND on that shard (their names, so there is one catalogue) FaultPeerBeatErr = peers.FaultBeatErr // a shard's peer-registry beat writes nothing: this replica stops proving its liveness THERE, while running on FaultSlowPoolPush = "slowPoolPush" // recomputePools stalls between reading R and pushing the derived sizes FaultDeliverFailureErr = "deliverFailureErr" // deliverFlowFailureToParent drops the parked-caller re-dispatch (lost delivery); unscoped, or scoped by the parked caller's task name for per-level control FaultCancelCommit = "cancelCommit" // the Cancel transaction errors FaultResumeCommit = "resumeCommit" // the Resume transaction errors FaultForkCommit = "forkCommit" // the Fork clone transaction errors )
--- Fault injection ---
const ( CheckpointResumeBeforeFlowWrite = "resumeBeforeFlowWrite" // resume(), just before its transaction's flow-status gate write CheckpointBeforeTransitionTx = "beforeTransitionTx" // processStep, after the step is marked completed, before the transition transaction CheckpointAfterCallerPark = "afterCallerPark" // processStep, after the subgraph caller step is parked, before createSubgraphFlow CheckpointBeforeRetryRewind = "beforeRetryRewind" // processStep, before the flow.Retry rewind transaction CheckpointBeforeCompleteFlowWrite = "beforeCompleteFlowWrite" // completeFlow(), just before its transaction's status-gate write CheckpointBeforeDeleteWrite = "beforeDeleteWrite" // deleteFlow(), just before its transaction's delete-stamp/interrupted-CAS write CheckpointBeforeReviveWrite = "beforeReviveWrite" // completeSurgraphFlow(), just before its transaction's caller-revive write CheckpointBeforeRecoveryReset = "beforeRecoveryReset" // processStep recovery defer, just before its fenced step-reset transaction // A COUNTING checkpoint (read with Visits, never a rendezvous), scoped by flow id so concurrent flows // count independently. No test arms Wait/Break on it, so Checkpoint just increments and returns; both the // count site (execution.go) and the read site (faninflowlock_test.go) consult e.seams directly. // // It exists for a single pin, and that pin guards a performance property no correctness test can see: a // NON-FINAL cohort arrival must issue ZERO flow-row statements. Grabbing the flow row for every arrival // serializes an entire cohort on one row (measured at fan-out width 64: 20 of 43 active backends queued on // that one statement), so the grab is deferred to the arrival that actually resolves the cohort. Nothing // about the flow's OUTCOME changes if someone reintroduces a per-arrival flow-row write - the fan-in still // fires and every existing fixture still passes - it just costs ~20% throughput silently. Hence a counter // rather than a state assertion. // // Counts are per flow and cumulative across the flow's whole life (Visits never resets), so a test reads // the delta it cares about. A Transact contention retry re-runs the closure and legitimately re-counts, so // a test asserting an exact count must be single-worker and contention-free. CheckpointFlowRowWrite = "flowRowWrite" // a dwarf_flows UPDATE issued by the transition transaction // Lifecycle rendezvous (fired at an event, used with Wait - not a freeze site): lets a test wait for // exact engine progress instead of polling status / sleeping. signalStop fires it BOTH unscoped and scoped // by (flowKey, status): the unscoped name catches whichever flow stops first, which is all a single-flow // test needs, while the scoped one lets a test wait for ONE flow to reach ONE status while other flows (a // subgraph child, a fan-out sibling, a peer replica's work) stop concurrently. Both are needed because a // scoped fire does not wake an unscoped waiter. // // The scoped form is meaningful only because signalStop runs POST-COMMIT: when it fires, the status is // durable, so a test reading the row immediately after sees it - the exact guarantee a status poll spun for. CheckpointFlowStopped = "flowStopped" // signalStop(), a flow just reached a stop (completed/failed/cancelled/interrupted) // Lifecycle rendezvous, fired BOTH unscoped and scoped by flow key when an Await has registered on the // latch board and is about to block. It is the mirror of CheckpointFlowStopped: that one says a flow // settled, this one says a caller was already waiting when it did. // // Registration order is not a correctness question - the board is POLLED, so a key that settles before // its caller arrives is reported by the next sweep, and `await` reads once before parking anyway. It is // a question about what a TEST exercised. Every test about a blocked caller being woken has to get its // Await onto the board before the thing it is waiting for happens, and the only alternative to this is // a sleep long enough to make that likely - which on a slow machine silently becomes its opposite: the // Await registers after the stop, its own first read answers it, every assertion still passes, and the // wake path under test never ran. A rendezvous makes the premise exact instead of probable. // // Fired through latch.Board's SetOnPark hook rather than from `await`, because registering and blocking // are deliberately one call there (the gap is the race Close is careful not to leave). Wired only when // the seams are enabled, so a production board carries no hook at all. CheckpointAwaitParked = "awaitParked" // Board.Latch(), an Await is on the board and about to block // Lifecycle rendezvous, fired at the END of a recovery pass - every detector in it has read the database // by then. recoveryLoop sweeps ON ENTRY (Startup), so that first pass runs CONCURRENTLY with the test body, // and a test that forges a wedge/orphan shape and then drives one detector itself is racing it: the sweep // sees the forged shape too and counts/logs it a second time. The pass is not instant - it is four scans on // one goroutine sharing the engine's connection pool, so against a loaded server it lands seconds after // Startup (measured on SQL Server: the sweep's own detectOrphanedFlows landed 27ms before a forge that // followed a Create+Await). Wait for this before forging; the wait is free once the sweep is behind (Visits). CheckpointRecoverySweepDone = "recoverySweepDone" // runRecoverySweep(), one full recovery pass is over // Fired per shard (scoped by the shard number, and once unscoped) when that shard's piston completes a // cycle that PUSHED - i.e. its cache partition now reflects the plan. Its name is the piston's, so there // is one catalogue, exactly like FaultRefillScanErr. // // What it exists for: `Offer` admits a step into an EMPTY partition unconditionally and stamps that // partition's band from the arrival, while `Cache.Pop` ranks partitions by that FROZEN band and never // consults the current global minimum - so a doorbell-admitted hint is indistinguishable from a // plan-selected one until the owning shard's next cycle reconciles it. Every shard reconciles on its own // cadence, so a test that needs the fleet's partitions to agree with the plan before it asserts dispatch // ORDER must wait for a cycle per shard; no amount of elapsed time substitutes, because one starved // piston is exactly the case that breaks it. CheckpointRefillCycleDone = piston.CheckpointCycleDone // a shard's piston reconciled its cache partition CheckpointRefillStole = piston.CheckpointStole // a shard's piston selected steps from OUTSIDE its residue class )
--- Execution checkpoints ---
const VariablePoolIdle = "poolIdle"
VariablePoolIdle is the idle-connection size the engine DERIVED for a shard, targeted by shard index (seamsJoin(VariablePoolIdle, strconv.Itoa(idx))). It is recorded wherever a derived size is pushed, so a test can read the half of shardPool's result that has no other witness: database/sql reports the configured max OPEN size through DBStats.MaxOpenConnections, but nothing reports the configured max idle - DBStats.Idle is the number of connections currently idle, which is traffic, not configuration. The value recorded is the engine's derivation, before the test-mode cap in internal/database clamps what the pool actually takes.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine is the standalone workflow orchestration engine.
func NewEngineUnderTest ¶ added in v0.10.0
NewEngineUnderTest constructs an engine wired for testing: isolated, auto-dropped databases keyed by the given test name. It takes no testing.TB - a name is all it needs, so a host driving the engine under its own harness (one with no *testing.T to hand over) reaches it the same way a Go test does. The caller configures the engine (SetHost, SetShard, ...), drives Startup, and owns teardown:
e := NewEngineUnderTest(t.Name()) defer e.Shutdown(ctx) e.SetHost(h) e.Startup(ctx)
The name is the isolation key: engines given the SAME name share one set of isolated databases (which is how a multi-replica test gives its peer engines shared state), and engines given DIFFERENT names are independent deployments. So a test standing up several INDEPENDENT engines gives each its own name (t.Name()+"#"+suffix), and a benchmark reused across warmup/measure passes gives each pass a fresh one.
Logging default: stderr at Error, so a CI failure surfaces the engine-level alarms (wedge sweeps / poll / refill faults) without the Info-level play-by-play noise (stderr, not t.Log, because a `go test` timeout panic drops buffered t.Log output but not stderr). DWARF_TEST_LOG_LEVEL overrides the level (e.g. "info" or "debug" for the flow-status play-by-play; "silent" or "off" for the discard logger). A benchmark or fuzz target silences the engine itself with SetLogger, since per-iteration logging would dominate the measurement / flood the fuzz output; SetLogger before Startup takes over entirely either way.
func (*Engine) Await ¶
Await blocks until a flow stops, then returns its outcome. Running out of time is an error; the flow is unaffected and keeps running, and the caller still holds the key to Await/Snapshot/Cancel it later.
Pass a ctx with a deadline. It is the only bound on the wait - a flow can run for as long as its work takes, and there is no notification the engine could time out on instead. A ctx without one is honored for a long fixed budget and then times out, which is a guard against blocking forever, not a wait to design around. A caller whose own deadline is shorter than the flow wants Poll, not Await.
func (*Engine) Continue ¶
func (e *Engine) Continue(ctx context.Context, threadKey string, additionalState any) (string, error)
Continue creates a new flow from the latest completed flow in a thread, inheriting that flow's policy (scheduling, baggage) - it does not take FlowOptions. For a turn with different policy, use Create with FlowOptions.ThreadKey.
func (*Engine) Create ¶
func (e *Engine) Create(ctx context.Context, workflowURL string, initialState any, opts *workflow.FlowOptions) (flowKey string, err error)
Create creates a new flow for a workflow and starts it, returning the running flow's key. opts carries the flow's policy (scheduling, DeleteOnCompletion, Baggage, ThreadKey); nil uses defaults. For a flow that must wait for an external trigger, have the entry task call flow.Interrupt and resume it with Resume (which, unlike a separate start, also delivers a payload).
Example ¶
Create makes and runs a flow, and accepts FlowOptions for scheduling, notifications, thread membership, and the opaque host baggage carried with the flow.
package main
import (
"context"
"fmt"
"github.com/microbus-io/dwarf/engine"
"github.com/microbus-io/dwarf/workflow"
)
func main() {
var eng *engine.Engine // obtained from NewEngine().…Startup(ctx)
ctx := context.Background()
flowKey, err := eng.Create(ctx, "greet", map[string]any{"name": "ada"},
&workflow.FlowOptions{
Priority: 10, // lower runs first
FairnessKey: "tenant-42", // fair scheduling bucket
Baggage: map[string]any{"actor": "ada"}, // read with workflow.BaggageFrom(ctx)
})
if err != nil {
panic(err)
}
// Create runs the flow immediately; Await blocks until it stops.
out, _ := eng.Await(ctx, flowKey)
fmt.Println(out.Status)
}
Output:
func (*Engine) DB ¶ added in v0.10.0
DB exposes the engine's shard set so a black-box test in another package can inspect flow/step rows directly. Test-only: it panics outside a test binary (!testing.Testing()), so production code never reaches the shards through it. The guard is testing.Testing() rather than "was this built by NewEngineUnderTest" so it also serves a test that builds its engine with the raw NewEngine (e.g. restart_test, which needs a real file DSN and no test-DB harness) - such an engine is not in test mode but is still a legitimate test engine.
func (*Engine) Fingerprint ¶
func (e *Engine) Fingerprint(ctx context.Context, flowKey string) (fingerprint string, status string, err error)
Fingerprint returns a fingerprint and status for change detection.
func (*Engine) FlowsStarted ¶ added in v0.10.0
FlowsStarted returns the count of flows this engine has started (Create/Continue/Fork/subgraph), a cheap atomic read that needs no configured meter. FlowsTerminated is its completion counterpart; started - terminated approximates in-flight work for backpressure.
func (*Engine) FlowsTerminated ¶ added in v0.10.0
FlowsTerminated returns the count of flows this engine has completed/failed/cancelled. See FlowsStarted.
func (*Engine) Fork ¶ added in v0.8.0
Fork clones a terminal flow's prefix up to the given step into a new, self-contained running flow and re-executes from that step with optional stateOverrides applied to it. The original flow is never modified. The fork inherits the original's scheduling and baggage (it does not take FlowOptions). Returns the new flow's key.
func (*Engine) HistoryMermaid ¶
HistoryMermaid writes the execution DAG of a flow as a Mermaid diagram.
func (*Engine) List ¶
func (e *Engine) List(ctx context.Context, query workflow.Query) ([]workflow.FlowSummary, string, error)
List queries flows by status, workflow name, or thread key, newest first, with cursor pagination (Query.Limit, default 100; the returned cursor fetches the next page). Query.Limit is a per-shard cap divided across shards, not a hard ceiling on the total: a multi-shard page can hold up to shards*ceil(Limit/shards) summaries (see Query.Limit). Pass Query.Shard, or truncate the page, for a strict count.
"Newest first" is per shard, not global. On a MULTI-SHARD fleet each shard contributes its own newest flows and the results are grouped by shard, so the concatenation is not in one descending time order - shard 2's newest flow follows shard 1's oldest returned one. There is no cross-shard order to give: the flow ids are per-shard sequences (a shard with fewer flows has lower ids, so they do not compare), and created_at would compare different database servers' clocks. A single-shard engine - the default - is globally newest-first. A caller that needs one ordered view across shards sorts the page itself, choosing what to trust; a UI that must not show interleaving artifacts can page one shard at a time with Query.Shard.
func (*Engine) Poll ¶ added in v0.9.3
Poll returns a flow's current outcome, blocking up to the ctx deadline for it to stop. Unlike Await, a ctx timeout is not an error: it returns the current non-terminal outcome, whose Stopped() reports false, so a caller bridging an open-ended flow to a bounded request (e.g. an HTTP poll) can answer within its budget and re-poll. A real failure still returns an error.
The ctx deadline IS the budget, so pass one; without it Poll blocks on the same long fallback budget as Await before answering, which is rarely what a polling caller wants.
func (*Engine) Purge ¶
Purge marks flows matching a query (and their subgraph subtrees) for deletion; a background reaper removes them shortly after. Marked flows are excluded from List/History immediately. Returns the count of roots marked - no more than 4096 per call; iterate to mark more. Query.Limit is divided per shard exactly as in List (up to ceil(Limit/shards) roots per shard), so a multi-shard call can mark more than Limit roots. Running flows are skipped.
func (*Engine) Run ¶
func (e *Engine) Run(ctx context.Context, workflowURL string, initialState any, opts *workflow.FlowOptions) (flowKey string, outcome *workflow.FlowOutcome, err error)
Run creates, starts, and awaits a flow in one call, returning the new flow's key alongside its outcome (the key is the flow's identity, not part of the outcome). opts carries scheduling and the opaque host Baggage; nil opts uses defaults.
Error semantics differ by phase. A create failure returns flowKey "" and a nil outcome - no flow exists. An await failure - most commonly the caller's ctx expiring before the flow stops - leaves the flow running (it is durable and not bound to this call) and returns its flowKey with a nil outcome and the error, so the caller keeps a handle to Await/Snapshot/Cancel it later. Run never cancels the flow on the caller's behalf; a caller that wants the flow torn down on timeout calls Cancel explicitly.
func (*Engine) Seams ¶ added in v0.10.0
Seams exposes the test instrumentation seams (fault injection and execution checkpoints) so a black-box test in another package can drive the engine's internal race windows deterministically. Test-only: it panics outside a test binary (!testing.Testing()); the seams are inert unless armed, and arming is itself only possible under testing.Testing(), so this exposes no production behaviour.
func (*Engine) SetDebugLogger ¶ added in v0.4.0
SetDebugLogger is a convenience that wires a human-readable text logger to stderr at debug level - shorthand for SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))). It is meant for development and test runs where you want to see the engine's (and its sequel DB layer's) internal logging without standing up an OTEL pipeline. Output goes to stderr, not stdout, so it never mixes with a program's data stream - the standard convention for diagnostic logs. Because it routes through SetLogger, it counts as an explicitly-set logger, so it also reaches sequel via the engine's existing SetLogger wiring (sequel's migration logs appear too). Construction-time only.
func (*Engine) SetDefaultPriority ¶ added in v0.4.0
SetDefaultPriority sets the default priority for new flows: an integer >= 1, lower runs first. Live: read fresh on each Create, so it takes effect on a running engine immediately.
The lower bound is not cosmetic. The refiller selects the strict-minimum band with a `priority=(SELECT MIN(priority) ...)` subquery, but a step is only a candidate at all through predicates the scan applies to a positive band; a flow stamped with a non-positive priority would sit `pending` forever, invisible to selection, while the doorbell re-rang it in a loop. The upper bound guards the column's int32 width - an int that overflows it (3_000_000_000, say) would wrap NEGATIVE and produce exactly that hang. `FlowOptions.Priority` is separately validated at Create, where 0 means "unset, take this default".
func (*Engine) SetEngineID ¶ added in v0.10.0
SetEngineID pins this replica's identity, overriding the random identifier minted per instance. The identity is what a replica registers in the shared peer registry to be counted for the connection-pool split across replicas. The default is random and fresh on every restart, which is correct for the common case (including several engines in one process, which must count as distinct replicas) but leaves a stale registry entry behind when a replica crashes: the entry lingers until it ages out, transiently over-counting replicas and shrinking every live replica's pool share in the meantime. Pinning a value that is STABLE across a replica's restarts (for example, derived from the deployment's own per-instance identity) lets a restarted replica reuse its entry instead, so a crash-restart never inflates the count.
The id must be positive (0 is reserved) and UNIQUE among replicas concurrently sharing the same databases: two live replicas with the same id register as one, under-counting replicas and over-sizing pools. Leave it unset (random) unless a stable, unique value is genuinely available - a wrong stable id is worse than the random default. Construction-time only.
func (*Engine) SetHost ¶ added in v0.4.0
SetHost registers the host the engine reaches the outside world through: it loads graphs, executes tasks, and (optionally) receives flow-stop notifications and carries cross-replica coordination signals. A host must implement LoadGraph and ExecuteTask; the remaining Host methods may be no-ops. Construction-time only.
func (*Engine) SetLogger ¶ added in v0.4.0
SetLogger sets the structured logger. The engine logs through the *Context variants (DebugContext/InfoContext/WarnContext/ErrorContext) so a handler that reads the context - e.g. the otelslog bridge - can correlate each record with the active step span. A host routes logs to OTEL by passing a logger whose handler bridges there. Defaults to a discard logger: until a logger is injected the engine (and its sequel DB layer) stay silent rather than writing to the application-owned slog.Default(). A nil logger resets to that silent default. Construction-time only - the engine resolves the logger once at Startup.
func (*Engine) SetMaxOpenConns ¶ added in v0.4.0
SetMaxOpenConns is an expert override that pins every shard's connection pool to exactly n open (and idle) connections, replacing the per-shard budget the engine derives from ShardSpec.VirtualCPUs. Operators normally never call this - provide VirtualCPUs instead and let the engine size the pool at the measured knee (12x the database's CPU count at 32 vCPUs or more, 6x below). The override exists for benchmarking (pool-size sweeps) and for deployments whose connection budget is constrained by something the engine cannot see (e.g. a shared database or an external pooler). Live: pushes to every open shard immediately.
func (*Engine) SetMeterProvider ¶ added in v0.4.0
func (e *Engine) SetMeterProvider(mp metric.MeterProvider) error
SetMeterProvider sets the OpenTelemetry MeterProvider the engine builds its dwarf_* instruments from. Defaults to the global otel.GetMeterProvider() (the no-op provider unless the host configures the OTEL SDK). The engine creates instruments under the "github.com/microbus-io/dwarf" scope; the provider's Resource carries the host service's identity. Construction-time only - the engine resolves the meter once at Startup.
func (*Engine) SetRefillInterval ¶ added in v0.10.0
SetRefillInterval is an expert override that PINS every piston's cycle period to d, replacing the value the engine derives from capacity/vCPUs/R (deriveRefillInterval). d <= 0 restores derivation. Operators normally never call this - the derived period tracks the cache sizing it depends on automatically.
The override exists for benchmarking (scan-rate sweeps): the period is measured, not tuned, so finding its optimum needs to hold it at a series of fixed values - INCLUDING values below the 20ms minimum gap, since the unlimited-scanning arm is one of the measured reference points (it costs 8% candidate churn and a 77% worse p99 while buying no throughput, and that number has to stay reproducible). So a pinned interval also lowers the pipeline's MinGap to match when it is the tighter of the two; restoring derivation restores the default gap. Without that the fuse would silently clamp every sub-20ms arm of a sweep to 20ms and quietly flatten the interesting end of the curve.
Live: applies on the next recomputeRefillIntervals, and this triggers one immediately on a running engine.
func (*Engine) SetShard ¶ added in v0.10.0
SetShard registers a database shard. Call once per shard; see ShardSpec for field semantics. When no shard is registered, Startup opens a single default shard 1 (in test mode, an isolated in-memory database).
Construction-time only: shards are opened and migrated at Startup and the set is immutable for the engine's life, so a call on a running engine is rejected. Changing the shard set requires a coordinated restart of every replica (a maintenance window): a flow created on a shard unknown to a peer replica is unroutable (404) there.
func (*Engine) SetTimeBudget ¶ added in v0.4.0
SetTimeBudget sets the default duration for a single task execution, used by any flow that does not override it via FlowOptions.TimeBudget. Live: read fresh on each Create (existing flows keep the budget frozen at their own Create). It must be positive.
The lower bound is not cosmetic. The budget is the ExecuteTask call's context deadline and, via the step row, the size of the crash-recovery lease - so a non-positive default hands every new flow a deadline that has already passed, failing every task instantly, engine-wide and silently. Unlike Priority and FairnessWeight, 0 cannot mean "unset" here: this setter IS the default. The floor is a millisecond rather than a nanosecond because the budget is persisted in MILLISECONDS: a positive duration below 1ms truncates to zero and produces exactly the expired-deadline case.
func (*Engine) SetTracerProvider ¶ added in v0.4.0
func (e *Engine) SetTracerProvider(tp trace.TracerProvider) error
SetTracerProvider sets the OpenTelemetry TracerProvider the engine builds its spans from. Defaults to the global otel.GetTracerProvider() (the no-op provider unless the host configures the OTEL SDK). The engine emits one span per flow and one span per task, nested to mirror the call structure, under the "github.com/microbus-io/dwarf" scope; the provider's Resource carries the host's identity. The host injects only the provider - it writes no span or context code. Construction-time only - the engine resolves the tracer once at Startup.
func (*Engine) SetWorkers ¶ added in v0.4.0
SetWorkers is an expert override that pins the maximum number of worker goroutines, replacing the derived default. The default is the lease-margin ceiling: the largest pool that keeps a synchronized completion storm (every in-flight task released at once by a recovering downstream) draining inside the crash-recovery lease margin, derived at Startup from each shard's connection budget and its measured round-trip time. It contains no assumption about task duration, so it is correct for any workload; the pool grows into it only on demand, so a short-task deployment never pays for the headroom.
Operators normally never call this. Reasons to: memory (the ceiling can be tens of thousands of workers, and each in-flight step also holds its state map - a bound the engine cannot see); a deliberately smaller global concurrency cap; deterministic tests (SetWorkers(1) serializes dispatch); and benchmark sweeps. Setting it ABOVE the ceiling is allowed - an operator may consciously trade the risk of duplicate task execution in a storm for long-task throughput - and is logged as a warning at Startup. SetWorkers(0) is a valid shape: a replica that creates, awaits, and serves reads but never executes tasks. Construction-time only: the pool bound is fixed at Startup, so a call on a running engine is rejected.
func (*Engine) ShardInfo ¶
func (e *Engine) ShardInfo(ctx context.Context) ([]ShardSummary, error)
ShardInfo returns health and size summaries for all shards.
func (*Engine) Shutdown ¶
Shutdown stops all worker goroutines and closes database connections. Idempotent: a call on an engine that is not running (never started, or already shut down) is a no-op, so it is safe to defer and to call more than once.
type Host ¶ added in v0.4.0
type Host interface {
// LoadGraph fetches a workflow graph definition by its URL (the addressable resolve key passed to
// Create). The flow's opaque baggage rides on ctx; read it with workflow.BaggageFrom(ctx) if loading
// is identity-dependent (authz, per-actor graphs). The engine validates the returned graph
// (graph.Validate) at Create and at subgraph spawn, so the host need not: returning (nil, nil) yields a
// 404 and a structurally invalid graph a 400, rather than a later dispatch-time failure.
LoadGraph(ctx context.Context, workflowURL string) (*workflow.Graph, error)
// ExecuteTask executes a single task within a workflow. taskURL is the task's dispatch URL (the real
// downstream address), not the graph node name. The flow carrier has its state pre-populated; the
// executor should call the task and let it write changes to the flow. The flow's opaque baggage rides
// on ctx - read it with workflow.BaggageFrom(ctx) (e.g. to mint a token).
//
// Execution is at-least-once and may be concurrent: a task can run more than once, and if a worker's
// lease is lost while it is still running (a task that overruns its ctx deadline, or a forward DB-clock
// step past the lease) a second worker re-runs it in parallel. The engine guarantees the flow's
// persisted state reflects exactly one execution, but exactly-once side effects are the task's
// responsibility - tasks must be idempotent. Honor the ctx deadline (it bounds the step's time budget);
// a task that ignores it can only be recovered by lease expiry, not cancelled.
ExecuteTask(ctx context.Context, taskURL string, flow *workflow.Flow) error
}
Host is the contract between the dwarf engine and the surrounding host application. The engine owns no transport of its own; it reaches workflow graphs and tasks exclusively through the host. Register it once via Engine.SetHost.
THE ENGINE SENDS NOTHING TO ITS PEERS, and a host therefore wires no inter-replica transport for it. Replicas sharing a database coordinate entirely by reading it: pending work, flow outcomes and fleet membership are all discovered by polling, on cadences the engine sets, so a fleet converges with no message passing between replicas at any volume - not per step, not per flow, not per deployment event. What a host must supply is exactly what only it can: the graphs and the task dispatch below.
type ShardSpec ¶ added in v0.10.0
type ShardSpec struct {
// Index identifies the shard. Indices must be >= 1 and unique, but need not be contiguous (shards
// 1 and 99 are fine). The index is encoded into every flow key created on the shard and drives
// routing, so the index-to-DSN mapping must be identical across all replicas and stable across
// restarts.
Index int
// DSN is the connection string of the shard's database (dialect auto-detected), used EXACTLY as
// given - the engine never formats or rewrites it, so a percent-encoded credential (a password
// "p@ss" written "p%40ss") survives intact. Each shard is declared with its own DSN; there is no
// template. An empty DSN is only valid in test mode, where the DSN IS a template (a "%d" is replaced
// with the shard index, which is what gives each shard its own isolated test database).
DSN string
// VirtualCPUs is the CPU count of the shard's database server - a fact off the instance's spec
// sheet. It drives the shard's connection budget (the pool is capped at the measured knee - 12x the
// CPU count on a server of 32 vCPUs or more, 6x below that, beyond which connections only queue - and
// on smaller servers actively destabilize or collapse throughput) and its placement weight (new flows are distributed across shards in proportion to
// measured capacity). Left at 0, the engine assumes 2 - the smallest machine any major cloud sells
// as a current-generation instance, so the assumed pool stays safe even if the real machine is
// smaller. Declare it: a large database sized as if it were a 2-CPU one runs at a fraction of its
// capacity.
VirtualCPUs int
// Cordoned excludes the shard from new-flow placement. Everything already resident proceeds
// normally: existing flows keep executing, and subgraph children, thread continuations (Continue),
// and forks - all shard-pinned - are still created on it. Use for retiring or overloaded shards.
Cordoned bool
}
ShardSpec declares one database shard: the facts about it the operator can readily provide, from which the engine derives its tuning (connection budget, placement weight).
type ShardSummary ¶
type ShardSummary struct {
Shard int `json:"shard,omitzero"`
Error string `json:"error,omitzero"`
LatencyMs int `json:"latencyMs,omitzero"`
Steps int `json:"steps,omitzero"`
Flows int `json:"flows,omitzero"`
}
ShardSummary is the health/size summary of a single database shard.
type TaskHandler ¶
TaskHandler is the signature for a test task handler. Read the flow's baggage, if any, with workflow.BaggageFrom(ctx).
type TestProxy ¶
type TestProxy struct {
// contains filtered or unexported fields
}
TestProxy routes graph fetches and task dispatches to registered handlers. It implements the Host interface for use with Engine.SetHost.
A multi-replica test needs nothing else from it: replicas coordinate through the database they share, so several engines pointed at one test database ARE a fleet, with no relay between them to stand up.
func NewTestProxy ¶
func NewTestProxy() *TestProxy
NewTestProxy creates a new test proxy with empty handler registries.
func (*TestProxy) ExecuteTask ¶
ExecuteTask implements Host.
func (*TestProxy) HandleGraph ¶
HandleGraph registers a workflow graph under the given name. The name should match the workflow URL passed to Engine.Create or Engine.Run.
func (*TestProxy) HandleTask ¶
func (p *TestProxy) HandleTask(name string, handler TaskHandler)
HandleTask registers a task handler under the given name. The name should match the task URL registered via graph.SetEndpoint.