Primer
Some nodes are heavy or risky: they start programs, call models, run scripts or query databases. If they all run inside the one server that also holds the workflow engine, a busy node slows everything, a crashing node can take the server with it, and the machine has to be big. The workers module moves such a node onto another machine. The difficult part is keeping everything else the same: back-pressure, retries, timeouts, pause and stop must work as if the node were local, and a worker that dies in the middle must not hang the run.Where workers sits
The vocabulary in eight cards
One node attempt, from check-in to a lost worker
A worker checks in, a placed node is sent to it, results come back, and then the worker dies in the middle of the next attempt. Click a step to see what happens, why, and what can go wrong.Check in
REGISTER with the token, its id, pools, plugin types and capacity. The session handler checks the token, stores the connection and answers ACK.Why it works this way. The worker connects out, so it needs no open port. The token keeps strangers from registering.Can go wrong. With the default plain TCP the token and everything that follows can be read on the network. The token is shared by all workers, so any holder can register under any id.Stay alive
LEASE with the number of running nodes and a draining flag. The registry renews the lease (15 s) and answers ACK.Why it works this way. A worker that dies without saying so is removed after one lease, so nothing is sent to it for long.Can go wrong. The control plane answers a lease without checking that it is still the leader, so a control plane that lost the lease but is alive keeps its workers.Choose a worker
pick keeps workers of the node’s pool that run the plugin type, are not draining and have a free slot, and takes the one with most free slots.Why it works this way. Work goes where there is room, and only to workers that declared the plugin type.Can go wrong. The choice and the counter increment are separate steps, so two attempts chosen together can both take the last slot; the worker does not enforce its capacity.Send the job
node.run channel on the worker’s connection and sends START (execution id, node id, plugin type and the node config), then the input messages as JSON, then COMPLETE.Why it works this way. One stream per attempt keeps back-pressure and cancellation working end to end.Can go wrong. The node config goes on the wire as written, including any literal secret in it. Payloads become plain JSON, so Java types are lost, and there is no size limit.Worker runs it
initialize, prepare and process on the incoming messages, and sends each output message as a MESSAGE frame, HEARTBEAT frames when the plugin beats, then COMPLETE.Why it works this way. The plugin runs exactly as it would locally, with the execution id, node id and heartbeat in the Reactor context.Can go wrong. The live log sink and the trace context are not passed, so process output is not streamed live and spans do not join the run’s trace.Worker dies
Lost worker X during the attempt, a failure the engine’s retry policy can retry.Can go wrong. The effect the plugin already had on the outside world (a request sent, a file written) is not undone, so a retry can repeat it. Plugins must be idempotent on the message id.Retry elsewhere
pick finds no connection for it and chooses another worker.Why it works this way. A worker failure becomes a delay, not a failed run, as long as another worker in the pool has room.Can go wrong. If no other worker qualifies, the retry fails at once with ‘No connected worker’, which is retried again with the node’s backoff until the policy gives up.A worker’s day in plain words
Starts
Waits
Is chosen
Works
Is stopped
Is lost
Components
Roles & auto-configuration
control plane or worker Decides from properties whether this server accepts workers (control plane) or is one (worker), and switches the matching beans on.-
With
yukta.workers.tokenset andyukta.workers.control-planeempty, the server is a control plane: it gets the session handler, the connection map, the node executor and theworker.sessioncontroller. It also needsspring.rsocket.server.portfor the RSocket server to listen. -
With
yukta.workers.control-planeset (one or morehost:port, comma separated), the server is a worker: it gets theNodeRunnerand theWorkerAgent, which connects out. Setting both makes it a worker only. A worker without a token fails at start. -
Worker settings:
id,pools(defaultdefault),plugin-types(default empty, so it runs nothing),capacity(4),lease(15 s),drain-timeout(30 s) andssl-bundle. -
The package is
com.infenia.workers, outside the application’s component scan (com.infenia.yukta) on purpose: its two controllers would otherwise be scanned without their properties. The module is only on the classpath with-Pyukta.workers=true. -
The auto-configuration registers reflection hints for the two frame records and
DefaultMessagefor the native image. - Guarantees. A control plane never runs nodes for itself through this module; a worker never accepts workers.
- State. None. Beans are created once.
- Ceiling. Role and settings are fixed at start (and, in a native image, at build time).
- On failure. A missing property gives a start-up error (token) or a silently inactive role (no port, empty plugin types).
yukta.workers.token, yukta.workers.control-plane, yukta.workers.id, yukta.workers.pools, yukta.workers.plugin-types, yukta.workers.capacity, spring.rsocket.server.port
Registration & leases
WorkerSessionHandler Accepts or refuses a worker, keeps its lease alive, and removes it when it goes.-
A worker opens one channel on the route
worker.sessionand its first frame must beREGISTERwith the shared token. The handler refuses (REJECT, then the stream ends) when the first frame is something else, when this server is not the leader, when the token differs (compared in constant time), or when the registry refuses (blank id, capacity below 1). -
On success the handler stores the worker’s connection, answers
ACK, and then answers everyLEASEframe:ACKif core’sWorkerRegistrystill knows the worker and renews its lease,REJECT(‘lease expired, register again’) if not. Any other frame is rejected. - When the stream ends, the handler removes the connection and the registry entry, but only the ones this registration made (compared by the registration time), so a worker that registered again is not removed by its own old stream.
-
Core’s
WorkerRegistrykeeps each worker’s pools, plugin types, capacity, used slots, draining flag and lease end; a lease lasts 15 s and expired workers vanish fromlist(). It feedsGET /api/workersand the gaugesyukta.workersandyukta.worker.slots. -
The leader check happens only at registration. A
LEASEframe is answered without asking whether this server is still the leader. - Guarantees. A worker is listed only while its lease is fresh; a refused worker is told why.
- State. In memory: the registry map and the connection map. Nothing is persisted.
-
Ceiling. The registry is a map scanned and pruned on every
list(); fine for tens of workers. - On failure. A lost lease makes the next lease frame a REJECT, which makes the agent reconnect.
yukta.workers.lease, yukta.workers.token
Worker agent
WorkerAgent Runs on a worker: connects out to the control plane, registers, renews its lease and reconnects when the connection is lost.-
It tries the configured addresses in turn, one per attempt. The first frame is
REGISTER(token, id, pools, plugin types, capacity); then aLEASEframe every third of the lease (5 s) carrying the number of nodes running and whether it is draining. - When the connection ends or the control plane answers REJECT, the agent logs a warning and reconnects with an exponential pause from 1 s to 30 s, for ever. The retry does not say that failures are ‘transient’, so the count is not reset after a long healthy connection; the pause reaches its maximum after a few reconnects and stays there (by Reactor’s rules; not measured).
-
Each reconnect calls
connectagain and remembers only the latest connection, so a connection that ended with a REJECT is not closed by the agent. -
stop()drains first: it sends a lease withdraining=trueat once, polls every 50 ms until no node is running ordrain-timeout(30 s) has passed, and then disposes the connection. Attempts still running then fail on the control plane as a lost worker. -
The agent is a Spring bean with
initMethod=startanddestroyMethod=stop, so draining runs during application shutdown and delays it by up to the drain timeout. - Guarantees. A worker that loses its control plane keeps trying to reach one; a stopping worker stops taking new work before it closes.
- State. In memory: the current connection, the draining flag and a sink for urgent frames.
- Ceiling. One lease frame per 5 s per worker.
- On failure. Connection loss is retried; a REJECT is treated like a loss; there is no give-up.
yukta.workers.control-plane, yukta.workers.lease, yukta.workers.drain-timeout
Transport & TLS
RSocket over TCP or TLS Opens the RSocket connection from the worker to the control plane, plain or with mutual TLS.-
Without
yukta.workers.ssl-bundlethe worker uses plain TCP. With a bundle it builds a Netty TLS client from the bundle’s key manager (its certificate) and trust manager, and verifies the control plane’s host name against the certificate. -
On the control plane, TLS is switched on with Spring’s own RSocket server settings (
spring.rsocket.server.ssl.bundleandclient-auth=needfor mutual TLS). The shared token is still checked in the REGISTER frame. -
The connection is established by the worker, so workers need no open inbound port. The control plane later sends node work down the same connection (it calls
node.runon the worker’s own responder). -
WorkerConnectionsmaps a worker id to its connection on the control plane. - Guarantees. With TLS and a trusted certificate the worker registers; an untrusted certificate or none does not (tested with certificates made by keytool).
- State. In memory: the connection map.
- Ceiling. One connection per worker; node attempts are channels on it.
- On failure. A TLS failure is a connection failure, so the agent retries with its pause.
yukta.workers.ssl-bundle, spring.rsocket.server.ssl.bundle
Placement & selection
RemoteNodes, pick() Turnsengine.placement on a node into a call to a worker, and chooses which worker.
-
When a workflow is prepared,
RemoteNodesreplaces the plugin of a node that hasengine.placementwith aRemoteProcessor. The local plugin still supplies type, schema, timeouts and validation. Placement is refused on anything that is not a processor and on SESSION_MEMORY, KV_STORE, WAIT_FOR_SIGNAL and SUB_WORKFLOW (a fixed list), and fails to prepare when the workers module is not built in. -
For each attempt
pickkeeps the workers in the node’s pool that list the plugin type, are not draining and have a free slot, and takes the one with most free slots (the smallest id on a tie). Free slots are the worker’s capacity minus the larger of the used slots from its last lease and the attempts this control plane has sent to it. -
If no worker qualifies the attempt fails at once with a
WorkflowExecutionExceptionthat the engine’s retry policy can retry. - The choice and the counter increment are two steps, not one atomic step.
- Guarantees. A node is sent only to a worker that declared the plugin type and had a free slot at the time of the choice.
- State. In memory: the in-flight counter per worker.
- Ceiling. The choice reads the registry list and sorts it for every attempt.
- On failure. No worker means an immediate, retryable failure; nothing queues.
engine.placement
Control-plane executor
WorkerNodeExecutor Runs one node attempt on the chosen worker and splices the worker’s output back into the node’s stream.-
It opens one RSocket
requestChannelon the routenode.runover the worker’s own connection and sendsSTART(execution id, node id, plugin type, node config), then each input message as aMESSAGEframe, thenCOMPLETE. -
Frames coming back are handled one by one:
MESSAGEbecomes a message again,HEARTBEATcalls the node’s heartbeat (so the idle clock restarts),COMPLETEends the stream,ERRORbecomes an exception. - Back-pressure and cancellation are RSocket’s: the control plane’s demand limits the worker, and a stop or timeout on the control plane cancels the channel so the worker disposes the plugin pipeline.
-
An error frame whose class is
IllegalArgumentExceptionbecomes anIllegalArgumentException(bad input, never retried). Any other class becomes aWorkflowExecutionExceptionwhose text names the worker, the class and the message. An error that is not one of those two types and not from a frame (the connection closing) becomes ‘Lost worker X during the attempt’, which the engine can retry. -
Messages are converted to and from plain JSON maps with the application’s
ObjectMapper. - Guarantees. Retries, onError, timeouts, pause and checkpoints keep working because they wrap this call exactly as they wrap a local plugin.
- State. In memory: the in-flight counter per worker.
- Ceiling. The control plane sees every message in both directions (it is a data hop).
- On failure. A lost worker mid-attempt is a retryable failure and the retry can land on another worker (tested, also with SIGKILL).
Node runner
NodeRunner Runs on a worker: receives a node attempt, runs the local plugin on the incoming messages and sends the output back.-
It is a Spring
@Controllerwith the routenode.run. The first frame must beSTART; the plugin type must be inyukta.workers.plugin-typesand must be a processor, else anIllegalArgumentExceptionerror frame comes back. -
For each attempt it calls the plugin’s
initializeandpreparewith the node config, thenprocesson the incoming messages. It puts the execution id, node id and a heartbeat callback into the Reactor context, as the local assembler does. It does not put the log sink or the run’s trace into the context. -
Output messages go back as
MESSAGEframes, thenCOMPLETE; any error becomes oneERRORframe with the class name and message. Plugin heartbeats becomeHEARTBEATframes. - A counter of running attempts feeds the lease frames and the drain. The runner itself enforces no capacity: it runs whatever the control plane sends.
- Guarantees. A worker runs only the plugin types it was started with, and only processors.
- State. In memory: the count of running attempts.
- Ceiling. Initialise and prepare run again for every attempt (a message, with per-message policies).
- On failure. Any plugin error ends the attempt with an error frame; nothing is retried on the worker.
yukta.workers.plugin-types, yukta.workers.capacity
Wire frames
WorkerFrame, NodeFrame Defines what crosses the wire: two small record types, one for the worker session and one for a node attempt.-
WorkerFramehas the types REGISTER, LEASE, ACK and REJECT and fields for token, worker id, pools, plugin types, capacity, used slots, draining and a message. -
NodeFramehas the kinds START, MESSAGE, HEARTBEAT, COMPLETE and ERROR and fields for execution id, node id, plugin type, config, message (a JSON map), error class and error message. - Both are serialised as JSON by Spring’s RSocket codecs. Neither carries a version. The design document also planned a SIGNAL frame, trace context, an attempt number and deadlines in START; none of these exists.
-
The records, and
DefaultMessage, are registered for reflection so a native image can read them. - Guarantees. A frame is a plain value; once built it is not changed.
- State. None.
- Ceiling. Messages are not size-limited by the module.
- On failure. A control plane and a worker built from different versions must still agree on these two records; nothing checks it.
Failover & drain
standbys, graceful stop Keeps workers attached to the right control plane and lets a worker leave without cutting off running nodes.-
With several control planes (a leader and standbys), a standby answers REGISTER with
REJECT 'this server is not the leader'; the agent tries the next address after its pause, so a worker ends up on the leader and finds the new one after a failover. - When the leader process dies, its connections close; the workers’ agents reconnect (see the backoff risk) and the new leader registers them.
- When a worker is stopped it drains (stops taking work, waits for running nodes up to the drain timeout), and the control plane skips draining workers when it picks.
- A control plane that lost the lease but is still running keeps the workers it already has: it checks leadership only when a worker registers.
- Guarantees. A worker never gets new work after it started draining; a control plane that is a standby never registers workers.
- State. In memory on both sides.
- Ceiling. Failover takes at least the lease of the control plane plus the workers’ pause.
- On failure. See the Failover and Drain scenarios.
yukta.workers.drain-timeout, yukta.ha.mode
Tests
real control plane and worker Shows the behaviour with a real control plane and real workers, not mocks.- Most tests start a control plane and a worker as two Spring applications in one JVM and talk over a real RSocket connection: remote node execution, lost worker, drain, follow-the-leader and the transport.
-
WorkersTlsTestmakes certificates with keytool and checks that a trusted worker registers and an untrusted one, or one with no certificate, does not. -
ChaosTeststarts workers as separate JVMs (WorkerMain) and kills one with SIGKILL during an attempt: the attempt fails retryably and the retry runs on the other worker. -
The boot module has acceptance tests for the control plane and the worker module, run with
-Pyukta.workers=true. - Guarantees. The failure cases that matter (lost worker, kill -9, drain, standby) are tested.
- State. Ports and processes started per test.
- Ceiling. Not tested: a deposed leader that stays alive, a very large message, a version mismatch, a native image.
- On failure. Tests need free local ports and, for chaos, the ability to start a JVM.
Native image
GraalVM roles Makes the control plane and the worker usable as native binaries.-
Spring decides property-conditioned beans when a native image is built, so a native image has to be built as the role it will have:
-Pyukta.workers=true -Pyukta.workers.native=control-planeorworker. The real token, address and id are read when it starts. - A native image built without that switch starts but takes no workers.
- Status in the module’s notes: both roles build and start, and a control plane reports no workers; a native worker did not register because the frame records had no reflection hints. The hints are added but not yet verified in a native image, because the rebuild was stopped for lack of memory.
- Guarantees. A JVM build is the verified path; the native path is not yet verified end to end.
- State. None.
- Ceiling. A native image is fixed to one role.
- On failure. A native worker may start and not register.
Scenarios
A placed node runs
A placed processor node from workflow preparation to its last output message.A worker joins
GET /api/workers).The workflow is prepared
engine.placement {pool}. Core replaces its plugin with a RemoteProcessor; preparing fails if the workers module is not built in, or if the node is SESSION_MEMORY, KV_STORE, WAIT_FOR_SIGNAL or SUB_WORKFLOW.An attempt starts
pick takes the worker with most free slots; the executor opens node.run and sends START with the node config, then the input messages.The worker runs the plugin
It ends
A worker dies mid-attempt
What the control plane and the engine do when a worker disappears in the middle of an attempt.The process is killed
The channel fails
Lost worker X during the attempt (a WorkflowExecutionException).The engine retries
pick no longer finds the dead worker’s connection and chooses another worker of the pool. This is tested with workers in separate JVMs.Side effects
attempt header let a plugin de-duplicate.A worker is stopped on purpose
How a worker leaves without cutting off the nodes it is running.Stop is requested
stop() sends a LEASE with draining=true at once.No new work
pick skips it. Nodes already running continue.Wait for the work
Close
The control plane changes leader
How workers find the new leader, and the case where they do not.Several addresses
The leader process dies
The new leader registers them
A leader that is only deposed
A stranger tries to register
Who may register, what a registered worker can see, and what mutual TLS adds.A wrong token
The right token, any identity
Then the jobs arrive
With mutual TLS
Capabilities
What this module does, how, what it guarantees and where the guarantee stops.Scale-out
Streaming
Registry
Failure
Security
High availability
Packaging
What carries over from a local node, and what does not
A placed node is wrapped by the same engine policies as a local one; these tables say where that holds and where it stops.Semantics of a placed node
What can run on a worker
Limits and defaults
Reference
Settings on each side
A server is a control plane when it has a token and no control-plane address; a worker when it has a control-plane address.The frames
Two record types; every frame has a kind and only the fields it needs.Do and don’t for operators
Short rules that follow from the guarantees.Architecture decisions
Workers connect out; the control plane calls back on that connection
Workers connect out; the control plane calls back on that connection
RSocket requestChannel per node attempt
RSocket requestChannel per node attempt
Remote node invocation, not whole runs on workers
Remote node invocation, not whole runs on workers
Lease-based registry in memory
Lease-based registry in memory
At-least-once for placed nodes
At-least-once for placed nodes
Drain before closing, then fail what is left
Drain before closing, then fail what is left