add = Task.of("add", int[].class).retries(3);
+
+try (Taskito queue = Taskito.builder().sqlite("tasks.db").open();
+ Worker worker = queue.worker()
+ .handle(add, p -> p[0] + p[1])
+ .start()) {
+ String id = queue.enqueue(add, new int[] {2, 3});
+ queue.awaitJob(id, java.time.Duration.ofSeconds(10));
+ System.out.println(queue.getResult(id, Integer.class).orElseThrow()); // → 5
+}`,
+ output: [
+ { glyph: "$", glyphKind: "p", text: "java -cp app.jar Tasks" },
+ {
+ glyph: "→",
+ glyphKind: "p",
+ text: "worker started · Rust core attached",
+ },
+ {
+ glyph: "✓",
+ glyphKind: "g",
+ text: "add(2, 3) =",
+ value: "5",
+ timing: "10 ms",
+ },
+ ],
+ docHref: "/java/getting-started/quickstart",
+ docLabel: "Read the Java quickstart",
+ },
];
export interface IconCard {
From 2af3871279a001b71bfb14ea6ec0d89179b18d82 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:01:16 +0530
Subject: [PATCH 11/16] docs: fix callout paragraph spacing
---
docs/app/styles/docs.css | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/docs/app/styles/docs.css b/docs/app/styles/docs.css
index 26605f90..8bae1b3e 100644
--- a/docs/app/styles/docs.css
+++ b/docs/app/styles/docs.css
@@ -525,6 +525,8 @@ body {
flex: none;
width: 22px;
height: 22px;
+ /* Optically center the icon on the first text line. */
+ margin-top: 1px;
color: var(--indigo-br);
}
.callout .cc {
@@ -532,6 +534,18 @@ body {
line-height: 1.6;
color: var(--txt2);
}
+/* MDX wraps callout text in ; prose margins/size would push it off the
+ icon line and override the callout's tighter type scale. */
+.callout .cc p {
+ font-size: inherit;
+ line-height: inherit;
+}
+.callout .cc > p:first-child {
+ margin-top: 0;
+}
+.callout .cc > p:last-child {
+ margin-bottom: 0;
+}
.callout .cc strong {
color: var(--txt);
font-weight: 600;
From a4f21901ca477ce556f6cb1d608cddf056da56ae Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:35:04 +0530
Subject: [PATCH 12/16] docs: document Java SDK in ARCHITECTURE.md
---
ARCHITECTURE.md | 89 ++++++++++++++++++++++++++++++-------------------
1 file changed, 55 insertions(+), 34 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b931849c..a39122af 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -21,31 +21,33 @@ It is deliberately *not* a tutorial on running Taskito (see the
Taskito is a polyglot system: **one Rust engine underneath, a thin language SDK shell
on top per host language** — Python via [PyO3](https://pyo3.rs), Node via
-[napi-rs](https://napi.rs). Each shell owns ergonomics and extensibility for its
+[napi-rs](https://napi.rs), Java via [JNI](https://github.com/jni-rs/jni-rs) (with an
+FFM fast-path on JDK 22+). Each shell owns ergonomics and extensibility for its
language; Rust owns storage, scheduling, dispatch, rate limiting, and worker
management. A shell talks to the core only through its compiled binding crate
-(`taskito._taskito` for Python, the `.node` addon for Node) — **a shell never reaches
-into core internals, and the core never imports a host-language type except at the
-binding edge.** The `WorkerDispatcher` trait in `taskito-core` is binding-free, so a
-new shell implements one trait against
+(`taskito._taskito` for Python, the `.node` addon for Node, the JNI `.so`/FFM for
+Java) — **a shell never reaches into core internals, and the core never imports a
+host-language type except at the binding edge.** The `WorkerDispatcher` trait in
+`taskito-core` is binding-free, so a new shell implements one trait against
[`BINDING_CONTRACT.md`](crates/taskito-core/BINDING_CONTRACT.md).
```text
-┌───────────────────────────────────┐ ┌───────────────────────────────────┐
-│ PYTHON SDK sdks/python/taskito/│ │ NODE SDK sdks/node/ │
-│ Queue (app.py) = 15 mixins · │ │ Queue/Worker · CLI · dashboard │
-│ async_support · serializers │ │ events/middleware · webhooks │
-│ FEATURE SUBSYSTEMS (pure-Python): │ │ resources · serializers │
-│ interception resources proxies │ │ (Node-native equivalents, not │
-│ workflows contrib batching … │ │ 1:1 ports of Python idioms) │
-└─────────────────┬─────────────────┘ └─────────────────┬─────────────────┘
- │ PyO3 (taskito._taskito) │ napi-rs (.node addon)
-┌───────────────────────────────────┐ ┌───────────────────────────────────┐
-│ crates/taskito-python/ (PyO3) │ │ crates/taskito-node/ (napi-rs) │
-│ py_queue/ · async_worker · │ │ JsQueue/JsWorker · dispatcher · │
-│ prefork bridge │ │ convert/ │
-└─────────────────┬─────────────────┘ └─────────────────┬─────────────────┘
- └───both implement ─┬─ WorkerDispatcher─┘
+┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
+│ PYTHON sdks/python │ │ NODE sdks/node │ │ JAVA sdks/java │
+│ Queue = 15 mixins · │ │ Queue/Worker · CLI · │ │ Queue→DefaultTaskito │
+│ async · serializers │ │ dashboard · events · │ │ workflows · DI · │
+│ interception · │ │ middleware · webhooks │ │ proxies · interception│
+│ resources · proxies │ │ resources · │ │ codecs · spring · │
+│ workflows · contrib │ │ serializers │ │ processor (@Task) │
+└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
+ PyO3 (._taskito) napi-rs (.node) JNI + FFM (.so)
+┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
+│ taskito-python │ │ taskito-node │ │ taskito-java │
+│ py_queue/ · │ │ JsQueue/JsWorker · │ │ backend · dispatcher │
+│ async_worker · │ │ dispatcher · convert │ │ convert · ffi · jvm │
+│ prefork bridge │ │ │ │ worker · mesh │
+└───────────┬───────────┘ └───────────┬───────────┘ └───────────┬───────────┘
+ └─ all three implement ───┬─ WorkerDispatcher ──────┘
┌─────────────────────────────────────┴─────────────────────────────────────┐
│ RUST CORE crates/taskito-core/ (no host language) │
│ scheduler/ (poll · dispatch · retry · reap · wake) │
@@ -60,7 +62,7 @@ new shell implements one trait against
WORKFLOWS crates/taskito-workflows/ — separate crate, own schema & stores
(SQLite · Postgres · Redis), surfaced per shell (Python:
- py_queue/workflow_ops/ · Node: bound in taskito-node)
+ py_queue/workflow_ops/ · Node & Java: bound in their crates)
MESH crates/taskito-mesh/ — optional decentralized work-stealing
overlay (SWIM gossip · hash ring · TCP steal); `mesh` feature
NATIVE ASYNC taskito-python/src/native_async/ — optional native-async pool
@@ -68,16 +70,17 @@ new shell implements one trait against
```
The dependency arrows point **downward only**. `taskito-core` knows nothing about
-Python, Node, PyO3, or napi; each binding crate (`taskito-python`, `taskito-node`)
-depends on `taskito-core`; each SDK package depends on its compiled addon. This
-acyclic shape is the property that keeps the codebase changeable — guard it.
+Python, Node, Java, PyO3, napi, or JNI; each binding crate (`taskito-python`,
+`taskito-node`, `taskito-java`) depends on `taskito-core`; each SDK package depends
+on its compiled addon. This acyclic shape is the property that keeps the codebase
+changeable — guard it.
---
## Layers & responsibilities
> Sections 1–2 describe the **Python SDK** (the original, most complete shell).
-> Sections 6–9 cover the shared crates and the **Node SDK**, the second shell.
+> Sections 6–10 cover the shared crates and the **Node** and **Java** SDKs.
### 1. Python API surface — `sdks/python/taskito/`
@@ -136,7 +139,7 @@ feature): `mod.rs`, `inspection.rs`, `worker.rs`, and `workflow_ops/` (lifecycle
nodes, fan_out, gates, queries, saga). `async_worker.rs` drives the
`AsyncWorkerPool` with `spawn_blocking` + GIL management. This layer converts
Python values to Rust and back; it holds **no business logic**. It implements the
-core's `WorkerDispatcher` trait — the same contract the Node binding implements.
+core's `WorkerDispatcher` trait — the same contract the Node and Java bindings implement.
### 4. Rust core — `crates/taskito-core/`
@@ -194,7 +197,24 @@ webhooks, and resource DI. Python-idiom features (proxies, interception) get Nod
**equivalents**, not 1:1 ports. The DB stays the source of truth, so a Python and a
Node worker can share one queue.
-### 9. Mesh — `crates/taskito-mesh/`
+### 9. Java SDK — `sdks/java/` + `crates/taskito-java/`
+
+The third language shell, peer to Python and Node. `crates/taskito-java/` is a
+[JNI](https://github.com/jni-rs/jni-rs) binding crate (`backend.rs`, `dispatcher.rs`,
+`convert.rs`, `queue/`, `workflows/`, `worker.rs`, `mesh.rs`) that implements the same
+core `WorkerDispatcher` trait, with an FFM fast-path (`ffi.rs`, `ffi_c.rs`) that
+bypasses JNI on JDK 22+. `sdks/java/` is the Gradle project (package
+`org.byteveda.taskito`, baseline JDK 17): a call on `Queue`/`Taskito` routes
+`DefaultTaskito → core.CoreFacade → spi.QueueBackend → internal.JniQueueBackend →
+JNI`, so the JNI seam is the shell's single point of contact with Rust. Feature
+packages mirror the other shells — `workflows/`, `resources/` (DI), `proxies/`,
+`interception/`, `serialization/` (payload codecs), `middleware/`, `webhooks/`,
+`autoscale/`, `contrib/`. Extra Gradle subprojects: `:processor` (compile-time
+`@TaskHandler` processing, GraalVM-clean), `:test-support` (JNI-free
+`InMemoryQueueBackend`), `:spring`, `:graalvm-smoke`. Python-idiom features get Java
+**equivalents**, not 1:1 ports; the DB stays the shared source of truth.
+
+### 10. Mesh — `crates/taskito-mesh/`
Optional (`mesh` feature). A decentralized work-stealing overlay — SWIM gossip
(UDP), a consistent-hash ring, a local deque, and TCP work-stealing — composed
@@ -211,11 +231,12 @@ violates one as a design regression, not a style nit.
1. **Dependencies point downward only.** SDK → its binding crate → core → storage.
`taskito-core` must not depend on any binding crate (`taskito-python`,
- `taskito-node`) or host-language type.
+ `taskito-node`, `taskito-java`) or host-language type.
2. **Each binding crate is its language's only seam.** A shell touches Rust solely
via its compiled addon's public surface — no reaching into struct internals. Keep
the surface contract in sync: `_taskito.pyi` for Python, the generated `.d.ts` for
- Node. A new language implements `WorkerDispatcher` against `BINDING_CONTRACT.md`.
+ Node, the `spi.QueueBackend` interface for Java. A new language implements
+ `WorkerDispatcher` against `BINDING_CONTRACT.md`.
3. **Asyncio is confined to `async_support/`** (plus the narrow, documented
exceptions: `app.py` uses only `iscoroutinefunction`; `contrib/fastapi.py`).
No inline `import asyncio` to dodge a boundary — split the module instead.
@@ -242,8 +263,8 @@ detailed versions.
4. Implement it for Redis in `redis_backend/`.
5. Wire it through the `delegate!` macro in `storage/mod.rs`.
6. Expose it on each shell that needs it: PyO3 in `crates/taskito-python/src/py_queue/`
- (then add the signature to `sdks/python/taskito/_taskito.pyi`) and/or napi in
- `crates/taskito-node/src/queue/`.
+ (then add the signature to `sdks/python/taskito/_taskito.pyi`), napi in
+ `crates/taskito-node/src/queue/`, and/or JNI in `crates/taskito-java/src/queue/`.
7. Test: a Rust test in `storage/sqlite/tests.rs` + the contract suite (runs against
all three backends in CI).
@@ -268,9 +289,9 @@ detailed versions.
## Why this scales
-The codebase is large (≈25k LOC Python, ≈29k LOC Rust across 5 crates, ≈7k LOC
-Node/TS) but not heavy: no god-objects, an acyclic dependency graph, one binding seam
-per language, and duplication erased by macros. The biggest files are deduplicating
+The codebase is large (≈25k LOC Python, ≈37k LOC Rust across 6 crates, ≈12k LOC Java,
+≈7k LOC Node/TS) but not heavy: no god-objects, an acyclic dependency graph, one
+binding seam per language, and duplication erased by macros. The biggest files are deduplicating
macros (`diesel_common/jobs.rs`) or inherently complex state machines (saga, scheduler)
— large because the *problem* is, not because concerns are tangled. Drift comes from
eroding the six boundary rules above; hold them and the design scales with the
From 18745c55df497b01a230b37dbe76cf379d2e7830 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:53:46 +0530
Subject: [PATCH 13/16] docs(java): add api-reference pages
---
.../docs/java/api-reference/batching.mdx | 91 ++++++++++++++++
.../docs/java/api-reference/canvas.mdx | 84 ++++++++++++++
.../content/docs/java/api-reference/meta.json | 23 +++-
.../docs/java/api-reference/overview.mdx | 90 +++++++++++++++
docs/content/docs/java/api-reference/saga.mdx | 103 ++++++++++++++++++
.../docs/java/api-reference/testing.mdx | 89 +++++++++++++++
6 files changed, 479 insertions(+), 1 deletion(-)
create mode 100644 docs/content/docs/java/api-reference/batching.mdx
create mode 100644 docs/content/docs/java/api-reference/canvas.mdx
create mode 100644 docs/content/docs/java/api-reference/overview.mdx
create mode 100644 docs/content/docs/java/api-reference/saga.mdx
create mode 100644 docs/content/docs/java/api-reference/testing.mdx
diff --git a/docs/content/docs/java/api-reference/batching.mdx b/docs/content/docs/java/api-reference/batching.mdx
new file mode 100644
index 00000000..443f0c35
--- /dev/null
+++ b/docs/content/docs/java/api-reference/batching.mdx
@@ -0,0 +1,91 @@
+---
+title: Batching
+description: "Batcher — buffer payloads and flush them as one enqueueMany call."
+---
+
+```java
+import org.byteveda.taskito.batch.Batcher;
+```
+
+`Batcher` buffers payloads for one task and enqueues them in a single
+`enqueueMany` call once the buffer reaches a size limit or a delay elapses —
+producer-side batching, to cut round-trips when many small jobs share a task.
+
+
+ This is a different concept from `Worker.Builder.batchSize(int)`. `Batcher`
+ batches *what a producer enqueues*; `Worker.Builder.batchSize` controls *how
+ many jobs a worker claims per scheduler poll* (dequeue batching). They share
+ a name only — see [Worker](/java/api-reference/worker#options).
+
+
+## Constructor
+
+```java
+Batcher(Taskito queue, Task task, int maxBatch, Duration maxDelay)
+static Batcher of(Taskito queue, Task task, int maxBatch, Duration maxDelay)
+```
+
+| Parameter | Description |
+|---|---|
+| `queue` | The client to enqueue through. |
+| `task` | The task every buffered payload is enqueued against. |
+| `maxBatch` | Flush once the buffer reaches this many payloads. Must be > 0. |
+| `maxDelay` | Flush this long after the first buffered payload arrived, even if `maxBatch` isn't reached. Must be positive. |
+
+```java
+try (Batcher batcher = Batcher.of(taskito, sendEmail, 100, Duration.ofMillis(500))) {
+ for (Email email : emails) {
+ batcher.add(email);
+ }
+}
+```
+
+## `add`
+
+```java
+List add(T payload)
+```
+
+Buffers `payload`. Returns the flushed job ids if this call pushed the buffer
+to `maxBatch` (triggering an immediate flush), otherwise an empty list — the
+delayed flush is still pending.
+
+## `flush`
+
+```java
+List flush()
+```
+
+Enqueues whatever is currently buffered right now, cancelling any pending
+delayed flush. Returns the new job ids, or an empty list if the buffer was
+empty.
+
+## `close`
+
+```java
+void close()
+```
+
+`AutoCloseable`. Flushes any remaining buffered payloads, then stops the
+batcher's background scheduler. Use try-with-resources so nothing buffered is
+lost when the producer shuts down.
+
+`Batcher` is backed by a single-thread daemon `ScheduledExecutorService` and
+guards its buffer with an internal lock — `add`/`flush`/`close` are safe to
+call from multiple threads.
+
+## Underlying producer calls
+
+`Batcher` is a thin wrapper over the batch producer methods on
+[`Taskito`](/java/api-reference/queue):
+
+```java
+ List enqueueMany(Task task, List payloads)
+ List enqueueMany(Task task, List payloads, EnqueueOptions options)
+ List enqueueAll(Task task, List payloads) // alias of enqueueMany
+```
+
+All three enqueue the full list in one storage call and return job ids in
+input order (no dedup). A single `EnqueueOptions` applies to every job in the
+batch — there is no per-job options list, so a batch can't mix, say,
+different priorities or delays across its items in one call.
diff --git a/docs/content/docs/java/api-reference/canvas.mdx b/docs/content/docs/java/api-reference/canvas.mdx
new file mode 100644
index 00000000..365cf97d
--- /dev/null
+++ b/docs/content/docs/java/api-reference/canvas.mdx
@@ -0,0 +1,84 @@
+---
+title: Canvas
+description: "Compact signature reference for Canvas.link, chain, group, and chord."
+---
+
+```java
+import org.byteveda.taskito.workflows.Canvas;
+```
+
+Shortcuts that build a [`Workflow`](/java/api-reference/workflows) from a few
+`Canvas.link` building blocks — sequential, parallel, or a parallel group
+joined by a callback. For worked examples and diagrams, see the
+[Canvas guide](/java/guides/workflows/canvas).
+
+## `Canvas.link`
+
+```java
+static Link link(String name, Task task, T payload)
+```
+
+A named step bound to a task and payload. `Link` is otherwise opaque — pass
+it straight into `chain`, `group`, or `chord`.
+
+## `Canvas.chain`
+
+```java
+static Workflow chain(String name, Link... links)
+```
+
+Runs `links` one after another — each link's step depends on the previous
+one.
+
+```java
+Workflow etl = Canvas.chain("etl",
+ Canvas.link("extract", extractTask, source),
+ Canvas.link("transform", transformTask, null),
+ Canvas.link("load", loadTask, null));
+
+taskito.submitWorkflow(etl);
+```
+
+## `Canvas.group`
+
+```java
+static Workflow group(String name, Link... links)
+```
+
+Runs `links` in parallel — no dependencies between them.
+
+```java
+Workflow notify = Canvas.group("notify",
+ Canvas.link("email", sendEmail, message),
+ Canvas.link("sms", sendSms, message));
+
+taskito.submitWorkflow(notify);
+```
+
+## `Canvas.chord`
+
+```java
+static Workflow chord(String name, Link callback, Link... group)
+```
+
+Runs `group` in parallel, then `callback` once every member of `group`
+completes. The callback depends on every group link's step; it runs with its
+own configured payload, not the group's aggregated results.
+
+```java
+Workflow report = Canvas.chord("report",
+ Canvas.link("merge", mergeTask, null), // callback
+ Canvas.link("q1", queryRegion, "east"),
+ Canvas.link("q2", queryRegion, "west"));
+
+taskito.submitWorkflow(report);
+```
+
+## Submitting
+
+Every `Canvas` constructor returns a full `Workflow` — there's no separate
+canvas-specific submit path. Submit it like any other workflow via
+[`Taskito.submitWorkflow`](/java/api-reference/queue), and build the worker
+with `.trackWorkflows()` so the run's state and node statuses are tracked.
+`chain`/`group`/`chord` steps are plain task nodes, so no deferred-node
+registration (`trackWorkflows(workflow)`) is required for them.
diff --git a/docs/content/docs/java/api-reference/meta.json b/docs/content/docs/java/api-reference/meta.json
index 3d70c26b..371cf33a 100644
--- a/docs/content/docs/java/api-reference/meta.json
+++ b/docs/content/docs/java/api-reference/meta.json
@@ -1 +1,22 @@
-{ "title": "API Reference", "root": true, "pages": ["index", "queue", "task", "worker", "result", "context", "resources", "serializers", "workflows", "errors", "cli"] }
+{
+ "title": "API Reference",
+ "root": true,
+ "pages": [
+ "index",
+ "overview",
+ "queue",
+ "task",
+ "worker",
+ "result",
+ "context",
+ "resources",
+ "serializers",
+ "workflows",
+ "canvas",
+ "saga",
+ "batching",
+ "testing",
+ "errors",
+ "cli"
+ ]
+}
\ No newline at end of file
diff --git a/docs/content/docs/java/api-reference/overview.mdx b/docs/content/docs/java/api-reference/overview.mdx
new file mode 100644
index 00000000..948ca947
--- /dev/null
+++ b/docs/content/docs/java/api-reference/overview.mdx
@@ -0,0 +1,90 @@
+---
+title: Overview
+description: "The layered architecture behind the client — from the public Taskito interface down to the native core."
+---
+
+```java
+import org.byteveda.taskito.Taskito;
+
+try (Taskito taskito = Taskito.builder().sqlite("taskito.db").open()) {
+ // taskito is the client — every operation in this reference goes through it
+}
+```
+
+The public API is a thin, typed shell over the Rust core. App code only ever
+touches the first layer below; the rest exist so the SDK stays testable and
+the native boundary stays narrow.
+
+## Layers
+
+| Layer | Package | Role |
+|---|---|---|
+| `Taskito` | `org.byteveda.taskito` | The public entry point. An `AutoCloseable` interface built via `Taskito.builder()...open()`. Every method documented in this reference is declared here. |
+| `DefaultTaskito` | `org.byteveda.taskito` (package-private) | The only implementation of `Taskito`. Owns predicates, gates, interceptors, resources, and middleware; delegates producer/inspection/admin calls to a `QueueBackend` and job-await polling to `CoreFacade`. |
+| `CoreFacade` | `org.byteveda.taskito.core` | Translation and durable-emulation layer between the typed API and the backend's JSON wire views — e.g. `awaitJob` polls `getJobJson` until the job reaches a terminal status. |
+| `QueueBackend` | `org.byteveda.taskito.spi` | The SPI every backend implements. App code depends only on this interface; it's also what a test fake targets (see [Testing](/java/api-reference/testing)). |
+| `JniQueueBackend` | `org.byteveda.taskito.internal` (package-private) | The production `QueueBackend` — calls into the native shell over JNI. All scheduling, storage, and locking lives in the Rust core; this layer only marshals JSON and bytes across the boundary. |
+
+A call like `taskito.enqueue(task, payload)` flows `Taskito` (interface) →
+`DefaultTaskito` (serializes the payload, runs gates/interceptors/middleware)
+→ `QueueBackend` (`JniQueueBackend` in production) → the native core.
+
+## `Queue` is a handle, not the client
+
+`Taskito.queue(name)` returns a `Queue` — a handle to one *named queue*, not
+the client itself:
+
+```java
+public interface Queue {
+ String name();
+ void pause();
+ void resume();
+ boolean isPaused();
+}
+```
+
+
+ Everything else — enqueue, inspect, workers, workflows, locks, periodics —
+ lives on `Taskito`. Reach for `Queue` only to pause or resume one named
+ queue; see [Taskito](/java/api-reference/queue).
+
+
+## Packages
+
+Every package lives under `org.byteveda.taskito`:
+
+| Package | Contents |
+|---|---|
+| *(root)* | `Taskito`, `Queue`, `NamedQueue`, `TaskitoException` |
+| `task` | `Task`, `EnqueueOptions`, `RetryPolicy` |
+| `annotation` | `@TaskHandler`, `@Resource`, `@Compressed`, `@Encrypted` |
+| `worker` | `Worker`, `Worker.Builder`, `Handler`, `HandlerRegistry` |
+| `workflows` | `Workflow`, `Step`, `Canvas`, `WorkflowRun`, gates, sagas, analysis |
+| `resources` | Worker dependency injection — scopes, pools, `ResourceContext` |
+| `serialization` | `Serializer`, `JsonSerializer`, `MsgpackSerializer`, payload codecs |
+| `locks` | `Lock`, `LockInfo` |
+| `predicates` | Enqueue-time gates — `Predicate`, `EnqueueGate`, `Recipes` |
+| `interception` | Enqueue interceptors — `Interceptor`, `Interception` |
+| `proxies` | Non-serializable argument proxies — `ProxyHandler`, `ProxySession` |
+| `middleware` | `Middleware`, `EnqueueContext`, `TaskContext` |
+| `events` | `EventName`, `OutcomeEvent` |
+| `errors` | The `TaskitoException` hierarchy |
+| `model` | Wire-view records — `Job`, `QueueStats`, `WorkerInfo`, … |
+| `scheduling` | `PeriodicTask` |
+| `autoscale` | `AutoscaleOptions`, `Autoscaler` |
+| `batch` | `Batcher` |
+| `webhooks` | `Webhook`, `WebhookManager` |
+| `contrib` | Optional integrations (Micrometer, Sentry) |
+| `dashboard` | Dashboard asset serving |
+| `cli` | The `taskito` command |
+| `spi` | `QueueBackend` — the interface every backend implements |
+| `core` | `CoreFacade` — translation and durable emulation |
+| `internal` | `JniQueueBackend` and the JNI transport (package-private) |
+
+## Maven coordinates
+
+| Artifact | Contents |
+|---|---|
+| `org.byteveda:taskito` | The SDK itself. |
+| `org.byteveda:taskito-test` | `InMemoryTaskito` / `InMemoryQueueBackend` — see [Testing](/java/api-reference/testing). |
+| `org.byteveda:taskito-spring` | The Spring Boot 3 starter. |
diff --git a/docs/content/docs/java/api-reference/saga.mdx b/docs/content/docs/java/api-reference/saga.mdx
new file mode 100644
index 00000000..9dfe3def
--- /dev/null
+++ b/docs/content/docs/java/api-reference/saga.mdx
@@ -0,0 +1,103 @@
+---
+title: Saga
+description: "Step.Builder.compensate, the saga run/node states, and the rollback contract."
+---
+
+Saga compensation is declared per step, at build time — there is no
+separate saga type or task-level default compensator. For the conceptual
+walkthrough and a full example, see the [Sagas guide](/java/guides/workflows/saga).
+
+## `Step.Builder.compensate`
+
+```java
+Step.Builder compensate(String compensateTask)
+Step.Builder compensate(Task> compensateTask)
+```
+
+Registers a rollback task for the step. If the run later fails, completed
+compensable steps roll back in reverse-dependency order, each compensation
+job receiving the step's own forward result as its payload.
+
+```java
+Workflow checkout = Workflow.named("checkout")
+ .step(Step.of("reserve", reserve, orderId).compensate(unreserve).build())
+ .step(Step.of("charge", charge, orderId).after("reserve").compensate(refund).build())
+ .step(Step.of("ship", ship, orderId).after("charge").build());
+```
+
+## Validation
+
+`Step.build()` rejects a compensator on nodes that have no forward result to
+replay:
+
+| Step kind | `compensate` allowed |
+|---|---|
+| Plain task step | Yes |
+| Fan-in step | Yes — the collected list is a real forward result |
+| Fan-out step | No — `IllegalArgumentException` at `build()` |
+| Gate | No — `IllegalArgumentException` at `build()` |
+| Sub-workflow | No — `IllegalArgumentException` at `build()` |
+
+## Compensator functions
+
+A compensator is a plain task handler — no special interface. Register it
+like any other task and give its payload type the forward task's result type:
+
+```java
+record Charge(String chargeId, String orderId) {}
+
+Task charge = Task.of("chargePayment", String.class);
+Task refund = Task.of("refundPayment", Charge.class);
+
+worker.handle(charge, orderId -> new Charge(stripe.charge(orderId).id(), orderId));
+worker.handle(refund, result -> { stripe.refund(result.chargeId()); return null; });
+```
+
+Each compensation job inherits the forward step's `queue`, `maxRetries`,
+`timeoutMs`, and `priority` — it retries and dead-letters like any other job.
+
+## Run states
+
+`WorkflowState` gains three saga states, all counted by `isTerminal()` except
+`COMPENSATING`:
+
+| State | Terminal | Meaning |
+|---|:---:|---|
+| `COMPENSATING` | No | The run failed and rollback jobs are in flight. |
+| `COMPENSATED` | Yes | All compensators for the run completed successfully. |
+| `COMPENSATION_FAILED` | Yes | At least one compensator itself failed; remaining un-rolled-back steps are left as-is. |
+
+`WorkflowRun.await(...)` unblocks on any terminal state, including these two.
+
+## Node states
+
+`NodeStatus` gains the parallel per-node states:
+
+| Status | Meaning |
+|---|---|
+| `COMPENSATING` | A compensation job has been enqueued for this node. |
+| `COMPENSATED` | The node's compensation job succeeded. |
+| `COMPENSATION_FAILED` | The node's compensation job exhausted its retries. |
+
+Only nodes that actually ran their forward task this run (`COMPLETED`) are
+compensable — `CACHE_HIT`, `FAILED`, `SKIPPED`, and pending nodes never had a
+side effect here to undo, so they're left alone.
+
+## Rollback mechanics
+
+When a run with compensable steps fails:
+
+1. Completed compensable steps are grouped into reverse-dependency waves —
+ deepest (most downstream) nodes first.
+2. Each wave's compensation jobs are enqueued in parallel; the next wave only
+ dispatches once the current one fully drains.
+3. If every wave drains without a failure, the run ends `COMPENSATED`. If any
+ compensation job fails, rollback stops there (fail-stop) and the run ends
+ `COMPENSATION_FAILED` — later waves are never dispatched.
+
+Every compensation job is enqueued with a deterministic unique key,
+`compensation:{runId}:{nodeName}`, and `compensation: true` metadata
+alongside `workflow_run_id` / `workflow_node_name` — so a tracker restart
+mid-rollback re-enqueues idempotently instead of duplicating the job, and a
+compensation job's own outcome is routed back into the saga rather than the
+forward run.
diff --git a/docs/content/docs/java/api-reference/testing.mdx b/docs/content/docs/java/api-reference/testing.mdx
new file mode 100644
index 00000000..c2da8e42
--- /dev/null
+++ b/docs/content/docs/java/api-reference/testing.mdx
@@ -0,0 +1,89 @@
+---
+title: Testing
+description: "InMemoryTaskito and InMemoryQueueBackend — a pure-Java backend for fast unit tests."
+---
+
+```java
+import org.byteveda.taskito.test.InMemoryTaskito;
+```
+
+Ships in the `org.byteveda:taskito-test` artifact (the `:test-support`
+subproject). The testing model is: swap in an in-memory backend, run a real
+in-process worker against it, and synchronize on results the same way you
+would against a native backend. For a walkthrough with assertions, see the
+[Testing guide](/java/guides/operations/testing).
+
+```kotlin
+testImplementation("org.byteveda:taskito-test:0.18.0")
+```
+
+## `InMemoryTaskito`
+
+```java
+static Taskito open()
+static Taskito open(Serializer serializer)
+```
+
+Opens a [`Taskito`](/java/api-reference/queue) over a fresh
+`InMemoryQueueBackend`. `open()` uses the default JSON serializer; `open(Serializer)`
+swaps it. To share one backend instance across a custom builder, construct it
+explicitly:
+
+```java
+Taskito taskito = Taskito.builder().open(new InMemoryQueueBackend());
+```
+
+## `InMemoryQueueBackend`
+
+A pure-Java `QueueBackend` implementation — no JNI, no disk. It provides the
+full producer, inspection, admin, locks, and log surface, plus a small
+in-process polling worker: dispatch → complete/fail/cancel → retry or
+dead-letter, honoring per-worker queue filters and priority/FIFO ordering
+among pending jobs.
+
+```java
+try (Taskito taskito = InMemoryTaskito.open()) {
+ String id = taskito.enqueue("add", List.of(2, 3));
+
+ try (Worker worker = taskito.worker()
+ .handle("add", List.class, numbers -> (int) numbers.get(0) + (int) numbers.get(1))
+ .start()) {
+ taskito.awaitJob(id, Duration.ofSeconds(5));
+ }
+
+ assertEquals(5, taskito.getResult(id, Integer.class).orElseThrow());
+}
+```
+
+## Limitations
+
+| Capability | Behavior |
+|---|---|
+| Workflows | **Unsupported.** Every workflow `QueueBackend` method (`submitWorkflow`, `expandFanOut`, `getWorkflowStatusJson`, …) throws `UnsupportedOperationException`. Use a native backend (e.g. a throwaway SQLite file) for workflow tests. |
+| Periodic tasks | `registerPeriodic` and friends are recorded in a catalog — `listPeriodic`, `deletePeriodic`, `pausePeriodic` all work against it — but registered periodics never fire. |
+
+## Faking dependencies
+
+There is no dedicated mock-resource type — register a stub factory the same
+way you'd register a real one:
+
+```java
+taskito.resource("db", context -> fakeDb);
+```
+
+The worker injects whatever is registered, so no real connection is opened
+during the test.
+
+## Synchronization
+
+`awaitJob(id, timeout)` blocks until the job reaches a terminal state and is
+the simplest synchronization point. An event listener with a `CountDownLatch`
+works too:
+
+```java
+CountDownLatch done = new CountDownLatch(1);
+Worker worker = taskito.worker()
+ .handle("echo", String.class, payload -> payload.length())
+ .on(EventName.SUCCESS, event -> done.countDown())
+ .start();
+```
From 6ee28c31592ecbd5f0060ac5c300f68ce32cd675 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:53:46 +0530
Subject: [PATCH 14/16] docs(java): add resources config/observability/testing
guides
---
.../java/guides/resources/configuration.mdx | 118 ++++++++++++++++++
.../docs/java/guides/resources/meta.json | 2 +-
.../java/guides/resources/observability.mdx | 75 +++++++++++
.../docs/java/guides/resources/testing.mdx | 94 ++++++++++++++
4 files changed, 288 insertions(+), 1 deletion(-)
create mode 100644 docs/content/docs/java/guides/resources/configuration.mdx
create mode 100644 docs/content/docs/java/guides/resources/observability.mdx
create mode 100644 docs/content/docs/java/guides/resources/testing.mdx
diff --git a/docs/content/docs/java/guides/resources/configuration.mdx b/docs/content/docs/java/guides/resources/configuration.mdx
new file mode 100644
index 00000000..32289935
--- /dev/null
+++ b/docs/content/docs/java/guides/resources/configuration.mdx
@@ -0,0 +1,118 @@
+---
+title: Configuration
+description: "The resource() overloads, choosing a scope, teardown, and PoolConfig tuning."
+---
+
+Every resource is registered programmatically through `Taskito.resource(...)`
+— there is no external config file to load and nothing to reload at runtime.
+Registration happens once per client, before you start a worker over it, and
+the same call shape applies whether the resource lives for the whole worker
+or for a single task.
+
+## Choosing a scope
+
+Pass a `ResourceScope` as the second argument (or omit it for the `WORKER`
+default):
+
+| Scope | Lifetime |
+|---|---|
+| `WORKER` (default) | One shared instance per worker |
+| `THREAD` | One instance per worker thread |
+| `TASK` | One instance per task invocation |
+| `REQUEST` | Fresh instance on every `use()` |
+| `POOLED` | Bounded pool, checked out per task |
+
+See [dependency injection](/java/guides/resources/dependency-injection) for
+what each scope is good for. This page walks through registering one of each:
+
+```java
+// WORKER — built once, shared by every task
+taskito.resource("db", ResourceScope.WORKER, ctx -> openPool(url), pool -> pool.close());
+
+// THREAD — built once per worker thread, not thread-safe so not shared further
+taskito.resource("formatter", ResourceScope.THREAD,
+ ctx -> new SimpleDateFormat("yyyy-MM-dd"));
+
+// TASK — built per invocation, torn down when it ends
+taskito.resource("tx", ResourceScope.TASK,
+ ctx -> beginTransaction(),
+ tx -> tx.rollbackIfOpen());
+
+// REQUEST — fresh every time a handler calls use(), never cached
+taskito.resource("requestId", ResourceScope.REQUEST,
+ ctx -> UUID.randomUUID().toString());
+
+// POOLED — bounded pool, sized by a PoolConfig
+taskito.resource("ftp",
+ PoolConfig.of(4).withPoolMin(1).withAcquireTimeout(Duration.ofSeconds(10)),
+ ctx -> connectFtp(),
+ conn -> conn.close());
+```
+
+## The `resource()` overloads
+
+| Overload | Effective scope | Disposer |
+|---|---|---|
+| `resource(name, factory)` | `WORKER` | none |
+| `resource(name, scope, factory)` | the given scope | none |
+| `resource(name, scope, factory, dispose)` | the given scope | runs when the scope ends |
+| `resource(name, pool, factory, dispose)` | `POOLED` (implied by the `PoolConfig`) | runs when the pool retires an instance |
+
+Every overload returns the `Taskito` client, so registrations chain:
+
+```java
+Taskito taskito = Taskito.builder().sqlite("tasks.db").open()
+ .resource("config", ctx -> loadConfig())
+ .resource("db", ctx -> {
+ Config config = ctx.use("config");
+ return openPool(config.databaseUrl());
+ });
+```
+
+## Teardown
+
+The `dispose` argument releases whatever the factory built. It runs when the
+resource's scope ends — worker and thread resources when the worker stops,
+task and request resources when the task finishes, pooled instances when the
+pool retires them (worker shutdown or `maxLifetime` expiry) — in reverse
+order of construction (LIFO), so a resource is always torn down before
+anything it depended on. A disposer that throws is logged, never propagated —
+it can't fail an already-settled job.
+
+## Pool tuning — `PoolConfig`
+
+| Field | Default | Tuning effect |
+|---|---|---|
+| `poolSize` | required | Hard cap on concurrent checkouts. Too low serializes tasks behind `acquireTimeout` waits; too high defeats the point of bounding an expensive resource. |
+| `poolMin` | `0` | Instances built eagerly at worker start. Raise it to avoid cold-start latency on the first burst of tasks; leave it at `0` for a pool that's rarely used. |
+| `acquireTimeout` | 10s | How long a checkout waits before failing the task with `ResourceException`. Shorter turns exhaustion into a fast, visible failure instead of a slow task. |
+| `maxLifetime` | unlimited | An idle instance older than this is disposed and rebuilt instead of reused — bounds how long a single instance can go without refreshing (e.g. a rotating credential). |
+
+`PoolConfig.of(poolSize)` gives the defaults; derive variations with
+`withPoolMin`, `withAcquireTimeout`, and `withMaxLifetime` — each returns a
+new, immutable `PoolConfig`.
+
+## Validation
+
+Registration fails immediately, before any factory ever runs, when the shape
+is inconsistent:
+
+- **Duplicate name** — registering the same name twice throws
+ `ResourceException`.
+- **`POOLED` without a `PoolConfig`**, or a `PoolConfig` on any other scope —
+ both throw `IllegalArgumentException` out of the registration call itself.
+
+One rule only surfaces once a factory runs, since it depends on what the
+factory does:
+
+- **Scope dependency guard** — inside a factory, `ctx.use(...)` may only
+ resolve same-or-longer-lived resources: `WORKER` and `POOLED` factories may
+ resolve only `WORKER` resources, a `THREAD` factory may resolve `WORKER` or
+ `THREAD`, and `TASK`/`REQUEST` factories may resolve any scope. Reaching for
+ a shorter-lived resource, or a cycle between factories, throws
+ `ResourceException` the first time the factory builds.
+
+
+ Register every resource before starting a worker over the client — a
+ worker leases the client's resource definitions once, at `worker().start()`.
+
diff --git a/docs/content/docs/java/guides/resources/meta.json b/docs/content/docs/java/guides/resources/meta.json
index 4b4d18b2..7c0f9aed 100644
--- a/docs/content/docs/java/guides/resources/meta.json
+++ b/docs/content/docs/java/guides/resources/meta.json
@@ -1 +1 @@
-{ "title": "Resources", "pages": ["dependency-injection", "interception", "proxies"] }
+{ "title": "Resources", "pages": ["configuration", "dependency-injection", "interception", "proxies", "observability", "testing"] }
diff --git a/docs/content/docs/java/guides/resources/observability.mdx b/docs/content/docs/java/guides/resources/observability.mdx
new file mode 100644
index 00000000..c0a81252
--- /dev/null
+++ b/docs/content/docs/java/guides/resources/observability.mdx
@@ -0,0 +1,75 @@
+---
+title: Observability
+description: "Read per-resource created / disposed / active counters with resourceMetrics()."
+---
+
+The resource runtime keeps three counters per registered name: how many
+instances were built, how many were disposed, and how many are live right
+now. Read them straight from the `Taskito` client — there's no separate
+collector, dashboard endpoint, or background reporter involved.
+
+## `resourceMetrics()`
+
+```java
+Map metrics = taskito.resourceMetrics();
+
+ResourceStat db = metrics.get("db");
+System.out.printf("db: created=%d disposed=%d active=%d%n",
+ db.created(), db.disposed(), db.active());
+```
+
+`ResourceStat` is a record of three counters:
+
+| Field | Meaning |
+|---|---|
+| `created` | Instances built since the client opened. |
+| `disposed` | Instances disposed — including pooled instances the pool has retired. |
+| `active` | `created - disposed`: instances currently live. |
+
+Every registered name appears in the map from the moment it's registered,
+with all three counters at `0` until something first builds it — you don't
+need a task to have run for a resource to show up.
+
+## Reading the counters
+
+### Live count — `active`
+
+A `WORKER`-scoped resource's `active` is `1` while the worker holds it and
+`0` before first use or after the worker closes. A `TASK`-scoped resource's
+`active` sits at `0` between jobs — it's built and disposed within a single
+invocation — and only reads `1` if you happen to sample while a task is
+mid-flight.
+
+### Leak detection — `created` vs `disposed`
+
+Both counters only climb; they never reset while the client is open. A
+growing gap between them across many completed jobs — rather than the
+expected transient blip while a task-scoped resource is in flight — points at
+a disposer that isn't running: a missing `dispose` argument, or a task-scoped
+resource retained somewhere past its task.
+
+### Pooled resources
+
+A `POOLED` resource reports through the same three counters: `created` and
+`disposed` move only when the pool actually builds or retires an instance —
+checkout and return don't touch them — so `active` tracks how many pool
+members currently exist, not how many are checked out right now:
+
+```java
+ResourceStat ftp = taskito.resourceMetrics().get("ftp");
+// created climbs toward poolSize as tasks force new instances to be built,
+// then stays flat once the pool is warm and checkouts start reusing them
+```
+
+## Typical uses
+
+- Periodic diagnostic logging from a monitoring thread or scheduled task.
+- Asserting `active == 0` after a worker closes in a test, to catch a
+ resource that outlived its scope.
+- Watching a pooled resource's `created` count under load to confirm
+ checkouts are reusing instances rather than rebuilding on every task.
+
+