A typed Java 17+ client over the Taskito Rust core, via a hand-written JNI shell
(crates/taskito-java).
Feature-complete: producer + inspection + admin + logs, worker task execution,
middleware, JSON/signed/encrypted/MessagePack serializers, dashboard, webhooks,
CLI, distributed locks, periodic/cron, and the full workflow engine (DAG,
fan-out/fan-in, gates, conditions, sub-workflows, sagas, analysis +
visualization, canvas). Also: worker resources (DI), enqueue predicates, a KEDA
scaler endpoint, producer batching, in-process autoscaling, observability
middleware (Micrometer Observation + Sentry), and a Spring Boot 3 starter.
Baseline: Java 17 (--release 17); on JDK 22+ hot byte ops take a Panama
(FFM) fast path automatically. The native library is bundled per platform —
no separate install.
// Gradle
implementation("org.byteveda:taskito:0.18.0")
annotationProcessor("org.byteveda:taskito-processor:0.18.0") // compile-time TaskHandler bindings<!-- Maven -->
<dependency>
<groupId>org.byteveda</groupId>
<artifactId>taskito</artifactId>
<version>0.18.0</version>
</dependency><!-- Maven: the processor is wired through the compiler plugin, not a dependency -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.byteveda</groupId>
<artifactId>taskito-processor</artifactId>
<version>0.18.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>Companion artifacts: org.byteveda:taskito-test (in-memory backend for unit
tests) and org.byteveda:taskito-spring (Boot 3 starter).
0.18 — source-breaking (pre-1.0): the client interface was renamed and the
name Queue now denotes a single named queue.
- The client you open is now
Taskito, notQueue:Taskito client = Taskito.builder()…open();(wasQueue queue = …).Taskito.builder()is unchanged. Queueis a per-queue handle fromTaskito.queue(name), exposingpause()/resume()/isPaused().client.pauseQueue("emails")→client.queue("emails").pause()(likewiseresume).listPausedQueues()stays on the client as the global view.
import com.fasterxml.jackson.core.type.TypeReference;
import org.byteveda.taskito.Taskito;
import org.byteveda.taskito.model.Job;
import org.byteveda.taskito.model.JobStatus;
import org.byteveda.taskito.model.QueueStats;
import org.byteveda.taskito.task.Task;
import java.util.Map;
// TypeReference preserves generics that a Class token can't; fluent options
// replace the EnqueueOptions builder for the common cases.
Task<Map<String, Object>> sendEmail =
Task.of("send_email", new TypeReference<Map<String, Object>>() {})
.queue("emails")
.priority(5);
try (Taskito taskito = Taskito.builder().sqlite("taskito.db").open()) {
String id = taskito.enqueue(sendEmail, Map.of("to", "a@b.c"));
Job job = taskito.getJob(id).orElseThrow(); // job.status == JobStatus.PENDING
QueueStats stats = taskito.stats();
taskito.cancel(id);
// Pause/resume are scoped to one named queue:
taskito.queue("emails").pause();
taskito.queue("emails").resume();
}Taskito.builder() also has .postgres(url) / .redis(url) shortcuts.
import org.byteveda.taskito.events.EventName;
import org.byteveda.taskito.task.Task;
import org.byteveda.taskito.worker.Worker;
Task<Map> add = Task.of("add", Map.class);
Taskito taskito = Taskito.builder().backend("sqlite").url("taskito.db").open();
Worker worker = taskito.worker()
.handle(add, p -> ((Number) p.get("a")).intValue() + ((Number) p.get("b")).intValue())
.concurrency(4)
.on(EventName.SUCCESS, e -> System.out.println("done: " + e.jobId))
.start();
// Close on SIGTERM/Ctrl-C; awaitShutdown() then unblocks. (Don't put the worker
// in try-with-resources AND call awaitShutdown() inside it — the block can't
// exit to trigger close(), so it would deadlock.)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
worker.close();
taskito.close();
}));
worker.awaitShutdown();Annotate handler methods; a compile-time processor generates a <Class>Tasks
companion with a typed Task constant per method (full generics, name declared
once) plus a bind(...). Add the processor with
annotationProcessor("org.byteveda:taskito-processor").
class EmailTasks {
@TaskHandler("send_email") // explicit name
String send(EmailPayload p) { ... }
@TaskHandler // name defaults to "report"
Report report(List<Metric> metrics) { ... }
}
// generated EmailTasksTasks:
String id = taskito.enqueue(EmailTasksTasks.SEND, payload);
taskito.worker()
.apply(b -> EmailTasksTasks.bind(b, new EmailTasks()))
.start();The annotation is source-retention and the processor emits plain code — zero runtime reflection, GraalVM-native-image friendly.
Define a DAG of tasks; steps run in topological order once every predecessor
finishes. Attach trackWorkflows() to the worker so node and run state advance
as jobs complete.
import org.byteveda.taskito.task.Task;
import org.byteveda.taskito.worker.Worker;
import org.byteveda.taskito.workflows.Workflow;
import org.byteveda.taskito.workflows.WorkflowRun;
import org.byteveda.taskito.workflows.WorkflowStatus;
Task<Integer> extract = Task.of("extract", Integer.class);
Task<Integer> transform = Task.of("transform", Integer.class);
Task<Integer> load = Task.of("load", Integer.class);
Workflow wf = Workflow.named("etl")
.step("extract", extract, 1)
.step("transform", transform, 2, "extract")
.step("load", load, 3, "transform");
WorkflowRun run = taskito.submitWorkflow(wf);
try (Worker worker = taskito.worker()
.handle(extract, p -> p * 10)
.handle(transform, p -> p + 1)
.handle(load, p -> p)
.trackWorkflows()
.start()) {
WorkflowStatus status = run.await(Duration.ofSeconds(30));
// status.state == WorkflowState.COMPLETED; status.node("load").get().status
}A failed step (after its retries) fails the run and skips downstream nodes;
run.cancel() skips pending nodes.
Payloads can also be supplied at submit instead of baked into each step —
declare structural steps with stepAfter(name, task, deps...) and pass a map:
Workflow etl = Workflow.named("etl")
.stepAfter("extract", extract)
.stepAfter("transform", transform, "extract")
.stepAfter("load", load, "transform");
taskito.submitWorkflow(etl, Map.of("extract", 5, "transform", 6, "load", 7));A step's effective payload is map.get(name) when present, else the one baked
into the step.
Fan-out / fan-in map a step over a producer's result list and gather the results:
Workflow wf = Workflow.named("pipeline")
.step("seed", seed, 4) // returns List.of(1,2,3,4)
.fanOut("square", square, "each", "seed") // runs square(x) per item
.fanIn("sum", sum, "all", "square"); // sum receives [1,4,9,16]trackWorkflows() advances run state from worker outcomes, so every worker
that processes workflow jobs must enable it — in a multi-worker deployment, a
run stalls on any node finished by a worker that did not opt in.
Approval gates park a step until it is resolved (or its timeout elapses); register the workflow so the worker holds downstream payloads:
Workflow wf = Workflow.named("deploy")
.step("build", build, 1)
.gate("approve", GateConfig.timeout(Duration.ofMinutes(30), GateAction.REJECT), "build")
.step("ship", ship, 2, "approve");
try (Worker w = taskito.worker().handle(build, ...).handle(ship, ...).trackWorkflows(wf).start()) {
w.approveGate(run.runId(), "approve"); // or w.rejectGate(runId, "approve", reason)
}Conditions gate a step on its predecessors' outcomes —
Step.of(...).onFailure() / .onSuccess() / .always(), or a callable
condition(ctx -> ...). Sub-workflows run a child workflow as a step
(Workflow.subWorkflow(name, child, after...)). Sagas roll a failed run
back: Step.of(...).compensate(undoTask) compensates completed steps in
reverse order, ending the run COMPENSATED.
WorkflowAnalysis (topological order, levels, ancestors/descendants),
WorkflowVisualization (Mermaid / DOT), and Canvas (chain/group/chord)
round out the engine.
taskito.use(new Middleware() {
@Override public void onEnqueue(EnqueueContext ctx) { /* validate / rewrite */ }
@Override public void before(TaskContext ctx) { /* trace */ }
@Override public void onDeadLetter(OutcomeEvent e) { /* alert */ }
});try (DashboardServer dashboard = DashboardServer.start(queue, 8080, token, staticDir)) {
// GET /api/stats, /api/jobs, /api/workers, ... ; POST /api/jobs/{id}/cancel, ...
dashboard.port();
}byte[] key = ...; // 16/24/32 bytes for AES
Taskito secure = Taskito.builder()
.backend("sqlite").url("taskito.db")
.serializer(new EncryptedSerializer(new JsonSerializer(), key))
.open();The primary way to use a non-serializable dependency (pool, client, logger) in a
handler: register it once and resolve it inside the worker. Scopes: WORKER
(built once, shared) and TASK (built + disposed per invocation).
taskito.resource("db", ctx -> openPool()); // WORKER
taskito.resource("tx", ResourceScope.TASK, ctx -> ctx.<Pool>use("db").begin(), Tx::close);
taskito.worker().handle(save, p -> Resources.<Tx>use("tx").save(p)).start();When a handler takes @Resource parameters, the @TaskHandler processor wires
them from the runtime for you — no Resources.use call needed.
Secondary to resources: when a specific resource identity must travel inside a
payload to another process, carry a signed ProxyRef and rebuild it on the
worker. Bind an optional TTL and purpose — both are folded into the HMAC.
Proxies proxies = new Proxies(hmacKey).register(new FileProxyHandler());
ProxyRef ref = proxies.deconstruct(file, Duration.ofMinutes(5), "report"); // producer
File same = proxies.resolve(ref, "report"); // worker (expiry + purpose checked)taskito.predicate("send_email", ctx -> payloadValid(ctx)); // boolean: false → PredicateRejectedException
taskito.gate("send_email", Recipes.businessHours(zone)); // allow / skip / defer / reject
Optional<String> id = taskito.tryEnqueue(emailTask, msg); // empty when a gate skips
// Recipes: businessHours / timeWindow / dayOfWeek / payloadMatches / featureFlag.try (Batcher<Event> batcher = Batcher.of(taskito, ingest, 500, Duration.ofMillis(200))) {
events.forEach(batcher::add); // flushed in one enqueueMany when full or after the delay
}taskito.worker().autoscale(AutoscaleOptions.of(2, 32)).handle(task, ...).start(); // resize by depth
try (Scaler scaler = Scaler.start(taskito, ScalerOptions.onPort(9090))) { /* GET /api/scaler for KEDA */ }taskito.use(new TaskitoObservation(observationRegistry)); // Micrometer (metrics + tracing)
taskito.use(new SentryMiddleware()); // report failures to Sentry
Taskito.builder().sqlite("t.db").serializer(new MsgpackSerializer()).open();io.micrometer:micrometer-observation, io.sentry:sentry, and
org.msgpack:jackson-dataformat-msgpack are compileOnly — add the one you use.
Add org.byteveda:taskito-spring; it auto-configures a Taskito bean from
taskito.url / taskito.pool-size / taskito.namespace. Define your own
Taskito bean to override it.
Packages are organized by feature; the root holds only the front door.
org.byteveda.taskito
├── Taskito client interface + entry — Taskito.builder()...open()
├── Queue named-queue handle (pause/resume) — Taskito.queue(name)
├── DefaultTaskito package-private client impl (not exported)
├── NamedQueue package-private Queue impl (not exported)
├── TaskitoException unchecked error base type
├── errors/ typed exceptions (Serialization/Crypto, Workflow, Lock,
│ Configuration, Webhook, Resource, PredicateRejected)
├── task/ Task, TaskFunction, EnqueueOptions
├── model/ Job, JobStatus, QueueStats, DeadJob, JobError,
│ TaskMetric, WorkerInfo, TaskLog, JobFilter (read-only views)
├── worker/ Worker runtime (concurrency, autoscale)
├── resources/ worker DI — ResourceRuntime, Resources.use(name), scopes
├── proxies/ signed cross-process references — Proxies, ProxyRef, handlers
├── interception/ enqueue-time arg interception — Interceptor, Interception
├── predicates/ enqueue gates — Predicate, EnqueueGate, EnqueueDecision, Recipes
├── batch/ Batcher — producer-side batching
├── autoscale/ Autoscaler, AutoscaleOptions
├── scaler/ Scaler — KEDA HTTP endpoint
├── locks/ Lock, LockInfo
├── scheduling/ PeriodicTask
├── workflows/ DAG builder, run, status, tracker; gates, conditions,
│ sub-workflows, sagas, Canvas, analysis + visualization
├── serialization/ Serializer SPI + JsonSerializer default; Signed/Encrypted/Msgpack
├── annotation/ @TaskHandler (source-retention; see :processor)
├── middleware/ Middleware hooks
├── contrib/ observability — TaskitoObservation (Micrometer), SentryMiddleware
├── events/ worker outcome events
├── dashboard/ webhooks/ cli/
├── spi/ QueueBackend — seam between API and the native layer
└── internal/ JNI bindings (NativeQueue, NativeWorkflows, NativeLoader, ...)
Subprojects: :processor (compile-time @TaskHandler), :test-support
(taskito-test in-memory backend), :spring (taskito-spring Boot 3 starter),
:graalvm-smoke (native-image CI check).
The :processor subproject is a standalone compile-time annotation processor
(TaskHandlerProcessor) — it depends on nothing, reading @TaskHandler
structurally and emitting plain task companions.
The spi.QueueBackend seam keeps the public API independent of JNI: it can be
backed by the native library (default) or an in-memory fake in tests, and leaves
room for a future FFM/Panama backend without touching the API.
./gradlew build # cargoBuild → stage native → compile → test → jar
./gradlew test # JUnit 5
./gradlew spotlessApply # format (palantir-java-format)
./gradlew spotlessCheck # verify formatting
./gradlew checkstyleMain # static analysis
./gradlew check # test + spotlessCheck + checkstyleThe Rust shell is built and checked with the workspace tooling:
cargo build -p taskito-java --release --features postgres,redis
cargo fmt -p taskito-java -- --check
cargo clippy -p taskito-java -- -D warningsAt runtime the platform binary is extracted from the JAR and loaded. For local development against a freshly built library, point the loader at it directly:
-Dtaskito.native.lib=/abs/path/to/target/release/libtaskito_java.soOn JDK 22+ the hot byte ops use a Project Panama (FFM) fast path; older JDKs (or
a jar built without the overlay) transparently fall back to JNI. FFM calls a
restricted native method, so a future JDK will deny it by default. The jar's
Enable-Native-Access: ALL-UNNAMED manifest attribute only covers java -jar;
apps that use the SDK as a classpath dependency should launch with
--enable-native-access=ALL-UNNAMED to grant access and silence the warning.