Extend Flow / Write a dispatcher
Verified against Flow 5.0.0-beta.39
A dispatcher is any process that claims task runs from Flow, runs them and reports how they ended. This page is for developers who write one with the dispatcher SDK. It covers the dependency, the handler, the shapes a dispatcher takes, and testing. The protocol's rules, from claims to compatibility, are in the Dispatcher protocol reference. To deploy the dispatcher Flow ships, see Flow's Kubernetes dispatcher. For how dispatchers fit into Flow, see Dispatchers.
spring-web (Spring Framework 7) and Jackson 3, and nothing else: no Spring Boot and no Kubernetes client.read:packages, to read the SDK from GitHub Packages.global Flow token with an actor kind. Declare one in core's configuration, where the actor kind defaults to SERVICE, or create one in Admin › Tokens with Actor set to Service or Agent. Flow refuses a token with no actor kind. See Give the dispatcher its token.http://flow:7700.The protocol is the boundary, not the process. A dispatcher runs as its own process only where a boundary separates it from Flow's engine: credentials the engine must not hold, a different network zone, isolation of untrusted content, or placement on infrastructure the engine can't reach. Without one, the dispatcher runs inside another process.
| Shape | Use when | How |
|---|---|---|
| A worker on infrastructure of its own | A boundary separates your runtime from the engine: Kubernetes, a serverless container, a VM with Docker | Run the minimal program as a service. Run several with different task types or filters to split the work. |
| A one-shot worker in a warm pool | Each task needs a fresh sandbox, such as a microVM, that runs one task and exits | Capacity 1, and stop after the first task. See A one-shot worker. |
| In-process, inside a host product | No boundary: the host product submits runs and runs their tasks | A bean in the host's process, using the host's HTTP client, log capture and tracing. See In-process. |
Flow's Kubernetes dispatcher is the first shape, built on the same SDK.
Every product tag publishes the SDK to GitHub Packages as io.boomerang:dispatcher-sdk, at the product's version. Use the version of the Flow you run against:
<repository>
<id>boomerang-flow</id>
<url>https://maven.pkg.github.com/boomerang-io/flow</url>
</repository>
<dependency>
<groupId>io.boomerang</groupId>
<artifactId>dispatcher-sdk</artifactId>
<version>5.0.0-beta.39</version>
</dependency>Maven sends your GitHub token from the <server> entry in ~/.m2/settings.xml whose id is boomerang-flow. SLF4J and logback are optional: add them only to capture task logs.
Implement TaskHandler and give it to Dispatcher.builder(). This is a complete dispatcher:
TaskHandler handler =
(task, context) -> {
// task.getSpec() has the image, command, script, env; task.getParams() the resolved params.
String output = runSomewhere(task);
context.uploadLog(output); // optional: the engine stores and serves it
return TaskResult.of(List.of(new RunResult("exitCode", "0")));
};
try (Dispatcher dispatcher =
Dispatcher.builder()
.engine("http://flow:7700", dispatcherToken) // a Flow machine token with an actor kind
.name("my-dispatcher") // with the host, its identity across restarts
.taskTypes(List.of("template", "custom", "script"))
.tasks(handler, () -> 10) // at most 10 in flight; 0 is no cap
.build()) {
dispatcher.start(); // returns at once; registers, then polls
waitForShutdown();
} // close() drains for 10 s, then stopsThe SDK tells Flow a task is starting before it calls run, and doesn't call it when Flow says not to. It reports the end from what run returns or throws:
| Situation | In the handler | What Flow does |
|---|---|---|
| The task succeeded | Return TaskResult.of(results) or TaskResult.of(results, message), or TaskResult.empty() for no results |
Ends it succeeded |
| It failed with a reason | Throw new TaskFailure("OOMKilled", message), with the results written before the failure if there are any |
Fails it, or retries it if the node asks for retries |
| A retry can't change the outcome | Throw TaskFailure.terminal("JobFailed", message), sent with retryable: false |
Fails it now, whatever the node's retry setting |
| The task never ran: no capacity, never began, refused for rate, or the runtime couldn't be reached | Throw TaskFailure.exceededQuota(message), startTimeout(message), rateLimited(message) or clusterUnavailable(message) |
Requeues it for another attempt |
| Anything else is thrown | Nothing more | Fails it with DispatchError |
| Flow ordered it terminated | context.isCancelled() turns true, context.onCancel(action) runs and your handler's cancel(task) is called. Stop and return. |
Nothing: the SDK reports no end |
StatusReason names every reason a dispatcher sends. Which end reasons requeue a task? says what Flow does with each.
A TaskRun is the order. These are the parts a handler uses:
| Method | Returns |
|---|---|
getSpec() |
The image, command, arguments, script, environment and working directory, with every reference filled in |
getParams(), param(name) |
The resolved parameters, each typed string (when the type is absent), array, object or secret |
getResults() |
The results the task declares. Report only these names. |
getTimeout() |
The budget in minutes. Null or 0 is unguarded. |
getTaskRef() |
The task's slug form, as the task filter takes it |
getWorkspace() |
The run workspace to mount, when the workflow declares one |
getAttempt(), getAttempts(), getRetry(), isLastAttempt() |
Which attempt this is, how earlier attempts ended, and whether a failure reported now is the node's last |
traceparent() |
The task run's W3C trace context |
A secret parameter's value is plain text on the order, so keep it out of your own logs. A for-each item is an ordinary task run with two more parameters, item and index. To run catalogue images, give the container the variables in What does the container receive?
Flow times a task out itself at its deadline and sends a terminate order. A dispatcher may also enforce timeout on its runtime: Flow's Kubernetes dispatcher does, and reports DeadlineExceeded.
To test a handler, call it with a TaskRun and new TaskContext(task, "d-1", null). To test the client, bind a MockRestServiceServer to the RestClient you pass to DispatcherClient.
A microVM or sandbox that takes one task and exits. Give it capacity 1 and stop after the first task:
CountDownLatch done = new CountDownLatch(1);
Dispatcher dispatcher =
Dispatcher.builder()
.engine(url, token).name("sandbox").host(sandboxId)
.taskTypes(List.of("script"))
.tasks((task, context) -> { try { return run(task, context); } finally { done.countDown(); } }, () -> 1)
.build();
dispatcher.start();
done.await();
dispatcher.stop(); // the end has been reported by now; the lease ends with the processEach poll asks for at most one task, so a worker never claims more than it can run. Use a stable name and let host vary. Each worker leaves a registration behind until Flow removes it, 7 days after its last connection. A pool controller decides how many workers to keep warm, but Flow has no queue-depth read to size the pool from.
The host product both submits runs and runs their tasks. Build the dispatcher as a bean in the host's process, with the host's RestClient so its proxy, certificates and timeouts apply. Capture the handler's logs as the task log, since there's no route for Flow to read them live, and hand it the host's ObservationRegistry so each task's spans join the run's trace:
TaskLogCapture taskLogs = new TaskLogCapture(); // 64 KB head + 64 KB tail per task
TaskLogAppender.attach(taskLogs); // logback; see "Logs" in the protocol reference for log4j2
Dispatcher.builder()
.client(new DispatcherClient(hostRestClient, engineUrl, token))
.name("in-process").taskTypes(List.of("ai"))
.tasks(handler, () -> 4)
.captureTaskLogs(taskLogs)
.observationRegistry(observationRegistry)
.build();There's no workflows(...) and no logs(...): an in-process dispatcher needs neither storage nor a live log route. The token goes on engine calls only. See Embed Flow.