Primer
Teams automate multi-step jobs: receive an event, ask an LLM, call an API, wait for a person, notify someone. Written as ad-hoc scripts, these have no retries, no visibility, no way to pause or stop them, and no way to continue after a crash. Yukta lets you describe the steps once, as a graph, and runs them reliably. Core is the engine inside the Yukta server that executes those graphs and lets you steer them while they run.Where it sits
Terms to know first
One example, end to end
Simplified: the field names of the node and edge JSON are illustrative; the engine block is real.Ticket arrives
Classify
Approve
Update ticket
Done
Dead letters
What wraps every node
- Merge parents (join rule)
- Valve: pause, step, skip, breakpoint
- Call the plugin (attempt, timeout, retry)
- Clocks and status
- Broadcast to every child
Components
Entry & Admission
front door, slots, queue Receives start, restart and recover requests and decides whether, when and in what order a run may begin.- A start request first loads the stored definition and prepares it (checks and compiles the graph). If several requests for the same workflow arrive together, they share one preparation instead of each doing the work.
- Then it asks the scheduler for a slot, which is permission to run. The caller receives an execution id only after a slot is granted. Example: the limit is 5 and 5 runs are active. A run finishes, freeing one slot, and two new requests arrive at that same moment. Exactly one gets the slot and an id; the other waits in the queue or gets HTTP 429. If ids were handed out before the slot check, both requests could get one and the limit would be exceeded.
- The scheduler can cap total concurrent runs, runs per workflow and runs per session (a team or user workspace). All caps are off by default. A start that does not fit either waits in a queue (status QUEUED) or is refused at once with HTTP 429 (‘too many requests’), depending on the on-limit setting.
- The queue is ordered by an optional priority number in the start payload (higher first). Within the same priority, the next free slot goes to the session with the fewest runs right now, so one busy session cannot keep everyone else waiting.
- A client can send an Idempotency-Key header. If its network drops and it sends the same start again with the same key (same session and workflow, within 10 minutes), it gets the first run’s id back instead of starting a second run. With the file log the keys survive a restart.
- On shutdown it stops accepting starts, refuses everything still queued and gives running runs 30 seconds to finish.
- Guarantees. No run gets an id without a slot, a repeated start with the same key never starts a second run, and every refusal is counted by reason.
- State. The slots and the queue are held in memory. Idempotency keys are saved in the execution log when one is configured. Limits changed at run time are logged and restored from the log on the next start; the log then wins over the configuration.
- Ceiling. One scheduler in one server process. The queue holds 100 starts by default and there are no limits at all until you set some.
- On failure. If the server crashes, queued starts lose their place in line. With a file or PostgreSQL log their start was already recorded, so they are marked ABORTED on the next boot together with the running ones, and can be recovered.
yukta.execution.max-concurrent / -per-workflow / -per-session, yukta.execution.on-limit (QUEUE|REJECT), yukta.execution.queue-capacity (100), queue-timeout, yukta.start.idempotency-ttl (10m)
Definition & Preparation
store, validate, compile Keeps workflow definitions and versions, and turns one into a validated, runnable graph.- Definitions are stored per workflow together with the newest 20 versions, so you can roll back. Storage is memory, files or PostgreSQL.
- Before a run, a validator checks the graph and refuses it with a message naming the node and the option at fault. It rejects loops, duplicate nodes or edges, connections to an output port the plugin does not have, graphs over 500 nodes or 2000 edges, and badly formed failure policies (for example a retry delay longer than the node’s timeout).
- The compiler then turns the graph into a runnable form, one assembler per node. The result is cached by the content of the definition. It is kept only if the stored definition has not changed in the meantime, so an old compile can never overwrite a newer edit.
- Each run records a fingerprint (hash) of the graph it used. Progress, history and the log all carry it, so you can always tell which version a run executed.
- Session, workflow, node and path identifiers are checked by Bean Validation annotations at the orchestrator boundary, which checks the shape of an identifier (for example a session id may not contain path separators) before it is used.
- Guarantees. A broken graph is refused before any plugin runs, and a run always keeps the version it started with.
- State. Definitions are durable with the file or PostgreSQL store. The compiled cache lives in memory and is simply rebuilt.
- Ceiling. Preparation cost grows with graph size, so graphs are capped at 500 nodes and 2000 edges.
- On failure. Editing a workflow does not change runs already in progress, and a resume is refused if the definition changed since the run started.
yukta.session.store-type = in-memory | file | postgres
Stream Engine
node runtime and policies Runs every node as a pipeline: merge parents, gate input, call the plugin, apply failure and time policy, fan out to children.- Joining: a node with several parents needs a rule for when one parent fails. ALL_SUCCESS (default) fails the node too, ALL_DONE carries on with the parents that worked, and ONE_SUCCESS carries on unless every parent failed.
- Valve: each node has a gate in front of it. It implements pause (stop taking new messages; the one being processed finishes), step (let exactly n messages through, then pause), breakpoint (hold when a message matches a condition) and skip (pass messages through without calling the plugin).
- Failure policy: a node’s optional ‘engine’ config can retry a failed message with growing delays, give up on a message that takes too long, send a failed message to an error output, or fail the whole node if too many messages fail. With retry or error routing on, a message’s output is held back until it is complete, so a failed attempt never leaves half a result downstream.
- Clocks: a node fails if it is silent for too long (idle timeout). Any input, output, retry attempt or heartbeat resets that clock, and pausing freezes it so a pause never causes a timeout. Plugins can declare their own default.
- Wiring: every child subscribes before the source starts, so no early message is lost, and the slowest child decides how fast data flows. This is back-pressure: a slow consumer makes upstream wait instead of buffering without limit.
- Guarantees. Data flows with back-pressure end to end. A node’s status only moves forward (PENDING, QUEUED, RUNNING, then an end state), so a late or out-of-order event can never reopen a finished node.
- State. Each run’s state, gates, failure counters and clocks are held in memory.
- Ceiling. A fan-out (one node feeding several) moves at the speed of its slowest branch. If one branch stops reading because its terminal failed, its siblings stall until the idle timeout. With retry or error routing, one message’s output is buffered until its attempt ends.
- On failure. When a node fails, the nodes behind it are marked UPSTREAM_FAILED rather than FAILURE, so only the node that actually failed shows FAILURE.
engine.retry / onError / trigger / elementTimeoutMs / deadlineMs / failureBudget (per node), timeoutSeconds (per node)
Plugin Boundary
the extension seam The contract between the engine and the work a node does: triggers produce messages, processors transform them, terminals consume them.- A plugin receives a stream of messages and a context holding the execution id, the node id, a heartbeat function and, for sub-workflows, a result collector. It never sees the engine’s own policy options.
- A plugin that does long, silent work (say a ten-minute script) calls the heartbeat now and then to tell the engine it is alive; otherwise the idle timeout would fail it. Blocking plugins run on virtual threads, which are cheap threads that do not tie up the main event loop.
- A named signal, for example ‘approved’, can be sent to a running plugin through the same ordered, audited path as other commands. WAIT_FOR_SIGNAL uses this to hold a message until a person decides.
- Safety rails: workflows may read only allow-listed environment variables, scripts may use only a small set of Java classes, and process execution can be limited to named commands. The expression and script engines are not part of core: expressions run in plugin-api and scripts in the script plugin (see Expressions & Secrets).
- Guarantees. With retry or error routing on, the plugin is called once per message and receives a stable message id plus an attempt counter (1, 2, 3…). Retries repeat the same id, so a plugin can use it to avoid doing the same side effect twice.
- State. State inside a plugin is the plugin’s own business. Signal waits (WAIT_FOR_SIGNAL and MCP_TOOL_CALL approvals) and KV_STORE are held in memory.
- Ceiling. Plugins run inside the same server process today, so a heavy or untrusted plugin competes with the engine for CPU and memory.
- On failure. Throwing IllegalArgumentException means ‘bad input’ and is never retried.
yukta.process.allowed-commands, yukta.process.allow-any (off), yukta.script.timeout-ms (10000), yukta.script.max-statements (50000000), yukta.mcp-client.allowed-tools, max-argument-bytes, deny-argument-patterns; MCP_TOOL_CALL requireApproval, approvalSignal, approvalTimeoutSeconds, yukta.llm.deny-input-patterns, deny-output-patterns, max-input-chars, YUKTA_LIMITS_MAX_BODY (1MB, 413 above), yukta.api-key, yukta.api-keys[].{name,key,role,sessions}; key may be sha256:<hex>, yukta.allowed-hosts, yukta.allowed-origins, yukta.variables.allowed
Supervisors
rules, AI, custom code Programs that watch every run and steer it: a rule engine, an AI agent or your own code, inside or outside the server.- A supervisor receives every execution event (run started, node failed, …) through a small buffer of 256. If it is too slow, events are dropped and counted, so a slow supervisor never slows the engine.
- It answers with commands such as pause. Commands are limited to 20 per second, can be run in dry-run mode (recorded but not applied) and are tagged with the supervisor’s id so the audit shows who did it. A supervisor does not see the results of its own commands.
- Reviewers are separate hooks that can veto any command, from a person or a supervisor, before it runs. The first reviewer to refuse wins, and the caller gets HTTP 403.
- Not everything has to run inside the server: an external agent can read the same events as a live SSE stream and send commands through REST or MCP.
- Guarantees. A misbehaving supervisor is contained: if it crashes it is restarted after a delay, and it can never block a run.
- State. Supervisors hold no engine state; their command counts are exposed as metrics.
- Ceiling. Event delivery is best effort, so a supervisor can miss events when it is overloaded.
- On failure. A supervisor sees the status change caused by its own command (a pause produces a PAUSED event) and may react to it again, so it can loop. Only the 20 per second cap limits it.
yukta.supervisor.max-commands-per-second (20), yukta.supervisor.dry-run, yukta.supervisor.rules.file
Control Plane
commands, order, audit The ordered, acknowledged way to change a run that is already going.- Commands include pause, resume, step, breakpoint, skip, retry node, stop, restart, restart from node, signal, change priority and change limits. All commands for one run enter one queue and are applied one after another in the order sent (first in, first out).
- The caller gets an answer only once the command has actually been applied, and every run command is written to the log as applied or rejected, with who sent it.
- Stop has three strengths: DRAIN lets work already in progress finish (with a deadline), CANCEL cancels it, KILL ends it immediately.
- Restart from node re-runs one node and everything after it, replaying the recorded outputs of the nodes before it, so earlier work is not repeated. Retry node does the same for a node that failed.
- A second, older channel exists beside run commands: the plugin-level control bus, which carries heartbeats, statistics and raw signals to a node’s plugin. It is keyed by workflow and node, bypasses the reviewers and is not audited like run commands. Live control handles and their valves are kept in an execution control store built by a factory per run.
- Guarantees. Commands for one run are applied in order, acknowledged and audited.
- State. Pending commands and the gates are in memory. The audit trail is written to the execution log.
- Ceiling. One batch consumer handles commands: ample for people and supervisors, not designed as a high-volume API.
- On failure. Changing limits or priority is written to the log but does not pass the reviewers, and the raw per-node command endpoint is neither reviewed nor audited and is keyed by workflow and node, not by run. Through it an approval wait can be released unseen. Over REST, limits need ADMIN and priority OPERATOR.
yukta.supervisor.* (reviewers)
Observation
tracker, progress, telemetry Turns what happens in a run into ordered state, live progress streams and metrics.- Every change (a node started, finished, failed) becomes an event in one ordered queue. State events are never dropped. Log lines are lower priority: if more than 10,000 are waiting, extra ones are dropped and counted.
- A single consumer reads the queue in batches and updates progress snapshots, run history and metrics. REST, SSE streams and MCP all read these snapshots.
- Each run is recorded as one trace observation with one child per node. When a tracing library is present they become a proper trace of the run.
- Useful meters: node durations and outcomes, retries, run duration, time spent queued, running and queued run counts, rejected starts by reason, checkpoint bytes and dropped events.
- Two helpers sit beside it: a cache of recent progress snapshots per run (30 minutes at most) that lets a late SSE client catch up, and the plugin log store, which copies tracker log lines into memory by default. A file-based plugin log reader and writer exist but are not wired into the application.
- Guarantees. State events are complete and in order, and the run and node timelines agree with the log.
- State. Everything is in memory. After a restart the newest runs are restored from the log.
- Ceiling. State events are never dropped and their queue has no size limit, and per-session state is kept until yukta.tracker.max-sessions evicts the oldest finished session (off by default), so memory can grow with the number of sessions and during event storms.
- On failure. The queue cannot overflow and silently stop tracking: bounded channels use a non-terminating ‘offer’ instead of ‘emit’.
tracing: add a Micrometer tracer to the classpath
Durability
log, checkpoints, recovery Makes a run explainable and resumable after the process dies.- Execution log: an append-only record of run starts (including the input payload), run ends, control commands and idempotency keys. One writer thread does the disk work, and the file is shrunk to the newest runs from time to time.
- Checkpoints: the output of every node is recorded. When a node finishes normally, a tiny ‘done’ marker file is written and forced to disk together with its output before downstream nodes continue. The memory version is capped by message count and size; the file version keeps everything.
- After a crash, on boot, runs that never finished are marked ABORTED and recent history is restored. Someone can then resume an aborted run: nodes with a done marker replay their recorded output and the others run again.
- The archive keeps the last 10 finished runs (graph, input, outputs) so they can be restarted.
- Guarantees. A resume trusts only the done marker, never the log. A node whose output exceeded the memory cap refuses a restart rather than replaying part of its output, unless a spill directory is set: then the node’s whole output moves to files and replays complete.
- State. Durable only if you configure directories (or PostgreSQL for the log).
- Ceiling. Checkpoints are files or memory, not PostgreSQL. All file I/O runs on one writer thread, and log events are not fsynced unless yukta.execution-log.fsync=true.
- On failure. There is a tiny window: if a plugin performs a side effect and the process dies before the done marker is written, that node runs again after a resume and the side effect can happen twice.
yukta.execution-log.dir, retained-runs (500), compact-after (10000), yukta.restart.dir, retained-executions (10), messages-per-node (200), bytes-per-node (4 MiB), spill-dir (off)
Persistence Backends
memory, files, PostgreSQL The storage behind sessions, definitions and the log, chosen by one property.- In-memory is the default and loses everything on restart. File storage writes atomically and forces data to disk. PostgreSQL is accessed through R2DBC (non-blocking access) at run time, with Flyway applying the schema migrations.
- PostgreSQL currently holds session config, workflow definitions with their versions and the execution log. Enable it by building with -Pyukta.persistence=postgres and setting the store type.
- The log interface is blocking, so the PostgreSQL log goes through one writer thread. It trims to the newest runs and never drops a run that has no end recorded.
- Guarantees. Behaviour is the same behind each store; the choice only changes what survives a restart.
- State. Durable by definition, except the in-memory option.
- Ceiling. Connection pool tuning, health checks and native-image support for R2DBC and Flyway are not finished.
- On failure. Node checkpoints still use files or memory even with PostgreSQL.
yukta.session.store-type, spring.r2dbc.url / spring.flyway.url
Sub-workflows
runs that start runs Lets one workflow call another stored workflow for each message, enabling composition and agent-style plans.- A SUB_WORKFLOW node starts another stored workflow of the same session for every message it receives. The child is an ordinary run with its own execution id, subject to the same limits, tracking and audit.
- The child’s start payload carries its nesting depth and the parent’s id; nesting is limited to 5 levels. The results from the child’s terminal nodes are collected, and the parent node emits the child’s id and results.
- If the child fails, the node fails, so the parent’s failure policy (retry, route, …) applies.
- Guarantees. A child is an ordinary run: tracked, limited and audited.
- State. The link between parent and child lives only in the start payload; run history does not show it yet.
- Ceiling. Each child needs its own slot while the parent keeps its own. If max-concurrent is small, the parent can end up waiting for children that are waiting for the parent’s slot.
- On failure. Stopping the parent does not stop its children yet, and a child cancelled with its parent may never receive a final status.
yukta.execution.max-concurrent must cover parent + children
Expressions & Secrets
${…} values, secrets Resolves the ${…} values in a node’s configuration against the current message and run, and hands out secrets by name.- A node’s configuration can contain {payload.x}), message metadata ({payload.secs:int}; an untyped value stays text. A timeout written as an expression is only known when the first message arrives, so the node starts with the default and is re-timed then.
- Environment and system values are read only from an allow-list: YUKTA_VAR_* variables, yukta.var.* properties, a few harmless names and anything listed in yukta.variables.allowed. Names that look like secrets stay blocked even when listed.
- Secrets are written decrypted:name. The default provider reads the environment variable YUKTA_SECRET_NAME, so values never appear in the workflow definition. ${context.x} reads a plain text, number or flag from the run’s Reactor context; keys starting yukta. and object values are hidden.
- The evaluators are not part of core. Conditions and mappings run through the expression utility in plugin-api, and scripts through a sandboxed JavaScript engine in the script plugin (only java.time and a few value classes are visible to scripts).
- Guarantees. A workflow cannot read an environment variable or system property outside the allow-list, and secret-looking names are never exposed.
- State. Nothing is stored: values are resolved per message from the message, the run context and the environment.
- Ceiling. Expression and script evaluation have no time or size limit of their own, so one expression or script can use a lot of CPU or memory. The context lookup is not restricted to a list of keys.
- On failure. A node whose configuration cannot be resolved fails that message like any other plugin failure, so retry and error routing apply.
yukta.variables.allowed / YUKTA_VARIABLES_ALLOWED, YUKTA_VAR_* and YUKTA_SECRET_* environment variables
Scenarios
Happy path
One start request travels from the front door to a finished run. Use it first to learn the vocabulary.A start request arrives
Prepare once, share it
Admission
Wire the graph
Messages flow
Observe and record
Finish
Overload
What happens when more work arrives than the server should run at once.A burst of starts
Limits kick in
Fair, prioritised queue
Queue full or too old
Shutdown under load
Node failure
How a failing step is retried, quarantined or allowed to fail the run, and one place where that design is weak.A message fails in a processor
Retry with backoff
Route the poison message
Failure budget
Joins decide what a failed parent means
A terminal that dies
Operator steers
How a person steers a run that is already going, using the same channel automation uses.Pause
Step and breakpoint
Skip or retry a node
Review and audit
Stop
Supervisor intervenes
How an automated watcher (rules or AI) observes runs and intervenes, and how it is kept from hurting them.Events are published
A supervisor receives them
It answers with commands
Reviewers and the loop
Crash and resume
What is lost and what is kept when the server dies mid-run, and how a run is continued afterwards.The JVM dies mid-run
Boot recovery
Someone asks to resume
POST /api/executions/{id}/recover (409 when refused), over MCP control_workflow with action RECOVER.Replay what finished, re-run the rest
The at-least-once window
Sub-workflow fan-out
How one workflow calls another and why that needs care with limits.A node starts a child per message
Children borrow the slot
Results flow back
Stopping the parent
Capabilities
What this module does, how, what it guarantees and where the guarantee stops.Execution
Failure
Time
Control
Governance
Durability
Observability
Extensibility
Security
Guarantees and limits
Delivery semantics
The five clocks
What survives a restart
Pitfalls
Architecture decisions
Reactor stream per edge with back-pressure
Reactor stream per edge with back-pressure
Single JVM with in-memory control state
Single JVM with in-memory control state
At-least-once with idempotent plugins
At-least-once with idempotent plugins
Durable node end marker, not log fsync
Durable node end marker, not log fsync
Forward-only node status
Forward-only node status
Supervisors and reviewers on one control bus
Supervisors and reviewers on one control bus
PostgreSQL through R2DBC, schema by Flyway
PostgreSQL through R2DBC, schema by Flyway
Remote workers as node invocation (proposed)
Remote workers as node invocation (proposed)