diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 99cfa4ef..9a83838d 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -49,6 +49,9 @@ jobs:
- name: Lint
run: pnpm lint
+ - name: Content parity
+ run: pnpm check:parity
+
- name: Build
env:
DOCS_BASE_PATH: /taskito
diff --git a/docs/README.md b/docs/README.md
index 35e28957..d6fb2a1f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -47,3 +47,39 @@ app/
Adding a page: drop an `.mdx` file under `content/docs/`, add it to the directory's
`meta.json`. It is picked up, prerendered, indexed for search, and slotted into the
sidebar automatically.
+
+## Shared content (one file, one URL per SDK)
+
+A file under `content/docs/shared/` mounts at the same path in **every** SDK tree:
+`shared/guides/operations/mesh.mdx` serves `/python/guides/operations/mesh`,
+`/node/...`, and `/java/...` from one source. Slug + fan-out logic lives in
+`app/lib/doc-slugs.ts` — the runtime loader, prerender walk, manifest plugin, and
+parity checks all resolve through it. Non-default-SDK mounts carry a
+` ` to the default-SDK URL, and `llms.txt` lists each shared
+page once.
+
+Authoring rules for shared pages:
+
+- **Prose is SDK-neutral.** Use ` ` / ``
+ for language-specific words; wrap SDK-specific paragraphs in ``.
+- **Code goes in ``** with one `` per SDK. CI fails a
+ shared page whose CodeTabs misses an SDK — add `data-parity-exempt` to the tag
+ only when a feature genuinely doesn't exist there (prefer `SdkOnly` instead).
+- **Frontmatter is shared** across all mounts — keep title/description
+ SDK-neutral.
+- **No `meta.json` under `shared/`.** List the page name in each SDK section's
+ `meta.json` (that's also how an SDK opts out of a topic).
+- **Collisions fail the build.** A per-SDK file at the same path as a shared file
+ is an error, never a silent override — delete the per-SDK file when migrating.
+- **Accuracy first.** Verify every per-SDK claim against that SDK's source before
+ writing it; a missing tab is better than a fabricated API.
+
+`pnpm check:parity` runs the CI gate (`scripts/parity/`): CodeTabs SDK coverage,
+slug collisions, redirect shadowing, plus an informational drift report ranking
+per-SDK topic pairs by word-count ratio — that report is the migration queue.
+Genuinely SDK-specific pages (Django/Flask/FastAPI, Express/Fastify/Nest,
+Spring/GraalVM, `postgres`, `dashboard-api`, `upgrading-0.15`, …) stay per-SDK.
+
+Future work: extracted code snippets (region-marked files compiled/tested in CI,
+inlined by a remark plugin — the `remarkPlugins` array in `vite.config.ts` is the
+seam) so examples can't rot.
diff --git a/docs/app/components/landing/scenario-finder.tsx b/docs/app/components/landing/scenario-finder.tsx
index f4d167dc..30e4b0b7 100644
--- a/docs/app/components/landing/scenario-finder.tsx
+++ b/docs/app/components/landing/scenario-finder.tsx
@@ -11,10 +11,6 @@ const GUIDE_OVERRIDES: Record> = {
node: "/node/guides/core/streaming",
java: "/java/guides/core/streaming",
},
- "/python/guides/workflows/sagas": {
- node: "/node/guides/workflows/saga",
- java: "/java/guides/workflows/saga",
- },
};
/** Resolve a scenario's Python guide path to the active SDK's docs. */
@@ -330,7 +326,7 @@ saga.delay () # any failure → comp
title: "Saga — compensation & rollback",
label: "Watch the saga roll back",
},
- guide: { to: "/python/guides/workflows/sagas", label: "Read the guide" },
+ guide: { to: "/python/guides/workflows/saga", label: "Read the guide" },
},
{
id: "worksteal",
diff --git a/docs/app/lib/content.ts b/docs/app/lib/content.ts
index ac4e4529..859c047a 100644
--- a/docs/app/lib/content.ts
+++ b/docs/app/lib/content.ts
@@ -1,4 +1,5 @@
import type { ComponentType } from "react";
+import { mountsForRelPath } from "./doc-slugs";
interface MdxModule {
default: ComponentType;
@@ -9,18 +10,13 @@ interface MdxModule {
// nav/search never pull these heavy (shiki-inflated) modules into a shared chunk.
const LOADERS = import.meta.glob("../../content/docs/**/*.mdx");
-function keyToSlug(key: string): string {
- const rel = key.replace(/^.*\/content\/docs\//, "").replace(/\.mdx$/, "");
- const parts = rel.split("/");
- if (parts[parts.length - 1] === "index") {
- parts.pop();
- }
- return `/${parts.join("/")}`.replace(/\/$/, "") || "/";
-}
-
const BY_SLUG = new Map Promise>();
for (const [key, loader] of Object.entries(LOADERS)) {
- BY_SLUG.set(keyToSlug(key), loader);
+ const rel = key.replace(/^.*\/content\/docs\//, "");
+ // A shared file registers the same loader at every SDK mount (one chunk).
+ for (const mount of mountsForRelPath(rel)) {
+ BY_SLUG.set(mount.slug, loader);
+ }
}
/** The dynamic import for a doc page's compiled component, or undefined if unknown. */
diff --git a/docs/app/lib/doc-paths.ts b/docs/app/lib/doc-paths.ts
index 233dee1f..224a43f1 100644
--- a/docs/app/lib/doc-paths.ts
+++ b/docs/app/lib/doc-paths.ts
@@ -1,24 +1,20 @@
import { readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
+import { mountsForRelPath } from "./doc-slugs";
// Filesystem walk of the MDX content tree, used by react-router.config's
// `prerender()` to enumerate every static doc URL at build time. The runtime
-// router (app/lib/content.ts) maps the same files via import.meta.glob — both
-// derive slugs identically so the prerendered set matches the served routes.
+// router (app/lib/content.ts) maps the same files through the same doc-slugs
+// module, so the prerendered set matches the served routes — including shared
+// files that fan out to one URL per SDK.
const CONTENT_DIR = fileURLToPath(
new URL("../../content/docs", import.meta.url),
);
-/** `content/docs/a/b.mdx` → `/a/b`; `…/a/index.mdx` → `/a`. */
-export function fileToDocPath(absFile: string): string {
- const rel = relative(CONTENT_DIR, absFile).replace(/\.mdx$/, "");
- const parts = rel.split(sep);
- if (parts[parts.length - 1] === "index") {
- parts.pop();
- }
- return `/${parts.join("/")}`.replace(/\/$/, "") || "/";
+function toRelPath(absFile: string): string {
+ return relative(CONTENT_DIR, absFile).split(sep).join("/");
}
function walk(dir: string, out: string[]): void {
@@ -36,5 +32,8 @@ function walk(dir: string, out: string[]): void {
export function allDocPaths(): string[] {
const files: string[] = [];
walk(CONTENT_DIR, files);
- return files.map(fileToDocPath).filter((p) => p !== "/");
+ return files
+ .flatMap((file) => mountsForRelPath(toRelPath(file)))
+ .map((mount) => mount.slug)
+ .filter((slug) => slug !== "/");
}
diff --git a/docs/app/lib/doc-slugs.ts b/docs/app/lib/doc-slugs.ts
new file mode 100644
index 00000000..a91379b2
--- /dev/null
+++ b/docs/app/lib/doc-slugs.ts
@@ -0,0 +1,46 @@
+// Explicit .ts extension: this module is also imported by the parity script
+// under plain Node (type stripping), where extensionless specifiers don't resolve.
+import { DEFAULT_SDK, SDK_IDS } from "./sdk-registry.ts";
+
+// The single source of slug derivation and shared-content fan-out. The runtime
+// loader (content.ts), the prerender walk (doc-paths.ts), the manifest plugin
+// (vite-plugin-docs-manifest.ts), and the parity script all resolve content
+// files through here, so the served, prerendered, and indexed URL sets can't
+// drift apart. Isomorphic on purpose: operates on posix content-relative paths,
+// no node imports.
+
+/** Directory under `content/docs` whose files mount once per SDK. */
+export const SHARED_DIR = "shared";
+
+/** One URL a content file is served at. `canonical` is set on fan-out mounts
+ * and points at the default-SDK mount (self-referential there). */
+export interface DocMount {
+ slug: string;
+ canonical?: string;
+}
+
+/** `a/b.mdx` → `/a/b`; `a/b/index.mdx` → `/a/b`. Posix content-relative path. */
+export function relPathToSlug(rel: string): string {
+ const parts = rel.replace(/\.mdx$/, "").split("/");
+ if (parts[parts.length - 1] === "index") {
+ parts.pop();
+ }
+ return `/${parts.join("/")}`.replace(/\/$/, "") || "/";
+}
+
+/** True when the file lives under the shared (fan-out) tree. */
+export function isSharedRelPath(rel: string): boolean {
+ return rel === SHARED_DIR || rel.startsWith(`${SHARED_DIR}/`);
+}
+
+/** Every URL a content file mounts at. A shared file fans out to one mount per
+ * SDK (`shared/x.mdx` → `/{sdk}/x`); anything else keeps its single 1:1 slug. */
+export function mountsForRelPath(rel: string): DocMount[] {
+ const slug = relPathToSlug(rel);
+ if (!isSharedRelPath(rel)) {
+ return [{ slug }];
+ }
+ const topic = slug.slice(`/${SHARED_DIR}`.length); // "/guides/x" or ""
+ const canonical = `/${DEFAULT_SDK}${topic}`;
+ return SDK_IDS.map((sdk) => ({ slug: `/${sdk}${topic}`, canonical }));
+}
diff --git a/docs/app/lib/llms.ts b/docs/app/lib/llms.ts
index 155bb02e..df203563 100644
--- a/docs/app/lib/llms.ts
+++ b/docs/app/lib/llms.ts
@@ -1,9 +1,17 @@
-import { SEARCH_DOCS } from "./search";
+import { SEARCH_DOCS, type SearchDoc } from "./search";
+
+// Shared pages mount at one URL per SDK; list each only once, at its
+// canonical (default-SDK) URL, so the corpus carries no duplicates.
+function uniqueDocs(): SearchDoc[] {
+ return SEARCH_DOCS.filter(
+ (doc) => !doc.canonical || doc.canonical === doc.id,
+ ).sort((a, b) => a.id.localeCompare(b.id));
+}
/** Index of every doc page (title + URL) — the `/llms.txt` body. */
export function llmsIndex(): string {
const lines = ["# Taskito documentation", ""];
- for (const doc of [...SEARCH_DOCS].sort((a, b) => a.id.localeCompare(b.id))) {
+ for (const doc of uniqueDocs()) {
lines.push(`- [${doc.title}](${doc.id})`);
}
return `${lines.join("\n")}\n`;
@@ -11,8 +19,8 @@ export function llmsIndex(): string {
/** Full corpus (title + stripped body per page) — the `/llms-full.txt` body. */
export function llmsFull(): string {
- const blocks = [...SEARCH_DOCS]
- .sort((a, b) => a.id.localeCompare(b.id))
- .map((doc) => `## ${doc.title}\nURL: ${doc.id}\n\n${doc.text}\n`);
+ const blocks = uniqueDocs().map(
+ (doc) => `## ${doc.title}\nURL: ${doc.id}\n\n${doc.text}\n`,
+ );
return `# Taskito documentation (full text)\n\n${blocks.join("\n---\n\n")}`;
}
diff --git a/docs/app/lib/manifest.ts b/docs/app/lib/manifest.ts
index 7752e730..ddcf697f 100644
--- a/docs/app/lib/manifest.ts
+++ b/docs/app/lib/manifest.ts
@@ -7,6 +7,9 @@ export interface DocMeta {
slug: string;
title: string;
description: string;
+ /** Default-SDK URL of a shared page; present only on fan-out mounts.
+ * Kept in sync with the plugin's DocMeta across the virtual-module boundary. */
+ canonical?: string;
}
export const DOC_METAS: DocMeta[] = DOCS;
diff --git a/docs/app/lib/redirects.ts b/docs/app/lib/redirects.ts
index a6467f48..ad345063 100644
--- a/docs/app/lib/redirects.ts
+++ b/docs/app/lib/redirects.ts
@@ -43,6 +43,10 @@ export const REDIRECTS: Record = {
"/python/more/comparison": "/resources/comparison",
"/python/more/faq": "/resources/faq",
"/python/more/changelog": "/resources/changelog",
+ // Shared-content migration: python's old "locking" and "sagas" names
+ // normalized to the canonical "locks" / "saga" slugs shared with node/java.
+ "/python/guides/reliability/locking": "/python/guides/reliability/locks",
+ "/python/guides/workflows/sagas": "/python/guides/workflows/saga",
};
/** The destination for a moved path, or undefined if it isn't a redirect. */
diff --git a/docs/app/lib/search.ts b/docs/app/lib/search.ts
index 969a611e..22b514a0 100644
--- a/docs/app/lib/search.ts
+++ b/docs/app/lib/search.ts
@@ -9,6 +9,8 @@ export interface SearchDoc {
section: string;
description: string;
text: string;
+ /** Default-SDK URL when this entry is an SDK mount of a shared page. */
+ canonical?: string;
}
function sectionOf(slug: string): string {
@@ -30,6 +32,7 @@ export const SEARCH_DOCS: SearchDoc[] = DOC_METAS.map((d) => ({
section: sectionOf(d.slug),
description: d.description,
text: `${d.description} ${humanizeSlug(d.slug)}`.trim(),
+ canonical: d.canonical,
}));
let index: MiniSearch | null = null;
diff --git a/docs/app/root.tsx b/docs/app/root.tsx
index 5b9b7a98..49ffef8e 100644
--- a/docs/app/root.tsx
+++ b/docs/app/root.tsx
@@ -27,12 +27,19 @@ export const links: Route.LinksFunction = () => [
// Apply the persisted theme before paint to avoid a light/dark flash.
const THEME_INIT = `(function(){try{var t=localStorage.getItem('taskito-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`;
-// Registry ids + default as inert JSON on the data attribute below.
-const SDK_CONFIG = JSON.stringify({ ids: [...SDK_IDS], def: DEFAULT_SDK });
+// Registry ids + default + deploy base as inert JSON on the data
+// attribute below; the base lets the boot script find the URL's SDK segment.
+const SDK_CONFIG = JSON.stringify({
+ ids: [...SDK_IDS],
+ def: DEFAULT_SDK,
+ base: import.meta.env.BASE_URL.replace(/\/$/, ""),
+});
-// No-flash SDK bootstrap: query > localStorage > default, validated against the
-// data-sdk-config above. Static string (no interpolation); keeps the SSR default on error.
-const SDK_INIT = `(function(){try{var c=JSON.parse(document.documentElement.getAttribute('data-sdk-config')),ids=c.ids,def=c.def;var u=new URLSearchParams(location.search).get('sdk');var s=ids.indexOf(u)>=0?u:(localStorage.getItem('taskito-sdk')||def);if(ids.indexOf(s)<0){s=def;}document.documentElement.setAttribute('data-sdk',s);}catch(e){}})();`;
+// No-flash SDK bootstrap: URL prefix > query > localStorage > default — same
+// precedence as useActiveSdk, so shared pages (one file mounted per SDK) paint
+// the URL's SDK variants immediately. Static string (no interpolation); keeps
+// the SSR default on error.
+const SDK_INIT = `(function(){try{var c=JSON.parse(document.documentElement.getAttribute('data-sdk-config')),ids=c.ids,def=c.def;var seg=location.pathname.slice(c.base.length).split('/')[1];var u=new URLSearchParams(location.search).get('sdk');var s=ids.indexOf(seg)>=0?seg:ids.indexOf(u)>=0?u:(localStorage.getItem('taskito-sdk')||def);if(ids.indexOf(s)<0){s=def;}document.documentElement.setAttribute('data-sdk',s);}catch(e){}})();`;
export function Layout({ children }: { children: React.ReactNode }) {
return (
diff --git a/docs/app/routes/docs.$.tsx b/docs/app/routes/docs.$.tsx
index c3362451..f56e13ad 100644
--- a/docs/app/routes/docs.$.tsx
+++ b/docs/app/routes/docs.$.tsx
@@ -1,6 +1,6 @@
import { MDXProvider } from "@mdx-js/react";
import { Fragment, lazy, Suspense, useEffect, useMemo } from "react";
-import { Link, useNavigate } from "react-router";
+import { Link, type MetaDescriptor, useNavigate } from "react-router";
import { PrevNext } from "@/components/docs";
import { mdxComponents } from "@/components/mdx";
import { getDocLoader } from "@/lib/content";
@@ -59,10 +59,21 @@ export function meta({ params }: Route.MetaArgs) {
];
}
const meta = docMeta(path);
- return [
+ const tags: MetaDescriptor[] = [
{ title: meta?.title ? `${meta.title} | Taskito` : "Taskito" },
{ name: "description", content: meta?.description ?? "" },
];
+ if (meta?.canonical) {
+ // Shared pages mount at one URL per SDK; point search engines at the
+ // default-SDK mount (self-referential there — still valid).
+ const base = import.meta.env.BASE_URL.replace(/\/$/, "");
+ tags.push({
+ tagName: "link",
+ rel: "canonical",
+ href: `${base}${meta.canonical}`,
+ });
+ }
+ return tags;
}
/** Stub shown at a moved URL: meta-refresh handles direct hits, this handles
diff --git a/docs/content/docs/java/getting-started/quickstart.mdx b/docs/content/docs/java/getting-started/quickstart.mdx
deleted file mode 100644
index 225e9e40..00000000
--- a/docs/content/docs/java/getting-started/quickstart.mdx
+++ /dev/null
@@ -1,56 +0,0 @@
----
-title: Quickstart
-description: "Define a task, enqueue a job, run a worker, read the result — in Java."
----
-
-Four steps: open a client, describe a task, enqueue a job, run a worker.
-
-```java
-import com.fasterxml.jackson.core.type.TypeReference;
-import java.time.Duration;
-import java.util.Map;
-import org.byteveda.taskito.Taskito;
-import org.byteveda.taskito.task.Task;
-import org.byteveda.taskito.worker.Worker;
-
-// 1. Describe the task: a name plus its payload type.
-Task> add =
- Task.of("add", new TypeReference>() {})
- .retries(3)
- .timeout(Duration.ofSeconds(30));
-
-try (Taskito taskito = Taskito.builder().sqlite("taskito.db").open()) {
- // 2. Enqueue a job (producer).
- String id = taskito.enqueue(add, Map.of("a", 2, "b", 3));
-
- // 3. Start a worker — same process or a separate one that shares the DB.
- try (Worker worker = taskito.worker()
- .handle(add, p -> p.get("a") + p.get("b"))
- .start()) {
-
- // 4. Read the result once the job is terminal.
- taskito.awaitJob(id, Duration.ofSeconds(20));
- int result = taskito.getResult(id, Integer.class).orElseThrow(); // 5
- }
-}
-```
-
-The producer and the worker only need to share storage — `enqueue` from your web
-app, run the worker from a separate process pointed at the same file / DSN.
-`awaitJob` blocks by polling, which keeps demos and tests simple; long-lived
-services react to completion through
-[worker events](/java/guides/core/workers#events) instead.
-
-
- `Task.of(name, type)` is a descriptor, not a function — the producer never
- needs the handler body. The worker binds the function with
- `.handle(task, fn)`, and payloads/results round-trip through the queue's
- serializer (JSON by default).
-
-
-## Where to next
-
-- [Concepts](/java/getting-started/concepts) — the mental model.
-- [Tasks](/java/guides/core/tasks) — typed descriptors and per-task config.
-- [Workers](/java/guides/core/workers) — running and stopping workers.
-- [Workflows](/java/guides/workflows) — multi-step DAGs.
diff --git a/docs/content/docs/java/guides/core/predicates.mdx b/docs/content/docs/java/guides/core/predicates.mdx
deleted file mode 100644
index b9027cd6..00000000
--- a/docs/content/docs/java/guides/core/predicates.mdx
+++ /dev/null
@@ -1,93 +0,0 @@
----
-title: Predicates
-description: "Gate enqueues with composable predicates and richer allow/skip/defer/reject gates."
----
-
-A gate is evaluated when a job is enqueued. If it rejects, no job is created —
-a declarative way to say "only enqueue this under condition X". Two flavors:
-boolean `Predicate`s and richer `EnqueueGate`s.
-
-## Boolean predicates
-
-`predicate(taskName, p)` registers a `Predicate` — `false` throws
-`PredicateRejectedException` and nothing is enqueued:
-
-```java
-Task charge = Task.of("charge", Integer.class);
-
-taskito.predicate("charge", ctx -> (Integer) ctx.payload() > 0);
-
-taskito.enqueue(charge, 50); // ok
-taskito.enqueue(charge, -1); // throws PredicateRejectedException
-```
-
-The `PredicateContext` carries the task name and the payload — after
-`onEnqueue` [middleware](/java/guides/extensibility/middleware) has run, so
-gates see the rewritten payload. Multiple predicates on one task must all pass.
-
-Combine predicates with `Predicates.allOf`, `anyOf`, and `not`:
-
-```java
-Predicate positive = ctx -> (Integer) ctx.payload() > 0;
-Predicate small = ctx -> (Integer) ctx.payload() <= 10_000;
-
-taskito.predicate("charge", Predicates.allOf(positive, small));
-taskito.predicate("dispatch", Predicates.not(isHoliday));
-```
-
-## Richer gates: allow / skip / defer / reject
-
-`gate(taskName, g)` registers an `EnqueueGate`, whose `EnqueueDecision` can do
-more than pass/fail. Gates run in registration order; the first non-allow
-decision wins.
-
-```java
-taskito.gate("charge", ctx -> switch (classify(ctx.payload())) {
- case OK -> EnqueueDecision.allow();
- case DUPLICATE -> EnqueueDecision.skip("already charged");
- case TOO_EARLY -> EnqueueDecision.defer(Duration.ofHours(1));
- case FRAUD -> EnqueueDecision.reject("fraud check failed");
-});
-```
-
-| Decision | `enqueue` | `tryEnqueue` |
-|---|---|---|
-| `allow()` | Job created. | Job id present. |
-| `skip(reason)` | Throws `EnqueueSkippedException`. | Returns empty. |
-| `defer(delay)` | Job created, scheduled `delay` from now (overrides any delay in the options). | Job id present. |
-| `deferUntil(instant)` | Job created, scheduled at the absolute `instant` (overrides any delay in the options). | Job id present. |
-| `reject(reason)` | Throws `PredicateRejectedException`. | Throws too. |
-
-`tryEnqueue` is the gate-aware form — a skip becomes an empty `Optional`
-instead of an exception:
-
-```java
-Optional id = taskito.tryEnqueue(charge, amount);
-```
-
-## Recipes
-
-`Recipes` ships ready-made gates for common policies — time-based ones defer
-out-of-window enqueues to the next open moment; filters skip silently:
-
-```java
-taskito.gate("dispatch", Recipes.businessHours(ZoneId.of("America/New_York")));
-taskito.gate("digest", Recipes.dayOfWeek(zone, DayOfWeek.MONDAY, DayOfWeek.THURSDAY));
-taskito.gate("sync", Recipes.timeWindow(zone, LocalTime.of(22, 0), LocalTime.of(6, 0)));
-taskito.gate("beta", Recipes.featureFlag("beta-export", flags));
-taskito.gate("charge", Recipes.payloadMatches(p -> ((Order) p).amount() > 0));
-```
-
-## Batches
-
-`enqueueMany` gates each payload; a single rejection throws and no jobs are
-enqueued — the batch API can't bypass a gate. Pre-filter the batch if you'd
-rather drop rejected entries silently.
-
-
- Gates run synchronously on the **producer** thread, so keep them fast and
- side-effect-free — they decide whether work is created at all. To react once
- a job is already running, use
- [middleware](/java/guides/extensibility/middleware) or
- [conditions](/java/guides/workflows) inside a workflow.
-
diff --git a/docs/content/docs/java/guides/core/queues.mdx b/docs/content/docs/java/guides/core/queues.mdx
deleted file mode 100644
index 5d59a24a..00000000
--- a/docs/content/docs/java/guides/core/queues.mdx
+++ /dev/null
@@ -1,55 +0,0 @@
----
-title: Queues & Priority
-description: "Route jobs to named queues, set priority, and pause/resume."
----
-
-Every job belongs to a queue (default `"default"`). Queues are just routing
-labels in shared storage — a worker chooses which queues it serves.
-
-```java
-Task sendEmail = Task.of("send_email", EmailPayload.class).queue("emails");
-
-String id = taskito.enqueue(sendEmail, payload);
-
-taskito.worker()
- .handle(sendEmail, p -> deliver(p))
- .queues("emails", "default") // serve two queues
- .start();
-```
-
-The queue can also be set per enqueue via
-`EnqueueOptions.builder().queue("emails")` — see
-[enqueue options](/java/guides/core/enqueue-options).
-
-## Priority
-
-Higher `priority` dequeues first within a queue:
-
-```java
-taskito.enqueue(report, payload, EnqueueOptions.builder().priority(10).build());
-// ahead of priority 0 jobs
-```
-
-## Pause and resume
-
-Pausing stops dispatch for a queue without stopping the worker — in-flight jobs
-finish, new ones wait. Pause/resume live on the named-queue handle from
-`taskito.queue(name)`:
-
-```java
-Queue emails = taskito.queue("emails");
-emails.pause();
-emails.isPaused(); // true
-taskito.listPausedQueues(); // ["emails"]
-emails.resume();
-```
-
-## Per-queue stats
-
-`statsByQueue(name)` scopes counters to one queue; `statsAllQueues()` maps every
-queue name to its stats:
-
-```java
-long backlog = taskito.statsByQueue("emails").pending;
-Map all = taskito.statsAllQueues();
-```
diff --git a/docs/content/docs/java/guides/core/scheduling.mdx b/docs/content/docs/java/guides/core/scheduling.mdx
deleted file mode 100644
index 94508eb5..00000000
--- a/docs/content/docs/java/guides/core/scheduling.mdx
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Scheduling
-description: "Register periodic tasks on a cron expression; a worker fires them when due."
----
-
-Schedule a task on a cron expression. A running worker enqueues it when due —
-the scheduler's maintenance loop drives this, so no extra process is needed.
-
-```java
-long nextFire = taskito.registerPeriodic(
- PeriodicTask.builder("daily-digest", "digest", "0 0 9 * * *")
- .payload(Map.of("edition", "morning"))
- .queue("emails")
- .timezone("America/New_York")
- .build());
-```
-
-`registerPeriodic` returns the next fire time (Unix ms). Re-registering the
-same name replaces the schedule.
-
-| Builder method | Description |
-|---|---|
-| `payload(object)` | Payload passed to the task on each fire. |
-| `queue(name)` | Queue to enqueue into. |
-| `timezone(iana)` | IANA timezone for the cron expression (default UTC). |
-| `enabled(flag)` | Register paused when `false` (default `true`). |
-
-
- The cron field order is **seconds first** (`sec min hour dom mon dow`).
- `"0 0 9 * * *"` is 09:00:00 daily, not every minute.
-
-
-## Manage schedules
-
-```java
-List all = taskito.listPeriodic(); // enabled and paused
-
-taskito.pausePeriodic("daily-digest"); // stop firing, keep the registration
-taskito.resumePeriodic("daily-digest");
-taskito.deletePeriodic("daily-digest"); // unschedule; false if unknown
-```
-
-For a one-off future run, use
-[`delay`](/java/guides/core/enqueue-options#delayed-jobs) instead of a periodic
-schedule.
diff --git a/docs/content/docs/java/guides/core/tasks.mdx b/docs/content/docs/java/guides/core/tasks.mdx
deleted file mode 100644
index ba1ab2bd..00000000
--- a/docs/content/docs/java/guides/core/tasks.mdx
+++ /dev/null
@@ -1,115 +0,0 @@
----
-title: Tasks
-description: "Describe tasks with Task.of, attach per-task defaults, and bind handlers."
----
-
-A task is a named unit of work. The name is the contract: producers enqueue by
-name, workers execute the bound handler. In Java a task is described by an
-immutable `Task` — the name plus the type its payload deserializes to.
-
-```java
-Task sendEmail = Task.of("send_email", EmailPayload.class);
-```
-
-For generic payloads that a `Class` token can't express, use a Jackson
-`TypeReference`:
-
-```java
-Task> sendEmail =
- Task.of("send_email", new TypeReference>() {});
-```
-
-## Per-task defaults
-
-Fluent methods attach default enqueue options; each returns a new descriptor
-(the type is immutable):
-
-```java
-Task sendEmail = Task.of("send_email", EmailPayload.class)
- .queue("emails")
- .priority(5)
- .retries(3)
- .timeout(Duration.ofSeconds(30))
- .delay(Duration.ofSeconds(10));
-```
-
-| Method | Description |
-|---|---|
-| `queue(name)` | Route jobs to a named queue ([queues](/java/guides/core/queues)). |
-| `priority(n)` | Higher dequeues first within a queue. |
-| `retries(n)` / `maxRetries(n)` | Attempts before dead-lettering. |
-| `timeout(duration)` / `timeoutMs(ms)` | Per-attempt timeout. |
-| `delay(duration)` / `delayMs(ms)` | Delay first execution. |
-| `retryPolicy(policy)` | Retry-backoff curve (below). |
-| `codecs(names...)` | Named payload codecs applied to this task's payload. |
-| `withOptions(options)` | Replace the defaults with an `EnqueueOptions` wholesale. |
-
-`RetryPolicy` shapes the wait between attempts — the budget still comes from
-`retries`:
-
-```java
-Task settle = Task.of("settle", Order.class)
- .retries(5)
- .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));
-// or exact per-attempt delays, applied without jitter:
-Task poll = Task.of("poll", Order.class)
- .retries(3)
- .retryPolicy(RetryPolicy.delays(Duration.ofSeconds(5), Duration.ofSeconds(30), Duration.ofMinutes(2)));
-```
-
-The core scheduler owns retry execution, so retries stay durable and survive
-worker crashes. Retry policies are registered with the worker on `start()`.
-
-## Enqueue and bind
-
-Producers enqueue against the descriptor (or a bare name); workers bind the
-function:
-
-```java
-String id = taskito.enqueue(sendEmail, payload); // typed
-String id2 = taskito.enqueue("send_email", payload); // by name, default options
-
-try (Worker worker = taskito.worker()
- .handle(sendEmail, p -> deliver(p)) // Task + TaskFunction
- .handle("resize", ImageJob.class, p -> resize(p)) // name + payload type
- .start()) { ... }
-```
-
-A handler is a `TaskFunction`: it receives the deserialized payload and
-returns a result (or `null`). The result is serialized and stored as the job
-result. `Handler.of(task, fn)` pairs the two for registration via
-`register(handler)` or a `HandlerRegistry`.
-
-## Typed tasks from TaskHandler
-
-Annotate handler methods with `@TaskHandler`; the compile-time processor
-(`org.byteveda:taskito-processor`) generates a `Tasks` companion with a
-typed `Task` constant per method plus a `bind(...)` — name declared once, full
-generics, zero runtime reflection:
-
-```java
-class EmailTasks {
- @TaskHandler("send_email") // explicit name
- String send(EmailPayload p) { ... }
-
- @TaskHandler // name defaults to "report"
- Report report(List metrics) { ... }
-}
-
-// generated EmailTasksTasks:
-String id = taskito.enqueue(EmailTasksTasks.SEND, payload);
-
-taskito.worker()
- .apply(b -> EmailTasksTasks.bind(b, new EmailTasks()))
- .start();
-```
-
-The annotation also accepts `queue`, `maxRetries`, `timeoutMs`, and `priority`
-for per-task defaults. See
-[installation](/java/getting-started/installation) for the processor setup.
-
-
- Register a handler for every task a worker's queues can carry — a job whose
- task has no handler on the claiming worker fails with "no handler
- registered". Re-registering a task name replaces the previous handler.
-
diff --git a/docs/content/docs/java/guides/core/workers.mdx b/docs/content/docs/java/guides/core/workers.mdx
deleted file mode 100644
index 16474905..00000000
--- a/docs/content/docs/java/guides/core/workers.mdx
+++ /dev/null
@@ -1,92 +0,0 @@
----
-title: Workers
-description: "Build, run, and stop workers; thread pools, events, graceful shutdown."
----
-
-A worker polls storage, claims due jobs, dispatches them to bound handlers, and
-writes results back. Build one from the client:
-
-```java
-Worker worker = taskito.worker()
- .handle(add, p -> p.get("a") + p.get("b"))
- .queues("default")
- .concurrency(4)
- .start();
-// ... later
-worker.close(); // stop dispatch, drain in-flight jobs, free the native worker
-```
-
-`start()` returns immediately; scheduling runs in the background on the Rust
-core, and handlers execute on a Java thread pool. The worker registers itself
-and heartbeats, so it appears in `listWorkers()` and on the dashboard;
-`close()` unregisters it.
-
-
- `@Async` runs the call inline on a Spring-managed thread pool the moment
- it's invoked — there's no separate producer/consumer step, and queued calls
- are lost on restart. A `Worker` is a separate, explicitly started process
- (or thread) that polls durable storage; nothing runs until one is up, and
- work already enqueued survives a restart because it lives in the store, not
- in a JVM executor.
-
-
-## Builder options
-
-| Option | Description |
-|---|---|
-| `handle(task, fn)` | Bind a handler ([tasks](/java/guides/core/tasks)). |
-| `queues(names...)` | Queue names to serve (default `"default"`). |
-| `concurrency(n)` | Fixed handler-thread count; `0` (default) uses a cached pool. |
-| `channelCapacity(n)` | In-flight dispatch buffer (default 128). |
-| `batchSize(n)` | Jobs claimed per poll (default 1) — see [execution model](/java/guides/core/execution-model). |
-| `autoscale(options)` | Resize the pool between min/max threads by queue depth. |
-| `mesh(options)` | Join a work-stealing mesh. |
-| `on(event, listener)` | Subscribe to job outcomes (below). |
-| `trackWorkflows()` | Drive [workflow](/java/guides/workflows) bookkeeping from this worker's outcomes. |
-| `apply(customizer)` | Apply a builder customizer (e.g. a generated `XxxTasks.bind`). |
-
-## Events
-
-Outcome listeners fire when the core decides a job's result:
-
-```java
-taskito.worker()
- .handle(add, p -> compute(p))
- .on(EventName.SUCCESS, e -> log.info("done: " + e.jobId))
- .on(EventName.DEAD, e -> alert(e.jobId, e.error))
- .start();
-```
-
-`EventName` values: `SUCCESS`, `RETRY`, `DEAD`, `CANCELLED`. For richer hooks
-(before/after execution, enqueue-time), use
-[middleware](/java/guides/extensibility/middleware).
-
-## Graceful shutdown
-
-`Worker` is `AutoCloseable`. `close()` stops scheduling, lets in-flight
-handlers finish (30 s grace, then interrupt), then frees the native worker;
-`stop()` only stops dispatch. `awaitShutdown()` blocks until `close()` is
-called — the usual service `main` wires it to a shutdown hook:
-
-```java
-Taskito taskito = Taskito.builder().sqlite("taskito.db").open();
-Worker worker = taskito.worker().handle(add, p -> compute(p)).start();
-
-Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- worker.close();
- taskito.close();
-}));
-worker.awaitShutdown();
-```
-
-
- Don't put the worker in try-with-resources *and* call `awaitShutdown()`
- inside the block — the block can't exit to trigger `close()`, so it
- deadlocks. Use try-with-resources for bounded work (tests), the shutdown-hook
- pattern for services.
-
-
-
- Producers and workers only share storage. Enqueue from a web process and run
- workers from a separate process pointed at the same file / DSN.
-
diff --git a/docs/content/docs/java/guides/extensibility/middleware.mdx b/docs/content/docs/java/guides/extensibility/middleware.mdx
deleted file mode 100644
index 50e4d1b6..00000000
--- a/docs/content/docs/java/guides/extensibility/middleware.mdx
+++ /dev/null
@@ -1,76 +0,0 @@
----
-title: Middleware
-description: "Wrap task execution with before/after/error and outcome hooks."
----
-
-Register middleware with `Taskito.use(...)` to wrap execution and react to
-outcomes. `Middleware` is an interface whose hooks are all no-op `default`
-methods — override only what you need. The `before`/`after`/`onError` hooks
-wrap each attempt; the outcome hooks fire after the core decides the result.
-
-```java
-taskito.use(new Middleware() {
- @Override
- public void before(TaskContext context) {
- log.info("start " + context.taskName);
- }
-
- @Override
- public void onError(TaskContext context, Throwable error) {
- log.error("threw " + context.taskName, error);
- }
-
- @Override
- public void onDeadLetter(OutcomeEvent event) {
- alertOps(event);
- }
-});
-```
-
-## Hooks
-
-| Hook | When |
-|---|---|
-| `onEnqueue(context)` | Producer-side, inside `enqueue`, before serialization. |
-| `before(context)` | Before each execution attempt. |
-| `after(context, result)` | After a successful attempt. |
-| `onError(context, error)` | When an attempt throws (before the retry/dead decision). |
-| `onCompleted(event)` | After a job completes successfully. |
-| `onRetry(event)` | After the core schedules a retry. |
-| `onDeadLetter(event)` | After a job dead-letters. |
-| `onCancel(event)` | After a job is cancelled. |
-
-The execution hooks receive a `TaskContext` with `taskName`, `jobId`, a
-mutable per-execution `attributes()` map shared across a job's hooks, and
-`job()` with lazily-loaded metadata. The outcome hooks receive an
-`OutcomeEvent` with `taskName`, `jobId`, `error`, `retryCount`, and `timedOut`
-(which separates a timeout from other failures). Multiple middlewares run in
-registration order for every phase. Execution hooks run inside the attempt —
-a `before` or `after` that throws fails the attempt like a handler error;
-outcome hooks are isolated — one that throws is caught and logged so it never
-starves the others.
-
-## Rewriting an enqueue
-
-`onEnqueue` receives a **mutable** `EnqueueContext`: replace `payload(...)` or
-`options(...)` to rewrite the job, add to `metadata()` to attach data that
-travels with it (readable at execution via `context.job().metadata()`), or
-throw to abort the enqueue.
-
-```java
-taskito.use(new Middleware() {
- @Override
- public void onEnqueue(EnqueueContext context) {
- context.metadata().put("tenant", currentTenant());
- }
-});
-```
-
-
- Middleware runs in the hot path — keep hooks light. The contrib
- [Micrometer](/java/guides/integrations/micrometer) and
- [Sentry](/java/guides/integrations/sentry) integrations are built as
- middleware. For typed convert/redirect/reject decisions on the producer, use
- an `Interceptor` (`Taskito.intercept(...)`) — interceptors run before
- middleware `onEnqueue`.
-
diff --git a/docs/content/docs/java/guides/extensibility/serializers.mdx b/docs/content/docs/java/guides/extensibility/serializers.mdx
deleted file mode 100644
index ef9cf19c..00000000
--- a/docs/content/docs/java/guides/extensibility/serializers.mdx
+++ /dev/null
@@ -1,87 +0,0 @@
----
-title: Pluggable Serializers
-description: "Pluggable payload serialization — JSON, MessagePack, and codec chains."
----
-
-Arguments and results are serialized with a pluggable `Serializer`. The native
-core treats payloads as opaque bytes, so serialization is purely a Java
-concern.
-
-- `JsonSerializer` — default; Jackson-backed, human-readable, debuggable.
-- `MsgpackSerializer` — compact binary via MessagePack. Optional: add
- `org.msgpack:jackson-dataformat-msgpack` to your build.
-- `SignedSerializer(delegate, key)` — wraps another serializer with an
- HMAC-SHA256 tag and rejects tampered payloads on read (authentication, not
- encryption).
-- `EncryptedSerializer(delegate, key)` — wraps another serializer with
- AES-GCM for confidentiality **and** integrity (16/24/32-byte key).
-
-```java
-Taskito taskito = Taskito.builder()
- .url("taskito.db")
- .serializer(new MsgpackSerializer())
- .open();
-```
-
-## Custom serializer
-
-Implement the `Serializer` interface — `serialize` to bytes, `deserialize`
-back:
-
-```java
-public final class MySerializer implements Serializer {
- @Override
- public byte[] serialize(Object value) { /* ... */ }
-
- @Override
- public T deserialize(byte[] bytes, Class type) { /* ... */ }
-}
-```
-
-
- Producers and workers must agree on the serializer — they exchange bytes
- through storage. A job enqueued with MessagePack can only be decoded by a
- worker using a compatible serializer.
-
-
-## Payload codecs
-
-A `PayloadCodec` is a byte-to-byte transform applied **after** serialization on
-the producer and reversed **before** deserialization on the worker — the
-cross-SDK contract for compression, encryption, and signing. Built-ins:
-
-- `GzipCodec` — gzip compression; decompression capped at 64 MiB (configurable)
- to guard against zip bombs.
-- `AesGcmCodec(key)` — AES-GCM encryption, fresh 12-byte IV per payload.
-- `HmacCodec(key)` — HMAC-SHA256 signing with constant-time verification.
-
-Chain them globally — applied in order on the way out, reversed on the way in:
-
-```java
-Taskito taskito = Taskito.builder()
- .url("taskito.db")
- .codec(new GzipCodec(), new AesGcmCodec(key)) // compress, then encrypt
- .open();
-```
-
-Or register **named** codecs and select them per task:
-
-```java
-Taskito taskito = Taskito.builder()
- .url("taskito.db")
- .codec("secret", new AesGcmCodec(key))
- .open();
-
-Task export = Task.of("export", Report.class).codecs("secret");
-```
-
-Handlers annotated `@Compressed` / `@Encrypted` get the `"compressed"` /
-`"encrypted"` codec names recorded on their generated task constants — register
-codecs under those names on both producers and workers. The key is supplied at
-runtime, never in the annotation.
-
-
- The same chain (or the same names) must be configured on producers and
- workers. Put `GzipCodec` before a signing or encryption codec so integrity
- is verified before decompressing.
-
diff --git a/docs/content/docs/java/guides/integrations/sentry.mdx b/docs/content/docs/java/guides/integrations/sentry.mdx
deleted file mode 100644
index ee1cb844..00000000
--- a/docs/content/docs/java/guides/integrations/sentry.mdx
+++ /dev/null
@@ -1,44 +0,0 @@
----
-title: Sentry Integration
-description: "Capture handler exceptions and dead-letters from Java workers in Sentry."
----
-
-Optional dependency: `io.sentry:sentry` (the SDK compiles against it as
-`compileOnly`; add it to your build). Call `Sentry.init(...)` in your app
-before registering the middleware — `SentryMiddleware` never initializes Sentry
-itself, and its hooks are safe no-ops when Sentry isn't initialized.
-
-```java
-import io.sentry.Sentry;
-import org.byteveda.taskito.contrib.SentryMiddleware;
-
-Sentry.init(options -> options.setDsn(System.getenv("SENTRY_DSN")));
-
-taskito.use(new SentryMiddleware());
-```
-
-Two things get reported:
-
-- **Every failed attempt** — the handler's exception is captured with its
- stack trace when it throws (`onError`). A job that recovers on retry still
- produced one event per failed attempt.
-- **Dead-letters** — when a job exhausts its retries, a `FATAL`-level message
- (`task dead-lettered: `) marks the terminal failure.
-
-Both carry the tags `taskito.task` (task name) and `taskito.job` (job id), so
-you can group and search by task.
-
-## Filtering tasks
-
-The one-argument constructor takes a `Predicate` over the task name;
-return `false` to skip a task entirely:
-
-```java
-taskito.use(new SentryMiddleware(task -> !task.startsWith("noisy.")));
-```
-
-
- Configure DSN, environment, and sampling with the Sentry SDK yourself —
- the middleware only captures events onto whatever scope your app has
- initialized.
-
diff --git a/docs/content/docs/java/guides/operations/deployment.mdx b/docs/content/docs/java/guides/operations/deployment.mdx
deleted file mode 100644
index ed5891ff..00000000
--- a/docs/content/docs/java/guides/operations/deployment.mdx
+++ /dev/null
@@ -1,95 +0,0 @@
----
-title: Deployment
-description: "Process model, native library packaging, and graceful shutdown in production."
----
-
-## Process model
-
-
- Unlike Celery or a JMS/RabbitMQ setup, taskito's scheduler lives inside the
- worker process and reads directly from SQLite, Postgres, or Redis. There's
- no separate broker process to run, monitor, or fail over — one less moving
- part in production.
-
-
-Producers and workers are separate processes that share storage. A typical
-deploy:
-
-- **Web / API process** — opens a `Taskito` and calls `enqueue`.
-- **Worker process(es)** — builds a worker over the same backend, registers
- handlers, and blocks:
-
-```java
-try (Taskito taskito = Taskito.builder().postgres(System.getenv("DATABASE_URL")).open();
- Worker worker = taskito.worker()
- .handle(sendEmail, handlers::sendEmail)
- .queues("default", "emails")
- .concurrency(8) // fixed pool; 0 (default) = cached pool
- .start()) {
- Runtime.getRuntime().addShutdownHook(new Thread(worker::close));
- worker.awaitShutdown();
-}
-```
-
-With SQLite, processes must share the file (same host / volume). With
-[Postgres or Redis](/java/guides/operations/backends), producers and workers
-can run on different machines.
-
-## Native library
-
-The jar bundles the native engine per platform
-(`linux-x86_64`, `linux-aarch64`, `osx-*`, `windows-*`) and extracts it to a
-per-user directory at first use — content-addressed and SHA-256-verified, so
-concurrent processes and upgrades are safe. Two system properties matter in
-containers:
-
-- `-Dtaskito.native.workdir=/path` — extraction directory, for hosts where
- `/tmp` is `noexec`.
-- `-Dtaskito.native.lib=/path/to/library` — skip extraction entirely and load
- an explicit binary (e.g. one you built for musl).
-
-Standard glibc-based images (`eclipse-temurin`, `debian`, `ubuntu`) work out
-of the box.
-
-## Scaling
-
-Run more worker processes (or hosts) against one store to scale throughput —
-atomic claims keep jobs from double-running. Within a process, size the pool
-with `concurrency(n)` or let it breathe with
-[autoscale](/java/guides/operations/autoscaling); across processes, scale
-replicas with [KEDA](/java/guides/operations/autoscaling); for locality across
-many workers, enable the [mesh](/java/guides/operations/mesh).
-
-## Producer-side batching
-
-High-volume producers should enqueue in batches — `enqueueMany(task, payloads)`
-writes a whole batch in one storage call. When payloads arrive one at a time,
-`Batcher` coalesces them: it buffers until `maxBatch` items or `maxDelay`
-elapses, then flushes as one `enqueueMany`:
-
-```java
-try (Batcher batcher = Batcher.of(taskito, ingest, 100, Duration.ofMillis(50))) {
- events.forEach(batcher::add); // add() returns job ids when it triggers a flush
-} // close() flushes what remains
-```
-
-## Graceful shutdown
-
-`worker.close()` stops dispatching, drains in-flight handlers (up to 30
-seconds, then interrupts and waits 30 more), then frees the native worker —
-wire it to a shutdown hook as above. `stop()` alone stops dispatching without
-tearing down.
-
-## Performance: the FFM fast path
-
-On JDK 22+ the hot byte operations automatically use a Java FFM (Panama) fast
-path packaged as a multi-release-jar overlay; older JDKs transparently fall
-back to JNI — same API either way. FFM calls are "restricted native access":
-add `--enable-native-access=ALL-UNNAMED` to your JVM flags to grant access and
-silence the warning (future JDKs will deny it by default).
-
-
- Always set a timeout on production tasks — a crashed worker's `RUNNING` jobs
- are reaped and retried once their timeout elapses. See the
- [failure model](/architecture/failure-model).
-
diff --git a/docs/content/docs/java/guides/operations/mesh.mdx b/docs/content/docs/java/guides/operations/mesh.mdx
deleted file mode 100644
index 55be0c14..00000000
--- a/docs/content/docs/java/guides/operations/mesh.mdx
+++ /dev/null
@@ -1,39 +0,0 @@
----
-title: Mesh Scheduling
-description: "Form a work-stealing overlay so idle workers pull from busy peers."
----
-
-Workers can form a decentralized **mesh** — SWIM gossip for peer discovery,
-consistent-hash placement, and TCP work-stealing — so idle nodes pull work from
-busy ones. The database stays the source of truth; the mesh only optimizes
-dispatch locality, so it is safe to add to any worker.
-
-```java
-Worker worker = taskito.worker()
- .handle(task, handler)
- .mesh(MeshOptions.builder()
- .port(7946) // UDP gossip; TCP steal binds port + 1
- .seed("10.0.0.2:7946") // peers to join (none = standalone)
- .encryptionKey(System.getenv("MESH_KEY")) // optional XOR-obfuscated gossip
- .build())
- .start();
-```
-
-Other builder tunables (with defaults): `bindAddr` (`0.0.0.0`),
-`advertiseAddr` (required behind NAT / when binding `0.0.0.0` across hosts),
-`enableStealing` (`true`), `affinityWeight` (`0.7`), `localBufferCapacity`
-(`64`), `maxStealBatch` (`4`), `stealThreshold` (`2`), `virtualNodes` (`150`),
-`stealRateLimit` (`10`/s, `0` = unlimited).
-
-Inspect the cluster from a running worker:
-
-```java
-worker.meshClusterInfo(); // Optional — empty unless started with mesh(...)
-```
-
-
- Mesh is an optimization, never a correctness dependency — atomic claims in
- storage still guarantee exactly-once dispatch. See
- [Mesh Scheduling](/architecture/mesh) for the SWIM/ring/work-stealing
- internals (shared engine).
-
diff --git a/docs/content/docs/java/guides/operations/testing.mdx b/docs/content/docs/java/guides/operations/testing.mdx
deleted file mode 100644
index 9ccfc2c5..00000000
--- a/docs/content/docs/java/guides/operations/testing.mdx
+++ /dev/null
@@ -1,84 +0,0 @@
----
-title: Testing
-description: "Unit-test with the in-memory backend, integration-test against real SQLite."
----
-
-Handlers are plain functions — unit-test them directly. One level up, the
-`taskito-test` artifact provides a **pure-Java in-memory backend**: the full
-client API with no native library and no disk, ideal for fast unit tests of
-producers, handlers, retries, and dead-lettering.
-
-```kotlin
-testImplementation("org.byteveda:taskito-test:0.18.0")
-```
-
-```java
-import org.byteveda.taskito.test.InMemoryTaskito;
-
-@Test
-void runsATaskEndToEnd() throws Exception {
- 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()) {
- Job job = taskito.awaitJob(id, Duration.ofSeconds(5)).orElseThrow();
- assertEquals(JobStatus.COMPLETE, job.status);
- }
-
- assertEquals(5, taskito.getResult(id, Integer.class).orElseThrow());
- }
-}
-```
-
-`InMemoryTaskito.open(serializer)` swaps the JSON default. To construct the
-backend explicitly (e.g. to share one across a custom builder), pass it to
-`Taskito.builder().open(new InMemoryQueueBackend())`.
-
-## Synchronization
-
-`awaitJob(id, timeout)` blocks until the job reaches a terminal state and is
-the simplest synchronization point — tests rarely need a sleep. Registering 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();
-```
-
-## Integration tests
-
-For coverage of the real storage engine (scheduling, claims, locks), open a
-throwaway SQLite database — the whole enqueue → execute → result path runs
-in-process:
-
-```java
-Path dir = Files.createTempDirectory("taskito-test");
-try (Taskito taskito = Taskito.builder().sqlite(dir.resolve("q.db").toString()).open()) {
- // real backend, no server
-}
-```
-
-Always close (or try-with-resources) workers — a leaked worker keeps polling
-across tests.
-
-## Faking dependencies
-
-Register a [resource](/java/guides/resources) with a stub in the test — the
-worker injects whatever is registered, so no real connection is opened:
-
-```java
-taskito.resource("db", context -> fakeDb);
-```
-
-
- The in-memory backend covers the queue contract, not the native engine:
- workflows are not supported in-memory, and storage-engine behavior (SQL
- claims, cron timing) is only exercised against a real backend. Keep a few
- SQLite-backed integration tests alongside the fast in-memory suite.
-
diff --git a/docs/content/docs/java/guides/operations/troubleshooting.mdx b/docs/content/docs/java/guides/operations/troubleshooting.mdx
deleted file mode 100644
index 4f704cce..00000000
--- a/docs/content/docs/java/guides/operations/troubleshooting.mdx
+++ /dev/null
@@ -1,69 +0,0 @@
----
-title: Troubleshooting
-description: "Common Java SDK issues and how to diagnose them."
----
-
-Turn on debug logs first — they show the worker claiming, running, and settling
-jobs:
-
-```java
-TaskitoLogger.setLevel(LogLevel.DEBUG);
-// or: TASKITO_LOG_LEVEL=debug java -jar app.jar
-```
-
-## A job stays `PENDING` and never runs
-
-- **No worker running** — a worker must be built with `taskito.worker()` and
- `start()`ed in a live process.
-- **Queue mismatch** — the worker only serves its `queues(...)` (default
- `default`); a job enqueued to another queue is ignored. Match them.
-- **Queue paused** — check `taskito.listPausedQueues()`;
- `taskito.queue(name).resume()`.
-- **Producer and worker use different storage** — they must point at the same
- URL. A relative SQLite path resolves per working directory.
-
-## A job fails with `no handler registered for task '...'`
-
-The worker dequeued a job whose task name has no handler on it. Register the
-handler (`handle(name, payloadType, fn)`) on the **worker** process — handler
-functions don't travel through the queue, only task names and payloads do.
-
-## `SQLite database is locked`
-
-SQLite allows one writer at a time. Under many concurrent workers this
-surfaces as lock contention. Options:
-
-- Keep workers on one machine and moderate concurrency, or
-- Move to [Postgres or Redis](/java/guides/operations/backends) for
- multi-process / multi-machine deployments.
-
-## `UnsatisfiedLinkError: no bundled native library for platform '...'`
-
-The jar has no native binary for this OS/architecture. Build the native
-library for the platform and point `-Dtaskito.native.lib=/path/to/library` at
-it. On hardened hosts where `/tmp` is mounted `noexec`, extraction succeeds
-but loading fails — set `-Dtaskito.native.workdir=/path/on/exec/volume`
-instead.
-
-## Payload deserialization errors
-
-- **Serializer mismatch** — producers and workers must use the same
- [serializer](/java/guides/extensibility/serializers) and codec chain.
-- **`no codec registered named '...'`** — the task was enqueued with named
- codecs; register the same names with `Taskito.builder().codec(name, codec)`
- on the worker.
-- **Generic payload types** — a non-Jackson serializer only supports plain
- `Class` payloads; use `JsonSerializer`/`MsgpackSerializer` (or a
- non-generic payload type) for `TypeReference` payloads.
-
-## A handler hangs and blocks shutdown
-
-`close()` drains in-flight handlers for 30 seconds, then interrupts and waits
-30 more before closing the native worker — a stuck handler delays shutdown by
-up to a minute. Set a task timeout so a stuck attempt is reaped, and keep
-handlers interruptible.
-
-
- Inspect failures with `taskito.jobErrors(id)` and `taskito.listDead(50, 0)`;
- see [job management](/java/guides/operations/inspection).
-
diff --git a/docs/content/docs/java/guides/reliability/error-handling.mdx b/docs/content/docs/java/guides/reliability/error-handling.mdx
deleted file mode 100644
index d9755ade..00000000
--- a/docs/content/docs/java/guides/reliability/error-handling.mdx
+++ /dev/null
@@ -1,97 +0,0 @@
----
-title: Error Handling
-description: "What happens when a handler throws — retries, timeouts, and the exception hierarchy."
----
-
-When a handler throws, Taskito decides the job's fate from its retry budget.
-Each failing attempt runs the `onError` middleware hook, then:
-
-1. **Retry** — if attempts remain, the job is rescheduled with
- [backoff](/java/guides/reliability/retries).
-2. **Dead-letter** — once the budget is exhausted, the job moves to the
- [dead-letter queue](/java/guides/reliability/dead-letter).
-
-```java
-Task FETCH = Task.of("fetch", String.class)
- .maxRetries(3)
- .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));
-```
-
-Returning normally is success; an uncaught throw is failure. `TaskFunction`
-declares `throws Exception`, so checked exceptions propagate without wrapping.
-There is no in-handler "stop retrying" signal — leave `maxRetries` at its
-default of `0` for fire-once tasks.
-
-## Timeouts
-
-A per-attempt [timeout](/java/guides/reliability/timeouts) counts as a failure;
-the outcome carries `timedOut = true` so hooks can tell timeouts apart from
-other errors.
-
-```java
-Task SLOW = Task.of("slow", String.class).timeout(Duration.ofSeconds(30));
-```
-
-## Inspecting failures
-
-Every attempt's error is recorded:
-
-```java
-List errors = queue.jobErrors(jobId); // one entry per failed attempt
-List failed = queue.listJobs(JobFilter.builder().status(JobStatus.FAILED).build());
-List dead = queue.listDead(50, 0); // exhausted jobs
-```
-
-## Reacting to outcomes
-
-[Middleware](/java/guides/extensibility/middleware) hooks and worker event
-listeners fire as the core decides each outcome:
-
-```java
-queue.use(new Middleware() {
- @Override
- public void onError(TaskContext context, Throwable error) {
- log.error(context.taskName, error); // each throwing attempt
- }
-
- @Override
- public void onRetry(OutcomeEvent event) {
- metrics.increment("retry", event.taskName);
- }
-
- @Override
- public void onDeadLetter(OutcomeEvent event) {
- alertOps(event);
- }
-});
-
-Worker worker = queue.worker()
- .handle(FETCH, this::fetch)
- .on(EventName.DEAD, event -> pageOncall(event))
- .start();
-```
-
-## The exception hierarchy
-
-Every SDK error is an unchecked `TaskitoException`; the `errors` package
-narrows it so callers can catch exactly what they care about:
-
-| Exception | Raised when |
-|---|---|
-| `ConfigurationException` | The client was misconfigured — missing connection URL, unusable storage directory. |
-| `SerializationException` | A payload or result failed to (de)serialize. |
-| `CryptoException` | A signing or encrypting serializer failed — HMAC mismatch, bad tag/IV. Subtype of `SerializationException`. |
-| `InterceptionException` | An [interceptor](/java/guides/resources/interception) rejected the enqueue; no job was created. |
-| `PredicateRejectedException` | An enqueue gate returned `Reject`; no job was created. |
-| `EnqueueSkippedException` | A gate returned `Skip` on `enqueue` — use `tryEnqueue` to get an empty `Optional` instead. |
-| `LockException` | A [distributed lock](/java/guides/reliability/locks) operation failed or was interrupted. |
-| `ResourceException` | A [worker resource](/java/guides/resources/dependency-injection) could not be resolved — unknown name, scope violation, exhausted pool. |
-| `ProxyException` | A [proxy ref](/java/guides/resources/proxies) failed to verify or reconstruct. |
-| `WorkflowException` | A workflow could not be driven to completion — run not found, missing payload or condition. |
-| `WebhookException` | A webhook could not be stored, loaded, signed, or its payload encoded. |
-
-
- A throw fails only the current *attempt*. To fail fast with no retries, keep
- `maxRetries` at `0`; to make failures safe to retry, keep handlers
- [idempotent](/java/guides/reliability/idempotency).
-
diff --git a/docs/content/docs/java/guides/reliability/guarantees.mdx b/docs/content/docs/java/guides/reliability/guarantees.mdx
deleted file mode 100644
index d597487b..00000000
--- a/docs/content/docs/java/guides/reliability/guarantees.mdx
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Delivery Guarantees
-description: "At-least-once execution, job claiming, and staying correct under retries."
----
-
-Taskito gives **at-least-once** execution: every enqueued job is attempted at
-least once. A job can also run *more* than once — a worker that crashes
-mid-task (after claiming it, before recording the result) leaves the job to be
-picked up again. Design handlers to tolerate that.
-
-## How a job is claimed
-
-A worker atomically claims a pending job — marking it running in a single
-guarded update — before executing, so two workers never run the same job
-concurrently. If that worker dies, the claim lapses and the scheduler dispatches
-the job again.
-
-
- There's no manual ack/nack: a claimed job's redelivery is driven by the
- claim lapsing, not by a consumer forgetting to acknowledge. You still get
- the same at-least-once contract you'd design a JMS listener around.
-
-
-## Staying correct: idempotency
-
-Because a job may run more than once, make side effects idempotent:
-
-- Upsert / conditional-write keyed by a stable id rather than blind inserts.
-- Dedupe external calls with a stable business id carried in the payload.
-- Collapse duplicate *enqueues* into one job with
- [`uniqueKey`](/java/guides/reliability/idempotency).
-
-```java
-Worker worker = queue.worker()
- .handle(CHARGE, order -> payments.charge(order.total(), order.id()))
- .start();
-```
-
-## Not guaranteed
-
-- **Exactly-once** — not offered anywhere in the system; pair at-least-once with
- idempotent handlers instead.
-- **Strict ordering** — within a queue, jobs dequeue by priority then age, but
- concurrent workers run them in parallel; don't rely on one finishing before
- another. Use a [workflow](/java/guides/workflows) when order matters.
-
-
- A completed job's result is stored and can be read by id from any process
- sharing the storage — `queue.getResult(jobId, Receipt.class)`. In tests,
- `queue.awaitJob(jobId, timeout)` blocks until the job settles.
-
diff --git a/docs/content/docs/java/guides/reliability/idempotency.mdx b/docs/content/docs/java/guides/reliability/idempotency.mdx
deleted file mode 100644
index 9178ae15..00000000
--- a/docs/content/docs/java/guides/reliability/idempotency.mdx
+++ /dev/null
@@ -1,52 +0,0 @@
----
-title: Idempotency
-description: "Dedupe concurrent enqueues with a uniqueKey; design tasks to re-run safely."
----
-
-## Deduping enqueues
-
-A `uniqueKey` makes a duplicate enqueue resolve to the **existing job** while
-the first job for that key is still pending or running:
-
-```java
-EnqueueOptions once = EnqueueOptions.builder()
- .uniqueKey("welcome:" + user.id())
- .build();
-
-String first = queue.enqueue(WELCOME, user.id(), once);
-String second = queue.enqueue(WELCOME, user.id(), once); // same id — no new job
-```
-
-Backed by a partial unique index in the store, so the dedup is atomic across
-producers and processes. Once the first job finishes, the key frees up.
-`EnqueueOptions.Builder.jobId(...)` is an alias for `uniqueKey` when the key
-you have in hand *is* the business id.
-
-Batch enqueues participate too — a duplicate key inside `enqueueMany` resolves
-to the existing job instead of failing the batch:
-
-```java
-List ids = queue.enqueueMany(WELCOME, payloads, once);
-// duplicates map to the already-enqueued job's id, in input order
-```
-
-## Idempotent tasks
-
-Separately from enqueue dedup, **handlers should be safe to run more than
-once**. At-least-once delivery means a crash after a side effect but before the
-result write will re-execute the task on recovery.
-
-```java
-Worker worker = queue.worker()
- .handle(CHARGE, order -> {
- // key the external effect on a stable id so a re-run is a no-op
- return payments.charge(order.total(), order.customer(), order.id());
- })
- .start();
-```
-
-
- Use the provider's idempotency key (payment APIs and the like), an upsert, or
- an "already done?" check at the top of the handler. See the
- [failure model](/architecture/failure-model) for the exact re-run windows.
-
diff --git a/docs/content/docs/java/guides/reliability/locks.mdx b/docs/content/docs/java/guides/reliability/locks.mdx
deleted file mode 100644
index 3b6697c9..00000000
--- a/docs/content/docs/java/guides/reliability/locks.mdx
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: Distributed Locking
-description: "TTL-bounded, owner-scoped locks backed by the queue's storage."
----
-
-Coordinate across processes without a separate lock server. Locks are
-TTL-bounded and owner-scoped, backed by the queue's storage — only the `Lock`
-instance that acquired a lock can extend or release it.
-
-
-
-## Scoped helper
-
-`withLock` acquires, runs, and releases — returning whether the body ran:
-
-```java
-boolean ran = queue.withLock("report:2026-06", 60_000, () -> rebuildReport());
-if (!ran) {
- log.info("another worker holds the lock");
-}
-```
-
-## Manual handle
-
-`Lock` is `AutoCloseable`, so try-with-resources releases it at block exit:
-
-```java
-try (Lock lock = queue.lock("resource", 30_000)) {
- if (lock.acquire()) {
- // ... critical section
- }
-} // released at block exit
-```
-
-| Method | Description |
-|---|---|
-| `acquire()` | Try once; `false` if another owner holds a live lock. |
-| `tryAcquire(Duration timeout)` | Retry every 50ms until obtained or the timeout elapses. |
-| `extend(long ttlMs)` | Push the expiry out if still held; `false` means the lock was lost. |
-| `release()` / `close()` | Give the lock up (no-op if not held). |
-| `queue.lockInfo(name)` | The current holder — owner id, acquired-at, expires-at — or empty. |
-
-`queue.lock(name)` without a TTL defaults to 30 seconds.
-
-
- Locks do not auto-extend. Choose a TTL comfortably longer than the protected
- work's worst case, or call `extend()` at checkpoints — a failed extend means
- the lock expired and another owner may now hold it, so stop the critical
- section. Expiry is what keeps a crashed owner from holding a lock forever.
-
diff --git a/docs/content/docs/java/guides/reliability/meta.json b/docs/content/docs/java/guides/reliability/meta.json
index ca953d03..99fadac6 100644
--- a/docs/content/docs/java/guides/reliability/meta.json
+++ b/docs/content/docs/java/guides/reliability/meta.json
@@ -1 +1 @@
-{ "title": "Reliability", "pages": ["error-handling", "guarantees", "retries", "timeouts", "idempotency", "rate-limiting", "concurrency", "dead-letter", "locks"] }
+{ "title": "Reliability", "pages": ["error-handling", "guarantees", "retries", "circuit-breakers", "timeouts", "idempotency", "rate-limiting", "concurrency", "dead-letter", "locks"] }
diff --git a/docs/content/docs/java/guides/reliability/retries.mdx b/docs/content/docs/java/guides/reliability/retries.mdx
deleted file mode 100644
index be6fd395..00000000
--- a/docs/content/docs/java/guides/reliability/retries.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Retries
-description: "Automatic retries with exponential backoff before dead-lettering."
----
-
-A task whose handler throws is retried up to `maxRetries` times before it
-dead-letters. Retries are scheduled by the core with exponential backoff.
-
-```java
-Task CHARGE = Task.of("charge", Order.class)
- .maxRetries(5)
- .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));
-```
-
-| Option | Description |
-|---|---|
-| `maxRetries` | Attempts after the first before dead-lettering. Defaults to `0` — never retry — so set it on any task you want retried. |
-| `RetryPolicy.exponential(base, max)` | Retry N waits about `base · 2^N`, capped at `max`, plus a random jitter of up to `base`. Without a policy the core defaults apply (1s base, 5min cap). |
-| `RetryPolicy.delays(d0, d1, ...)` | Explicit per-attempt delays, applied exactly with no jitter. Supply at least `maxRetries` delays — once the list is exhausted, further retries fire immediately. |
-
-The retry *budget* travels with the job (`maxRetries`); the *backoff curve*
-(`retryPolicy`) is registered with the worker when it starts. Override the
-budget per job at enqueue time:
-
-```java
-queue.enqueue(CHARGE, order, EnqueueOptions.builder().maxRetries(1).build());
-```
-
-Observe retries as they happen — via middleware `onRetry` or a worker event
-listener; once the budget is exhausted the job moves to the
-[dead-letter queue](/java/guides/reliability/dead-letter) and the `DEAD` event
-fires:
-
-```java
-Worker worker = queue.worker()
- .handle(CHARGE, this::charge)
- .on(EventName.RETRY, event -> log.info("retrying {}", event.taskName))
- .on(EventName.DEAD, event -> alertOps(event))
- .start();
-```
-
-
- Retries re-run the whole handler, so design tasks to be
- [idempotent](/java/guides/reliability/idempotency) — a crash after a side
- effect but before the result write will re-execute.
-
diff --git a/docs/content/docs/java/guides/resources/interception.mdx b/docs/content/docs/java/guides/resources/interception.mdx
deleted file mode 100644
index 59661e75..00000000
--- a/docs/content/docs/java/guides/resources/interception.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: Enqueue Interception
-description: "Validate, rewrite, redirect, or reject jobs before they are serialized."
----
-
-Interceptors run on the **producer side**, inside every `enqueue`, before the
-payload is serialized. Use them to validate inputs, redact secrets, or reroute
-jobs across every task — in one place instead of at every call site.
-
-An `Interceptor` sees the task name and payload and returns one of four
-`Interception` strategies:
-
-| Strategy | Effect |
-|---|---|
-| `Interception.pass()` | Enqueue the payload unchanged. |
-| `Interception.convert(payload)` | Replace the payload (e.g. with a [proxy ref](/java/guides/resources/proxies)). |
-| `Interception.redirect(taskName, payload)` | Enqueue a different task (and payload) instead. |
-| `Interception.reject(reason)` | Block the enqueue — `InterceptionException` is thrown and no job is created. |
-
-```java
-queue.intercept((taskName, payload) -> {
- if (payload instanceof String s && s.startsWith("pw:")) {
- return Interception.convert("***"); // redact before it reaches storage
- }
- return Interception.pass();
-});
-```
-
-Interceptors run synchronously in registration order; each sees the previous
-one's result. A `Redirect` retargets the rest of the pipeline — later
-interceptors, middleware, and gates all see the new task name and payload.
-
-## Validation
-
-Rejecting from an interceptor aborts the enqueue — the job is never created and
-the error propagates to the caller:
-
-```java
-queue.intercept((taskName, payload) -> {
- if (taskName.equals("charge") && ((Order) payload).total() < 0) {
- return Interception.reject("charge amount must be non-negative");
- }
- return Interception.pass();
-});
-
-queue.enqueue(CHARGE, invalidOrder); // throws InterceptionException — nothing enqueued
-```
-
-## Rewriting options — the `onEnqueue` hook
-
-Interceptors decide the payload's fate; to rewrite enqueue *options* or attach
-metadata, use [middleware](/java/guides/extensibility/middleware) `onEnqueue`.
-It receives a mutable `EnqueueContext` after interceptors have run:
-
-```java
-queue.use(new Middleware() {
- @Override
- public void onEnqueue(EnqueueContext context) {
- context.metadata().put("enqueuedBy", currentUser()); // travels with the job
- context.options(context.options().toBuilder().priority(5).build());
- }
-});
-```
-
-Mutations take effect: the context's payload is what gets serialized, its
-options are what reach the core, and a non-empty metadata map becomes the job's
-metadata blob. Throwing from `onEnqueue` aborts the enqueue.
-
-## Pipeline order
-
-Every enqueue runs **interceptors → `onEnqueue` middleware → enqueue gates**,
-then serializes, codec-encodes, and submits. Gates see the payload that will
-actually be stored, after any rewrites.
-
-## Edge cases
-
-- **Batches** — `enqueueMany` runs each payload through the interceptors, so a
- batch can't bypass the contract. `Convert` rewrites the item; `Reject` fails
- the whole batch; `Redirect` is unsupported in a batch (it would move an item
- out of the single-task batch) and throws.
-- **Redirect + payload codecs** — redirecting a task that has
- [payload codecs](/java/guides/core/serialization) throws: the source task's
- codec chain can't be applied to the target task without corrupting the
- payload.
-- **Null result** — an interceptor returning `null` is a bug and throws
- `InterceptionException`.
-
-
- Keep interceptors fast and side-effect-free — they run on the producer thread
- inside every `enqueue` call.
-
diff --git a/docs/content/docs/java/guides/workflows/canvas.mdx b/docs/content/docs/java/guides/workflows/canvas.mdx
deleted file mode 100644
index e27348f8..00000000
--- a/docs/content/docs/java/guides/workflows/canvas.mdx
+++ /dev/null
@@ -1,69 +0,0 @@
----
-title: Canvas Primitives
-description: "Chain, group, and chord shorthands for common workflow shapes."
----
-
-The `Canvas` helpers express common DAG shapes without hand-wiring `after`
-edges. Each takes `Canvas.link(name, task, payload)` building blocks and
-returns a `Workflow`, so the result submits like any other workflow — and you
-can keep adding steps to it with the regular builder methods.
-
-## chain
-
-Run steps in sequence — each after the previous one:
-
-```java
-Workflow etl = Canvas.chain("etl",
- Canvas.link("extract", extractTask, 1),
- Canvas.link("transform", transformTask, 2),
- Canvas.link("load", loadTask, 3));
-
-queue.submitWorkflow(etl);
-// extract → transform → load
-```
-
-## group
-
-Run steps in parallel (no dependencies between them):
-
-```java
-Workflow notify = Canvas.group("notify",
- Canvas.link("email", sendEmail, message),
- Canvas.link("sms", sendSms, message),
- Canvas.link("push", sendPush, message));
-
-queue.submitWorkflow(notify);
-// email | sms | push
-```
-
-To hang a parallel group off an existing step, add the steps with the builder
-instead — `Canvas` shortcuts always start from roots:
-
-```java
-Workflow wf = Workflow.named("notify")
- .step("prepare", prepareTask, 1)
- .step("email", sendEmail, message, "prepare")
- .step("sms", sendSms, message, "prepare");
-// prepare → (email | sms)
-```
-
-## chord
-
-A parallel group joined by a callback that runs once every member completes:
-
-```java
-Workflow report = Canvas.chord("report",
- Canvas.link("merge", mergeTask, 0), // callback
- Canvas.link("q1", queryRegion, "east"), // group...
- Canvas.link("q2", queryRegion, "west"));
-
-queue.submitWorkflow(report);
-// (q1 | q2) → merge
-```
-
-
- A `chord`'s callback runs *after* the group but receives its own payload —
- the members' results are not auto-passed. To aggregate results into one
- step, use [fan-out / fan-in](/java/guides/workflows/fan-out), which collects
- child results into the combiner's payload.
-
diff --git a/docs/content/docs/java/guides/workflows/gates.mdx b/docs/content/docs/java/guides/workflows/gates.mdx
deleted file mode 100644
index a9b6ac81..00000000
--- a/docs/content/docs/java/guides/workflows/gates.mdx
+++ /dev/null
@@ -1,130 +0,0 @@
----
-title: Approval Gates
-description: "Pause a workflow run until a human or external system approves or rejects."
----
-
-An approval gate pauses a workflow run until something external resolves it.
-No task runs at the gate — the run simply waits.
-
-```java
-Workflow release = Workflow.named("release-pipeline")
- .step("build", buildTask, artifact)
- .gate("approve-deploy",
- GateConfig.timeout(
- Duration.ofHours(24),
- GateAction.REJECT,
- "Review build artifacts before deploying to production."),
- "build")
- .step("deploy", deployTask, artifact, "approve-deploy");
-
-WorkflowRun run = queue.submitWorkflow(release);
-
-try (Worker worker = queue.worker()
- .handle(buildTask, p -> p)
- .handle(deployTask, p -> p)
- .trackWorkflows(release) // the tracker holds deploy's payload
- .start()) {
- // Later — once a human signs off:
- worker.approveGate(run.runId(), "approve-deploy");
- run.await(Duration.ofSeconds(30));
-}
-```
-
- gate["approve-deploy\n(waiting_approval)"]:::waiting
- gate -- approved --> deploy:::run
- gate -- rejected --> deploy_skip["deploy\n(skipped)"]:::skip`}
-/>
-
-
- A gated workflow must be registered on the tracking worker with
- `trackWorkflows(workflow)`. The gate's downstream steps are deferred nodes —
- their jobs are only created when the gate resolves, and the tracker supplies
- their payloads from the registered definition.
-
-
-## Gate node status
-
-While a gate is pending, its node status is `WAITING_APPROVAL`. The run itself
-stays in `RUNNING` state. Downstream steps do not start until the gate
-resolves.
-
-```java
-WorkflowStatus status = run.status().orElseThrow();
-status.node("approve-deploy").orElseThrow().status; // WAITING_APPROVAL
-```
-
-## Resolving a gate
-
-Resolution goes through the `Worker` that is tracking workflows — calling
-either method on a worker built without `trackWorkflows()` throws a
-`WorkflowException`.
-
-```java
-// Approve — the gate completes and downstream steps are enqueued.
-worker.approveGate(run.runId(), "approve-deploy");
-
-// Reject with a reason — downstream steps are skipped, run transitions to FAILED.
-worker.rejectGate(run.runId(), "approve-deploy", "Artifacts failed QA review.");
-```
-
-## Timeout behaviour
-
-Build the gate with one of the `GateConfig` factories:
-
-```java
-GateConfig.manual(); // waits forever
-GateConfig.timeout(Duration.ofHours(24), GateAction.REJECT); // auto-reject after 24 h
-GateConfig.timeout(Duration.ofHours(24), GateAction.APPROVE, "Auto-ships unless stopped.");
-```
-
-When the timeout elapses the gate auto-resolves according to its `GateAction`:
-
-| `GateAction` | Effect |
-|---|---|
-| `REJECT` (default) | Same as `rejectGate` — downstream skipped, run `FAILED` |
-| `APPROVE` | Same as `approveGate` — downstream proceeds |
-
-The timeout must be positive; the timer is driven by the tracking worker and
-is cancelled when the gate is resolved manually first.
-
-## Gate options
-
-| `GateConfig` component | Type | Default | Description |
-|---|---|---|---|
-| `timeout` | `Duration` | `null` (wait forever) | Auto-resolve after this long |
-| `onTimeout` | `GateAction` | `REJECT` | Resolution taken when the timeout elapses |
-| `message` | `String` | `null` | Human-readable description shown to the approver |
-
-## Rejection behaviour
-
-Rejecting a gate marks the gate node `FAILED` and skips its default
-(`on_success`) successors; the workflow run ends in state `FAILED`. Any
-`on_failure` / `always` successors still run — use those (see
-[conditions](/java/guides/workflows/conditions)) if you need a rejection
-branch.
-
-
- Calling `approveGate` or `rejectGate` on a gate that has already been
- resolved (approved, rejected, or timed out) is a no-op — the tracker ignores
- stale resolutions.
-
-
-## Webhooks and external triggers
-
-Any external system can drive a gate by reaching the process that hosts the
-tracking worker. A common pattern is an HTTP endpoint in that process which
-receives a webhook and resolves the gate:
-
-```java
-// Resolving a gate is a privileged state transition: authenticate the caller,
-// verify the webhook signature, and authorize access to this run first.
-void onDeployApproval(String runId, boolean approved, String reason) {
- if (approved) {
- worker.approveGate(runId, "approve-deploy");
- } else {
- worker.rejectGate(runId, "approve-deploy", reason);
- }
-}
-```
diff --git a/docs/content/docs/java/guides/workflows/saga.mdx b/docs/content/docs/java/guides/workflows/saga.mdx
deleted file mode 100644
index 6e309bae..00000000
--- a/docs/content/docs/java/guides/workflows/saga.mdx
+++ /dev/null
@@ -1,149 +0,0 @@
----
-title: Sagas
-description: "Automatically roll back completed steps in reverse order when a workflow run fails."
----
-
-Saga compensation runs rollback tasks in reverse-dependency order when a
-workflow run fails. Each step that completed and declared a compensator gets
-rolled back; steps that never ran are left alone.
-
-## Basic example
-
-```java
-Task reserve = Task.of("reserveInventory", Integer.class);
-Task unreserve = Task.of("unreserveInventory", Integer.class);
-Task charge = Task.of("chargePayment", Integer.class);
-Task refund = Task.of("refundPayment", Integer.class);
-Task ship = Task.of("shipOrder", Integer.class);
-// No compensator for ship — if it never succeeded, there's nothing to undo.
-
-int orderId = 4212; // the workflow input every step receives
-
-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").maxRetries(0).build());
-
-WorkflowRun run = queue.submitWorkflow(checkout);
-
-try (Worker worker = queue.worker()
- .handle(reserve, id -> id)
- .handle(charge, id -> id)
- .handle(ship, id -> { throw new IllegalStateException("Warehouse offline"); })
- .handle(refund, chargeResult -> chargeResult) // undo charge
- .handle(unreserve, reserveResult -> reserveResult) // undo reserve
- .trackWorkflows()
- .start()) {
- WorkflowStatus status = run.await(Duration.ofSeconds(30));
- // ship fails → charge rolled back (refund) → reserve rolled back (unreserve)
- status.state; // COMPENSATED
- status.node("charge").orElseThrow().status; // COMPENSATED
-}
-```
-
- charge --> ship
- ship -. "run fails" .-> refund
- refund --> unreserve`}
-/>
-
-## How it works
-
-1. `ship` dead-letters — the tracker detects the run's failure.
-2. The tracker collects every completed node that declared a compensator and
- groups them into reverse-dependency waves: deepest (most downstream) nodes
- first.
-3. It enqueues each wave's compensation jobs, passing the forward step's
- result as the payload. Compensators are ordinary registered tasks — they
- run in the worker pool like any other job.
-4. Rollbacks within one wave run in parallel; the next wave only dispatches
- after the previous one drains. The run's state is `COMPENSATING` while
- rollbacks are in flight.
-5. Each rollback inherits the forward step's `queue`, `maxRetries`,
- `timeoutMs`, and `priority`.
-6. When all rollbacks complete the run transitions to `COMPENSATED`.
-7. If a rollback itself dead-letters, the run ends `COMPENSATION_FAILED` —
- remaining un-rolled-back steps are left as-is (fail-stop; no partial
- retry).
-
-Each compensation job is enqueued with a deterministic idempotency key, so a
-tracker restart during compensation does not double-run a rollback. The jobs
-carry `compensation:true` metadata and never advance the forward run.
-
-## Run terminal states
-
-| State | Meaning |
-|---|---|
-| `COMPLETED` | All steps succeeded |
-| `FAILED` | A step failed; no compensators applied |
-| `COMPENSATED` | A step failed; all compensators completed |
-| `COMPENSATION_FAILED` | A step failed; at least one compensator also failed |
-
-
- `COMPENSATION_FAILED` is a manual-intervention state. Inspect
- `status.nodes` for `COMPENSATION_FAILED` node statuses to identify which
- rollback failed and whether data is partially consistent.
-
-
-## Compensator payload
-
-The compensation task receives the forward step's return value as its payload.
-Design forward tasks to return enough context for the compensator to act:
-
-```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; });
-```
-
-## Steps without compensators
-
-Steps that do not declare `compensate` are skipped during rollback. A step
-that never ran (because it was downstream of the failing step) is also
-skipped — only **completed** steps with a compensator are rolled back.
-
-```java
-Workflow partial = Workflow.named("partial-saga")
- .step(Step.of("a", taskA, 1).compensate(undoA).build()) // rolled back if run fails after a completes
- .step("b", taskB, 2, "a") // no compensator — left as-is
- .step(Step.of("c", taskC, 3).after("b").compensate(undoC).build());
-
-// If b fails: only a is rolled back (b never completed; c never ran).
-// If c fails: c has no completed result, so only a is rolled back.
-```
-
-Cache-hit nodes are also never compensated: a `CACHE_HIT` skipped its forward
-task in this run, so there are no side effects here to undo. And a
-compensator may only be declared on a plain task step — gates, sub-workflows,
-and fan-out nodes have no forward result to replay, so `Step.build()` rejects
-`compensate` on them.
-
-## Combining with conditions
-
-Conditions and compensation compose. An `onFailure()` handler runs first (as
-the run settles toward failure); once the run is failed, the tracker then
-rolls back the completed compensable steps:
-
-```java
-int orderId = 4212; // the workflow input every step receives
-
-Workflow safeCheckout = Workflow.named("safe-checkout")
- .step(Step.of("reserve", reserve, orderId).compensate(unreserve).build())
- .step(Step.of("charge", charge, orderId).after("reserve").compensate(refund).build())
- .step("ship", ship, orderId, "charge")
- .step(Step.of("notifyFailure", sendFailureEmail, orderId)
- .onFailure()
- .after("ship")
- .build());
-```
diff --git a/docs/content/docs/java/more/examples/benchmark.mdx b/docs/content/docs/java/more/examples/benchmark.mdx
deleted file mode 100644
index 7b7ded00..00000000
--- a/docs/content/docs/java/more/examples/benchmark.mdx
+++ /dev/null
@@ -1,117 +0,0 @@
----
-title: Benchmark
-description: "A self-contained throughput program: measure enqueue rate, processing rate, and end-to-end latency on your own hardware."
----
-
-Numbers depend heavily on your machine, backend, and task body, so the only
-number that matters is the one you measure. This program stages a batch of
-no-op jobs, drains them with a worker, and reports the three rates that bound
-a queue.
-
-## Benchmark.java
-
-```java
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import org.byteveda.taskito.Taskito;
-import org.byteveda.taskito.model.Job;
-import org.byteveda.taskito.model.JobFilter;
-import org.byteveda.taskito.model.JobStatus;
-import org.byteveda.taskito.task.Task;
-import org.byteveda.taskito.worker.Worker;
-
-public final class Benchmark {
- private static final int N = 50_000;
- private static final Task NOOP = Task.of("noop", Integer.class);
-
- public static void main(String[] args) throws Exception {
- // Fresh database per run — a reused file skews stats with old rows.
- Path dir = Files.createTempDirectory("taskito-bench");
- try (Taskito taskito = Taskito.builder().sqlite(dir.resolve("bench.db").toString()).open()) {
-
- // 1. Enqueue throughput — stage N jobs in batches of 1,000.
- long t0 = System.currentTimeMillis();
- for (int i = 0; i < N; i += 1_000) {
- List batch = new ArrayList<>(1_000);
- for (int k = 0; k < 1_000; k++) {
- batch.add(i + k);
- }
- taskito.enqueueMany(NOOP, batch);
- }
- long enqueueMs = System.currentTimeMillis() - t0;
-
- // 2. Processing throughput — drain the backlog, polling stats until empty.
- long t1 = System.currentTimeMillis();
- long processMs;
- try (Worker worker = taskito.worker()
- .handle(NOOP, n -> null)
- .concurrency(8)
- .batchSize(64)
- .channelCapacity(512)
- .start()) {
- while (taskito.stats().completed < N) {
- Thread.sleep(20);
- }
- processMs = System.currentTimeMillis() - t1;
- }
-
- // 3. End-to-end latency — created → completed across a sample of jobs.
- List latencies = taskito
- .listJobs(JobFilter.builder().status(JobStatus.COMPLETE).limit(1_000).build())
- .stream()
- .filter(job -> job.completedAt != null)
- .map(job -> job.completedAt - job.createdAt)
- .sorted()
- .toList();
- long p50 = latencies.get((int) (latencies.size() * 0.5));
- long p99 = latencies.get((int) (latencies.size() * 0.99));
-
- System.out.printf("enqueue: %d jobs/s%n", Math.round(N / (enqueueMs / 1000.0)));
- System.out.printf("process: %d jobs/s%n", Math.round(N / (processMs / 1000.0)));
- System.out.printf("latency: p50 %dms p99 %dms%n", p50, p99);
- }
- }
-}
-```
-
-## Running it
-
-```bash
-java -cp app.jar Benchmark
-```
-
-Sample output (illustrative — measure your own):
-
-```
-enqueue: 110000 jobs/s
-process: 30000 jobs/s
-latency: p50 9ms p99 45ms
-```
-
-## What drives the numbers
-
-| Lever | Effect |
-| --- | --- |
-| `enqueueMany` | one storage round-trip per batch instead of per job |
-| `batchSize` | jobs claimed per scheduler poll — fewer polls under load |
-| `channelCapacity` | in-flight buffer between the scheduler and the handler pool |
-| `concurrency` | handler threads draining the channel |
-| Backend | SQLite (WAL) for a single node; Postgres/Redis to scale out workers |
-| Task body | a real handler's I/O usually dominates — benchmark *your* task too |
-
-
- The hot path is the Rust core — enqueue, dequeue, and result handling happen
- natively; your JVM only runs the handler body. Raise `batchSize` and
- `channelCapacity` together to keep a busy worker saturated.
-
-
-## Key patterns
-
-| Pattern | Where |
-| --- | --- |
-| Batched staging | `taskito.enqueueMany` |
-| Drain to a target | poll `taskito.stats().completed` |
-| Worker throughput knobs | `batchSize` + `channelCapacity` + `concurrency` |
-| Latency percentiles | `listJobs` + `completedAt − createdAt` |
diff --git a/docs/content/docs/node/getting-started/quickstart.mdx b/docs/content/docs/node/getting-started/quickstart.mdx
deleted file mode 100644
index 135392fa..00000000
--- a/docs/content/docs/node/getting-started/quickstart.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Quickstart
-description: "Define a task, enqueue a job, run a worker, await the result — in TypeScript."
----
-
-Four steps: construct a queue, register a task, enqueue a job (add it to
-storage), run a worker (a process that claims and executes queued jobs).
-
-```ts
-import { Queue } from "@byteveda/taskito";
-
-const queue = new Queue({ dbPath: "taskito.db" });
-
-// 1. Register a task with optional per-task config.
-queue.task("add", (a: number, b: number) => a + b, {
- maxRetries: 3,
- retryBackoff: { baseMs: 1000, maxMs: 60_000 },
- timeoutMs: 30_000,
- maxConcurrent: 4,
-});
-
-// 2. Enqueue a job (producer).
-const id = queue.enqueue("add", [2, 3], { priority: 5 });
-
-// 3. Start a worker — same process or a separate one that shares the DB.
-const worker = queue.runWorker({ queues: ["default"] });
-
-// 4. Await the result.
-const result = await queue.result(id); // 5
-worker.stop();
-```
-
-The producer and the worker only need to share storage — `enqueue` from your web
-app, `runWorker` from a separate process pointed at the same `dbPath` / DSN.
-
-
- Tasks can be `async`. The dispatcher awaits the returned promise; an `async`
- task that does I/O runs on the native async pool without a thread per job.
-
-
-## Where to next
-
-- [Concepts](/node/getting-started/concepts) — the mental model.
-- [Tasks](/node/guides/core/tasks) — registration and per-task config.
-- [Workers](/node/guides/core/workers) — running and stopping workers.
-- [Workflows](/node/guides/workflows) — multi-step DAGs.
diff --git a/docs/content/docs/node/guides/core/predicates.mdx b/docs/content/docs/node/guides/core/predicates.mdx
deleted file mode 100644
index efe53be4..00000000
--- a/docs/content/docs/node/guides/core/predicates.mdx
+++ /dev/null
@@ -1,59 +0,0 @@
----
-title: Predicates
-description: "Gate enqueues with composable predicates evaluated at submit time."
----
-
-A gate is a predicate evaluated when a job is enqueued. If it returns `false`,
-the enqueue is rejected with a `PredicateRejectedError` and no job is created —
-a declarative way to say "only enqueue this under condition X".
-
-```ts
-queue.task("charge", (amount: number) => settle(amount));
-
-queue.gate("charge", ({ args }) => args[0] > 0); // never enqueue a non-positive charge
-
-queue.enqueue("charge", [50]); // ok
-queue.enqueue("charge", [-1]); // throws PredicateRejectedError
-```
-
-The predicate receives the task name and the (already
-[`onEnqueue`](/node/guides/resources/interception)-rewritten) `args`, typed to the
-task's signature.
-
-## Multiple gates
-
-Register more than one — they all must pass:
-
-```ts
-queue.gate("charge", ({ args }) => args[0] > 0);
-queue.gate("charge", ({ args }) => args[0] <= 10_000);
-```
-
-## Composition
-
-Combine predicates with `allOf`, `anyOf`, and `not`:
-
-```ts
-import { allOf, anyOf, not } from "@byteveda/taskito";
-
-const businessHours = () => {
- const h = new Date().getHours();
- return h >= 9 && h < 17;
-};
-const highPriority = ({ args }) => args[0].priority === "high";
-
-queue.gate("dispatch", anyOf(businessHours, highPriority));
-queue.gate("dispatch", not(isHoliday));
-```
-
-## Batches
-
-`enqueueMany` gates each entry; a single rejection throws (no jobs are enqueued).
-Pre-filter the batch if you'd rather drop rejected entries silently.
-
-
- Gates run on the **producer** side, so they decide whether work is created at
- all. To react once a job is already running, use
- [middleware](/node/guides/extensibility/middleware) or
- [conditions](/node/guides/workflows/conditions) inside a workflow.
-
diff --git a/docs/content/docs/node/guides/core/queues.mdx b/docs/content/docs/node/guides/core/queues.mdx
deleted file mode 100644
index 7667a5e2..00000000
--- a/docs/content/docs/node/guides/core/queues.mdx
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: Queues & Priority
-description: "Route jobs to named queues, set priority, and pause/resume."
----
-
-Every job belongs to a queue (default `"default"`). Queues are just routing
-labels in shared storage — a worker chooses which queues it serves.
-
-```ts
-const id = queue.enqueue("send_email", [user], { queue: "emails" });
-
-queue.runWorker({ queues: ["emails", "default"] }); // serve two queues
-```
-
-## Priority
-
-Higher `priority` dequeues first within a queue:
-
-```ts
-queue.enqueue("report", [id], { priority: 10 }); // ahead of priority 0 jobs
-```
-
-
- Priority direction is **inverted**. BullMQ treats a *lower* `priority`
- number as higher priority (`1` runs before `10`). taskito is the other way
- round — a *higher* `priority` number dequeues first. Porting a BullMQ
- priority scheme means inverting the numbers, not copying them.
-
-
-## Pause and resume
-
-Pausing stops dispatch for a queue without stopping the worker — in-flight jobs
-finish, new ones wait.
-
-```ts
-queue.pauseQueue("emails");
-queue.listPausedQueues(); // ["emails"]
-queue.resumeQueue("emails");
-```
-
-## Per-queue limits
-
-```ts
-queue.configureQueue("emails", {
- rateLimit: "50/s",
- maxConcurrent: 10,
-});
-```
-
-Queue-level limits are checked before per-task limits in the scheduler.
diff --git a/docs/content/docs/node/guides/core/scheduling.mdx b/docs/content/docs/node/guides/core/scheduling.mdx
deleted file mode 100644
index 646405b4..00000000
--- a/docs/content/docs/node/guides/core/scheduling.mdx
+++ /dev/null
@@ -1,42 +0,0 @@
----
-title: Scheduling
-description: "Register periodic tasks on a cron expression; a worker fires them when due."
----
-
-Schedule a registered task on a cron expression. A running worker enqueues it
-when due — the scheduler's maintenance loop drives this, so no extra process is
-needed.
-
-```ts
-queue.task("digest", (date: string) => sendDigest(date));
-
-// cron is 6/7-field, seconds first: sec min hour day-of-month month day-of-week
-queue.registerPeriodic("daily-digest", "digest", "0 0 9 * * *", {
- args: ["2026-06-16"],
- timezone: "America/New_York",
-});
-```
-
-`registerPeriodic(name, taskName, cron, options?)` returns the next fire time
-(Unix ms). Re-registering the same `name` replaces the schedule.
-
-| Option | Description |
-|---|---|
-| `args` | Arguments passed to the task on each fire. |
-| `timezone` | IANA timezone for the cron expression (default UTC). |
-| `queue` | Queue to enqueue into. |
-
-
- The cron field order is **seconds first** (`sec min hour dom mon dow`).
- `"0 0 9 * * *"` is 09:00:00 daily, not every minute.
-
-
-For a one-off future run, use [`delayMs`](/node/guides/core/enqueue-options#delayed-jobs)
-instead of a periodic schedule.
-
-> **Coming from BullMQ?** BullMQ attaches a repeat schedule to a job via
-> `add(name, data, { repeat: { pattern: cron } })` — the schedule lives inside
-> an enqueue call. taskito separates the two: `registerPeriodic(name,
-> taskName, cron, options?)` is its own call, independent of any single
-> `enqueue()`, and `deletePeriodic`/`pausePeriodic`/`resumePeriodic` manage it
-> by name afterward.
diff --git a/docs/content/docs/node/guides/core/tasks.mdx b/docs/content/docs/node/guides/core/tasks.mdx
deleted file mode 100644
index 17d83b58..00000000
--- a/docs/content/docs/node/guides/core/tasks.mdx
+++ /dev/null
@@ -1,62 +0,0 @@
----
-title: Tasks
-description: "Register task functions and attach per-task execution config."
----
-
-A task is a named function registered on the queue. The name is the contract:
-producers enqueue by name, workers execute the registered function.
-
-```ts
-queue.task("add", (a: number, b: number) => a + b);
-```
-
-Tasks may be sync or `async`. The return value is serialized and stored as the
-job result.
-
-## Per-task config
-
-The optional third argument configures execution for every job of that task:
-
-```ts
-queue.task("add", (a: number, b: number) => a + b, {
- maxRetries: 3,
- retryBackoff: { baseMs: 1000, maxMs: 60_000 },
- timeoutMs: 30_000,
- maxConcurrent: 4,
- rateLimit: "100/m",
- circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 30_000 },
-});
-```
-
-| Option | Description |
-|---|---|
-| `maxRetries` | Attempts before dead-lettering. See [retries](/node/guides/reliability/retries). |
-| `retryBackoff` | Exponential backoff bounds (`baseMs`, `maxMs`). |
-| `timeoutMs` | Per-attempt [timeout](/node/guides/reliability/timeouts). |
-| `maxConcurrent` | Max simultaneously-running jobs of this task ([concurrency](/node/guides/reliability/concurrency)). |
-| `rateLimit` | [Rate limit](/node/guides/reliability/rate-limiting) string, `count/unit` (e.g. `"100/m"`). |
-| `circuitBreaker` | Trip after repeated failures ([circuit breakers](/node/guides/reliability/circuit-breakers)). |
-
-
- Register a task before enqueuing or running a worker for it. Per-task config
- is applied to the scheduler at registration time — re-registering replaces it.
-
-
-## Configure the queue or a queue name
-
-Beyond per-task config, you can set defaults for an entire named queue:
-
-```ts
-queue.configureQueue("emails", { rateLimit: "50/s" });
-```
-
-
- BullMQ splits a job into `queue.add(name, data)` on one side and a
- `new Worker(queueName, processor)` callback on the other — the processor
- lives wherever you construct the `Worker`. taskito ties the handler to the
- task name once, via `queue.task(name, handler, options)`; `enqueue()` just
- references that name, and `runWorker()` takes no processor argument. Also
- note: `enqueue()` returns the job id (a `string`), not a rich `Job` object —
- fetch a snapshot with `queue.getJob(id)` or block on
- [`queue.result(id)`](/node/api-reference/result).
-
diff --git a/docs/content/docs/node/guides/core/workers.mdx b/docs/content/docs/node/guides/core/workers.mdx
deleted file mode 100644
index 8c5eb20b..00000000
--- a/docs/content/docs/node/guides/core/workers.mdx
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Workers
-description: "Run, scope, and stop workers; sync vs async dispatch."
----
-
-A worker polls storage, claims due jobs, dispatches them to registered task
-functions, and writes results back.
-
-```ts
-const worker = queue.runWorker({ queues: ["default"] });
-// ... later
-worker.stop(); // drains and shuts down the scheduler + heartbeat
-```
-
-`runWorker` returns immediately; the worker runs in the background on the Rust
-core. A worker registers itself and heartbeats every 5 s, so it appears on the
-[dashboard](/node/guides/operations/dashboard) Workers panel; `stop()`
-unregisters it.
-
-## Sync vs async tasks
-
-- **Sync** task functions run on an OS-thread pool.
-- **`async`** task functions run on a native async pool — no thread per job, no
- blocking for I/O-bound work. The dispatcher awaits the returned promise.
-
-```ts
-queue.task("fetch", async (url: string) => (await fetch(url)).text());
-```
-
-## Options
-
-| Option | Description |
-|---|---|
-| `queues` | Queue names to serve (default `["default"]`). |
-| `advanceWorkflows` | Drive [workflow](/node/guides/workflows) bookkeeping (default `true`; set `false` on workers that never process steps). |
-| `mesh` | Join a work-stealing [mesh](/node/guides/operations/mesh). |
-
-
- Producers and workers only share storage. Enqueue from a web process and run
- workers from a separate process pointed at the same `dbPath` / DSN.
-
-
-
- BullMQ's `new Worker(queueName, processor, { concurrency: N })` runs `N`
- jobs at once **per worker process** — concurrency scales with how many
- workers you start. taskito's `runWorker()` takes no processor or
- concurrency argument (handlers come from `queue.task()`); simultaneous
- execution is bounded by per-task/per-queue
- [`maxConcurrent`](/node/guides/reliability/concurrency), enforced by the
- scheduler across *all* workers on shared storage, not per process.
-
diff --git a/docs/content/docs/node/guides/extensibility/middleware.mdx b/docs/content/docs/node/guides/extensibility/middleware.mdx
deleted file mode 100644
index 7b9202ce..00000000
--- a/docs/content/docs/node/guides/extensibility/middleware.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Per-Task Middleware
-description: "Wrap task execution with before/after/error and outcome hooks."
----
-
-Register middleware to wrap execution and react to outcomes. The
-`before`/`after`/`onError` hooks wrap each attempt and are awaited; the outcome
-hooks fire after the core decides the result.
-
-```ts
-queue.use({
- before: (ctx) => log.info("start", ctx.taskName),
- after: (ctx, result) => log.info("ok", ctx.taskName),
- onError: (ctx, err) => log.error("threw", ctx.taskName, err),
- onRetry: (e) => metrics.inc("retry", e.taskName),
- onDeadLetter: (e) => alertOps(e),
-});
-```
-
-## Hooks
-
-| Hook | When | Awaited |
-|---|---|---|
-| `onEnqueue(ctx)` | Producer-side, inside `enqueue`, before serialization. | no (sync) |
-| `before(ctx)` | Before each execution attempt. | yes |
-| `after(ctx, result)` | After a successful attempt. | yes |
-| `onError(ctx, err)` | When an attempt throws (before the retry/dead decision). | yes |
-| `onCompleted(e)` | After a job completes successfully. | no |
-| `onRetry(e)` | After the core schedules a retry. | no |
-| `onDeadLetter(e)` | After a job dead-letters. | no |
-| `onCancel(e)` | After a job is cancelled. | no |
-
-The execution hooks (`before`/`after`/`onError`) receive a context with
-`taskName`, `jobId`, and `args`. The outcome hooks receive an event with
-`taskName`, `jobId`, `queue`, `retryCount`, and `timedOut` (which separates a
-timeout from other failures). `onEnqueue` receives a *mutable* enqueue context —
-see [Enqueue interception](/node/guides/resources/interception). Every hook —
-`before`, `after`, `onError`, and the outcome hooks — runs in the same
-registration order for every middleware; the Node SDK doesn't reverse `after`
-the way an "onion" middleware model would.
-
-
- Middleware wraps execution and can do async work in the hot path — keep it
- light. The contrib [observability](/node/guides/integrations) integrations are
- built as middleware.
-
diff --git a/docs/content/docs/node/guides/extensibility/serializers.mdx b/docs/content/docs/node/guides/extensibility/serializers.mdx
deleted file mode 100644
index d2c29cb8..00000000
--- a/docs/content/docs/node/guides/extensibility/serializers.mdx
+++ /dev/null
@@ -1,42 +0,0 @@
----
-title: Pluggable Serializers
-description: "Pluggable arg/result serialization — JSON or msgpack."
----
-
-Arguments and results are serialized with a pluggable `Serializer`. The Rust
-core treats payloads as opaque bytes, so the serializer is purely a Node
-concern.
-
-- `JsonSerializer` — default; human-readable, debuggable.
-- `MsgpackSerializer` — compact binary, smaller payloads.
-- `SignedSerializer(secret, inner?)` — wraps another codec with an HMAC-SHA256
- tag and rejects tampered payloads on read (authentication, not encryption).
-- `EncryptedSerializer(secret, inner?)` — wraps another codec with AES-256-GCM
- for confidentiality **and** integrity.
-
-```ts
-import { Queue, MsgpackSerializer } from "@byteveda/taskito";
-
-new Queue({ dbPath: "taskito.db", serializer: new MsgpackSerializer() });
-```
-
-## Custom serializer
-
-Implement the `Serializer` interface — `serialize` to bytes, `deserialize` back:
-
-```ts
-import type { Serializer } from "@byteveda/taskito";
-
-const mySerializer: Serializer = {
- serialize: (value) => Buffer.from(JSON.stringify(value)),
- deserialize: (bytes) => JSON.parse(Buffer.from(bytes).toString()),
-};
-
-new Queue({ dbPath: "taskito.db", serializer: mySerializer });
-```
-
-
- Producers and workers must agree on the serializer — they exchange bytes
- through storage. A job enqueued with msgpack can only be decoded by a worker
- using a compatible serializer.
-
diff --git a/docs/content/docs/node/guides/integrations/prometheus.mdx b/docs/content/docs/node/guides/integrations/prometheus.mdx
deleted file mode 100644
index ce4932c2..00000000
--- a/docs/content/docs/node/guides/integrations/prometheus.mdx
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: Prometheus Metrics
-description: "Execution metrics and polled queue/DLQ depth gauges for Node.js workers."
----
-
-Peer dependency: `prom-client`. Import from the `taskito/contrib/prometheus`
-subpath.
-
-```ts
-import {
- prometheusMiddleware,
- PrometheusStatsCollector,
-} from "@byteveda/taskito/contrib/prometheus";
-import { register } from "prom-client";
-import express from "express";
-
-// 1. Instrument job executions.
-queue.use(prometheusMiddleware());
-
-// 2. Poll queue/DLQ depth gauges every 10 s.
-const collector = new PrometheusStatsCollector(queue);
-collector.start();
-
-// 3. Expose metrics.
-const app = express();
-app.get("/metrics", async (_req, res) => {
- res.set("Content-Type", register.contentType);
- res.end(await register.metrics());
-});
-```
-
-## Metrics
-
-| Metric | Type | Labels | Description |
-|---|---|---|---|
-| `taskito_jobs_total` | Counter | `task`, `status` | Finished executions by outcome (`status` = `completed` or `failed`) |
-| `taskito_job_duration_seconds` | Histogram | `task` | Execution duration |
-| `taskito_active_workers` | Gauge | — | Workers currently running |
-| `taskito_retries_total` | Counter | `task` | Retry attempts |
-| `taskito_queue_depth` | Gauge | `queue` | Pending jobs per queue (polled by `PrometheusStatsCollector`) |
-| `taskito_dlq_size` | Gauge | — | Dead-letter queue size (polled) |
-
-## `prometheusMiddleware` options
-
-| Option | Type | Default | Description |
-|---|---|---|---|
-| `namespace` | `string` | `"taskito"` | Metric name prefix |
-| `register` | `Registry` | global `register` | prom-client registry to register metrics into |
-| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task |
-| `buckets` | `number[]` | prom-client defaults | Histogram bucket boundaries (seconds) |
-
-## `PrometheusStatsCollector` options
-
-| Option | Type | Default | Description |
-|---|---|---|---|
-| `namespace` | `string` | `"taskito"` | Metric name prefix |
-| `register` | `Registry` | global `register` | Registry to write gauges into |
-| `intervalMs` | `number` | `10000` | Poll interval in milliseconds |
-
-The internal timer is unref'd so it does not prevent Node from exiting. Call
-`.stop()` on graceful shutdown.
-
-```ts
-process.on("SIGTERM", () => {
- collector.stop();
- worker.stop();
-});
-```
-
-
- Metrics for one namespace are built once per registry, so registering
- multiple middlewares is safe. Pass the same `register` to the middleware and
- the collector to keep everything in one registry.
-
diff --git a/docs/content/docs/node/guides/integrations/sentry.mdx b/docs/content/docs/node/guides/integrations/sentry.mdx
deleted file mode 100644
index 3db58bad..00000000
--- a/docs/content/docs/node/guides/integrations/sentry.mdx
+++ /dev/null
@@ -1,42 +0,0 @@
----
-title: Sentry Integration
-description: "Capture the original exception when a Node.js job dead-letters."
----
-
-Peer dependency: `@sentry/node`. Call `Sentry.init(...)` in your app before
-registering the middleware. Import from the `taskito/contrib/sentry` subpath.
-
-```ts
-import * as Sentry from "@sentry/node";
-import { sentryMiddleware } from "@byteveda/taskito/contrib/sentry";
-
-Sentry.init({ dsn: process.env.SENTRY_DSN });
-
-queue.use(sentryMiddleware());
-```
-
-The real exception (with its stack trace) is captured in `onError` and held per
-job, then reported to Sentry when the job **dead-letters** — so each dead job
-produces a single event carrying the original stack plus task/job/queue tags.
-Successful jobs and jobs that recover on retry are never reported.
-
-## Options
-
-| Option | Type | Default | Description |
-|---|---|---|---|
-| `tagPrefix` | `string` | `"taskito"` | Prefix for Sentry tag keys |
-| `captureRetries` | `boolean` | `false` | Also report each retried failure as a `"warning"` event |
-| `level` | `SeverityLevel` | `"error"` | Severity for the terminal dead-letter event |
-| `extraTags` | `(event) => Record` | — | Extra tags merged onto the event |
-| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task |
-
-Tags set on every event: `taskito.task_name`, `taskito.job_id`,
-`taskito.queue`, `taskito.retry_count`, and `taskito.timed_out` (when the job
-timed out).
-
-
- `sentryMiddleware` never calls `Sentry.init` — configure the DSN, environment,
- and sampling with the Sentry SDK yourself. If a task dead-letters from a
- timeout without throwing, a synthetic error is captured so the failure is
- still recorded.
-
diff --git a/docs/content/docs/node/guides/operations/deployment.mdx b/docs/content/docs/node/guides/operations/deployment.mdx
deleted file mode 100644
index 26d920f4..00000000
--- a/docs/content/docs/node/guides/operations/deployment.mdx
+++ /dev/null
@@ -1,40 +0,0 @@
----
-title: Deployment
-description: "Process model, shared storage, and graceful shutdown in production."
----
-
-## Process model
-
-Producers and workers are separate processes that share storage. A typical
-deploy:
-
-- **Web / API process** — constructs a `Queue` and calls `enqueue`.
-- **Worker process(es)** — `taskito run ./app.js` (or `queue.runWorker()`),
- pointed at the same backend.
-
-With SQLite, processes must share the file (same host / volume). With
-[Postgres or Redis](/node/guides/operations/backends), producers and workers
-can run on different machines.
-
-## Scaling
-
-Run more worker processes (or hosts) against one store to scale throughput —
-atomic claims keep jobs from double-running. Bound per-task load with
-[concurrency](/node/guides/reliability/concurrency) and
-[rate limits](/node/guides/reliability/rate-limiting). For locality across many
-workers, enable the [mesh](/node/guides/operations/mesh).
-
-## Graceful shutdown
-
-Stop the worker on a signal so in-flight jobs drain:
-
-```ts
-const worker = queue.runWorker();
-process.on("SIGTERM", () => worker.stop());
-```
-
-
- Always set a [timeout](/node/guides/reliability/timeouts) on production tasks —
- a crashed worker's `running` jobs are reaped and retried once their timeout
- elapses. See the [failure model](/architecture/failure-model).
-
diff --git a/docs/content/docs/node/guides/operations/keda.mdx b/docs/content/docs/node/guides/operations/keda.mdx
deleted file mode 100644
index 5bed8a32..00000000
--- a/docs/content/docs/node/guides/operations/keda.mdx
+++ /dev/null
@@ -1,68 +0,0 @@
----
-title: KEDA Autoscaling
-description: "Scale Node workers on Kubernetes by queue depth via the bundled scaler server."
----
-
-[KEDA](https://keda.sh) scales a worker Deployment up and down based on queue
-depth. The SDK ships a scaler server that KEDA's `metrics-api` scaler polls.
-
-## Scaler server
-
-```ts
-import { Queue, serveScaler } from "@byteveda/taskito";
-
-const queue = new Queue({ backend: "postgres", dsn: process.env.DATABASE_URL });
-serveScaler(queue, { port: 9091, targetQueueDepth: 10 });
-```
-
-Or from the CLI, over a backend:
-
-```bash
-taskito --backend postgres --dsn "$DATABASE_URL" scaler --port 9091
-```
-
-| Option | Default | Description |
-|---|---|---|
-| `port` | `9091` | Listen port |
-| `host` | `0.0.0.0` | Bind address (reachable in-cluster) |
-| `targetQueueDepth` | `10` | Target depth per replica, returned to KEDA |
-| `queue` | all | Restrict the metric to one queue |
-
-## Endpoints
-
-| Endpoint | Returns |
-|---|---|
-| `GET /api/scaler[?queue=]` | `{ metricValue, targetValue, queueName }` |
-| `GET /health` | `{ status: "ok" }` |
-
-`metricValue` is the current pending count; KEDA scales toward
-`ceil(metricValue / targetValue)` replicas.
-
-```json
-{ "metricValue": 42, "targetValue": 10, "queueName": "*" }
-```
-
-## ScaledObject
-
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: ScaledObject
-metadata:
- name: taskito-workers
-spec:
- scaleTargetRef:
- name: taskito-worker
- minReplicaCount: 1
- maxReplicaCount: 20
- triggers:
- - type: metrics-api
- metadata:
- url: "http://taskito-scaler:9091/api/scaler"
- valueLocation: "metricValue"
- targetValue: "10"
-```
-
-
- Run the scaler as its own small Deployment + Service (not in the worker pod) so
- scaling to zero workers doesn't take the metric source down with it.
-
diff --git a/docs/content/docs/node/guides/operations/mesh.mdx b/docs/content/docs/node/guides/operations/mesh.mdx
deleted file mode 100644
index 9851ce78..00000000
--- a/docs/content/docs/node/guides/operations/mesh.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: Mesh Scheduling
-description: "Form a work-stealing overlay so idle workers pull from busy peers."
----
-
-Workers can form a decentralized **mesh** — SWIM gossip for peer discovery,
-consistent-hash placement, and TCP work-stealing — so idle nodes pull work from
-busy ones. The database stays the source of truth; the mesh only optimizes
-dispatch locality.
-
-```ts
-queue.runWorker({
- queues: ["default"],
- mesh: {
- port: 7946, // UDP gossip; TCP steal binds port + 1
- seeds: ["10.0.0.2:7946"], // peers to join (empty = standalone)
- steal: true,
- encryptionKey: process.env.MESH_KEY, // optional XOR-encrypt gossip
- },
-});
-```
-
-Other tunables: `bindAddr`, `advertiseAddr` (NAT), `affinityWeight`,
-`localBuffer`, `stealBatch`, `stealThreshold`, `virtualNodes`, `stealRateLimit`.
-
-Requires the addon built with the `mesh` cargo feature (`build:native` enables
-it; published prebuilts include it).
-
-
- Mesh is an optimization, never a correctness dependency — atomic claims in
- storage still guarantee exactly-once dispatch. See
- [Mesh Scheduling](/architecture/mesh) for the SWIM/ring/work-stealing
- internals (shared engine).
-
diff --git a/docs/content/docs/node/guides/operations/testing.mdx b/docs/content/docs/node/guides/operations/testing.mdx
deleted file mode 100644
index f2f77b1b..00000000
--- a/docs/content/docs/node/guides/operations/testing.mdx
+++ /dev/null
@@ -1,75 +0,0 @@
----
-title: Testing
-description: "Test tasks and workers against a real queue with vitest."
----
-
-Tasks are plain functions — unit-test them directly. For integration coverage,
-run a real worker against a throwaway SQLite database; the whole enqueue →
-execute → result path runs in-process, no mocks of the core needed.
-
-```ts
-import { mkdtempSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, expect, it } from "vitest";
-import { Queue, type Worker } from "@byteveda/taskito";
-
-let worker: Worker | undefined;
-afterEach(() => {
- worker?.stop(); // always stop — a leaked worker keeps polling across tests
- worker = undefined;
-});
-
-function newQueue(): Queue {
- // a fresh temp DB per test keeps them independent
- return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "test-")), "q.db") });
-}
-
-it("runs a task end to end", async () => {
- const queue = newQueue();
- queue.task("add", (a: number, b: number) => a + b);
-
- const id = queue.enqueue("add", [2, 3]);
- worker = queue.runWorker();
-
- expect(await queue.result(id)).toBe(5);
-});
-```
-
-## Polling for side effects
-
-When you assert on a side effect rather than a return value, poll until it
-settles instead of sleeping a fixed time:
-
-```ts
-async function waitFor(predicate: () => boolean, timeoutMs = 4000) {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- if (predicate()) return true;
- await new Promise((r) => setTimeout(r, 20));
- }
- return false;
-}
-```
-
-## Faking dependencies
-
-Register a [resource](/node/guides/resources/dependency-injection) with a stub in
-the test — the worker injects whatever is registered, so no real connection is
-opened:
-
-```ts
-import { useResource } from "@byteveda/taskito";
-
-queue.resource("db", () => fakeDb);
-queue.task("sync", async () => {
- const db = await useResource("db");
- // ...
-});
-```
-
-
- Awaiting `queue.result(id)` is the simplest synchronization point — it resolves
- when the job reaches a terminal state and rejects on failure, so a test rarely
- needs an explicit sleep.
-
diff --git a/docs/content/docs/node/guides/operations/troubleshooting.mdx b/docs/content/docs/node/guides/operations/troubleshooting.mdx
deleted file mode 100644
index d844a0df..00000000
--- a/docs/content/docs/node/guides/operations/troubleshooting.mdx
+++ /dev/null
@@ -1,64 +0,0 @@
----
-title: Troubleshooting
-description: "Common Node SDK issues and how to diagnose them."
----
-
-Turn on debug logs first — they show the worker claiming, running, and settling
-jobs:
-
-```ts
-import { setLogLevel } from "@byteveda/taskito";
-setLogLevel("debug");
-// or: TASKITO_LOG_LEVEL=debug node app.js
-```
-
-## A job stays `pending` and never runs
-
-- **No worker running** — `queue.runWorker()` must be called in a live process.
-- **Queue mismatch** — the worker only serves its `queues` (default
- `["default"]`); a job enqueued to another `namespace`/queue is ignored. Match
- them.
-- **Queue paused** — check `queue.listPausedQueues()`; `resumeQueue(name)`.
-- **Producer and worker use different storage** — they must point at the same
- `dbPath`/`dsn`. A relative SQLite path resolves per working directory.
-
-## Worker runs but throws `TaskNotRegisteredError`
-
-The worker dequeued a job whose task name was never registered on it. Register
-the handler (`queue.task(name, fn)`) on the **worker** process — task functions
-don't travel through the queue, only their names and args do.
-
-## `SQLite database is locked`
-
-SQLite allows one writer at a time. Under many concurrent workers this surfaces
-as lock contention. Options:
-
-- Keep workers on one machine and moderate concurrency, or
-- Move to [Postgres or Redis](/node/guides/operations/backends) for multi-process
- / multi-machine deployments.
-
-## `Cannot find module '.../native/index.js'`
-
-The native addon isn't built. Run `pnpm build:native`. Workflows and mesh also
-require the addon to be built **with their cargo features** (included in
-`build:native`) — a "feature not enabled" error at `queue.workflows` means the
-addon was built without them.
-
-## Jobs run, but latency is high
-
-- Raise `batchSize` and `channelCapacity` on `runWorker` to claim and buffer more
- per poll — see the [execution model](/node/guides/core/execution-model).
-- Add more worker processes against shared storage.
-- Check you aren't capped by a per-task `maxConcurrent` or
- [rate limit](/node/guides/reliability/rate-limiting).
-
-## A handler hangs and blocks others
-
-A long **synchronous** computation blocks the event loop and stalls every
-in-flight job. Offload CPU-bound work to `worker_threads` and `await` it, or set
-a [`timeoutMs`](/node/guides/reliability/timeouts) so a stuck attempt is aborted.
-
-
- Inspect failures with `await queue.getJobErrors(id)` and `await queue.deadLetters()`; see
- [job management](/node/guides/operations/inspection).
-
diff --git a/docs/content/docs/node/guides/reliability/circuit-breakers.mdx b/docs/content/docs/node/guides/reliability/circuit-breakers.mdx
deleted file mode 100644
index 7177104a..00000000
--- a/docs/content/docs/node/guides/reliability/circuit-breakers.mdx
+++ /dev/null
@@ -1,36 +0,0 @@
----
-title: Circuit Breakers
-description: "Trip a task after repeated failures to shed load on a failing dependency."
----
-
-A circuit breaker stops dispatching a task after it fails too often in a window,
-giving a failing downstream dependency time to recover instead of hammering it.
-
-```ts
-queue.task("call_flaky_api", callApi, {
- circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 30_000 },
-});
-```
-
-| Field | Description |
-|---|---|
-| `threshold` | Failures within `windowMs` that trip the breaker. |
-| `windowMs` | Rolling window for counting failures. |
-| `cooldownMs` | How long the breaker stays open before a trial dispatch. |
-| `halfOpenMaxProbes` | Probe jobs let through while half-open (default 5). |
-| `halfOpenSuccessRate` | Success rate (0.0–1.0) among probes required to close (default 0.8). |
-
-While **open**, jobs of that task are held rather than dispatched. After
-`cooldownMs` the breaker half-opens and lets a limited number of probe jobs
-through (`halfOpenMaxProbes`); if enough of them succeed
-(`halfOpenSuccessRate`) the breaker closes, otherwise it re-opens.
-
-
- Pair with [retries](/node/guides/reliability/retries): retries handle
- transient blips on a healthy dependency; the breaker handles a dependency
- that is down, so you stop spending the retry budget on it.
-
-
-> **Coming from BullMQ?** BullMQ has no built-in circuit breaker — it's a
-> taskito-specific addition. You'd otherwise reach for a separate library
-> (e.g. `opossum`) alongside a BullMQ `Worker`.
diff --git a/docs/content/docs/node/guides/reliability/error-handling.mdx b/docs/content/docs/node/guides/reliability/error-handling.mdx
deleted file mode 100644
index 0365264a..00000000
--- a/docs/content/docs/node/guides/reliability/error-handling.mdx
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Error Handling
-description: "What happens when a task throws — retries, timeouts, and the dead-letter queue."
----
-
-When a task throws (or its promise rejects), Taskito decides the job's fate from
-its retry budget. Each failing attempt runs the `onError` middleware hook, then:
-
-1. **Retry** — if attempts remain, the job is rescheduled with
- [backoff](/node/guides/reliability/retries) (a growing delay between
- attempts, so retries don't hammer a struggling dependency).
-2. **Dead-letter** — once the budget is exhausted, the job moves to the
- [dead-letter queue](/node/guides/reliability/dead-letter) (DLQ — a holding
- area for permanently-failed jobs you can inspect and replay).
-
-```ts
-queue.task("fetch", fetchUrl, {
- maxRetries: 3,
- retryBackoff: { baseMs: 1000, maxMs: 60_000 },
-});
-```
-
-Returning normally is success; an uncaught throw is failure. There is no
-in-handler "stop retrying" signal — set `maxRetries: 0` for fire-once tasks.
-
-## Timeouts
-
-A per-attempt [timeout](/node/guides/reliability/timeouts) aborts the job's
-`AbortSignal` and counts as a failure; the outcome carries `timedOut: true` so
-hooks can tell timeouts apart from other errors.
-
-```ts
-queue.task("slow", handler, { timeoutMs: 30_000 });
-```
-
-## Inspecting failures
-
-Every attempt's error is recorded:
-
-```ts
-await queue.getJobErrors(id); // one entry per failed attempt
-await queue.listJobs({ status: "failed" });
-await queue.deadLetters(); // exhausted jobs
-```
-
-## Reacting to outcomes
-
-[Middleware](/node/guides/extensibility/middleware) and
-[events](/node/guides/extensibility/events) fire as the core decides each
-outcome:
-
-```ts
-queue.use({
- onError: (ctx, err) => log.error(ctx.taskName, err), // each throwing attempt
- onRetry: (e) => metrics.inc("retry", e.taskName),
- onDeadLetter: (e) => alertOps(e),
-});
-queue.on("job.dead", (e) => pageOncall(e));
-```
-
-
- A throw fails only the current *attempt*. To fail fast with no retries, set
- `maxRetries: 0`; to make failures safe to retry, keep handlers
- [idempotent](/node/guides/reliability/idempotency).
-
diff --git a/docs/content/docs/node/guides/reliability/guarantees.mdx b/docs/content/docs/node/guides/reliability/guarantees.mdx
deleted file mode 100644
index d04369b9..00000000
--- a/docs/content/docs/node/guides/reliability/guarantees.mdx
+++ /dev/null
@@ -1,54 +0,0 @@
----
-title: Delivery Guarantees
-description: "At-least-once execution, job claiming, and staying correct under retries."
----
-
-Taskito gives **at-least-once** execution: every enqueued job runs to completion
-at least once. A job can also run *more* than once — a worker that crashes
-mid-task (after claiming it, before recording the result) leaves the job to be
-picked up again. Design handlers to tolerate that.
-
-## How a job is claimed
-
-A worker atomically claims a pending job — marking it running in a single
-guarded update — before executing, so two workers never run the same job
-concurrently. If that worker dies, the claim lapses and the scheduler dispatches
-the job again.
-
-## Staying correct: idempotency
-
-Because a job may run more than once, make side effects idempotent:
-
-- Upsert / conditional-write keyed by a stable id rather than blind inserts.
-- Dedupe external calls with the job id from `currentJob()`.
-- Collapse duplicate *enqueues* into one job with
- [`uniqueKey`](/node/guides/reliability/idempotency).
-
-```ts
-import { currentJob } from "@byteveda/taskito";
-
-queue.task("charge", async (orderId: string) => {
- const job = currentJob();
- await payments.charge(orderId, { idempotencyKey: job?.jobId });
-});
-```
-
-## Not guaranteed
-
-- **Exactly-once** — not offered anywhere in the system; pair at-least-once with
- idempotent handlers instead.
-- **Strict ordering** — within a queue, jobs dequeue by priority then age, but
- concurrent workers run them in parallel; don't rely on one finishing before
- another. Use a [workflow](/node/guides/workflows) when order matters.
-
-
- A completed job's result is stored and can be awaited by id from any process
- sharing the storage — see [result](/node/api-reference/result).
-
-
-> **Coming from BullMQ?** BullMQ's `QueueEvents` is a dedicated Redis Streams
-> consumer, so any process can listen for job outcomes. taskito's
-> `queue.on(event, handler)` fires inside the **worker process** handling the
-> job — it isn't a cross-process subscription. For visibility from another
-> process, poll `queue.getJob(id)` / `queue.stats()`, or use the
-> [dashboard](/node/guides/operations/dashboard) / REST API.
diff --git a/docs/content/docs/node/guides/reliability/idempotency.mdx b/docs/content/docs/node/guides/reliability/idempotency.mdx
deleted file mode 100644
index 91e978a9..00000000
--- a/docs/content/docs/node/guides/reliability/idempotency.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Idempotency
-description: "Dedupe concurrent enqueues with a uniqueKey; design tasks to re-run safely."
----
-
-## Deduping enqueues
-
-A `uniqueKey` makes a duplicate enqueue a **no-op** while the first job for that
-key is still pending or running:
-
-```ts
-queue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` });
-queue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` }); // ignored
-```
-
-Backed by a partial unique index in the store, so the dedup is atomic across
-producers and processes. Once the first job finishes, the key frees up.
-
-> **Coming from BullMQ?** BullMQ's usual dedup trick is an explicit custom
-> `jobId` passed to `add()`. taskito's `uniqueKey` is the same idea, but
-> scoped to "while the first job is pending/running" rather than a permanent
-> reservation — enqueue the same key again later, after the job completes,
-> and it creates a fresh job.
-
-## Idempotent tasks
-
-Separately from enqueue dedup, **task functions should be safe to run more than
-once**. At-least-once delivery means a crash after a side effect but before the
-result write will re-execute the task on recovery (the stale reaper re-enqueues
-it).
-
-```ts
-queue.task("charge", async (order: Order) => {
- // key the external effect on a stable id so a re-run is a no-op
- await stripe.charges.create(
- { amount: order.total, customer: order.customer },
- { idempotencyKey: order.id },
- );
-});
-```
-
-
- Use the provider's idempotency key (Stripe, etc.), an upsert, or a "already
- done?" check at the top of the task. See the
- [failure model](/architecture/failure-model) for the exact re-run windows.
-
diff --git a/docs/content/docs/node/guides/reliability/locks.mdx b/docs/content/docs/node/guides/reliability/locks.mdx
deleted file mode 100644
index 7760de61..00000000
--- a/docs/content/docs/node/guides/reliability/locks.mdx
+++ /dev/null
@@ -1,54 +0,0 @@
----
-title: Distributed Locking
-description: "TTL-bounded, owner-scoped locks backed by the queue's storage."
----
-
-Coordinate across processes without a separate lock server. Locks are
-TTL-bounded and owner-scoped, backed by the queue's storage. A held lock
-auto-extends at `ttlMs / 3`, so a slow critical section never loses it.
-
-
-
-## Scoped helper
-
-`withLock` acquires, runs, and releases — throwing if the lock is held
-elsewhere:
-
-```ts
-await queue.withLock("report:2026-06", async () => {
- await rebuildReport();
-});
-```
-
-## Manual handle
-
-```ts
-using lock = queue.lock("resource", { ttlMs: 30_000 });
-if (lock.acquire()) {
- // ... critical section
-} // released at block exit (via `using`)
-```
-
-`lock.extend(ms)`, `lock.info()`, and `lock.ownerId` round out the API. With
-`using`, the lock releases automatically when the block exits; otherwise call
-`lock.release()`.
-
-
- Expired locks are reaped by the worker's maintenance loop, so a crashed owner
- never holds a lock past its TTL. Choose a `ttlMs` comfortably longer than the
- protected work's worst case.
-
-
-> **Coming from BullMQ?** BullMQ's locking is internal — a `lockDuration`
-> per job that stops two workers double-processing it, not something you can
-> call from application code. taskito's `queue.lock()` / `withLock()` is a
-> general-purpose distributed lock you can use for *any* critical section, job
-> processing or not.
diff --git a/docs/content/docs/node/guides/reliability/retries.mdx b/docs/content/docs/node/guides/reliability/retries.mdx
deleted file mode 100644
index 52b9036e..00000000
--- a/docs/content/docs/node/guides/reliability/retries.mdx
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Retries
-description: "Automatic retries with exponential backoff before dead-lettering."
----
-
-A task that throws is retried up to `maxRetries` times before it
-[dead-letters](/node/guides/reliability/dead-letter). Retries are scheduled
-with exponential backoff — the delay roughly doubles each attempt.
-
-```ts
-queue.task("charge", chargeCard, {
- maxRetries: 5,
- retryBackoff: { baseMs: 1000, maxMs: 60_000 },
-});
-```
-
-| Option | Description |
-|---|---|
-| `maxRetries` | Attempts after the first before dead-lettering. `0` = never retry. |
-| `retryBackoff.baseMs` | First backoff; doubles each attempt. |
-| `retryBackoff.maxMs` | Cap on the backoff delay. |
-
-Override per job at enqueue time:
-
-```ts
-queue.enqueue("charge", [order], { maxRetries: 1 });
-```
-
-The `job.retrying` [event](/node/guides/extensibility/events) fires on each
-retry; once exhausted, the job moves to the
-[dead-letter queue](/node/guides/reliability/dead-letter) and `job.dead` fires.
-
-
- Retries re-run the whole task, so design tasks to be
- [idempotent](/node/guides/reliability/idempotency) — a crash after a side
- effect but before the result write will re-execute.
-
-
-> **Coming from BullMQ?** Watch the off-by-one: BullMQ's `attempts` counts the
-> *total* tries including the first (`attempts: 3` = 1 try + 2 retries).
-> taskito's `maxRetries` counts only the retries *after* the first
-> (`maxRetries: 3` = 1 try + 3 retries = 4 total). BullMQ's
-> `backoff: { type: "exponential", delay }` maps to `retryBackoff.baseMs`;
-> taskito only offers exponential backoff, not BullMQ's pluggable custom
-> backoff strategies.
diff --git a/docs/content/docs/node/guides/resources/interception.mdx b/docs/content/docs/node/guides/resources/interception.mdx
deleted file mode 100644
index b1c201c0..00000000
--- a/docs/content/docs/node/guides/resources/interception.mdx
+++ /dev/null
@@ -1,112 +0,0 @@
----
-title: Argument Interception
-description: "Validate, redact, or rewrite jobs before they are serialized."
----
-
-The `onEnqueue` middleware hook runs on the **producer side**, inside
-`queue.enqueue`, before the arguments are serialized. Use it to validate inputs,
-redact secrets, or rewrite enqueue options across every task — in one place
-instead of at every call site.
-
-```ts
-queue.use({
- onEnqueue: (ctx) => {
- // redact anything that looks like a secret before it reaches storage
- ctx.args = ctx.args.map((a) =>
- typeof a === "string" && a.startsWith("pw:") ? "***" : a,
- );
- },
-});
-```
-
-## The context
-
-`onEnqueue` receives a mutable context:
-
-| Field | Description |
-|---|---|
-| `taskName` | The task being enqueued (read-only). |
-| `args` | The positional arguments — reassign or mutate before serialization. |
-| `options` | The enqueue options — set `metadata`, `priority`, `namespace`, and so on. |
-
-Mutations take effect: `args` is what gets serialized, `options` is what reaches
-the core.
-
-```ts
-queue.use({
- onEnqueue: (ctx) => {
- ctx.options.metadata = JSON.stringify({ enqueuedBy: currentUser() });
- },
-});
-```
-
-## Validation
-
-Throwing from `onEnqueue` aborts the enqueue — the job is never created and the
-error propagates to the caller:
-
-```ts
-queue.use({
- onEnqueue: (ctx) => {
- if (ctx.taskName === "charge" && (ctx.args[0] as number) < 0) {
- throw new Error("charge amount must be non-negative");
- }
- },
-});
-
-queue.enqueue("charge", [-5]); // throws — nothing is enqueued
-```
-
-
- `onEnqueue` is synchronous (so is `enqueue`) and runs in middleware
- registration order. It is the producer-side counterpart to the execution hooks
- in [Middleware](/node/guides/extensibility/middleware).
-
-
-## Enqueue interceptors
-
-`queue.intercept(interceptor)` is a separate, more structural mechanism. It
-runs *before* `onEnqueue`, per-task defaults, middleware, and gates, and
-decides the fate of the whole enqueue rather than mutating a shared context
-in place:
-
-```ts
-import { Interception } from "@byteveda/taskito";
-
-queue.intercept((taskName, args) => {
- if (taskName === "chargeCard" && (args[0] as number) < 0) {
- return Interception.reject("charge amount must be non-negative");
- }
- return Interception.pass();
-});
-```
-
-An interceptor returns one of four outcomes:
-
-| Outcome | Effect |
-|---|---|
-| `Interception.pass()` | Enqueue unchanged. |
-| `Interception.convert(args)` | Replace the args; the task name stays the same. |
-| `Interception.redirect(taskName, args)` | Enqueue a different task with new args instead. |
-| `Interception.reject(reason)` | Block the enqueue — `enqueue`/`enqueueMany` throws `InterceptionError`. |
-
-Multiple interceptors chain in registration order, each seeing the previous
-one's (possibly redirected) task name and args. `redirect` is rejected for
-`enqueueMany` (a batch is stored under one task name) and for tasks
-registered with per-task [codecs](/node/api-reference/serializers) — the
-redirect target's codec chain can't be resolved from a bare name.
-
-
- `intercept` and `onEnqueue` solve overlapping problems differently:
- `intercept` runs first and can redirect to a different task or reject
- outright; `onEnqueue` runs after and mutates the (possibly redirected)
- context in place. Most apps need only one.
-
-
-## Why no proxies?
-
-Python's SDK also ships object *proxies* — a system for shipping non-serializable
-objects (file handles, sessions) through the queue. Node doesn't need it: a
-handler closes over its own resources, or pulls them from
-[dependency injection](/node/guides/resources/dependency-injection). Keep
-non-serializable state on the worker and pass plain data through the queue.
diff --git a/docs/content/docs/node/guides/workflows/canvas.mdx b/docs/content/docs/node/guides/workflows/canvas.mdx
deleted file mode 100644
index edefa42e..00000000
--- a/docs/content/docs/node/guides/workflows/canvas.mdx
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Canvas Primitives
-description: "Chain, group, and chord shorthands for common workflow shapes."
----
-
-The builder's canvas helpers express common DAG shapes without hand-wiring
-`after` edges. Each takes `steps` of the form `{ name, task, ...stepOptions }`
-and returns the builder, so they chain with `.step()` and one another.
-
-## chain
-
-Run steps in sequence — each after the previous one:
-
-```ts
-queue.workflows
- .define("etl")
- .chain([
- { name: "extract", task: "extract" },
- { name: "transform", task: "transform" },
- { name: "load", task: "load", maxRetries: 5 },
- ])
- .submit();
-// extract → transform → load
-```
-
-Pass `{ after }` to chain onto an existing step.
-
-## group
-
-Run steps in parallel (all after `options.after`, or as roots):
-
-```ts
-queue.workflows
- .define("notify")
- .step("prepare", "prepare")
- .group(
- [
- { name: "email", task: "sendEmail" },
- { name: "sms", task: "sendSms" },
- { name: "push", task: "sendPush" },
- ],
- { after: "prepare" },
- )
- .submit();
-// prepare → (email | sms | push)
-```
-
-## chord
-
-A parallel group joined by a callback that runs once every member completes:
-
-```ts
-queue.workflows
- .define("report")
- .chord(
- [
- { name: "q1", task: "queryRegion" },
- { name: "q2", task: "queryRegion" },
- ],
- { name: "merge", task: "merge" },
- )
- .submit();
-// (q1 | q2) → merge
-```
-
-
- `chord`'s callback runs *after* the group but receives its own `args` — the
- members' results are not auto-passed. To aggregate results into one step, use
- [fan-out / fan-in](/node/guides/workflows/fan-out), which collects child
- results into the combiner's argument.
-
diff --git a/docs/content/docs/node/guides/workflows/gates.mdx b/docs/content/docs/node/guides/workflows/gates.mdx
deleted file mode 100644
index 72c7a130..00000000
--- a/docs/content/docs/node/guides/workflows/gates.mdx
+++ /dev/null
@@ -1,122 +0,0 @@
----
-title: Approval Gates
-description: "Pause a workflow run until a human or external system approves or rejects."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-An approval gate pauses a workflow run until something external resolves it.
-No task runs at the gate — the run simply waits.
-
-```ts
-const handle = queue.workflows
- .define("release-pipeline")
- .step("build", "buildTask")
- .gate("approve-deploy", {
- after: "build",
- message: "Review build artifacts before deploying to production.",
- timeoutMs: 24 * 60 * 60 * 1000, // 24 h
- onTimeout: "reject",
- })
- .step("deploy", "deployTask", { after: "approve-deploy" })
- .submit();
-
-queue.runWorker();
-
-// Later — from any process that has access to the same storage:
-queue.workflows.approveGate(handle.runId, "approve-deploy");
-```
-
- gate["approve-deploy\n(waiting_approval)"]:::waiting
- gate -- approved --> deploy:::run
- gate -- rejected --> deploy_skip["deploy\n(skipped)"]:::skip`}
-/>
-
-## Gate node status
-
-While a gate is pending, its node status is `waiting_approval`. The run itself
-stays in `running` state. Downstream steps do not start until the gate resolves.
-
-```ts
-const nodes = handle.nodes();
-const gate = nodes.find(n => n.nodeName === "approve-deploy");
-console.log(gate?.status); // "waiting_approval"
-```
-
-## Resolving a gate
-
-Get the `runId` from the submit handle and call one of the three resolution
-methods. Resolution works from any process that opens the same storage — the
-resolver does not need to be the worker process.
-
-```ts
-// Approve — downstream steps are enqueued.
-queue.workflows.approveGate(runId, "approve-deploy");
-
-// Reject — downstream steps are skipped, run transitions to "failed".
-queue.workflows.rejectGate(runId, "approve-deploy");
-
-// Reject with a reason recorded in storage.
-queue.workflows.rejectGate(runId, "approve-deploy", "Artifacts failed QA review.");
-
-// Unified helper — pass a boolean.
-queue.workflows.resolveGate(runId, "approve-deploy", true); // approve
-queue.workflows.resolveGate(runId, "approve-deploy", false, "Reason."); // reject
-```
-
-## Timeout behaviour
-
-When `timeoutMs` elapses the gate auto-resolves according to `onTimeout`:
-
-| `onTimeout` | Effect |
-|---|---|
-| `"reject"` (default) | Same as calling `rejectGate` — downstream skipped, run `failed` |
-| `"approve"` | Same as calling `approveGate` — downstream proceeds |
-
-Setting `timeoutMs` without `onTimeout` defaults to `"reject"`.
-
-## Gate options
-
-| Option | Type | Default | Description |
-|---|---|---|---|
-| `after` | `string \| string[]` | — | Predecessor node name(s) |
-| `message` | `string` | — | Human-readable description shown in the dashboard |
-| `timeoutMs` | `number` | — | Auto-resolve after this many milliseconds |
-| `onTimeout` | `"approve" \| "reject"` | `"reject"` | Resolution on timeout |
-
-## Rejection behaviour
-
-Rejecting a gate marks the gate node `failed` and skips its default
-(`on_success`) successors; the workflow run ends in state `"failed"`. Any
-`on_failure` / `always` successors still run — use those (see
-[conditions](/node/guides/workflows/conditions)) if you need a rejection branch.
-
-
- Calling `approveGate` or `rejectGate` on a gate that has already been
- resolved (approved, rejected, or timed out) is a no-op — the tracker ignores
- stale resolutions.
-
-
-## Webhooks and external triggers
-
-Because resolution is just an API call over shared storage, you can drive gates
-from any external system. A common pattern is an HTTP endpoint in your API
-server that receives a webhook and calls `approveGate`:
-
-```ts
-app.post("/hooks/deploy-approval", async (req, res) => {
- // Resolving a gate is a privileged state transition: authenticate the caller,
- // verify the webhook signature, and authorize access to this run first.
- const { runId, approved, reason } = req.body;
- queue.workflows.resolveGate(runId, "approve-deploy", approved, reason);
- res.sendStatus(204);
-});
-```
-
-> **Coming from BullMQ?** `FlowProducer` has no pause/resume primitive — a
-> human-in-the-loop approval step is something you'd build yourself (e.g. a
-> job that polls a database flag). taskito's `.gate()` is a first-class step
-> kind: the run genuinely pauses, and `approveGate`/`rejectGate` resolve it
-> from any process.
diff --git a/docs/content/docs/node/guides/workflows/saga.mdx b/docs/content/docs/node/guides/workflows/saga.mdx
deleted file mode 100644
index 22bfed9c..00000000
--- a/docs/content/docs/node/guides/workflows/saga.mdx
+++ /dev/null
@@ -1,151 +0,0 @@
----
-title: Sagas
-description: "Automatically roll back completed steps in reverse order when a workflow run fails."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-Saga compensation runs rollback tasks in reverse-dependency order when a
-workflow run fails. Each step that completed and has a `compensate` task gets
-rolled back; steps that never ran are left alone.
-
-## Basic example
-
-```ts
-queue.task("reserveInventory", (orderId: string) => ({ reserved: true, orderId }));
-queue.task("unreserveInventory", (result: { reserved: boolean; orderId: string }) => { /* undo */ });
-
-queue.task("chargePayment", (orderId: string) => ({ charged: true, orderId }));
-queue.task("refundPayment", (result: { charged: boolean; orderId: string }) => { /* undo */ });
-
-queue.task("shipOrder", (orderId: string) => { throw new Error("Warehouse offline"); });
-// No compensate for ship — if it never succeeded, there's nothing to undo.
-
-const handle = queue.workflows
- .define("checkout")
- .step("reserve", "reserveInventory", { compensate: "unreserveInventory" })
- .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" })
- .step("ship", "shipOrder", { after: "charge" })
- .submit();
-
-queue.runWorker();
-
-const run = await handle.wait();
-// ship fails → charge rolled back (refundPayment) → reserve rolled back (unreserveInventory)
-// run.state === "compensated"
-```
-
- charge --> ship
- ship -. "run fails" .-> refund
- refund --> unreserve`}
-/>
-
-## How it works
-
-1. `ship` dead-letters — the run's failure is detected by the tracker.
-2. The tracker collects every completed node that has a `compensate` task,
- ordered in reverse-dependency order (most downstream first).
-3. It enqueues each compensate task, passing the forward step's result as the
- single argument. Compensate tasks are ordinary registered tasks — they run in
- the worker pool like any other job.
-4. Rollbacks execute sequentially in that reverse order. Each inherits the
- forward step's `queue`, `maxRetries`, and `timeoutMs`.
-5. When all rollbacks complete the run transitions to `"compensated"`.
-6. If a rollback itself dead-letters, the run ends in `"compensation_failed"` —
- remaining un-rolled-back steps are left as-is (fail-stop; no partial retry).
-
-Compensation is storage-driven and idempotent: each rollback enqueue is
-deduplicated, so a worker restart during compensation does not double-run a
-rollback.
-
-## Run terminal states
-
-| State | Meaning |
-|---|---|
-| `"completed"` | All steps succeeded |
-| `"failed"` | A step failed; no compensators were defined |
-| `"compensated"` | A step failed; all compensators completed |
-| `"compensation_failed"` | A step failed; at least one compensator also failed |
-
-
- `"compensation_failed"` is a manual-intervention state. Inspect
- `handle.nodes()` to identify which rollback failed and whether data is
- partially consistent.
-
-
-> **Coming from BullMQ?** There's no built-in saga/compensation concept in
-> BullMQ — you'd hand-roll rollback logic in your own failure handler. taskito's
-> `compensate` option and automatic reverse-order rollback are taskito-only.
-
-## Compensate task signature
-
-The compensate task receives the forward step's return value as its single
-positional argument. Design forward tasks to return enough context for the
-compensator to act:
-
-```ts
-queue.task("chargePayment", async (orderId: string) => {
- const charge = await stripe.charge(orderId);
- return { chargeId: charge.id, orderId }; // compensator needs chargeId
-});
-
-queue.task("refundPayment", async (result: { chargeId: string; orderId: string }) => {
- await stripe.refund(result.chargeId);
-});
-```
-
-## Steps without compensators
-
-Steps that do not declare `compensate` are skipped during rollback. A step that
-never ran (because it was downstream of the failing step) is also skipped — only
-**completed** steps with a compensate task are rolled back.
-
-```ts
-queue.workflows
- .define("partial-saga")
- .step("a", "taskA", { compensate: "undoA" }) // rolled back if run fails after a completes
- .step("b", "taskB", { after: "a" }) // no compensate — left as-is
- .step("c", "taskC", { after: "b", compensate: "undoC" })
- .submit();
-
-// If b fails: only a is rolled back (b never completed; c never ran).
-// If c fails: c has no result yet, so only a is rolled back.
-```
-
-## Step options
-
-| Option | Type | Description |
-|---|---|---|
-| `after` | `string \| string[]` | Predecessor node name(s) |
-| `compensate` | `string` | Registered task name to run as the rollback for this step |
-| `maxRetries` | `number` | Retry limit for the forward step (inherited by compensate) |
-| `timeoutMs` | `number` | Timeout for the forward step (inherited by compensate) |
-| `priority` | `number` | Queue priority |
-| `queue` | `string` | Queue name |
-
-## Combining with conditions
-
-Conditions and compensation compose. An `on_failure` handler runs first (as the
-run settles toward failure); once the run is failed, the tracker then rolls back
-the completed compensable steps:
-
-```ts
-queue.workflows
- .define("safe-checkout")
- .step("reserve", "reserveInventory", { compensate: "unreserveInventory" })
- .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" })
- .step("ship", "shipOrder", { after: "charge" })
- .step("notifyFailure", "sendFailureEmail", {
- after: "ship",
- condition: "on_failure",
- })
- .submit();
-```
diff --git a/docs/content/docs/node/more/examples/benchmark.mdx b/docs/content/docs/node/more/examples/benchmark.mdx
deleted file mode 100644
index da46a7fb..00000000
--- a/docs/content/docs/node/more/examples/benchmark.mdx
+++ /dev/null
@@ -1,87 +0,0 @@
----
-title: Benchmark
-description: "A self-contained throughput script: measure enqueue rate, processing rate, and end-to-end latency on your own hardware."
----
-
-Numbers depend heavily on your machine, backend, and task body, so the only
-number that matters is the one you measure. This script stages a batch of no-op
-jobs, drains them with a worker, and reports the three rates that bound a queue.
-
-## benchmark.ts
-
-```ts
-import { Queue } from "@byteveda/taskito";
-
-const N = 50_000;
-const queue = new Queue({ dbPath: "bench.db" });
-
-queue.task("noop", (_n: number) => undefined);
-
-// 1. Enqueue throughput — stage N jobs in batches of 1,000.
-const t0 = Date.now();
-for (let i = 0; i < N; i += 1_000) {
- queue.enqueueMany("noop", Array.from({ length: 1_000 }, (_, k) => ({ args: [i + k] })));
-}
-const enqueueMs = Date.now() - t0;
-
-// 2. Processing throughput — drain the backlog, polling stats until empty.
-const t1 = Date.now();
-const worker = queue.runWorker({ queues: ["default"], batchSize: 64, channelCapacity: 512 });
-while ((await queue.stats()).completed < N) {
- await new Promise((r) => setTimeout(r, 20));
-}
-const processMs = Date.now() - t1;
-worker.stop();
-
-// 3. End-to-end latency — created → completed across a sample of jobs.
-const sample = await queue.listJobs({ status: "complete", limit: 1_000 });
-const latencies = sample
- .filter((j) => j.completedAt)
- .map((j) => (j.completedAt as number) - j.createdAt)
- .sort((a, b) => a - b);
-const p50 = latencies[Math.floor(latencies.length * 0.5)];
-const p99 = latencies[Math.floor(latencies.length * 0.99)];
-
-console.log(`enqueue: ${Math.round(N / (enqueueMs / 1000))} jobs/s`);
-console.log(`process: ${Math.round(N / (processMs / 1000))} jobs/s`);
-console.log(`latency: p50 ${p50}ms p99 ${p99}ms`);
-```
-
-## Running it
-
-```bash
-node benchmark.ts
-```
-
-Sample output (illustrative — measure your own):
-
-```
-enqueue: 120000 jobs/s
-process: 35000 jobs/s
-latency: p50 8ms p99 42ms
-```
-
-## What drives the numbers
-
-| Lever | Effect |
-| --- | --- |
-| `enqueueMany` | one storage round-trip per batch instead of per job |
-| `batchSize` | jobs claimed per scheduler poll — fewer polls under load |
-| `channelCapacity` | in-flight buffer between the scheduler and the worker |
-| Backend | SQLite (WAL) for a single node; Postgres/Redis to scale out workers |
-| Task body | a real handler's I/O usually dominates — benchmark *your* task too |
-
-
- The hot path is the Rust core — enqueue, dequeue, and result handling never
- touch the JS event loop except to run your handler. Raise `batchSize` and
- `channelCapacity` together to keep a busy worker saturated.
-
-
-## Key patterns
-
-| Pattern | Where |
-| --- | --- |
-| Batched staging | `queue.enqueueMany` |
-| Drain to a target | poll `await queue.stats()` for `.completed` |
-| Worker throughput knobs | `runWorker({ batchSize, channelCapacity })` |
-| Latency percentiles | `listJobs` + `completedAt - createdAt` |
diff --git a/docs/content/docs/python/api-reference/queue/index.mdx b/docs/content/docs/python/api-reference/queue/index.mdx
index c1724c34..dfcc4d1c 100644
--- a/docs/content/docs/python/api-reference/queue/index.mdx
+++ b/docs/content/docs/python/api-reference/queue/index.mdx
@@ -114,7 +114,7 @@ Register a function as a background task. Returns a [`TaskWrapper`](/python/api-
| `serializer` | `Serializer \| None` | `None` | Per-task serializer override. Falls back to queue-level serializer. |
| `max_concurrent` | `int \| None` | `None` | Max concurrent running instances. `None` = no limit. |
| `idempotent` | `bool` | `False` | Auto-derive a dedup key from `sha256(task_name\|payload)` so duplicate `.delay()`/`.apply_async()` calls while a job is pending or running return the same job ID. |
-| `compensates` | `TaskWrapper \| str \| None` | `None` | Saga compensator for this task, enqueued in reverse order if a later workflow step fails. See [Sagas](/python/guides/workflows/sagas). |
+| `compensates` | `TaskWrapper \| str \| None` | `None` | Saga compensator for this task, enqueued in reverse order if a later workflow step fails. See [Sagas](/python/guides/workflows/saga). |
| `batch` | `bool \| dict \| None` | `None` | Enable producer-side batching (`True` for defaults, or a dict with `max_size`/`max_wait_ms`). See [Batching](/python/guides/core/batching). |
| `predicate` | `Predicate \| Callable \| None` | `None` | Gate execution on a runtime condition, checked at enqueue time and worker-dispatch time. See [Predicates](/python/guides/core/predicates). |
| `on_false` | `str` | `"defer"` | What to do when `predicate` returns `False` — `"defer"` (re-schedule) or `"cancel"` (terminally skip). |
diff --git a/docs/content/docs/python/api-reference/saga.mdx b/docs/content/docs/python/api-reference/saga.mdx
index 3371e12c..67651632 100644
--- a/docs/content/docs/python/api-reference/saga.mdx
+++ b/docs/content/docs/python/api-reference/saga.mdx
@@ -14,7 +14,7 @@ from taskito.workflows.saga import (
)
```
-For the conceptual overview, see [Sagas](/python/guides/workflows/sagas).
+For the conceptual overview, see [Sagas](/python/guides/workflows/saga).
---
@@ -156,4 +156,4 @@ When a parent workflow's saga reaches a sub-workflow node whose child run carrie
3. Child saga starts (depth = parent depth + 1).
4. When the child saga finalises (`Compensated` or `CompensationFailed`), the parent's node is marked accordingly and the parent's wave advances.
-If the parent step has its own explicit `compensates=…`, that wins — no propagation happens. See [Sagas](/python/guides/workflows/sagas) for a worked example.
+If the parent step has its own explicit `compensates=…`, that wins — no propagation happens. See [Sagas](/python/guides/workflows/saga) for a worked example.
diff --git a/docs/content/docs/python/getting-started/quickstart.mdx b/docs/content/docs/python/getting-started/quickstart.mdx
deleted file mode 100644
index b4fb59b5..00000000
--- a/docs/content/docs/python/getting-started/quickstart.mdx
+++ /dev/null
@@ -1,137 +0,0 @@
----
-title: Quickstart
-description: "Build your first task queue in 5 minutes."
----
-
-import { Tab, Tabs } from "fumadocs-ui/components/tabs";
-
-## 1. Define tasks
-
-Create a file called `tasks.py`:
-
-```python
-from taskito import Queue
-
-# Create a queue backed by SQLite
-queue = Queue(db_path="tasks.db")
-
-@queue.task()
-def add(a: int, b: int) -> int:
- return a + b
-
-@queue.task(max_retries=3, retry_backoff=2.0)
-def send_email(to: str, subject: str, body: str) -> str:
- # Your email sending logic here
- print(f"Sending email to {to}: {subject}")
- return f"sent to {to}"
-```
-
-## 2. Start a worker
-
-A **worker** pulls jobs from the queue and runs them. Start one first — the CLI
-worker runs in the foreground and stays up, processing jobs as they arrive:
-
-
-
-
-```bash
-# Runs in its own terminal and blocks, processing jobs until you stop it (Ctrl+C)
-taskito worker --app tasks:queue
-```
-
-
-
-
-```python
-# Runs the worker in a background thread inside your own process
-import threading
-from tasks import queue
-
-t = threading.Thread(target=queue.run_worker, daemon=True)
-t.start()
-```
-
-
-
-
-```python
-import asyncio
-from tasks import queue
-
-async def main():
- await queue.arun_worker()
-
-asyncio.run(main())
-```
-
-
-
-
-
- Nothing executes until a worker is consuming the queue. Enqueued jobs sit in
- `pending` until a worker claims them — so leave this worker running while you
- enqueue jobs in the next step.
-
-
-## 3. Enqueue jobs and get results
-
-From another terminal (or anywhere in your app), enqueue a job and block for its
-result. The worker from step 2 picks it up and runs it:
-
-```python
-from tasks import add
-
-# Enqueue — returns a JobResult handle immediately; the function does NOT run here
-job = add.delay(2, 3)
-print(f"Job ID: {job.id}") # 01936...
-print(f"Status: {job.status}") # "pending" — until a worker claims it
-
-# Block until the worker finishes the job (exponential-backoff polling)
-result = job.result(timeout=30)
-print(result) # 5
-
-# Async variant
-result = await job.aresult(timeout=30)
-```
-
-## 4. Monitor
-
-```python
-from tasks import queue
-
-stats = queue.stats()
-print(stats)
-# {'pending': 0, 'running': 0, 'completed': 5, 'failed': 0, 'dead': 0, 'cancelled': 0}
-```
-
-Or use the CLI:
-
-```bash
-# One-shot stats
-taskito info --app tasks:queue
-
-# Live dashboard (refreshes every 2s)
-taskito info --app tasks:queue --watch
-```
-
-### Web dashboard
-
-For a full visual interface with job browsing, metrics charts, dead letter
-management, and queue controls:
-
-```bash
-taskito dashboard --app tasks:queue
-```
-
-Open `http://localhost:8080` in your browser. The dashboard includes pages
-covering every aspect of your task queue — no extra dependencies needed.
-
-[Dashboard guide →](/python/guides/dashboard)
-
-## Next steps
-
-- [Tasks](/python/guides/core/tasks) — decorator options, `.delay()` vs `.apply_async()`
-- [Workers](/python/guides/core/workers) — CLI flags, graceful shutdown, worker count
-- [Retries](/python/guides/reliability/retries) — exponential backoff, dead letter queue
-- [Workflows](/python/guides/workflows) — chain, group, chord
-- [Testing](/python/guides/operations/testing) — run tasks synchronously in tests with `queue.test_mode()`
diff --git a/docs/content/docs/python/guides/core/queues.mdx b/docs/content/docs/python/guides/core/queues.mdx
deleted file mode 100644
index d12a05ee..00000000
--- a/docs/content/docs/python/guides/core/queues.mdx
+++ /dev/null
@@ -1,145 +0,0 @@
----
-title: Queues & Priority
-description: "Named queues, integer priority, queue-level rate limits and concurrency caps."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-## Named queues
-
-Route tasks to different queues for isolation and dedicated processing:
-
-```python
-@queue.task(queue="emails")
-def send_email(to, subject, body):
- ...
-
-@queue.task(queue="reports")
-def generate_report(report_id):
- ...
-
-@queue.task() # Goes to "default" queue
-def process_data(data):
- ...
-```
-
-### Worker queue subscription
-
-Workers can listen to specific queues:
-
-```bash
-# Process only email tasks
-taskito worker --app myapp:queue --queues emails
-
-# Process multiple queues
-taskito worker --app myapp:queue --queues emails,reports
-
-# Process all registered queues (default)
-taskito worker --app myapp:queue
-```
-
-Or programmatically:
-
-```python
-queue.run_worker(queues=["emails", "reports"])
-```
-
-
- Separate I/O-bound tasks (API calls, emails) from CPU-bound tasks (data
- processing, report generation) into different queues. Run them on different
- worker processes for optimal resource usage.
-
-
-## Priority
-
-Higher priority jobs are dequeued first within the same queue. Priority is an
-integer — higher values mean more urgent.
-
-### Default priority
-
-Set at task registration:
-
-```python
-@queue.task(priority=10)
-def urgent_task(data):
- ...
-
-@queue.task(priority=0) # Default
-def normal_task(data):
- ...
-```
-
-### Override at enqueue time
-
-```python
-# This specific job is extra urgent
-urgent_task.apply_async(args=(data,), priority=100)
-```
-
-### How it works
-
-Jobs are dequeued using a compound index: `(queue, status, priority DESC, scheduled_at ASC)`. This means:
-
-1. Higher priority jobs go first
-2. Among equal priority, older jobs (earlier `scheduled_at`) go first
-3. Each queue is processed independently
-
-```python
-# These three jobs are in the same queue
-low = task.apply_async(args=(1,), priority=1)
-mid = task.apply_async(args=(2,), priority=5)
-high = task.apply_async(args=(3,), priority=10)
-
-# Processing order: high (10), mid (5), low (1)
-```
-
-## Queue-level limits
-
-Apply a rate limit or concurrency cap to an entire queue, independently of
-per-task settings. These limits are checked in the scheduler before any
-per-task limits.
-
-### Rate limiting a queue
-
-```python
-queue.set_queue_rate_limit("default", "100/m") # Max 100 jobs per minute
-queue.set_queue_rate_limit("emails", "20/s") # Max 20 emails per second
-```
-
-The format is the same as `rate_limit` on `@queue.task()`: `"N/s"`, `"N/m"`,
-or `"N/h"`.
-
-### Capping concurrency per queue
-
-```python
-queue.set_queue_concurrency("default", 10) # Max 10 jobs running at once
-queue.set_queue_concurrency("reports", 2) # Heavy tasks: max 2 at a time
-```
-
-`set_queue_concurrency` limits how many jobs from that queue run
-simultaneously across all workers.
-
-
- Queue-level limits apply to all tasks in the queue regardless of their
- individual settings. Per-task `rate_limit` and `max_concurrent` are checked
- afterwards and may impose stricter caps. Set queue limits to protect shared
- downstream resources (APIs, databases) and per-task limits to manage
- individual task capacity.
-
-
-Both methods can be called at any point before or after `run_worker()` starts.
-
-## Default queue settings
-
-Configure defaults at the Queue level:
-
-```python
-queue = Queue(
- db_path="myapp.db",
- default_priority=0, # Default priority for all tasks
- default_retry=3, # Default max retries
- default_timeout=300, # Default timeout in seconds
-)
-```
-
-Individual `@queue.task()` decorators override these defaults.
diff --git a/docs/content/docs/python/guides/core/scheduling.mdx b/docs/content/docs/python/guides/core/scheduling.mdx
deleted file mode 100644
index a0fcb1c5..00000000
--- a/docs/content/docs/python/guides/core/scheduling.mdx
+++ /dev/null
@@ -1,160 +0,0 @@
----
-title: Scheduling
-description: "Delayed tasks via apply_async(delay=) and periodic tasks with 6-field cron expressions."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-taskito supports both **delayed tasks** (run once in the future) and
-**periodic tasks** (run on a cron schedule).
-
-## Delayed tasks
-
-Schedule a task to run after a delay:
-
-```python
-# Run 1 hour from now
-send_email.apply_async(
- args=("user@example.com", "Reminder", "Don't forget!"),
- delay=3600, # seconds
-)
-
-# Run 30 seconds from now
-cleanup.apply_async(args=(), delay=30)
-```
-
-The job is created immediately with `status=pending` but won't be picked up by
-a worker until the `scheduled_at` timestamp is reached.
-
-## Periodic tasks
-
-Register recurring tasks with cron expressions:
-
-```python
-@queue.periodic(cron="0 */5 * * * *")
-def health_check():
- """Run every 5 minutes."""
- ping_services()
-
-@queue.periodic(cron="0 0 0 * * *")
-def daily_cleanup():
- """Run at midnight every day."""
- queue.purge_completed(older_than=86400)
-
-@queue.periodic(cron="0 0 9 * * 1", args=("weekly",))
-def weekly_report(report_type):
- """Run every Monday at 9:00 AM."""
- generate_report(report_type)
-```
-
-### Cron expression format
-
-taskito uses **6-field cron expressions** (with seconds):
-
-```
-┌─────────── second (0-59)
-│ ┌───────── minute (0-59)
-│ │ ┌─────── hour (0-23)
-│ │ │ ┌───── day of month (1-31)
-│ │ │ │ ┌─── month (1-12)
-│ │ │ │ │ ┌─ day of week (0-6, Sun=0)
-│ │ │ │ │ │
-* * * * * *
-```
-
-> **Coming from Celery:** Celery's `crontab()` only maps to the last 5 fields
-> (minute, hour, day of month, month, day of week) — prepend a seconds field
-> to get taskito's 6-field format.
-
-| Expression | Schedule |
-|---|---|
-| `*/30 * * * * *` | Every 30 seconds |
-| `0 */5 * * * *` | Every 5 minutes |
-| `0 0 * * * *` | Every hour |
-| `0 30 * * * *` | Every hour at :30 |
-| `0 0 */2 * * *` | Every 2 hours |
-| `0 0 0 * * *` | Every day at midnight |
-| `0 0 9 * * *` | Every day at 9:00 AM |
-| `0 0 9 * * 1-5` | Weekdays at 9:00 AM |
-| `0 30 9 * * 1-5` | Weekdays at 9:30 AM |
-| `0 0 0 1 * *` | First day of every month at midnight |
-| `0 0 0 * * 0` | Every Sunday at midnight |
-| `0 0 0 1 1 *` | January 1st at midnight (yearly) |
-
-### Decorator options
-
-```python
-@queue.periodic(
- cron="0 0 * * * *", # Required: cron expression
- name="hourly-cleanup", # Optional: explicit name
- args=(3600,), # Optional: positional args
- kwargs={"force": True}, # Optional: keyword args
- queue="maintenance", # Optional: target queue
- timezone="America/New_York", # Optional: IANA timezone (default: UTC)
-)
-def cleanup(older_than, force=False):
- ...
-```
-
-### Timezone support
-
-By default, cron expressions are evaluated in UTC. Pass any IANA timezone name
-to schedule in a specific timezone:
-
-```python
-@queue.periodic(cron="0 0 9 * * 1-5", timezone="America/New_York")
-def morning_report():
- """Run weekdays at 9:00 AM Eastern."""
- generate_report()
-
-@queue.periodic(cron="0 0 18 * * *", timezone="Europe/London")
-def end_of_day_summary():
- """Run at 6:00 PM London time."""
- send_summary()
-```
-
-Timezone handling uses `chrono-tz` under the hood. Daylight saving time
-transitions are handled automatically. The `timezone` parameter defaults to
-UTC when omitted.
-
-### How periodic tasks work
-
-1. Periodic tasks are registered with the Rust scheduler when the worker starts
-2. The scheduler checks for due tasks every ~3 seconds
-3. When a task is due, a new job is enqueued automatically
-4. The task's `next_run` is computed using the cron expression
-5. Periodic task state is persisted in the `periodic_tasks` SQLite table
-
-
- Periodic tasks are only active while a worker is running. If no worker is
- running, tasks accumulate and the **next due** job is enqueued when a worker
- starts.
-
-
-## Edge cases
-
-### Task takes longer than the interval
-
-If a periodic task's execution time exceeds its cron interval, the next run
-is **skipped**, not stacked. Periodic tasks use `unique_key` deduplication
-internally — if the previous run is still pending or running, the new
-enqueue is silently dropped.
-
-### Multiple workers running periodic tasks
-
-Safe by design. Each worker's scheduler checks for due periodic tasks
-independently, but they all use the same `unique_key` for deduplication. Only
-one instance of each periodic task runs at a time, regardless of how many
-workers are active.
-
-### Timezone handling
-
-```python
-@queue.periodic(cron="0 9 * * *", timezone="America/New_York")
-def morning_report():
- ...
-```
-
-Without `timezone`, cron expressions are evaluated in **UTC**. Specify a
-timezone string (any valid IANA timezone) to schedule in local time. Daylight
-saving transitions are handled automatically via `chrono-tz`.
diff --git a/docs/content/docs/python/guides/core/tasks.mdx b/docs/content/docs/python/guides/core/tasks.mdx
deleted file mode 100644
index 8e22a7ac..00000000
--- a/docs/content/docs/python/guides/core/tasks.mdx
+++ /dev/null
@@ -1,238 +0,0 @@
----
-title: Tasks
-description: "Define tasks with @queue.task() — decorator options, naming, enqueue, batching."
----
-
-Tasks are Python functions registered with a queue via the `@queue.task()` decorator.
-
-## Defining a task
-
-```python
-from taskito import Queue
-
-queue = Queue(db_path="myapp.db")
-
-@queue.task()
-def process_data(data: dict) -> str:
- # Your logic here
- return "done"
-```
-
-## Decorator options
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `name` | `str \| None` | Auto-generated | Explicit task name. Defaults to `module.qualname`. |
-| `max_retries` | `int` | `3` | Max retry attempts before moving to DLQ. |
-| `retry_backoff` | `float` | `1.0` | Base delay in seconds for exponential backoff. |
-| `retry_delays` | `list[float] \| None` | `None` | Per-attempt delays in seconds, overrides backoff. e.g. `[1, 5, 30]`. |
-| `max_retry_delay` | `int \| None` | `None` | Cap on backoff delay in seconds (default 300 s). |
-| `timeout` | `int` | `300` | Max execution time in seconds (hard timeout). |
-| `soft_timeout` | `float \| None` | `None` | Cooperative time limit; checked via `current_job.check_timeout()`. |
-| `priority` | `int` | `0` | Default priority (higher = more urgent). |
-| `rate_limit` | `str \| None` | `None` | Rate limit string, e.g. `"100/m"`. |
-| `queue` | `str` | `"default"` | Named queue to submit to. |
-| `circuit_breaker` | `dict \| None` | `None` | Circuit breaker config: `{"threshold": 5, "window": 60, "cooldown": 120}`. |
-| `middleware` | `list[TaskMiddleware] \| None` | `None` | Per-task middleware, applied in addition to queue-level middleware. |
-| `inject` | `list[str] \| None` | `None` | Worker resource names to inject as keyword arguments. See [Resource System](/python/guides/resources). |
-| `serializer` | `Serializer \| None` | `None` | Per-task serializer override. Falls back to the queue-level serializer. |
-| `max_concurrent` | `int \| None` | `None` | Max concurrent running instances of this task. `None` means no limit. |
-
-```python
-@queue.task(
- name="emails.send",
- max_retries=5,
- retry_backoff=2.0,
- max_retry_delay=60, # cap backoff at 60 s
- timeout=60,
- priority=10,
- rate_limit="100/m",
- queue="emails",
- max_concurrent=10,
-)
-def send_email(to: str, subject: str, body: str):
- ...
-```
-
-### Custom retry delays
-
-Use `retry_delays` to specify exact wait times between each retry attempt instead of exponential backoff:
-
-```python
-@queue.task(retry_delays=[1, 5, 30]) # 1s after 1st fail, 5s after 2nd, 30s after 3rd
-def flaky_api_call():
- ...
-```
-
-### Soft timeouts
-
-A soft timeout raises `SoftTimeoutError` only when the task cooperatively checks:
-
-```python
-from taskito import current_job
-
-@queue.task(timeout=300, soft_timeout=60)
-def long_running(items):
- for item in items:
- current_job.check_timeout() # raises SoftTimeoutError if soft_timeout exceeded
- process(item)
-```
-
-### Circuit breakers
-
-Automatically open a circuit after repeated failures and refuse new executions during the cooldown period:
-
-```python
-@queue.task(circuit_breaker={"threshold": 5, "window": 60, "cooldown": 120})
-def call_external_api():
- ...
-```
-
-- `threshold`: number of failures to trip the breaker
-- `window`: rolling time window in seconds
-- `cooldown`: seconds the breaker stays open before allowing a retry
-
-### Per-task middleware
-
-Apply middleware to a specific task only:
-
-```python
-from taskito.contrib.sentry import SentryMiddleware
-
-@queue.task(middleware=[SentryMiddleware()])
-def important_task():
- ...
-```
-
-### Job expiration
-
-`expires` is an **enqueue-time** option (not a decorator option) — skip a job that
-wasn't started within the deadline:
-
-```python
-@queue.task()
-def time_sensitive():
- ...
-
-# Skip if not started within 5 minutes
-time_sensitive.apply_async(args=(), expires=300)
-```
-
-### Max retry delay
-
-Cap the exponential backoff so waits don't grow unbounded:
-
-```python
-@queue.task(retry_backoff=2.0, max_retries=10, max_retry_delay=120)
-def flaky_service():
- ...
-# Delays: 2, 4, 8, 16, 32, 64, 120, 120, 120 s (capped at 2 min)
-```
-
-### Per-task concurrency limit
-
-Prevent a single task type from consuming all workers:
-
-```python
-@queue.task(max_concurrent=3)
-def expensive_render():
- ...
-# At most 3 instances of expensive_render run simultaneously across all workers.
-```
-
-### Per-task serializer
-
-Override the queue-level serializer for a specific task:
-
-```python
-from taskito.serializers import JsonSerializer
-
-@queue.task(serializer=JsonSerializer())
-def api_event(payload: dict) -> dict:
- ...
-```
-
-The per-task serializer is used for the full round-trip: arguments are serialized with it at enqueue time and deserialized with it on the worker before the task function is called. Both the sync worker and the native async worker honour the per-task serializer, falling back to the queue-level serializer for tasks that have none registered.
-
-Useful when a task needs a different format (e.g., human-readable JSON for audit tasks) or when the payload is not picklable.
-
-## Task naming
-
-By default, tasks are named using `module.qualname`:
-
-```python
-# In myapp/tasks.py
-@queue.task()
-def process(): # Named: myapp.tasks.process
- ...
-```
-
-You can override with an explicit name:
-
-```python
-@queue.task(name="my-custom-name")
-def process(): # Named: my-custom-name
- ...
-```
-
-## Enqueuing jobs
-
-### `.delay()` — quick submit
-
-Submit with default options:
-
-```python
-job = send_email.delay("user@example.com", "Hello", "World")
-```
-
-### `.apply_async()` — full control
-
-Override any option at enqueue time:
-
-```python
-job = send_email.apply_async(
- args=("user@example.com", "Hello", "World"),
- priority=100, # Override priority
- delay=3600, # Run 1 hour from now
- queue="urgent-emails", # Override queue
- max_retries=10, # Override retries
- timeout=120, # Override timeout
- unique_key="welcome-user@example.com", # Deduplicate
- metadata='{"source": "signup"}', # Attach JSON metadata
-)
-```
-
-### Direct call
-
-Calling a task directly runs it synchronously, bypassing the queue:
-
-```python
-result = send_email("user@example.com", "Hello", "World") # Runs immediately
-```
-
-## Batch enqueue
-
-Enqueue many jobs in one transaction with `task.map()` or `queue.enqueue_many()`:
-
-```python
-jobs = send_email.map([
- ("alice@example.com", "Hi", "Body"),
- ("bob@example.com", "Hi", "Body"),
-])
-```
-
-See [Batch enqueue](/python/guides/advanced-execution/batch-enqueue) for
-`enqueue_many()`, per-item overrides, and per-item result tracking.
-
-## Metadata
-
-Attach arbitrary JSON metadata to jobs:
-
-```python
-job = process.apply_async(
- args=(data,),
- metadata='{"user_id": 42, "source": "api"}',
-)
-```
-
-Metadata is stored with the job and visible in dead letter queue entries.
diff --git a/docs/content/docs/python/guides/core/workers.mdx b/docs/content/docs/python/guides/core/workers.mdx
deleted file mode 100644
index afe21689..00000000
--- a/docs/content/docs/python/guides/core/workers.mdx
+++ /dev/null
@@ -1,310 +0,0 @@
----
-title: Workers
-description: "OS-thread, prefork, and async worker pools — startup, count, specialization, shutdown."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-import { Tab, Tabs } from "fumadocs-ui/components/tabs";
-
-Workers process queued jobs. taskito runs workers as OS threads within a
-single process, managed by a Rust scheduler.
-
-## Starting a worker
-
-
-
-
-```bash
-taskito worker --app myapp.tasks:queue
-```
-
-| Flag | Description |
-|---|---|
-| `--app` | Python path to your Queue instance (`module:attribute`) |
-| `--queues` | Comma-separated queue names (default: all registered) |
-
-
-
-
-```python
-# Blocks the current thread
-queue.run_worker()
-
-# With specific queues
-queue.run_worker(queues=["emails", "reports"])
-```
-
-
-
-
-```python
-import threading
-
-t = threading.Thread(target=queue.run_worker, daemon=True)
-t.start()
-
-# Your application continues...
-```
-
-
-
-
-```python
-import asyncio
-
-async def main():
- # Runs worker in a thread pool, non-blocking
- await queue.arun_worker()
-
-asyncio.run(main())
-```
-
-
-
-
-## Worker count
-
-By default, taskito auto-detects the number of CPU cores:
-
-```python
-queue = Queue(db_path="myapp.db", workers=0) # Auto-detect (default)
-queue = Queue(db_path="myapp.db", workers=8) # Explicit count
-```
-
-## Prefork pool
-
-The default worker pool uses OS threads, which share a single Python GIL
-(Global Interpreter Lock — only one thread runs Python bytecode at a time). For
-CPU-bound tasks, use the prefork pool — it spawns separate child processes,
-each with its own GIL:
-
-```python
-queue.run_worker(pool="prefork", app="myapp:queue", workers=4)
-```
-
-```bash
-taskito worker --app myapp:queue --pool prefork
-```
-
-Each child is a full Python interpreter that imports your app, builds the task
-registry, and executes tasks independently.
-
-### When to use prefork
-
-| Workload | Pool | Why |
-|----------|------|-----|
-| I/O-bound (HTTP, DB) | `thread` (default) | Threads release the GIL during I/O |
-| CPU-bound (data processing) | `prefork` | Each process has its own GIL |
-| Mixed | `prefork` | CPU tasks benefit; I/O tasks work fine too |
-
-### How it works
-
-|"Job JSON"| P["PreforkPool"]
- P -->|stdin| C1["Child 1 (own GIL)"]
- P -->|stdin| C2["Child 2 (own GIL)"]
- P -->|stdin| CN["Child N (own GIL)"]
-
- C1 -->|stdout| R1["Reader 1"]
- C2 -->|stdout| R2["Reader 2"]
- CN -->|stdout| RN["Reader N"]
-
- R1 -->|JobResult| RCH["Result Channel"]
- R2 -->|JobResult| RCH
- RN -->|JobResult| RCH
-
- RCH --> ML["Result Handler"]`}
-/>
-
-Jobs are serialized as JSON Lines over stdin pipes. Each child reads a job,
-executes the task wrapper (with middleware, resources, proxies), and writes
-the result as JSON to stdout. The parent's reader threads parse results and
-feed them to the scheduler.
-
-### Configuration
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `pool` | `str` | `"thread"` | Worker pool type: `"thread"` or `"prefork"` |
-| `app` | `str` | — | Import path to Queue (required for prefork) |
-| `workers` | `int` | CPU count | Number of child processes |
-
-
- The `app` parameter must be an importable path like `"myapp.tasks:queue"`.
- Each child process imports this path to build its task registry. Tasks
- defined inside functions or closures cannot be imported by children.
-
-
-## Worker specialization
-
-Tag workers to route jobs to specific machines or capabilities:
-
-```python
-# Start a worker that only processes jobs tagged for GPU or heavy workloads
-queue.run_worker(tags=["gpu", "heavy"])
-```
-
-Jobs submitted to a queue with `tags` are only picked up by workers that have
-all the required tags. Workers without tags process untagged jobs.
-
-```bash
-# CLI equivalent
-taskito worker --app myapp:queue --tags gpu,heavy
-```
-
-
- Workers are **OS threads**, not processes. Each worker acquires the Python
- GIL only during task execution, so the scheduler and dispatch logic run
- without GIL contention.
-
-
-## Graceful shutdown
-
-taskito supports graceful shutdown via `Ctrl+C`:
-
-1. **First `Ctrl+C`**: Stops accepting new jobs, waits for in-flight tasks to
- complete (up to `drain_timeout` seconds)
-2. **Second `Ctrl+C`**: Force-kills immediately
-
-Configure the drain timeout when constructing the queue:
-
-```python
-queue = Queue(db_path="myapp.db", drain_timeout=60) # wait up to 60 seconds
-```
-
-The default `drain_timeout` is 30 seconds.
-
-```
-$ taskito worker --app myapp:queue
-[taskito] Starting worker...
-[taskito] Registered tasks: 3
-[taskito] Queues: default, emails
-^C
-[taskito] Shutting down gracefully (waiting for in-flight jobs)...
-[taskito] Worker stopped.
-```
-
-### Programmatic shutdown
-
-```python
-# From another thread or signal handler
-queue._inner.request_shutdown()
-```
-
-## Worker discovery
-
-Inspect live workers across all machines:
-
-```python
-for w in queue.workers():
- print(f"{w['worker_id']} on {w['hostname']} (pid {w['pid']}, {w['status']})")
-```
-
-Each worker entry includes:
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `worker_id` | `str` | Unique ID (UUIDv7) |
-| `hostname` | `str` | OS hostname |
-| `pid` | `int` | Process ID |
-| `status` | `str` | `"active"`, `"draining"`, or deleted on exit |
-| `pool_type` | `str` | `"thread"`, `"prefork"`, or `"native-async"` |
-| `started_at` | `int` | Registration timestamp (ms) |
-| `queues` | `str` | Comma-separated queue names |
-| `threads` | `int` | Worker thread/process count |
-| `last_heartbeat` | `int` | Last heartbeat timestamp (ms) |
-
-### Status lifecycle
-
- active: register
- active --> draining: shutdown signal
- draining --> [*]: clean exit
- active --> [*]: crash (reaped after 30s)`}
-/>
-
-### Lifecycle events
-
-Subscribe to worker lifecycle changes:
-
-```python
-from taskito import EventType
-
-@queue.on_event(EventType.WORKER_ONLINE)
-def on_online(event_type, payload):
- print(f"Worker {payload['worker_id']} joined")
-
-@queue.on_event(EventType.WORKER_OFFLINE)
-def on_offline(event_type, payload):
- print(f"Worker {payload['worker_id']} went away")
-
-@queue.on_event(EventType.WORKER_UNHEALTHY)
-def on_unhealthy(event_type, payload):
- print(f"Worker {payload['worker_id']} unhealthy: {payload['resources']}")
-```
-
-| Event | Fires when | Payload |
-|-------|-----------|---------|
-| `WORKER_ONLINE` | Worker registered in storage | `worker_id`, `queues`, `pool` |
-| `WORKER_OFFLINE` | Dead worker reaped (no heartbeat for 30s) | `worker_id` |
-| `WORKER_UNHEALTHY` | Resource health transitions to unhealthy | `worker_id`, `resources` |
-
-## Async tasks
-
-`async def` task functions are dispatched natively — they run on a dedicated
-event loop thread, not wrapped in `asyncio.run()` on a worker thread.
-
-```python
-@queue.task()
-async def fetch_data(url: str) -> dict:
- import httpx
- async with httpx.AsyncClient() as client:
- r = await client.get(url)
- return r.json()
-```
-
-Control the max number of async tasks running concurrently:
-
-```python
-queue = Queue(
- db_path="myapp.db",
- workers=4, # OS threads for sync tasks
- async_concurrency=200, # concurrent async tasks (default: 100)
-)
-```
-
-See [Native Async Tasks](/python/guides/advanced-execution) for the full guide.
-
-## How workers work
-
- (Tokio async)"] -->|"sync job"| CH["Bounded Channel"]
- S -->|"async job"| AP["Native Async Pool"]
-
- CH --> W1["Worker 1"]
- CH --> W2["Worker 2"]
- CH --> WN["Worker N"]
-
- AP --> EL["Event Loop Thread"]
-
- W1 -->|Result| RCH["Result Channel"]
- W2 -->|Result| RCH
- WN -->|Result| RCH
- EL -->|Result| RCH
-
- RCH --> ML["Result Handler"]
- ML -->|"complete / retry / DLQ"| DB[("SQLite")]`}
-/>
-
-1. The **scheduler** runs in a dedicated Tokio async thread, polling SQLite for ready jobs every 50ms
-2. Sync jobs are sent to the **worker thread pool** via a bounded channel; each worker acquires the GIL and runs the Python function
-3. Async jobs are dispatched to the **native async pool** and scheduled on a dedicated Python event loop
-4. Results from both pools flow back through a **result channel** to the main loop
-5. The main loop updates job status in SQLite (complete, retry, or DLQ)
-
-For multi-worker deployments, [mesh scheduling](/python/guides/operations/mesh)
-adds gossip-based discovery, task affinity, and work-stealing between
-workers.
diff --git a/docs/content/docs/python/guides/extensibility/middleware.mdx b/docs/content/docs/python/guides/extensibility/middleware.mdx
deleted file mode 100644
index bfebc9a1..00000000
--- a/docs/content/docs/python/guides/extensibility/middleware.mdx
+++ /dev/null
@@ -1,229 +0,0 @@
----
-title: Per-Task Middleware
-description: "TaskMiddleware base class with 7 lifecycle hooks — global, per-task, with mutable on_enqueue options."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-taskito supports a middleware system that lets you run code at key points in
-the task lifecycle. Middleware can be applied globally (to all tasks) or
-per-task.
-
-## TaskMiddleware base class
-
-Create middleware by subclassing `TaskMiddleware` and overriding the hooks
-you need:
-
-```python
-from taskito import TaskMiddleware
-
-class LoggingMiddleware(TaskMiddleware):
- def before(self, ctx):
- print(f"[START] {ctx.task_name} (job {ctx.id})")
-
- def after(self, ctx, result, error):
- status = "OK" if error is None else f"FAILED: {error}"
- print(f"[END] {ctx.task_name}: {status}")
-
- def on_retry(self, ctx, error, retry_count):
- print(f"[RETRY] {ctx.task_name} attempt {retry_count}: {error}")
-```
-
-### Hook signatures
-
-| Hook | Called when |
-|---|---|
-| `before(ctx)` | Before task execution |
-| `after(ctx, result, error)` | After task execution (success or failure) |
-| `on_retry(ctx, error, retry_count)` | A job fails and will be retried |
-| `on_enqueue(task_name, args, kwargs, options)` | A job is about to be enqueued |
-| `on_dead_letter(ctx, error)` | A job exhausts all retries and moves to the DLQ (dead letter queue — a holding area for jobs that exhausted their retries) |
-| `on_timeout(ctx)` | A job hits its timeout limit |
-| `on_cancel(ctx)` | A job is cancelled during execution |
-
-The `ctx` parameter is a `JobContext` — the same object as `current_job` —
-providing `ctx.id`, `ctx.task_name`, `ctx.retry_count`, and `ctx.queue_name`.
-
-
- `on_retry`, `on_dead_letter`, `on_timeout`, and `on_cancel` are called by
- the Rust result handler after the scheduler records the outcome. They fire
- after `after()` and after the corresponding event is emitted on the event
- bus. Exceptions raised inside these hooks are logged and do not affect job
- processing.
-
-
-### `on_timeout` — handling timed-out jobs
-
-`on_timeout` fires when the Rust scheduler detects a stale job that
-exceeded its hard `timeout`. Detection happens in the maintenance reaper,
-which periodically scans for jobs still marked as running past their
-deadline.
-
-When a job times out, `on_timeout` is called **before** `on_retry` (if the
-job will be retried) or `on_dead_letter` (if retries are exhausted). This
-lets you react to the timeout itself independently of whether the job will
-be retried:
-
-```python
-class TimeoutAlerter(TaskMiddleware):
- def on_timeout(self, ctx):
- # Fires for every timed-out job, regardless of retry/DLQ outcome
- logger.error("Job %s (%s) timed out", ctx.id, ctx.task_name)
-
- def on_retry(self, ctx, error, retry_count):
- # Fires after on_timeout when the job will be retried
- logger.warning("Retrying %s (attempt %d)", ctx.task_name, retry_count)
-
- def on_dead_letter(self, ctx, error):
- # Fires after on_timeout when retries are exhausted
- logger.critical("Job %s exhausted retries after timeout", ctx.id)
-```
-
-
- Use `on_timeout` to update dashboards, fire alerts, or record SLA
- violations. Combine with `on_retry` and `on_dead_letter` for full
- visibility into the job's fate after the timeout.
-
-
-### `on_enqueue` — modifying enqueue parameters
-
-`on_enqueue` is unique: it fires before the job is written to the database,
-and the `options` dict is **mutable**. Modify it to change how the job is
-enqueued:
-
-```python
-class PriorityBoostMiddleware(TaskMiddleware):
- def on_enqueue(self, task_name, args, kwargs, options):
- # Bump priority for urgent tasks during business hours
- import datetime
- hour = datetime.datetime.now().hour
- if 9 <= hour < 18 and task_name.startswith("alerts."):
- options["priority"] = max(options.get("priority", 0), 50)
-```
-
-Keys present in `options`: `priority`, `delay`, `queue`, `max_retries`,
-`timeout`, `unique_key`, `metadata`.
-
-## Queue-level middleware
-
-Apply middleware to **all tasks** by passing it to the `Queue` constructor:
-
-```python
-from taskito import Queue
-
-queue = Queue(middleware=[LoggingMiddleware()])
-```
-
-## Per-task middleware
-
-Apply middleware to a **specific task** using the `middleware` parameter:
-
-```python
-@queue.task(middleware=[MetricsMiddleware()])
-def process(data):
- ...
-```
-
-Per-task middleware runs **after** global middleware, in registration order.
-
-## Example: metrics middleware
-
-```python
-import time
-from taskito import TaskMiddleware
-
-class MetricsMiddleware(TaskMiddleware):
- def before(self, ctx):
- ctx._start_time = time.monotonic()
-
- def after(self, ctx, result, error):
- elapsed = time.monotonic() - ctx._start_time
- status = "success" if error is None else "failure"
- metrics.histogram("task.duration_seconds", elapsed, tags={
- "task": ctx.task_name,
- "status": status,
- })
-```
-
-## Composition and ordering
-
-### Multiple middleware on the same task
-
-```python
-import time
-from taskito import TaskMiddleware
-
-class TimingMiddleware(TaskMiddleware):
- def before(self, ctx):
- ctx._start = time.monotonic()
- def after(self, ctx, result, error):
- elapsed = time.monotonic() - ctx._start
- print(f"{ctx.task_name} took {elapsed:.3f}s")
-
-class LoggingMiddleware(TaskMiddleware):
- def before(self, ctx):
- print(f"Starting {ctx.task_name}[{ctx.id}]")
- def after(self, ctx, result, error):
- print(f"Finished {ctx.task_name}[{ctx.id}]")
-
-@queue.task(middleware=[TimingMiddleware(), LoggingMiddleware()])
-def process(data):
- ...
-```
-
-### Execution order
-
-1. **Global middleware** (registered via `Queue(middleware=[...])`) runs first
-2. **Per-task middleware** (via `@queue.task(middleware=[...])`) runs second
-3. Within each group, middleware runs in **registration order**
-4. `after()` hooks run in **reverse order** (like a stack)
-
-### Exception handling
-
-If a middleware hook raises an exception:
-
-- **`before()`**: the exception is logged, but subsequent middleware `before()` hooks still run. The task executes normally.
-- **`after()`**: the exception is logged. Other `after()` hooks still run.
-- **`on_retry()` / `on_dead_letter()`**: logged and swallowed — these are notification hooks, not control flow.
-
-Middleware exceptions never prevent task execution or result handling.
-
-## Middleware vs hooks
-
-Coming from Celery signals? Use these instead of connecting to `task_prerun` /
-`task_postrun` / `task_failure`: queue-level **hooks** for global behavior, or
-per-task **middleware** for scoped behavior.
-
-taskito has two systems for running code around tasks:
-
-| | Hooks (`@queue.on_failure`, etc.) | Middleware (`TaskMiddleware`) |
-|---|---|---|
-| **Scope** | Queue-level only | Queue-level or per-task |
-| **Interface** | Decorated functions | Class with up to 7 hooks |
-| **Context** | Receives `task_name, args, kwargs` | Receives `JobContext` |
-| **Enqueue hook** | No | Yes (`on_enqueue`, can mutate options) |
-| **Retry hook** | No | Yes (`on_retry`) |
-| **DLQ / timeout / cancel hooks** | No | Yes |
-| **Execution order** | After middleware | Before hooks |
-
-Middleware runs **inside** the task wrapper (closer to the task function),
-while hooks run **outside**. In practice, middleware `before()` fires
-first, then `before_task` hooks. On completion, `on_success`/`on_failure`
-hooks fire, then middleware `after()`.
-
-## Combining with OpenTelemetry
-
-The built-in `OpenTelemetryMiddleware` is itself a `TaskMiddleware`, so it
-composes naturally with your own middleware:
-
-```python
-from taskito import Queue
-from taskito.contrib.otel import OpenTelemetryMiddleware
-
-queue = Queue(middleware=[
- OpenTelemetryMiddleware(),
- LoggingMiddleware(),
-])
-```
-
-See the [OpenTelemetry guide](/python/guides/integrations/otel) for setup details.
diff --git a/docs/content/docs/python/guides/extensibility/serializers.mdx b/docs/content/docs/python/guides/extensibility/serializers.mdx
deleted file mode 100644
index ce037d77..00000000
--- a/docs/content/docs/python/guides/extensibility/serializers.mdx
+++ /dev/null
@@ -1,222 +0,0 @@
----
-title: Pluggable Serializers
-description: "CloudpickleSerializer, JsonSerializer, MsgPackSerializer, EncryptedSerializer, custom Serializer protocol."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-taskito uses a pluggable serializer for task arguments and results. By
-default, it uses `SmartSerializer`, but you can switch to
-`JsonSerializer`, `CloudpickleSerializer` directly, or provide your own.
-
-## Built-in serializers
-
-### SmartSerializer (default)
-
-Encodes plain data (dicts, lists, strings, numbers, booleans, `None`,
-tuples) via MessagePack — fast and compact — and transparently falls
-back to `CloudpickleSerializer` for anything MessagePack can't encode
-(lambdas, closures, custom class instances). A one-byte tag on each
-payload records which codec produced it, so `loads()` always picks the
-right path. This is the default — no configuration needed.
-
-```python
-from taskito import Queue
-
-queue = Queue() # uses SmartSerializer
-```
-
-### CloudpickleSerializer
-
-Handles lambdas, closures, and complex Python objects unconditionally —
-every payload goes through cloudpickle, skipping `SmartSerializer`'s
-MessagePack fast path. Use it directly if you want that predictability,
-or as the inner serializer for `EncryptedSerializer` / `SignedSerializer`.
-
-```python
-from taskito import Queue, CloudpickleSerializer
-
-queue = Queue(serializer=CloudpickleSerializer())
-```
-
-### JsonSerializer
-
-Produces human-readable JSON payloads. Useful for debugging, cross-language
-interop, or when you only pass simple types (strings, numbers, dicts, lists).
-
-```python
-from taskito import Queue, JsonSerializer
-
-queue = Queue(serializer=JsonSerializer())
-```
-
-### MsgPackSerializer
-
-MessagePack serialization: faster than cloudpickle, produces smaller
-payloads, and is cross-language compatible. `msgpack` ships as a core
-taskito dependency, so no extra install is required — `taskito[msgpack]`
-exists for discoverability if you want to spell it out in your own
-requirements file:
-
-```bash
-pip install taskito[msgpack]
-```
-
-```python
-from taskito.serializers import MsgPackSerializer
-
-queue = Queue(serializer=MsgPackSerializer())
-```
-
-
- `MsgPackSerializer` only handles basic types: dicts, lists, strings,
- numbers, booleans, and `None`. It does not support lambdas, closures, or
- arbitrary Python objects. Use `CloudpickleSerializer` when you need to
- pass complex objects.
-
-
-### EncryptedSerializer
-
-AES-256-GCM encryption for task arguments and results. Payloads stored in
-the database are opaque ciphertext — only the key holder can read them.
-Requires the `encryption` extra:
-
-```bash
-pip install taskito[encryption]
-```
-
-```python
-import os
-from taskito.serializers import EncryptedSerializer
-
-queue = Queue(serializer=EncryptedSerializer(key=os.environ["QUEUE_KEY"]))
-```
-
-The key must be exactly 32 bytes, base64-encoded. Generate one with:
-
-```bash
-python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
-```
-
-By default, `EncryptedSerializer` wraps `CloudpickleSerializer`. To wrap a
-different serializer:
-
-```python
-from taskito.serializers import EncryptedSerializer, MsgPackSerializer
-
-queue = Queue(serializer=EncryptedSerializer(key=key, inner=MsgPackSerializer()))
-```
-
-### SignedSerializer
-
-HMAC-SHA256 integrity tag for task arguments and results. Unlike
-`EncryptedSerializer`, it does **not** hide the payload — it authenticates it.
-A worker refuses to deserialize any bytes that were not produced with the
-shared key, so an attacker who can write to the queue's storage (the SQLite
-file, a Postgres table, or Redis) cannot smuggle in a forged payload.
-
-```python
-import os
-from taskito.serializers import SignedSerializer, SmartSerializer
-
-key = os.urandom(32) # share this across producers and workers
-queue = Queue(serializer=SignedSerializer(SmartSerializer(), key))
-```
-
-
- The default serializer can execute code on load (cloudpickle handles
- lambdas and arbitrary objects). Without signing, anyone able to write to
- the backing store can achieve remote code execution on every worker that
- dequeues a crafted job. `SignedSerializer` closes that path. The key must
- be at least 32 bytes of CSPRNG output and identical on producers and
- workers.
-
-
-For both confidentiality **and** integrity, wrap one in the other:
-
-```python
-from taskito.serializers import EncryptedSerializer, SignedSerializer, SmartSerializer
-
-inner = EncryptedSerializer(SmartSerializer(), key=enc_key)
-queue = Queue(serializer=SignedSerializer(inner, sign_key))
-```
-
-## When to use each
-
-| | SmartSerializer | CloudpickleSerializer | JsonSerializer | MsgPackSerializer | EncryptedSerializer |
-|---|---|---|---|---|---|
-| **Complex objects** | Yes (cloudpickle fallback) | Yes | No | No | Depends on inner serializer |
-| **Debugging** | Binary payloads (opaque) | Binary payloads (opaque) | Human-readable JSON | Binary (opaque) | Ciphertext (opaque) |
-| **Cross-language** | Python only | Python only | Any language | Any language | Python only (by default) |
-| **Performance** | Best for plain data | Good | Good for simple types | Best | Adds encryption overhead |
-| **Security** | None | None | None | None | AES-256-GCM |
-| **Extra dependency** | No | No | No | No | `cryptography` (`taskito[encryption]`) |
-| **Default** | Yes | No | No | No | No |
-
-**Rule of thumb**: use the default `SmartSerializer` unless you have a
-specific reason to switch — it gets you compact MessagePack payloads for
-plain data with an automatic cloudpickle fallback for anything
-MessagePack can't encode. Use `EncryptedSerializer` when tasks carry
-sensitive data that must not be readable in the database.
-
-**Coming from Celery?** Celery defaults to JSON (`task_serializer="json"`);
-taskito defaults to `SmartSerializer`, which is pickle-based (via
-cloudpickle) for anything MessagePack can't represent. It handles closures
-and arbitrary objects that JSON can't, but that fallback path is
-Python-only and **executes code on load** — use it only with trusted
-payloads. Switch to `JsonSerializer` for cross-language or untrusted data,
-or wrap any serializer in `SignedSerializer` to authenticate payloads
-(HMAC) before they are deserialized.
-
-## Custom serializers
-
-Implement the `Serializer` protocol with two methods. `MsgPackSerializer`
-already ships built-in (see above) — this example is a stand-in for any
-serializer not covered by the built-ins:
-
-```python
-from taskito import Serializer
-
-class MyMsgpackSerializer:
- def dumps(self, obj) -> bytes:
- import msgpack
- return msgpack.packb(obj)
-
- def loads(self, data: bytes):
- import msgpack
- return msgpack.unpackb(data, raw=False)
-
-queue = Queue(serializer=MyMsgpackSerializer())
-```
-
-The protocol requires:
-
-| Method | Signature | Description |
-|---|---|---|
-| `dumps` | `(obj: Any) -> bytes` | Serialize a Python object to bytes |
-| `loads` | `(data: bytes) -> Any` | Deserialize bytes back to a Python object |
-
-The serializer is used for both task arguments (`(args, kwargs)` tuple) and
-return values.
-
-
- `job.result()` uses the queue's configured serializer for
- deserialization. If you're using `JsonSerializer` or a custom serializer,
- results are correctly deserialized with that serializer — not hardcoded
- cloudpickle.
-
-
-## Configuration
-
-Pass the serializer to the `Queue` constructor:
-
-```python
-queue = Queue(
- db_path="myapp.db",
- serializer=JsonSerializer(),
-)
-```
-
-All tasks on the queue use the same serializer. The serializer must be
-consistent between the enqueuing code and the worker — using different
-serializers will cause deserialization failures.
diff --git a/docs/content/docs/python/guides/integrations/prometheus.mdx b/docs/content/docs/python/guides/integrations/prometheus.mdx
deleted file mode 100644
index 62f89eb2..00000000
--- a/docs/content/docs/python/guides/integrations/prometheus.mdx
+++ /dev/null
@@ -1,206 +0,0 @@
----
-title: Prometheus Metrics
-description: "PrometheusMiddleware + PrometheusStatsCollector — counters, histograms, gauges, /metrics server."
----
-
-taskito provides Prometheus metrics via a middleware and an optional stats
-collector thread.
-
-## Installation
-
-```bash
-pip install taskito[prometheus]
-```
-
-This installs `prometheus-client` as a dependency.
-
-## PrometheusMiddleware
-
-Add `PrometheusMiddleware` to your queue to track per-task execution
-metrics:
-
-```python
-from taskito import Queue
-from taskito.contrib.prometheus import PrometheusMiddleware
-
-queue = Queue(db_path="myapp.db", middleware=[PrometheusMiddleware()])
-```
-
-### Configuration
-
-```python
-PrometheusMiddleware(
- namespace="myapp",
- extra_labels_fn=lambda ctx: {"env": "prod", "region": "us-east-1"},
- disabled_metrics={"resource", "proxy"},
- task_filter=lambda name: not name.startswith("internal."),
-)
-```
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `namespace` | `str` | `"taskito"` | Prefix for all metric names. |
-| `extra_labels_fn` | `Callable[[JobContext], dict[str, str]] \| None` | `None` | Returns extra labels to add to job metrics. Receives `JobContext`. |
-| `disabled_metrics` | `set[str] \| None` | `None` | Metric groups or individual names to skip. Groups: `"jobs"`, `"queue"`, `"resource"`, `"proxy"`, `"intercept"`. |
-| `task_filter` | `Callable[[str], bool] \| None` | `None` | Predicate that receives a task name. Return `True` to export metrics for the task, `False` to skip it. `None` exports all tasks. |
-
-### Metrics tracked
-
-| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
-| `taskito_jobs_total` | Counter | `task`, `status` | Total jobs processed (status: `completed` or `failed`) |
-| `taskito_job_duration_seconds` | Histogram | `task` | Job execution duration |
-| `taskito_active_workers` | Gauge | — | Number of currently executing workers |
-| `taskito_retries_total` | Counter | `task` | Total retry attempts |
-
-## PrometheusStatsCollector
-
-For queue-level metrics, use the stats collector. It polls `queue.stats()`
-on a background thread:
-
-```python
-from taskito.contrib.prometheus import PrometheusStatsCollector
-
-collector = PrometheusStatsCollector(queue, interval=10)
-collector.start()
-```
-
-### Configuration
-
-```python
-PrometheusStatsCollector(
- queue,
- interval=10,
- namespace="myapp",
- disabled_metrics={"intercept"},
-)
-```
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `queue` | `Queue` | — | The Queue instance to poll. |
-| `interval` | `float` | `10.0` | Seconds between polls. |
-| `namespace` | `str` | `"taskito"` | Prefix for metric names. Must match `PrometheusMiddleware` namespace to share metric objects. |
-| `disabled_metrics` | `set[str] \| None` | `None` | Metric groups or names to skip. Same groups as `PrometheusMiddleware`. |
-
-### Metrics tracked
-
-| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
-| `taskito_queue_depth` | Gauge | `queue` | Number of pending jobs |
-| `taskito_dlq_size` | Gauge | — | Number of dead-letter jobs |
-| `taskito_worker_utilization` | Gauge | — | Ratio of running jobs to total workers (0.0–1.0) |
-
-## Metrics server
-
-Start a standalone `/metrics` endpoint for Prometheus to scrape:
-
-```python
-from taskito.contrib.prometheus import start_metrics_server
-
-start_metrics_server(port=9090)
-```
-
-This uses `prometheus_client.start_http_server` under the hood.
-
-### This is a different `/metrics` than the dashboard's
-
-`start_metrics_server` opens its own dedicated HTTP server — it is not
-the same endpoint as the dashboard's `GET /metrics` (see
-[REST API](/python/guides/dashboard/rest-api)). Both ultimately call
-`prometheus_client.generate_latest()`, but each only serves metrics
-registered in *its own process*:
-
-- Run `start_metrics_server` in the same process as your
- `PrometheusMiddleware` / `PrometheusStatsCollector` (typically the
- worker process) — this is the endpoint you'll usually scrape.
-- The dashboard's `GET /metrics` only shows something useful if the
- dashboard process itself registered metrics (e.g. it also runs
- `PrometheusMiddleware`). If your worker and dashboard run as separate
- processes — the common deployment — scrape the worker's
- `start_metrics_server` instead.
-
-## Full example
-
-```python
-from taskito import Queue
-from taskito.contrib.prometheus import (
- PrometheusMiddleware,
- PrometheusStatsCollector,
- start_metrics_server,
-)
-
-queue = Queue(db_path="myapp.db", middleware=[PrometheusMiddleware()])
-
-# Start metrics endpoint
-start_metrics_server(port=9090)
-
-# Start queue stats polling
-collector = PrometheusStatsCollector(queue, interval=10)
-collector.start()
-```
-
-Prometheus scrape config:
-
-```yaml
-scrape_configs:
- - job_name: taskito
- static_configs:
- - targets: ["localhost:9090"]
-```
-
-## Grafana dashboard tips
-
-Useful panels for a taskito Grafana dashboard:
-
-- **Throughput** — `rate(taskito_jobs_total[5m])` by `task` and `status`
-- **Duration p95** — `histogram_quantile(0.95, rate(taskito_job_duration_seconds_bucket[5m]))`
-- **Queue depth** — `taskito_queue_depth` by `queue`
-- **DLQ size** — `taskito_dlq_size` with alert threshold
-- **Worker utilization** — `taskito_worker_utilization` as a gauge
-
-## Combining with other middleware
-
-`PrometheusMiddleware` composes with other middleware:
-
-```python
-from taskito.contrib.otel import OpenTelemetryMiddleware
-from taskito.contrib.sentry import SentryMiddleware
-
-queue = Queue(
- db_path="myapp.db",
- middleware=[
- OpenTelemetryMiddleware(),
- PrometheusMiddleware(),
- SentryMiddleware(),
- ],
-)
-```
-
-See the [Middleware guide](/python/guides/extensibility/middleware) for more
-on combining middleware.
-
-## Alerting on high DLQ size
-
-Using the same setup as the [full example](#full-example) above, a
-couple of alerting rules worth adding:
-
-```yaml
-groups:
- - name: taskito
- rules:
- - alert: HighDLQSize
- expr: taskito_dlq_size > 10
- for: 5m
- labels:
- severity: warning
- annotations:
- summary: "taskito dead letter queue has {{ $value }} entries"
- - alert: HighErrorRate
- expr: rate(taskito_jobs_total{status="failed"}[5m]) > 0.1
- for: 2m
- labels:
- severity: critical
- annotations:
- summary: "High task failure rate: {{ $value }} failures/sec"
-```
diff --git a/docs/content/docs/python/guides/integrations/sentry.mdx b/docs/content/docs/python/guides/integrations/sentry.mdx
deleted file mode 100644
index d53262c6..00000000
--- a/docs/content/docs/python/guides/integrations/sentry.mdx
+++ /dev/null
@@ -1,135 +0,0 @@
----
-title: Sentry Integration
-description: "SentryMiddleware — automatic exception capture, scope tags, retry breadcrumbs."
----
-
-taskito provides a `SentryMiddleware` that automatically captures task
-errors and sets rich context for Sentry.
-
-## Installation
-
-```bash
-pip install taskito[sentry]
-```
-
-This installs `sentry-sdk` as a dependency.
-
-## Setup
-
-Initialize the Sentry SDK as usual, then add `SentryMiddleware` to your
-queue:
-
-```python
-import sentry_sdk
-from taskito import Queue
-from taskito.contrib.sentry import SentryMiddleware
-
-sentry_sdk.init(dsn="https://examplePublicKey@o0.ingest.sentry.io/0")
-
-queue = Queue(db_path="myapp.db", middleware=[SentryMiddleware()])
-```
-
-## What it does
-
-### Scope tags
-
-Each task execution gets a Sentry scope with the following tags (prefix
-customizable via `tag_prefix`):
-
-| Tag | Value |
-|-----|-------|
-| `taskito.task_name` | The registered task name |
-| `taskito.job_id` | The job ID |
-| `taskito.queue` | The queue name |
-| `taskito.retry_count` | Current retry attempt |
-
-### Transaction name
-
-The Sentry transaction is set to `taskito:` by default.
-Customizable via `transaction_name_fn`.
-
-### Automatic error capture
-
-When a task raises an exception, `SentryMiddleware` calls
-`sentry_sdk.capture_exception()` automatically. The exception appears in
-Sentry with all the context tags attached.
-
-### Retry breadcrumbs
-
-When a task is retried, a breadcrumb is added with:
-
-- **Category**: `taskito` (matches `tag_prefix`)
-- **Level**: `warning`
-- **Message**: `Retrying (attempt ): `
-
-This gives you a trail of retry attempts leading up to a final failure.
-
-## Configuration
-
-```python
-SentryMiddleware(
- tag_prefix="myapp",
- transaction_name_fn=lambda ctx: f"task-{ctx.task_name}",
- task_filter=lambda name: not name.startswith("internal."),
- extra_tags_fn=lambda ctx: {"worker.host": socket.gethostname()},
-)
-```
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `tag_prefix` | `str` | `"taskito"` | Prefix for Sentry tag keys and breadcrumb category. |
-| `transaction_name_fn` | `Callable[[JobContext], str] \| None` | `None` | Custom transaction name builder. Receives `JobContext`. Defaults to `:`. |
-| `task_filter` | `Callable[[str], bool] \| None` | `None` | Predicate on task name. Return `True` to report, `False` to skip. `None` reports all tasks. |
-| `extra_tags_fn` | `Callable[[JobContext], dict[str, str]] \| None` | `None` | Returns extra Sentry tags to set. Receives `JobContext`. |
-
-## Combining with other middleware
-
-`SentryMiddleware` composes with other observability middleware:
-
-```python
-from taskito.contrib.otel import OpenTelemetryMiddleware
-from taskito.contrib.prometheus import PrometheusMiddleware
-
-queue = Queue(
- db_path="myapp.db",
- middleware=[
- OpenTelemetryMiddleware(),
- PrometheusMiddleware(),
- SentryMiddleware(),
- ],
-)
-```
-
-See the [Middleware guide](/python/guides/extensibility/middleware) for more
-on combining middleware.
-
-## Full example
-
-```python
-import sentry_sdk
-from taskito import Queue
-from taskito.contrib.sentry import SentryMiddleware
-
-# Initialize Sentry first
-sentry_sdk.init(
- dsn="https://examplePublicKey@o0.ingest.sentry.io/0",
- traces_sample_rate=1.0,
-)
-
-# Create queue with Sentry middleware
-queue = Queue(db_path="myapp.db", middleware=[SentryMiddleware()])
-
-@queue.task(max_retries=3)
-def process_payment(order_id: str, amount: float):
- """Process a payment — errors are automatically reported to Sentry."""
- result = payment_gateway.charge(order_id, amount)
- if not result.success:
- raise PaymentError(f"Payment failed: {result.error}")
- return result.transaction_id
-```
-
-When `process_payment` fails:
-
-1. The error appears in Sentry with tags `taskito.task_name=myapp.tasks.process_payment`, `taskito.job_id=...`, `taskito.queue=default`
-2. If the task retries, each retry is recorded as a breadcrumb
-3. The final failure (after all retries) includes the full breadcrumb trail
diff --git a/docs/content/docs/python/guides/operations/deployment.mdx b/docs/content/docs/python/guides/operations/deployment.mdx
deleted file mode 100644
index ef3507e0..00000000
--- a/docs/content/docs/python/guides/operations/deployment.mdx
+++ /dev/null
@@ -1,416 +0,0 @@
----
-title: Deployment
-description: "systemd, Docker, WAL mode, backups, multi-worker, sizing, production checklist."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-This guide covers running taskito in production environments.
-
-
- Unlike Celery (which needs Redis or RabbitMQ as a message broker), taskito's
- scheduler lives inside the worker process and reads directly from SQLite or
- Postgres. There's no separate broker process to run, monitor, or fail over —
- one less moving part in production.
-
-
-## SQLite file location
-
-Choose a persistent, backed-up location for your database:
-
-```python
-queue = Queue(db_path="/var/lib/myapp/taskito.db")
-```
-
-SQLite runs in **WAL** (write-ahead logging) mode by default — see
-[WAL mode and backups](#wal-mode-and-backups) below — which appends writes to
-a separate log file instead of the main database file, letting many readers
-proceed concurrently with the one writer.
-
-**Best practices:**
-
-- Use an absolute path — relative paths depend on the working directory
-- Place the database on local storage (not NFS or network mounts) — SQLite file locking doesn't work reliably over network filesystems
-- Ensure the directory exists and the worker process has read/write permissions
-- The database file, WAL file (`taskito.db-wal`), and shared memory file (`taskito.db-shm`) must all be on the same filesystem
-
-## systemd service
-
-Create `/etc/systemd/system/taskito-worker.service`:
-
-```ini
-[Unit]
-Description=taskito worker
-After=network.target
-
-[Service]
-Type=simple
-User=myapp
-Group=myapp
-WorkingDirectory=/opt/myapp
-ExecStart=/opt/myapp/.venv/bin/taskito worker --app myapp:queue
-Restart=always
-RestartSec=5
-
-# Graceful shutdown — taskito handles SIGINT
-KillSignal=SIGINT
-TimeoutStopSec=35
-
-# Environment
-Environment=PYTHONPATH=/opt/myapp
-
-[Install]
-WantedBy=multi-user.target
-```
-
-```bash
-sudo systemctl daemon-reload
-sudo systemctl enable taskito-worker
-sudo systemctl start taskito-worker
-
-# Check logs
-journalctl -u taskito-worker -f
-```
-
-
- Set `TimeoutStopSec` to slightly longer than your longest task timeout
- (default graceful shutdown is 30s). This gives in-flight tasks time to
- complete before systemd force-kills the process.
-
-
-## Docker
-
-### Dockerfile
-
-```dockerfile
-FROM python:3.12-slim
-
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY . .
-
-# Store the database in a volume
-VOLUME /data
-
-CMD ["taskito", "worker", "--app", "myapp:queue"]
-```
-
-
- The `taskito` CLI and the `Queue` constructor do not read a `TASKITO_DB_PATH`
- environment variable — that env var name is only recognized by the Flask and
- Django integrations. If your `myapp:queue` module builds the `Queue`
- directly, read the path yourself (and keep the `ENV TASKITO_DB_PATH=...`
- line in the Dockerfile above), as shown below.
-
-
-```python
-# myapp.py
-import os
-from taskito import Queue
-
-queue = Queue(db_path=os.environ.get("TASKITO_DB_PATH", "/data/taskito.db"))
-```
-
-### docker-compose.yml
-
-```yaml
-services:
- worker:
- build: .
- volumes:
- - taskito-data:/data
- stop_signal: SIGINT
- stop_grace_period: 35s
-
- dashboard:
- build: .
- command: taskito dashboard --app myapp:queue --host 0.0.0.0
- volumes:
- - taskito-data:/data
- ports:
- - "8080:8080"
-
-volumes:
- taskito-data:
-```
-
-
- The worker and dashboard must access the **same SQLite file**. In Docker,
- use a named volume shared between containers. Do not use bind mounts on
- network storage.
-
-
-### Graceful shutdown in containers
-
-taskito handles `SIGINT` for graceful shutdown. Configure your container
-orchestrator to send `SIGINT` (not `SIGTERM`):
-
-- **Docker Compose**: `stop_signal: SIGINT`
-- **Kubernetes**: use a `preStop` hook or configure `STOPSIGNAL` in the Dockerfile:
-
-```dockerfile
-STOPSIGNAL SIGINT
-```
-
-For Kubernetes, set `terminationGracePeriodSeconds` to match your longest
-task timeout:
-
-```yaml
-spec:
- terminationGracePeriodSeconds: 60
- containers:
- - name: worker
- ...
-```
-
-## WAL mode and backups
-
-taskito uses SQLite in WAL (Write-Ahead Logging) mode for concurrent
-read/write access. This affects how you back up the database.
-
-**Do NOT** simply copy the `.db` file while the worker is running — you may
-get a corrupted backup if the WAL hasn't been checkpointed.
-
-**Safe backup methods:**
-
-```bash
-# Option 1: Use sqlite3 .backup command (safe, online)
-sqlite3 /var/lib/myapp/taskito.db ".backup /backups/taskito-$(date +%Y%m%d).db"
-
-# Option 2: Use the SQLite VACUUM INTO command
-sqlite3 /var/lib/myapp/taskito.db "VACUUM INTO '/backups/taskito-$(date +%Y%m%d).db';"
-```
-
-Both methods are safe while the worker is running.
-
-## Postgres deployment
-
-If you're using the [Postgres backend](/python/guides/operations/postgres),
-deployment is simpler in several ways:
-
-- **No shared-file constraints** — workers connect over the network, no need for shared volumes or local storage
-- **Multi-machine workers** — run workers on separate hosts against the same database
-- **Standard backups** — use `pg_dump` instead of `sqlite3 .backup`
-
-### Docker Compose with Postgres
-
-```yaml
-services:
- postgres:
- image: postgres:16
- environment:
- POSTGRES_DB: myapp
- POSTGRES_USER: taskito
- POSTGRES_PASSWORD: secret
- volumes:
- - pgdata:/var/lib/postgresql/data
-
- worker:
- build: .
- environment:
- TASKITO_BACKEND: postgres
- TASKITO_DB_URL: postgresql://taskito:secret@postgres:5432/myapp
- depends_on:
- - postgres
- stop_signal: SIGINT
- stop_grace_period: 35s
-
-volumes:
- pgdata:
-```
-
-### Postgres backups
-
-```bash
-# Dump the taskito schema
-pg_dump -h localhost -U taskito -d myapp -n taskito > backup.sql
-
-# Restore
-psql -h localhost -U taskito -d myapp < backup.sql
-```
-
-See the [Postgres Backend guide](/python/guides/operations/postgres) for full
-configuration details.
-
-## Database maintenance
-
-### Auto-cleanup
-
-Set `result_ttl` to automatically purge old completed jobs:
-
-```python
-queue = Queue(
- db_path="/var/lib/myapp/taskito.db",
- result_ttl=86400, # Purge completed/dead jobs older than 24 hours
-)
-```
-
-### Manual cleanup
-
-```python
-# Purge completed jobs older than 7 days
-queue.purge_completed(older_than=604800)
-
-# Purge dead letters older than 30 days
-queue.purge_dead(older_than=2592000)
-```
-
-### Database size
-
-SQLite databases grow as jobs accumulate. Without cleanup, expect roughly:
-
-- ~1 KB per job (metadata + small payloads)
-- ~1-10 KB per job with large arguments or results
-
-With `result_ttl` set, the database stays compact. You can also periodically
-run `VACUUM` to reclaim space:
-
-```bash
-sqlite3 /var/lib/myapp/taskito.db "VACUUM;"
-```
-
-
- `VACUUM` rewrites the entire database and requires exclusive access. Run
- it during low-traffic periods or during a maintenance window.
-
-
-## Monitoring in production
-
-### Dashboard
-
-Run the built-in dashboard alongside the worker:
-
-```bash
-taskito dashboard --app myapp:queue --host 0.0.0.0 --port 8080
-```
-
-Place it behind a reverse proxy with authentication for production use —
-the dashboard has no built-in auth.
-
-### Programmatic stats
-
-Poll `queue.stats()` and export to your monitoring system:
-
-```python
-import time
-
-def export_metrics():
- while True:
- stats = queue.stats()
- # Export to Prometheus, Datadog, StatsD, etc.
- gauge("taskito.pending", stats["pending"])
- gauge("taskito.running", stats["running"])
- gauge("taskito.dead", stats["dead"])
- time.sleep(15)
-```
-
-### Hooks for alerting
-
-```python
-@queue.on_failure
-def alert_on_failure(task_name, args, kwargs, error):
- # Send to PagerDuty, Slack, email, etc.
- notify(f"Task {task_name} failed: {error}")
-```
-
-### Health check endpoint
-
-If you're using FastAPI:
-
-```python
-from fastapi import FastAPI
-from taskito.contrib.fastapi import TaskitoRouter
-
-app = FastAPI()
-app.include_router(TaskitoRouter(queue), prefix="/tasks")
-
-# GET /tasks/stats returns queue health
-# Use this as a health check endpoint in your load balancer
-```
-
-## Multiple workers
-
-taskito is designed as a **single-process** task queue when using SQLite.
-Running multiple worker processes against the same SQLite file is possible
-(WAL mode allows concurrent access), but:
-
-- Only one process can write at a time — this limits throughput
-- SQLite lock contention increases with more writers
-- There is no distributed coordination between workers
-
-For most single-machine workloads, one worker process with multiple threads
-(the default) is sufficient:
-
-```python
-queue = Queue(
- db_path="myapp.db",
- workers=8, # 8 OS threads in the worker pool
-)
-```
-
-If you need distributed workers across multiple machines, use the
-[Postgres backend](/python/guides/operations/postgres) which removes the
-single-writer constraint and supports multi-machine deployments.
-
-## SQLite scaling limits
-
-taskito uses SQLite as its storage backend. Understanding its limitations
-helps you plan for production:
-
-**Single-writer constraint.** SQLite allows only one write transaction at a
-time. WAL mode lets reads proceed concurrently with writes, but all writes
-are serialized. This is the primary throughput ceiling.
-
-**Expected throughput.** On modern hardware with an SSD, expect:
-
-- **1,000–5,000 jobs/second** for enqueue + dequeue cycles (small payloads)
-- Throughput decreases with larger payloads, complex queries, or spinning disks
-- The connection pool (a fixed set of reusable database connections, avoiding
- the overhead of opening a new one per query) size is fixed at 8 for the
- SQLite backend — it is not configurable from the Python `Queue`
- constructor. The `pool_size` argument on `Queue()` only takes effect on the
- [Postgres backend](/python/guides/operations/postgres#connection-pooling)
-
-**When to upgrade to Postgres:**
-
-- You need multi-machine distributed workers
-- You consistently exceed ~5,000 jobs/second sustained throughput
-- Multiple processes contend heavily for writes (high lock wait times)
-- You need sub-millisecond dequeue latency under high load
-
-taskito's [Postgres backend](/python/guides/operations/postgres) addresses all
-of these limitations while keeping the same API.
-
-## Sizing your deployment
-
-| Throughput | Backend | Workers | Pool | Notes |
-|-----------|---------|---------|------|-------|
-| < 100 jobs/s | SQLite | 4 | thread | Default config works fine |
-| 100–1K jobs/s | SQLite | 8–16 | thread or prefork | Increase `workers`, monitor WAL size |
-| 1K–5K jobs/s | SQLite | 16 | prefork | Prefork for CPU-bound; SQLite handles this well with WAL |
-| 5K–20K jobs/s | Postgres | 16–32 | prefork | Switch to Postgres for concurrent writers |
-| 20K–50K jobs/s | Postgres | 32+ | prefork | Multiple worker processes, tune `pool_size` |
-| > 50K jobs/s | — | — | — | Consider Celery + RabbitMQ for this scale |
-
-`Pool` is the worker execution pool: `thread` (default, one process with
-multiple OS threads) or `prefork` (multiple worker subprocesses, true CPU
-parallelism). See [Prefork Workers](/python/guides/advanced-execution/prefork)
-for when and how to switch.
-
-
- These are rough guidelines for noop tasks. Real throughput depends on task
- duration, payload size, and I/O patterns.
-
-
-## Checklist
-
-- [ ] Use an absolute path for `db_path`
-- [ ] Place SQLite on local (not network) storage
-- [ ] Set `result_ttl` to prevent unbounded database growth
-- [ ] Set `timeout` on tasks to recover from worker crashes
-- [ ] Configure `SIGINT` as the stop signal in your process manager
-- [ ] Set up failure hooks or monitoring for alerting
-- [ ] Back up the database using `sqlite3 .backup` (not file copy), or `pg_dump` for Postgres
-- [ ] Place the dashboard behind a reverse proxy with authentication
diff --git a/docs/content/docs/python/guides/operations/keda.mdx b/docs/content/docs/python/guides/operations/keda.mdx
deleted file mode 100644
index 8aabe033..00000000
--- a/docs/content/docs/python/guides/operations/keda.mdx
+++ /dev/null
@@ -1,228 +0,0 @@
----
-title: KEDA Autoscaling
-description: "Kubernetes event-driven autoscaling for taskito workers via the bundled scaler server."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-[KEDA](https://keda.sh) (Kubernetes Event-driven Autoscaling) can scale your
-taskito worker deployment up and down based on queue depth. taskito ships a
-dedicated scaler server that KEDA queries directly.
-
-
- Requires a Kubernetes cluster with the [KEDA operator](https://keda.sh/docs/latest/deploy/)
- installed. For bare metal, Docker, or systemd without Kubernetes, use the
- [bare-metal autoscaler](/python/guides/operations/autoscaler) instead.
-
-
-
- The scaler's `/api/scaler` endpoint is unauthenticated by design — see
- [Security: Scaler bind address](/python/guides/operations/security#scaler-bind-address).
- Keep it reachable only from inside the cluster (`ClusterIP`, never a public
- LoadBalancer or Ingress).
-
-
-## Scaler server
-
-Start the scaler alongside your worker:
-
-```bash
-taskito scaler --app myapp:queue --port 9091
-```
-
-| Flag | Default | Description |
-|---|---|---|
-| `--app` | — | Python path to the `Queue` instance |
-| `--host` | `0.0.0.0` | Bind address |
-| `--port` | `9091` | Bind port |
-| `--target-queue-depth` | `10` | Scaling target hint returned to KEDA |
-
-The scaler exposes three endpoints:
-
-| Endpoint | Description |
-|---|---|
-| `GET /api/scaler` | Returns current queue depth and scaling target for KEDA |
-| `GET /metrics` | Prometheus text format (requires `prometheus-client`) |
-| `GET /health` | Liveness check — always returns `{"status": "ok"}` |
-
-### `/api/scaler` response
-
-```json
-{
- "metricValue": 42,
- "targetValue": 10,
- "queueName": "default"
-}
-```
-
-Filter to a specific queue:
-
-```
-GET /api/scaler?queue=emails
-```
-
-### Programmatic usage
-
-```python
-from taskito.scaler import serve_scaler
-
-serve_scaler(queue, host="0.0.0.0", port=9091, target_queue_depth=10)
-```
-
-## Kubernetes deployment
-
-Deploy the scaler as a separate `Deployment` and expose it as a `ClusterIP`
-service:
-
-```yaml
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: taskito-scaler
-spec:
- replicas: 1
- selector:
- matchLabels:
- app: taskito-scaler
- template:
- metadata:
- labels:
- app: taskito-scaler
- spec:
- containers:
- - name: scaler
- image: your-image:latest
- command: ["taskito", "scaler", "--app", "myapp:queue", "--port", "9091"]
- ports:
- - containerPort: 9091
----
-apiVersion: v1
-kind: Service
-metadata:
- name: taskito-scaler
-spec:
- selector:
- app: taskito-scaler
- ports:
- - port: 9091
- targetPort: 9091
-```
-
-## ScaledObject (HTTP trigger)
-
-Scale a long-running worker `Deployment` based on pending job count:
-
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: ScaledObject
-metadata:
- name: taskito-worker
- namespace: default
-spec:
- scaleTargetRef:
- name: taskito-worker # your worker Deployment name
- pollingInterval: 15 # seconds between KEDA polls
- cooldownPeriod: 60 # seconds before scaling to zero
- minReplicaCount: 0 # scale to zero when idle
- maxReplicaCount: 10
- triggers:
- - type: metrics-api
- metadata:
- url: "http://taskito-scaler:9091/api/scaler"
- valueLocation: "metricValue"
- targetValue: "10"
-```
-
-Filter to a specific queue name:
-
-```yaml
- url: "http://taskito-scaler:9091/api/scaler?queue=emails"
-```
-
-## ScaledJob (ephemeral batch workers)
-
-For batch/ETL workloads, use `ScaledJob` to create short-lived Kubernetes
-Jobs — one pod per N pending tasks:
-
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: ScaledJob
-metadata:
- name: taskito-batch-worker
- namespace: default
-spec:
- jobTargetRef:
- template:
- spec:
- containers:
- - name: taskito-worker
- image: your-image:latest
- command: ["taskito", "worker", "--app", "myapp:queue"]
- restartPolicy: Never
- pollingInterval: 15
- successfulJobsHistoryLimit: 5
- failedJobsHistoryLimit: 5
- maxReplicaCount: 20
- scalingStrategy:
- strategy: default # or "accurate" for 1:1 job-to-pod mapping
- triggers:
- - type: metrics-api
- metadata:
- url: "http://taskito-scaler:9091/api/scaler"
- valueLocation: "metricValue"
- targetValue: "5" # one pod per 5 pending jobs
-```
-
-## Scaling with Prometheus
-
-If you already have Prometheus scraping your workers, you can skip the scaler
-server and use the Prometheus KEDA trigger directly:
-
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: ScaledObject
-metadata:
- name: taskito-worker-prometheus
- namespace: default
-spec:
- scaleTargetRef:
- name: taskito-worker
- pollingInterval: 15
- cooldownPeriod: 60
- minReplicaCount: 0
- maxReplicaCount: 10
- triggers:
- - type: prometheus
- metadata:
- serverAddress: "http://prometheus:9090"
- metricName: taskito_queue_depth
- query: sum(taskito_queue_depth{queue="default"})
- threshold: "10"
- - type: prometheus
- metadata:
- serverAddress: "http://prometheus:9090"
- metricName: taskito_worker_utilization
- query: taskito_worker_utilization{queue="default"}
- threshold: "0.8"
-```
-
-See the [Prometheus integration](/python/guides/integrations) for setting up
-the metrics collector.
-
-## Deploy templates
-
-Ready-to-use YAML templates are included in the repository under
-`deploy/keda/`:
-
-| File | Purpose |
-|---|---|
-| `scaled-object.yaml` | `ScaledObject` using the HTTP scaler endpoint |
-| `scaled-object-prometheus.yaml` | `ScaledObject` using Prometheus metrics |
-| `scaled-job.yaml` | `ScaledJob` for ephemeral batch workers |
-
-
- When using SQLite, all worker replicas must share the same database
- volume. For multi-replica Kubernetes deployments, use the
- [Postgres backend](/python/guides/operations/postgres) — workers connect
- over the network and there's no shared-file constraint.
-
diff --git a/docs/content/docs/python/guides/operations/mesh.mdx b/docs/content/docs/python/guides/operations/mesh.mdx
deleted file mode 100644
index 9943830f..00000000
--- a/docs/content/docs/python/guides/operations/mesh.mdx
+++ /dev/null
@@ -1,419 +0,0 @@
----
-title: Mesh Scheduling
-description: "Gossip-based worker discovery, consistent-hashing affinity, and work-stealing for distributed task dispatch."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-import { Tab, Tabs } from "fumadocs-ui/components/tabs";
-
-Mesh scheduling adds a decentralized overlay network on top of the
-[standard scheduler](/architecture/scheduler). Workers discover each other via
-gossip, route tasks through a consistent-hashing ring, and steal work from
-busy peers — all without a central coordinator.
-
-
- The `taskito` package published to PyPI does **not** include the `mesh`
- cargo feature. `MeshWorker` is importable from it, but passing it to
- `run_worker` has no effect. To use mesh scheduling you must build the
- extension from source with the feature enabled:
- `uv run maturin develop --features mesh,workflows`
-
-
-## Quick start
-
-```python
-from taskito import Queue, MeshWorker
-
-queue = Queue(db_path="tasks.db")
-
-@queue.task()
-def process(item_id: int):
- ...
-
-# First worker — no seeds needed, becomes the initial cluster node
-mesh = MeshWorker(port=7946)
-queue.run_worker(queues=["default"], mesh=mesh)
-```
-
-In a second process (or on another machine):
-
-```python
-# Joins the cluster by seeding from the first worker
-mesh = MeshWorker(
- port=7946,
- seeds=["first-worker-host:7946"],
-)
-queue.run_worker(queues=["default"], mesh=mesh)
-```
-
-The second worker discovers the first via gossip. If you add a third, it only
-needs to seed from *any* existing member — gossip propagates the full
-membership list automatically.
-
-## How it works
-
-Mesh scheduling composes five primitives that work together:
-
-| Primitive | What it does |
-|---|---|
-| **SWIM gossip** | UDP protocol for worker discovery and failure detection (~1.5s vs 30s DB heartbeat) |
-| **Consistent hashing** | xxhash ring with virtual nodes maps tasks to preferred workers (soft affinity) |
-| **Local deque** | In-memory buffer between DB polls — workers drain the deque before hitting the [storage backend](/architecture/storage) |
-| **Work-stealing** | TCP protocol lets idle workers steal jobs from busy peers |
-| **Adaptive load balancing** | Prefetch size and poll timing auto-tune based on cluster state |
-
-The database remains the source of truth. Gossip is an optimization layer —
-if it fails, workers fall back to standard DB polling. See
-[Architecture: Mesh Scheduling](/architecture/mesh) for protocol-level details.
-
-## Configuration
-
-All mesh settings live in `MeshWorker`, keeping the
-[`Queue`](/python/api-reference/queue) class clean:
-
-```python
-mesh = MeshWorker(
- port=7946, # gossip UDP port (steal port = port + 1)
- seeds=["host:7946"], # seed nodes for cluster join
- steal=True, # enable work-stealing
- affinity_weight=0.7, # 0.0–1.0, how strongly tasks prefer their hashed worker
- local_buffer=64, # local deque capacity
- steal_batch=4, # max jobs stolen per request
- steal_threshold=2, # steal when own deque ≤ this
- virtual_nodes=150, # consistent-hash ring virtual nodes per worker
- bind_addr="0.0.0.0", # network interface to bind
- encryption_key=None, # base64-encoded key for gossip encryption
- steal_rate_limit=10, # max steal requests per peer per second
-)
-```
-
-### Parameter reference
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `port` | `int` | `7946` | Gossip UDP port. Steal TCP port is `port + 1`. Must be 1024–65535. |
-| `seeds` | `list[str]` | `[]` | Addresses of existing cluster members (`host:port`). |
-| `steal` | `bool` | `True` | Whether this worker can steal from and be stolen from. |
-| `affinity_weight` | `float` | `0.7` | Task-to-worker affinity strength. `0.0` = no affinity, `1.0` = strong preference. |
-| `local_buffer` | `int` | `64` | Max jobs buffered locally before back-pressuring prefetch. |
-| `steal_batch` | `int` | `4` | Max jobs transferred per steal request. |
-| `steal_threshold` | `int` | `2` | Trigger stealing when own buffer drops to this level. |
-| `virtual_nodes` | `int` | `150` | Hash ring virtual nodes per worker. More = more even distribution. |
-| `bind_addr` | `str` | `"0.0.0.0"` | Network interface for gossip and steal servers. |
-| `encryption_key` | `str \| None` | `None` | Base64 key for XOR gossip encryption. All nodes must share the same key. |
-| `steal_rate_limit` | `int` | `10` | Max steal requests accepted per peer per second. `0` = unlimited. |
-
-## Cluster topology
-
-### Single-machine (development)
-
-Run multiple workers on one host with different ports. Each worker process
-needs a unique gossip port (steal port is automatically `port + 1`):
-
-```python
-# worker_a.py
-from taskito import Queue, MeshWorker
-
-queue = Queue(db_path="tasks.db")
-
-@queue.task()
-def send_email(to: str, subject: str):
- ...
-
-mesh = MeshWorker(port=7946)
-queue.run_worker(mesh=mesh)
-```
-
-```python
-# worker_b.py — seeds from worker A
-from myapp import queue # same queue, different process
-
-mesh = MeshWorker(port=7948, seeds=["127.0.0.1:7946"])
-queue.run_worker(mesh=mesh)
-```
-
-### Multi-machine (production)
-
-Point seeds at known stable nodes. You don't need to list every node —
-gossip propagates membership automatically:
-
-```python
-import os
-
-SEEDS = ["scheduler-1.internal:7946", "scheduler-2.internal:7946"]
-
-mesh = MeshWorker(
- port=7946,
- seeds=SEEDS,
- encryption_key=os.environ["MESH_ENCRYPTION_KEY"],
- steal_rate_limit=20,
-)
-queue.run_worker(
- queues=["default", "emails"],
- mesh=mesh,
-)
-```
-
-
- Workers need UDP (gossip) and TCP (steal) access to each other. Open both
- the gossip port and steal port (gossip + 1) between all mesh workers.
-
-
-### Docker Compose example
-
-```yaml
-services:
- worker-1:
- build: .
- command: taskito worker --app myapp:queue
- environment:
- MESH_PORT: "7946"
- MESH_SEEDS: "" # first node, no seeds
- MESH_KEY: ${MESH_ENCRYPTION_KEY}
- ports:
- - "7946:7946/udp" # gossip
- - "7947:7947/tcp" # steal
-
- worker-2:
- build: .
- command: taskito worker --app myapp:queue
- environment:
- MESH_PORT: "7946"
- MESH_SEEDS: "worker-1:7946"
- MESH_KEY: ${MESH_ENCRYPTION_KEY}
- ports:
- - "7948:7946/udp"
- - "7949:7947/tcp"
- depends_on:
- - worker-1
-```
-
-
- This compose file is illustrative, not runnable as-is: the `taskito worker`
- CLI has no mesh flags and never reads `MESH_PORT`/`MESH_SEEDS`/`MESH_KEY`.
- Mesh is only enabled by calling `queue.run_worker(mesh=...)` in your own
- Python code. Replace `command: taskito worker --app myapp:queue` with an
- entrypoint that imports `get_mesh()` below and calls
- `queue.run_worker(mesh=get_mesh())` directly (e.g.
- `command: python -m myapp`).
-
-
-```python
-# myapp.py — reads mesh config from environment
-import os
-from taskito import Queue, MeshWorker
-
-queue = Queue(db_path="tasks.db")
-
-def get_mesh() -> MeshWorker | None:
- port = os.environ.get("MESH_PORT")
- if not port:
- return None
- seeds_raw = os.environ.get("MESH_SEEDS", "")
- seeds = [s.strip() for s in seeds_raw.split(",") if s.strip()]
- return MeshWorker(
- port=int(port),
- seeds=seeds,
- encryption_key=os.environ.get("MESH_KEY"),
- )
-```
-
-## Task affinity
-
-The consistent-hashing ring maps each task name to a preferred worker. When
-a worker prefetches jobs from the DB, it sorts them:
-
-- **Owned tasks** (hashed to this worker) go to the back of the deque —
- executed first (LIFO)
-- **Non-owned tasks** go to the front — available for stealing (FIFO)
-
-Affinity is soft: any worker can run any task. The `affinity_weight`
-parameter controls how aggressively the ring biases dispatch. Set it to
-`0.0` to disable affinity entirely.
-
-This improves cache locality — if a task always runs on the same worker,
-its imports, connections, and warm caches stay hot. Particularly useful with
-[worker resources](/python/guides/resources/overview) that have expensive
-initialization.
-
-```python
-# High affinity — tasks strongly prefer their hashed worker
-mesh = MeshWorker(affinity_weight=1.0)
-
-# No affinity — pure load-balancing, no task-to-worker preference
-mesh = MeshWorker(affinity_weight=0.0)
-```
-
-## Work-stealing
-
-When a worker's local buffer drops to `steal_threshold`, it looks for the
-busiest peer (via gossip-reported buffer lengths) and sends a TCP steal
-request.
-
-The victim pops jobs from the cold end of its deque (non-owned tasks first)
-and sends them back. The thief executes them locally — no DB round-trip
-needed since the jobs are already claimed as `Running` in the database.
-
-Stealing is rate-limited per peer (`steal_rate_limit`) to prevent
-thundering herd effects.
-
-
-
- ```python
- mesh = MeshWorker(steal=True, steal_batch=4, steal_threshold=2)
- ```
- Workers automatically balance load across the cluster. When one worker
- gets a burst of jobs, idle peers steal the overflow within milliseconds.
-
-
- ```python
- mesh = MeshWorker(steal=False)
- ```
- Workers still benefit from gossip discovery, consistent hashing, and local
- deque prefetch — but won't steal from or donate jobs to peers. Useful when
- tasks have strong per-worker state dependencies.
-
-
-
-### Tuning work-stealing
-
-| Scenario | Recommended settings |
-|---|---|
-| Bursty, short tasks | `steal_batch=8, steal_threshold=4` — steal more, earlier |
-| Long-running tasks | `steal_batch=1, steal_threshold=1` — steal conservatively |
-| Many workers (10+) | `steal_rate_limit=5` — prevent steal storms |
-| Few workers (2–3) | `steal_rate_limit=0` — unlimited, low contention |
-
-## Gossip encryption
-
-Enable symmetric XOR encryption for gossip messages with a shared
-base64-encoded key:
-
-```python
-import base64
-import os
-
-# Generate a key (share this across all workers)
-key = base64.b64encode(os.urandom(32)).decode()
-print(key) # store in environment variable or secret manager
-
-mesh = MeshWorker(encryption_key=key)
-```
-
-All nodes in the cluster must use the same key. Nodes with mismatched keys
-will fail to decode gossip messages and won't join the cluster.
-
-
- Gossip encryption covers the UDP membership protocol only. Work-stealing
- uses unencrypted TCP. For full transport security, use network-level
- encryption (WireGuard, VPN, or a service mesh).
-
-
-## Combining with other features
-
-Mesh scheduling works alongside all existing taskito features:
-
-```python
-from taskito import Queue, MeshWorker
-from taskito.resources import ResourceDefinition, Scope
-
-queue = Queue(db_path="tasks.db")
-
-# Worker resources work normally — each mesh worker manages its own pool
-@queue.worker_resource(scope=Scope.WORKER)
-def db_pool():
- return create_connection_pool()
-
-# Rate limits and concurrency are per-task, enforced in the DB layer
-@queue.task(max_concurrent=5, rate_limit="100/m")
-def process_order(order_id: int, db_pool=None):
- ...
-
-# Batch dequeue works with mesh — prefetch fills the local deque
-queue_config = Queue(db_path="tasks.db", scheduler_batch_size=10)
-
-mesh = MeshWorker(seeds=["peer:7946"])
-queue.run_worker(mesh=mesh)
-```
-
-See also:
-- [Per-task concurrency](/python/guides/core/tasks#concurrency) for `max_concurrent`
-- [Rate limiting](/python/guides/core/tasks#rate-limiting) for `rate_limit`
-- [Worker resources](/python/guides/resources/overview) for DI into tasks
-- [Batch dequeue](/python/guides/core/scheduling#batch-dequeue) for `scheduler_batch_size`
-
-## Mixing mesh and non-mesh workers
-
-Mesh workers coexist safely with [standard workers](/python/guides/core/workers).
-Both use the same [storage backend](/architecture/storage) for atomic job
-claims — mesh just reduces how often workers hit the DB.
-
-```python
-# Standard worker — no mesh, polls DB directly
-queue.run_worker(queues=["default"])
-
-# Mesh worker — same DB, but prefetches + steals
-mesh = MeshWorker(seeds=["other-mesh-node:7946"])
-queue.run_worker(queues=["default"], mesh=mesh)
-```
-
-Non-mesh workers are invisible to the mesh (no gossip, no stealing) but
-still process jobs normally. You can gradually roll out mesh to your fleet
-without downtime.
-
-## Monitoring
-
-Mesh activity appears in Rust log output at `debug` and `info` levels:
-
-```bash
-# See all mesh activity
-RUST_LOG=taskito_mesh=debug taskito worker --app myapp:queue
-
-# See only gossip membership changes
-RUST_LOG=taskito_mesh::swim=info taskito worker --app myapp:queue
-```
-
-Key log messages:
-
-| Level | Message | Meaning |
-|---|---|---|
-| `info` | `gossip listening on ...` | Gossip server started |
-| `info` | `discovered peer X at Y` | New cluster member joined |
-| `info` | `member X declared dead` | Failure detector confirmed crash |
-| `info` | `leave broadcast sent` | Graceful shutdown leave |
-| `debug` | `giving N jobs to thief X` | Responded to steal request |
-| `debug` | `ack from X resolved probe` | Healthy ping-ack cycle |
-
-If you use [Prometheus](/python/guides/integrations/prometheus), mesh metrics flow
-through the same worker observability pipeline.
-
-## Graceful shutdown
-
-When a mesh worker shuts down (via `SIGINT`, `SIGTERM`, or
-[`request_shutdown()`](/python/api-reference/queue#request_shutdown)), it broadcasts
-a `Leave` message to all known peers. They remove it from the ring
-immediately — no suspicion timeout needed.
-
-If a worker crashes without leaving, the SWIM failure detector kicks in:
-direct ping fails → indirect ping via intermediaries → suspicion → declared
-dead after `suspicion_multiplier × ln(N+1) × protocol_period`.
-
-With default settings (multiplier=4, period=500ms), a crashed worker in a
-3-node cluster is detected in roughly `4 × ln(4) × 500ms ≈ 2.8s`.
-
-## When to use mesh
-
-**Good fit:**
-- Multiple workers processing the same queues (2+ workers)
-- High job throughput where DB polling is a bottleneck
-- Tasks that benefit from cache locality (imports, connections, warm state)
-- Environments where sub-second failure detection matters
-- Horizontal scaling where workers come and go frequently
-
-**Not needed:**
-- Single worker setups
-- Low throughput (< 100 jobs/second)
-- Workers processing completely different queues (no overlap = no stealing)
-- Already using [bare-metal autoscaler](/python/guides/operations/autoscaler) or
- [KEDA](/python/guides/operations/keda) for process-level scaling (mesh is
- *within-process* optimization, not a replacement for autoscaling)
diff --git a/docs/content/docs/python/guides/operations/troubleshooting.mdx b/docs/content/docs/python/guides/operations/troubleshooting.mdx
deleted file mode 100644
index 0dfc61e2..00000000
--- a/docs/content/docs/python/guides/operations/troubleshooting.mdx
+++ /dev/null
@@ -1,261 +0,0 @@
----
-title: Troubleshooting
-description: "Diagnose stuck jobs, unresponsive workers, growing databases, latency, and missing tasks."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-Common issues and how to fix them.
-
-## Jobs stuck in running
-
-**Symptom**: jobs stay in `running` status long after they should have finished.
-
-**Diagnosis**: the worker process that picked up the job crashed before
-marking it complete.
-
-```python
-# Check how many jobs are stuck
-stats = queue.stats()
-print(stats) # {'running': 47, 'pending': 0, ...}
-
-# See which jobs are stuck
-stuck = queue.list_jobs(status="running", limit=20)
-for job in stuck:
- d = job.to_dict()
- print(f"{d['id']} | {d['task_name']} | started {d['started_at']}")
-```
-
-**Fix**: the stale reaper handles this automatically — it detects jobs that
-have exceeded their `timeout_ms` and retries them. If a job has no timeout
-set, it stays stuck forever.
-
-`timeout_ms` is the internal, database-stored deadline in milliseconds; you
-set it in seconds via `@queue.task(timeout=...)` (or `default_timeout` on
-`Queue()`) and taskito converts it under the hood.
-
-Recovery is automatic once a `timeout` is set: the stale reaper marks the job
-failed and then retries it (or moves it to the dead letter queue). A job stuck
-with **no** timeout stays `running` indefinitely — set a timeout going forward,
-and re-enqueue the work with a fresh `.delay()` if you need it to run now.
-
-To prevent this in future, always set a timeout on production tasks:
-
-```python
-@queue.task(timeout=300) # 5 minutes max
-def process_data(payload):
- ...
-```
-
-
- Jobs without `timeout_ms` are never reaped. The stale reaper only detects
- jobs that have exceeded their deadline.
-
-
-## Worker is unresponsive
-
-**Symptom**: worker process is alive but not processing jobs. Heartbeat is stale.
-
-**Diagnosis**: check worker status via the heartbeat API.
-
-```python
-workers = queue.workers()
-for w in workers:
- print(f"{w['worker_id']}: {w['status']} (last seen: {w['last_heartbeat']})")
-```
-
-**Possible causes**:
-
-1. **GIL-bound CPU task**: a long-running CPU task is holding the GIL
- (Global Interpreter Lock — only one thread runs Python bytecode at a time),
- blocking the scheduler thread from dispatching new jobs. The scheduler
- runs in Rust, but it still needs the GIL to call Python functions.
-
- Fix: switch to the [prefork pool](/python/guides/advanced-execution/prefork)
- for CPU-bound tasks — it runs tasks in separate subprocesses instead of
- threads, so one task's GIL usage can't block the scheduler.
-
- ```bash
- taskito worker --app myapp:queue --pool prefork
- ```
-
-2. **Deadlock**: a task is waiting on a resource held by another task in the
- same worker. Check for circular waits in your task code.
-
-3. **Infinite loop**: a task is looping without yielding. Add a timeout to
- detect this:
-
- ```python
- @queue.task(timeout=60)
- def risky_task():
- ...
- ```
-
-## Database growing too large
-
-**Symptom**: the SQLite file keeps growing; disk space is filling up.
-
-**Diagnosis**: completed job records and their result payloads are accumulating.
-
-**Fix**: set `result_ttl` to auto-purge old results.
-
-```python
-queue = Queue(
- db_path="myapp.db",
- result_ttl=86400, # Purge completed/dead jobs older than 24 hours
-)
-```
-
-Manually purge existing backlog:
-
-```python
-# Purge completed jobs older than 7 days
-queue.purge_completed(older_than=604800)
-
-# Purge dead-lettered jobs older than 30 days
-queue.purge_dead(older_than=2592000)
-```
-
-After purging, reclaim disk space:
-
-```bash
-sqlite3 myapp.db "VACUUM;"
-```
-
-
- `VACUUM` rewrites the entire database and requires exclusive access. Run
- it during low-traffic periods.
-
-
-## High job latency
-
-**Symptom**: jobs sit in `pending` for longer than expected before starting.
-
-**Diagnosis**: check the queue depth and scheduler configuration.
-
-```python
-stats = queue.stats()
-print(f"Pending: {stats['pending']}, Running: {stats['running']}")
-```
-
-**Possible causes and fixes**:
-
-1. **Scheduler poll interval too high**: default is 50ms. Jobs can wait up
- to one poll interval before being picked up.
-
- ```python
- queue = Queue(scheduler_poll_interval_ms=10) # Poll every 10ms
- ```
-
- Lower values increase CPU/DB usage. Balance based on your latency
- requirements.
-
-2. **Not enough workers**: all workers are busy. Increase the worker count.
-
- ```python
- queue = Queue(workers=16)
- ```
-
-3. **Rate limiting**: the task or queue has a rate limit active.
-
- ```python
- # Check if rate limiting is the culprit
- # Rate-limited jobs are rescheduled 1 second into the future
- pending = queue.list_jobs(status="pending", limit=10)
- for job in pending:
- print(job.to_dict()["scheduled_at"])
- ```
-
-4. **Database performance**: slow dequeue queries. Check SQLite WAL size or
- Postgres query plans.
-
-## Memory usage growing
-
-**Symptom**: worker process memory climbs over time.
-
-**Causes**:
-
-1. **Large result payloads**: task return values are stored in the database
- but also held in the scheduler's result buffer briefly. If tasks return
- large objects (images, dataframes), memory spikes.
-
- Fix: return a reference (file path, object key) instead of the data itself.
-
- ```python
- # Bad — large result stored in memory and DB
- @queue.task()
- def process_image(path: str) -> bytes:
- return open(path, "rb").read()
-
- # Good — return a path
- @queue.task()
- def process_image(path: str) -> str:
- out = path + ".processed"
- # ... write output to out ...
- return out
- ```
-
-2. **Accumulated job records**: without `result_ttl`, the database grows
- unbounded. See "Database growing too large" above.
-
-3. **Resource leaks in tasks**: a task opens a file or connection and never
- closes it. Use context managers.
-
-## Periodic task running twice
-
-**Symptom**: a periodic task fires more than once per interval, or appears
-to run on two workers simultaneously.
-
-**Behavior**: this is safe by design. Periodic tasks use `unique_key`
-deduplication — when a periodic task is due, each worker's scheduler checks
-and tries to enqueue it, but only one enqueue succeeds because the
-`unique_key` constraint prevents duplicates.
-
-If you see two completed jobs for the same periodic task in the same
-interval, check:
-
-```python
-# Look for duplicate completions
-jobs = queue.list_jobs(status="complete", limit=50)
-periodic_jobs = [j for j in jobs if "daily_report" in j.to_dict()["task_name"]]
-for j in periodic_jobs:
- print(j.to_dict()["completed_at"])
-```
-
-If you're genuinely seeing duplicate execution, ensure all workers use the
-same database (same SQLite file path or same Postgres DSN).
-
-## Task not found in worker
-
-**Symptom**: worker logs `TaskNotFound` or jobs fail with an error like
-`unknown task: myapp.tasks.process`.
-
-**Cause**: the task name registered at enqueue time doesn't match what the
-worker has registered.
-
-Task names default to `module.function_name`. If you enqueue from one module
-path and run the worker with a different import path, the names won't match.
-
-**Diagnosis**:
-
-```python
-# Check the task name stored in the job
-job = queue.get_job(job_id)
-print(job.to_dict()["task_name"]) # e.g. "myapp.tasks.process"
-
-# Check what the worker has registered
-print([t["name"] for t in queue.registered_tasks()])
-```
-
-**Fix**: use consistent import paths. If the task is `myapp/tasks.py:process`,
-always import it as `myapp.tasks.process` — not `tasks.process` (relative)
-or `src.myapp.tasks.process` (with src prefix).
-
-You can also set an explicit name to decouple the task name from the module
-path:
-
-```python
-@queue.task(name="process-data")
-def process(payload):
- ...
-```
diff --git a/docs/content/docs/python/guides/reliability/circuit-breakers.mdx b/docs/content/docs/python/guides/reliability/circuit-breakers.mdx
deleted file mode 100644
index 491d38c0..00000000
--- a/docs/content/docs/python/guides/reliability/circuit-breakers.mdx
+++ /dev/null
@@ -1,159 +0,0 @@
----
-title: Circuit Breakers
-description: "Sample-based half-open recovery — protect downstream services from cascading failures."
----
-
-Circuit breakers prevent cascading failures by temporarily stopping task
-execution when a task fails repeatedly. This is especially useful for tasks
-that call external APIs or services.
-
-> Celery has no built-in circuit breaker — this is taskito-native, configured per
-> task with `@queue.task(circuit_breaker={...})`.
-
-## How it works
-
-A circuit breaker tracks failures within a time window and transitions
-through three states:
-
- Closed
- Closed --> Open: failures >= threshold within window
- Open --> HalfOpen: cooldown elapsed
- HalfOpen --> Closed: success rate >= threshold
- HalfOpen --> Open: success rate impossible OR timeout`}
-/>
-
-- **Closed** — normal operation. Tasks execute as usual. Failures are counted.
-- **Open** — too many failures. Tasks are immediately rejected without execution.
-- **Half-Open** — after the cooldown period, up to N probe requests are
- allowed through (default 5). Success and failure counts are tracked. The
- circuit closes when the success rate meets the threshold (default 80%). If
- too many probes fail and the threshold becomes mathematically impossible,
- the circuit immediately re-opens. If probes don't complete within the
- cooldown period, the circuit re-opens as a safety valve.
-
-## Configuration
-
-Enable circuit breakers per task using the `circuit_breaker` parameter:
-
-```python
-@queue.task(
- circuit_breaker={
- "threshold": 5, # Open after 5 failures
- "window": 60, # Within a 60-second window
- "cooldown": 300, # Stay open for 5 minutes before half-open
- "half_open_probes": 5, # Allow 5 probe requests in half-open
- "half_open_success_rate": 0.8, # Close when 80% of probes succeed
- }
-)
-def call_external_api(endpoint: str) -> dict:
- return requests.get(endpoint).json()
-```
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `threshold` | `int` | `5` | Number of failures to trigger the breaker |
-| `window` | `int` | `60` | Time window in seconds for counting failures |
-| `cooldown` | `int` | `300` | Seconds to wait before allowing a test request |
-| `half_open_probes` | `int` | `5` | Number of probe requests allowed in half-open |
-| `half_open_success_rate` | `float` | `0.8` | Required success rate (0.0–1.0) to close from half-open |
-
-## Inspecting circuit breaker state
-
-### Python API
-
-```python
-breakers = queue.circuit_breakers()
-for cb in breakers:
- print(f"{cb['task_name']}: {cb['state']} (failures: {cb['failure_count']})")
-```
-
-### Dashboard API
-
-Requires a running [dashboard](/python/guides/dashboard) (default port `8080`):
-
-```bash
-curl http://localhost:8080/api/circuit-breakers
-```
-
-```json
-[
- {
- "task_name": "myapp.tasks.call_external_api",
- "state": "open",
- "failure_count": 5,
- "last_failure": 1700000010000,
- "cooldown_until": 1700000310000
- }
-]
-```
-
-## When to use
-
-Circuit breakers are most useful for tasks that interact with external systems:
-
-- **External API calls** — prevent hammering a down service
-- **Database connections** — stop retrying when the database is unreachable
-- **Third-party services** — email providers, payment gateways, etc.
-
-For purely internal computation tasks, circuit breakers are usually
-unnecessary — standard retries with backoff are sufficient.
-
-## Combining with retries
-
-Circuit breakers and retries work together. A task with both will:
-
-1. Retry on failure up to `max_retries` times (with backoff)
-2. Count each final failure toward the circuit breaker threshold
-3. Once the breaker opens, new jobs for that task are rejected immediately
-
-```python
-@queue.task(
- max_retries=3,
- retry_backoff=2.0,
- circuit_breaker={"threshold": 5, "window": 120, "cooldown": 600},
-)
-def send_email(to: str, subject: str, body: str):
- smtp.send(to, subject, body)
-```
-
-## Examples
-
-### External payment API
-
-```python
-@queue.task(
- max_retries=3,
- circuit_breaker={"threshold": 3, "window": 60, "cooldown": 120},
-)
-def charge_customer(customer_id: str, amount: float):
- response = requests.post(
- "https://api.payment-provider.com/charge",
- json={"customer": customer_id, "amount": amount},
- timeout=10,
- )
- response.raise_for_status()
- return response.json()
-```
-
-If the payment API goes down, the circuit breaker opens after 3 failures
-within 60 seconds, preventing a flood of requests to the failing service.
-After 2 minutes, up to 5 probe requests are allowed through. If at least 80%
-succeed (4 out of 5), the circuit closes.
-
-### Health check with monitoring
-
-```python
-from taskito.events import EventType
-
-# Log when circuit breakers change state
-def monitor_breakers(event_type: EventType, payload: dict):
- breakers = queue.circuit_breakers()
- open_breakers = [b for b in breakers if b["state"] == "open"]
- if open_breakers:
- names = ", ".join(b["task_name"] for b in open_breakers)
- print(f"WARNING: Open circuit breakers: {names}")
-
-queue.on_event(EventType.JOB_FAILED, monitor_breakers)
-```
diff --git a/docs/content/docs/python/guides/reliability/error-handling.mdx b/docs/content/docs/python/guides/reliability/error-handling.mdx
deleted file mode 100644
index 250cef80..00000000
--- a/docs/content/docs/python/guides/reliability/error-handling.mdx
+++ /dev/null
@@ -1,368 +0,0 @@
----
-title: Error Handling & Troubleshooting
-description: "Debug task failures, inspect error history, common failure modes, and the exception hierarchy."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-This guide covers how to debug task failures, inspect error history, handle
-common problems, and understand what happens when things go wrong.
-
-## Task failure lifecycle
-
-When a task raises an exception:
-
-1. The error message is recorded in the `job_errors` table
-2. If `retry_count < max_retries`, the job is rescheduled with exponential backoff
-3. If all retries are exhausted, the job moves to the **dead letter queue** (status: `dead`)
-
- B["Error recorded"]
- B --> C{"Retries left?"}
- C -->|Yes| D["Reschedule with backoff"]
- C -->|No| E["Move to DLQ"]`}
-/>
-
-## Inspecting error history
-
-Every failed attempt is recorded. Use `job.errors` to see the full history:
-
-```python
-job = unreliable_task.delay()
-
-# After the job fails and retries...
-for error in job.errors:
- print(f"Attempt {error['attempt']}: {error['error']} at {error['failed_at']}")
-```
-
-Each error entry:
-
-| Field | Type | Description |
-|---|---|---|
-| `id` | `str` | Unique error record ID |
-| `job_id` | `str` | The job this error belongs to |
-| `attempt` | `int` | Attempt number (0-indexed) |
-| `error` | `str` | Error message string |
-| `failed_at` | `int` | Timestamp in milliseconds |
-
-## Diagnosing dead letters
-
-When a job exhausts all retries, it lands in the DLQ. Inspect dead letters to
-understand what went wrong:
-
-```python
-dead = queue.dead_letters(limit=20)
-
-for d in dead:
- print(f"Task: {d['task_name']}")
- print(f"Error: {d['error']}")
- print(f"Retries: {d['retry_count']}")
- print(f"Queue: {d['queue']}")
- print()
-```
-
-### Common patterns
-
-**Same error on every attempt** — The failure is deterministic (e.g., bad
-arguments, missing dependency). Fix the root cause, then replay:
-
-```python
-new_job_id = queue.retry_dead(dead[0]["id"])
-```
-
-**Intermittent errors** — The failure is transient (e.g., network timeout).
-Replaying will likely succeed:
-
-```python
-# Replay all dead letters
-for d in dead:
- queue.retry_dead(d["id"])
-```
-
-
- Replayed jobs preserve the original job's `priority`, `max_retries`,
- `timeout`, and `result_ttl` settings — no need to re-specify them.
-
-
-**Error message mentions serialization** — see Serialization Errors below.
-
-## Common error scenarios
-
-### Serialization errors
-
-taskito uses `cloudpickle` to serialize task arguments and return values.
-Serialization fails when:
-
-- **Passing unpicklable objects**: open file handles, database connections, sockets, thread locks
-- **Module-level objects that can't be resolved**: dynamically generated classes without stable import paths
-- **Very large objects**: while cloudpickle has no hard limit, extremely large payloads slow down SQLite writes
-
-**Fix**: pass simple, serializable data (strings, numbers, dicts, lists) as
-task arguments. Reconstruct complex objects inside the task:
-
-```python
-# Bad — passing a connection object
-@queue.task()
-def query(conn, sql): # conn can't be pickled
- return conn.execute(sql)
-
-# Good — pass connection info, create inside the task
-@queue.task()
-def query(db_url, sql):
- conn = create_connection(db_url)
- return conn.execute(sql)
-```
-
-### Task not found in registry
-
-```
-KeyError: "Task 'myapp.process_data' not found in registry"
-```
-
-This means the worker doesn't have the task registered. Causes:
-
-- The module defining the task wasn't imported before starting the worker
-- The task name changed (function renamed or moved to a different module)
-- The `--app` flag points to the wrong module
-
-**Fix**: ensure all task modules are imported when your `Queue` instance is
-created. Typically, define tasks in the same module as the queue, or import
-them in your app's `__init__.py`.
-
-### SQLite lock contention
-
-```
-OperationalError: database is locked
-```
-
-SQLite allows concurrent reads (WAL mode — write-ahead logging, lets many readers and one writer run concurrently), but only one writer at a time.
-taskito sets `busy_timeout=5000ms` to wait for locks, but heavy write loads
-can still cause contention.
-
-**Causes:**
-
-- Multiple processes writing to the same SQLite file simultaneously
-- Very large batch inserts blocking the writer
-- Long-running transactions from external tools (e.g., DB browser) holding locks
-
-**Fixes:**
-
-- Avoid opening the database file with other tools while the worker is running
-- Use [`enqueue_many()` / `task.map()`](/python/guides/advanced-execution/batch-enqueue) for batch inserts — they use a single transaction
-- If running multiple processes, ensure only one worker process writes at a time
-
-### Timeout reaping
-
-```
-Task timed out after 10s
-```
-
-If a task exceeds its `timeout`, the scheduler detects it (every ~5 seconds)
-and marks it as failed, triggering retry/DLQ logic.
-
-```python
-@queue.task(timeout=30) # 30 second timeout
-def long_task():
- ...
-```
-
-
- Timeout reaping marks the job as failed, but **does not kill the Python
- thread**. The task function continues running until it finishes — the
- result is simply discarded. For CPU-bound tasks that might hang, consider
- adding your own internal timeout logic.
-
-
-### Soft timeouts
-
-While hard timeouts (above) are enforced by the scheduler, **soft timeouts**
-let the task itself react to time pressure:
-
-```python
-from taskito import current_job
-
-@queue.task(soft_timeout=30)
-def long_task():
- for chunk in data_chunks:
- process(chunk)
- current_job.check_timeout() # raises SoftTimeoutError after 30s
-```
-
-| Timeout type | Mechanism | Exception |
-|---|---|---|
-| Hard timeout (`timeout`) | Scheduler reaps the job externally | `TaskTimeoutError` (internal) |
-| Soft timeout (`soft_timeout`) | Task checks elapsed time via `check_timeout()` | `SoftTimeoutError` |
-
-Soft timeouts are **cooperative** — the task must call `check_timeout()` at
-safe points.
-
-### Worker crash behavior
-
-If the worker process crashes (kill -9, OOM, power loss):
-
-- **Running jobs** stay in `running` status in SQLite
-- On the next worker start, the scheduler's **stale job reaper** detects jobs
- that have been `running` longer than their timeout and marks them as failed
-- If no timeout is set, stale jobs with no timeout remain in `running` status
- until manually cleaned up
-
-**Recommendation**: always set a `timeout` on tasks so that stale jobs are
-automatically recovered:
-
-```python
-@queue.task(timeout=300) # 5 minute timeout
-def process(data):
- ...
-```
-
-## Debugging tips
-
-### Check queue stats
-
-Quick health check:
-
-```python
-stats = queue.stats()
-print(stats)
-# {'pending': 0, 'running': 0, 'completed': 450, 'failed': 2, 'dead': 1, 'cancelled': 0}
-```
-
-Or via CLI:
-
-```bash
-taskito info --app myapp:queue --watch
-```
-
-### Use hooks for alerting
-
-Set up failure hooks to get notified immediately:
-
-```python
-@queue.on_failure
-def on_task_failure(task_name, args, kwargs, error):
- print(f"FAILED: {task_name} — {error}")
- # Send to Slack, PagerDuty, etc.
-```
-
-### Test mode for isolation
-
-Use [test mode](/python/guides/operations) to run tasks synchronously and inspect
-errors without a worker:
-
-```python
-with queue.test_mode() as results:
- risky_task.delay()
-
- if results[0].failed:
- print(results[0].error)
- print(results[0].traceback)
-```
-
-### Inspect the SQLite database directly
-
-For deep debugging, query the SQLite database:
-
-```bash
-sqlite3 myapp.db "SELECT id, task_name, status, error FROM jobs WHERE status = 3 LIMIT 10;"
-```
-
-Status codes: 0=pending, 1=running, 2=complete, 3=failed, 4=dead, 5=cancelled.
-
-## Error handling patterns
-
-### Exception filtering
-
-Use `retry_on` and `dont_retry_on` to control which exceptions trigger retries:
-
-```python
-@queue.task(
- max_retries=5,
- retry_on=[ConnectionError, TimeoutError],
- dont_retry_on=[ValueError],
-)
-def call_api(url):
- resp = requests.get(url, timeout=10)
- resp.raise_for_status()
- return resp.json()
-```
-
-See [Retries — Exception Filtering](/python/guides/reliability/retries) for details.
-
-### Cancelling running tasks
-
-Cancel a job that is already executing:
-
-```python
-# Request cancellation
-queue.cancel_running_job(job_id)
-```
-
-Inside the task, check for cancellation at safe points:
-
-```python
-from taskito import current_job
-
-@queue.task()
-def long_task(items):
- for item in items:
- process(item)
- current_job.check_cancelled() # raises TaskCancelledError
-```
-
-`check_cancelled()` raises `TaskCancelledError` if cancellation was requested.
-Place these checks at natural breakpoints in long-running tasks.
-
-
- Cancellation is **cooperative** — the task must call `check_cancelled()` to
- observe it. If the task never checks, it runs to completion.
-
-
-### Cleanup on failure
-
-Use try/finally or hooks to clean up resources:
-
-```python
-@queue.task()
-def process_file(path):
- tmp = download_to_temp(path)
- try:
- return parse(tmp)
- finally:
- os.unlink(tmp)
-```
-
-## Exception hierarchy
-
-taskito defines a hierarchy of exceptions for precise error handling:
-
-```
-TaskitoError (base)
-├── TaskTimeoutError — hard timeout exceeded
-├── SoftTimeoutError — soft timeout exceeded (check_timeout)
-├── TaskCancelledError — task cancelled (check_cancelled)
-├── MaxRetriesExceededError — all retry attempts exhausted
-├── SerializationError — serialization/deserialization failure
-├── CircuitBreakerOpenError — circuit breaker is open
-├── RateLimitExceededError — rate limit exceeded
-├── JobNotFoundError — job ID not found (also a KeyError)
-└── QueueError — queue-level operational error
-```
-
-All exceptions inherit from `TaskitoError`, so you can catch the base class
-for broad handling:
-
-```python
-from taskito import TaskitoError, SoftTimeoutError, TaskCancelledError
-
-try:
- result = job.result(timeout=30)
-except TaskitoError as e:
- print(f"Taskito error: {e}")
-```
-
-Import any exception directly from the `taskito` package:
-
-```python
-from taskito import TaskCancelledError, SoftTimeoutError, SerializationError
-```
diff --git a/docs/content/docs/python/guides/reliability/guarantees.mdx b/docs/content/docs/python/guides/reliability/guarantees.mdx
deleted file mode 100644
index 83076030..00000000
--- a/docs/content/docs/python/guides/reliability/guarantees.mdx
+++ /dev/null
@@ -1,155 +0,0 @@
----
-title: Delivery Guarantees
-description: "At-least-once delivery, claim_execution, idempotency patterns, deduplication."
----
-
-Taskito provides **at-least-once delivery**. Every enqueued job will be
-executed at least once, but may be executed more than once if a worker
-crashes mid-execution.
-
-## What this means
-
-- A job **will not be lost** — if a worker dies, the scheduler detects the stale job and retries it
-- A job **may run twice** — if a worker crashes after starting but before marking the job complete
-- A job **will not run concurrently** — `claim_execution` prevents two workers from picking up the same job
-
-## Why not exactly-once?
-
-Exactly-once *delivery* is impossible: a worker can run a task's side effect and
-then crash before it acknowledges completion, so the job is retried and the side
-effect runs again. No protocol closes this gap in general — the effect and the
-acknowledgement can't be committed atomically across separate systems. What you
-can get is exactly-once *effect*, by making tasks idempotent (dedup keys) or
-routing writes through a transactional outbox/inbox. Taskito's approach matches
-Celery, SQS, and most production job systems: deliver at least once, design
-tasks to handle duplicates.
-
-## How recovery works
-
->DB: dequeue + claim_execution
- S->>W: dispatch job
- W->>W: execute task
- Note over W: Worker crashes here
- Note over S: timeout_ms elapses...
- S->>DB: reap_stale_jobs detects stuck job
- S->>DB: mark failed + schedule retry
- S->>W: dispatch again (new attempt)
- W->>DB: complete + clear claim`}
-/>
-
-The `claim_execution` mechanism prevents two workers from executing the same
-job simultaneously. But it cannot prevent re-execution after a crash — the
-claim is cleared when the stale reaper detects the timeout.
-
-## Writing idempotent tasks
-
-Since tasks may run more than once, design them to be safe on re-execution:
-
-### Use database upserts
-
-```python
-@queue.task()
-def create_user(email, name):
- # UPSERT — safe to run twice
- db.execute(
- "INSERT INTO users (email, name) VALUES (?, ?) "
- "ON CONFLICT (email) DO UPDATE SET name = ?",
- (email, name, name),
- )
-```
-
-### Use idempotency keys
-
-```python
-@queue.task()
-def charge_customer(order_id, amount):
- # Check if already charged
- if db.execute("SELECT 1 FROM charges WHERE order_id = ?", (order_id,)).fetchone():
- return # Already processed
-
- payment_provider.charge(amount, idempotency_key=f"order-{order_id}")
- db.execute("INSERT INTO charges (order_id, amount) VALUES (?, ?)", (order_id, amount))
-```
-
-### Use unique tasks for deduplication
-
-```python
-# Only one pending/running instance per key
-job = send_report.apply_async(
- args=(user_id,),
- unique_key=f"report-{user_id}",
-)
-```
-
-If a job with the same `unique_key` is already pending or running, the
-duplicate enqueue does **not** create a second job — it returns the existing
-job's ID, so the two calls coalesce onto one execution. See
-[Unique Tasks](/python/guides/advanced-execution/unique-tasks) and
-[Idempotency](/python/guides/reliability/idempotency) for details.
-
-### Avoid side effects that can't be undone
-
-```python
-# Bad — sends duplicate emails on retry
-@queue.task()
-def notify(user_id):
- send_email(user_id, "Your order shipped")
-
-# Good — check before sending
-@queue.task()
-def notify(user_id):
- if not db.execute("SELECT notified FROM orders WHERE user_id = ?", (user_id,)).fetchone()[0]:
- send_email(user_id, "Your order shipped")
- db.execute("UPDATE orders SET notified = 1 WHERE user_id = ?", (user_id,))
-```
-
-## Deduplication window
-
-`unique_key` prevents duplicate enqueue only while a job with that key is
-**pending or running**. Once the job completes (or is dead-lettered/cancelled),
-the same `unique_key` can be enqueued again.
-
-```python
-job1 = task.apply_async(args=(1,), unique_key="order-123") # Enqueued
-job2 = task.apply_async(args=(1,), unique_key="order-123") # Coalesced onto job1
-assert job2.id == job1.id # same job — no duplicate created
-# ... job1 completes ...
-job3 = task.apply_async(args=(1,), unique_key="order-123") # Enqueued (new job)
-```
-
-## How claim execution works
-
-Before dispatching a job to a worker thread, the scheduler calls
-`claim_execution(job_id, worker_id)`. This is an atomic `SET NX` (SQLite:
-`INSERT OR IGNORE`, Postgres: `INSERT ... ON CONFLICT DO NOTHING`, Redis:
-`SET NX`). If another scheduler instance already claimed the job, the claim
-fails and the job is skipped.
-
-This prevents **duplicate dispatch** (two workers picking up the same job).
-It does NOT prevent **duplicate execution** after a crash — the claim is
-cleared by the stale reaper when it detects the timeout.
-
-## Framework vs task responsibility
-
-| Concern | Who handles it |
-|---------|---------------|
-| Job dispatch deduplication | Framework (`claim_execution`) |
-| Job enqueue deduplication | Framework (`unique_key`) |
-| Crash recovery | Framework (stale reaper) |
-| Idempotent execution | **You** (task code) |
-| Side-effect safety | **You** (task code) |
-
-## Summary
-
-| Guarantee | Taskito | Celery | SQS |
-|-----------|---------|--------|-----|
-| Delivery | At-least-once | At-least-once | At-least-once |
-| Duplicate prevention | `claim_execution` (dispatch-level) | Visibility timeout | Visibility timeout |
-| Deduplication | `unique_key` (enqueue-level) | Manual | Message dedup ID |
-| Crash recovery | Stale reaper (timeout-based) | Worker ack timeout | Visibility timeout |
diff --git a/docs/content/docs/python/guides/reliability/idempotency.mdx b/docs/content/docs/python/guides/reliability/idempotency.mdx
deleted file mode 100644
index c5d5dcc9..00000000
--- a/docs/content/docs/python/guides/reliability/idempotency.mdx
+++ /dev/null
@@ -1,85 +0,0 @@
----
-title: Idempotency
-description: "Auto-derived deduplication keys — declare a task idempotent and identical calls coalesce to a single execution."
----
-
-When a task represents an action that **must not run twice** (charging a card,
-sending a webhook, applying a credit), set `idempotent=True` on the task.
-Taskito derives a deduplication key from the task name and serialized arguments.
-Two enqueues with identical arguments while the first job is pending or running
-return the **same job ID** — the second call becomes a no-op.
-
-## Declaring an idempotent task
-
-```python
-@queue.task(idempotent=True)
-def charge_customer(customer_id: int, amount_cents: int) -> str:
- return payment_provider.charge(customer_id, amount_cents)
-
-job1 = charge_customer.delay(42, 1000)
-job2 = charge_customer.delay(42, 1000)
-
-assert job1.id == job2.id # second call coalesced into the first
-```
-
-## How the key is derived
-
-Auto-keys follow the format `auto:`. Hashing
-happens *after* serialization, so two calls with logically equal but
-type-different arguments collide only when the serializer emits identical bytes
-— the same contract that the wire payload uses.
-
-The dedup slot is held while the job is `pending` or `running`. Once the job
-reaches a terminal state (`completed`, `failed`, `dead`), the slot is freed and
-a fresh call with the same arguments creates a new job.
-
-## Per-call overrides
-
-```python
-# Custom semantic key (overrides the auto-derived hash)
-charge_customer.apply_async(
- args=(42, 1000),
- idempotency_key="invoice-2026-05-08-42",
-)
-
-# Force a fresh execution even on an idempotent task
-charge_customer.apply_async(args=(42, 1000), idempotent=False)
-```
-
-| Parameter | Type | Effect |
-|-----------|------|--------|
-| `idempotent=True` (decorator) | `bool` | Default for every `delay()` / `apply_async()` |
-| `idempotency_key="…"` (call) | `str` | Explicit key. Overrides auto-derivation. |
-| `idempotent=False` (call) | `bool` | Disable auto-derivation for this submission |
-| `unique_key="…"` (call) | `str` | The underlying storage dedup key. Highest precedence — overrides `idempotency_key` and auto-derivation. |
-
-Precedence (high to low): `unique_key` → `idempotency_key` → auto-derivation.
-
-## Pitfalls
-
-- **Don't pass moving values as arguments.** `datetime.now()`, `uuid4()`, and
- similar will produce a different hash on every call, so dedup never triggers.
- Move them inside the task body or pass a stable identifier instead.
-- **Argument order matters** — `charge(42, 1000)` and `charge(amount=1000, customer_id=42)`
- serialize differently and therefore hash differently. Be consistent at the
- call site or use an explicit `idempotency_key`.
-- **Serializer matters.** If you change the per-task serializer between
- releases, hashes change and in-flight jobs from the old release won't dedupe
- against new submissions. Use explicit `idempotency_key` for keys that must
- survive deploys.
-- **Test mode skips dedup.** `queue.test_mode()` runs tasks synchronously
- without storage, so idempotency is a no-op there.
-
-## Bulk submissions
-
-`enqueue_many()` accepts a per-job `idempotency_keys` list, or a uniform
-`idempotent=True` that auto-derives a key per row:
-
-```python
-queue.enqueue_many(
- task_name="myapp.charge_customer",
- args_list=[(42, 1000), (43, 2000), (42, 1000)],
- idempotent=True,
-)
-# Third entry collides with the first → only two distinct jobs created.
-```
diff --git a/docs/content/docs/python/guides/reliability/index.mdx b/docs/content/docs/python/guides/reliability/index.mdx
index 99067418..e43ba915 100644
--- a/docs/content/docs/python/guides/reliability/index.mdx
+++ b/docs/content/docs/python/guides/reliability/index.mdx
@@ -36,7 +36,7 @@ description: "Harden your task queue for production workloads."
/>
diff --git a/docs/content/docs/python/guides/reliability/locking.mdx b/docs/content/docs/python/guides/reliability/locking.mdx
deleted file mode 100644
index 23c8f336..00000000
--- a/docs/content/docs/python/guides/reliability/locking.mdx
+++ /dev/null
@@ -1,176 +0,0 @@
----
-title: Distributed Locking
-description: "Database-backed mutual-exclusion across workers — sync, async, auto-extend."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-taskito provides a distributed lock primitive backed by the same database
-used for the task queue. Locks work across multiple worker processes on one
-machine, and across multiple machines when the backend is a shared server
-(Postgres or Redis) rather than a local SQLite file.
-
-> Celery has no built-in lock — teams reach for `celery-once` or a Redis lock.
-> taskito's `queue.lock()` uses the queue database directly, so there's nothing
-> extra to run.
-
-## Overview
-
-Use distributed locks when multiple workers or processes must not execute
-the same critical section at the same time — for example, refreshing a
-shared cache, running a singleton periodic task, or accessing an external
-API with a single-writer constraint.
-
-## Sync context manager
-
-```python
-with queue.lock("cache-refresh"):
- refresh_cache()
-```
-
-The lock is automatically released when the `with` block exits, even if an
-exception is raised.
-
-
-
-### Parameters
-
-```python
-queue.lock(
- name: str,
- ttl: int = 30,
- auto_extend: bool = True,
- owner_id: str | None = None,
- timeout: float | None = None,
- retry_interval: float = 0.1,
-)
-```
-
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `name` | `str` | — | Lock name. All processes using the same name compete for the same lock. |
-| `ttl` | `int` | `30` | Lock time-to-live (TTL) in seconds. Auto-extended if `auto_extend=True`. |
-| `auto_extend` | `bool` | `True` | Automatically extend the lock before it expires (background thread). |
-| `owner_id` | `str \| None` | `None` | Custom owner identifier. Defaults to a random UUID per acquisition. |
-| `timeout` | `float \| None` | `None` | Max seconds to wait for the lock. `None` raises immediately if unavailable. |
-| `retry_interval` | `float` | `0.1` | Seconds between retry attempts when waiting for the lock. |
-
-## Async context manager
-
-```python
-async with queue.alock("cache-refresh"):
- await refresh_cache()
-```
-
-`alock()` accepts the same parameters as `lock()` and is safe to use inside
-async functions and FastAPI/Django async views.
-
-## Auto-extension
-
-When `auto_extend=True` (the default), a background thread extends the lock's
-TTL at roughly half the TTL interval. This prevents the lock from expiring
-during a long-running operation without requiring you to set an artificially
-large TTL.
-
-```python
-# This lock will stay alive for as long as the block runs,
-# even if it takes several minutes.
-with queue.lock("long-job", ttl=30, auto_extend=True):
- run_slow_operation()
-```
-
-## Acquisition timeout
-
-By default, `lock()` raises `LockNotAcquired` immediately if the lock is held
-by another process. Pass `timeout` to wait:
-
-```python
-try:
- with queue.lock("resource", timeout=5.0):
- do_work()
-except LockNotAcquired:
- print("Could not acquire lock within 5 seconds")
-```
-
-The lock is retried every `retry_interval` seconds until `timeout` is
-exceeded.
-
-## Cross-process locking
-
-Because lock state is stored in the database, locks are effective across
-multiple worker processes on the same machine. Locking across **different**
-machines works too, but only when they share a networked database — SQLite
-is a local file, so cross-machine locking requires the Postgres or Redis
-backend:
-
-```python
-# Process A (machine 1)
-with queue.lock("billing-run"):
- run_billing()
-
-# Process B (machine 2) — will wait or raise while process A holds the lock
-with queue.lock("billing-run"):
- run_billing()
-```
-
-
- On SQLite, cross-process locking works via WAL mode and exclusive
- transactions, but SQLite file locking is machine-local — it only works
- across processes on the same machine. For multi-machine deployments, use
- the PostgreSQL backend, where `SELECT FOR UPDATE SKIP LOCKED` provides true
- distributed semantics, or the Redis backend, which also supports
- `queue.lock()`.
-
-
-## Error handling
-
-```python
-from taskito import LockNotAcquired
-
-try:
- with queue.lock("my-lock", timeout=2.0):
- critical_section()
-except LockNotAcquired:
- # Another process holds the lock; handle gracefully
- log.warning("Skipping — another process is running the critical section")
-```
-
-## Low-level API
-
-For advanced use cases, you can manage locks manually without the context manager:
-
-```python
-# Acquire
-lock_id = queue._inner.acquire_lock("my-lock", ttl=30)
-
-# Extend
-queue._inner.extend_lock("my-lock", lock_id, ttl=30)
-
-# Inspect
-info = queue._inner.get_lock_info("my-lock")
-# {"name": "my-lock", "owner_id": "...", "expires_at": 1710000000}
-
-# Release
-queue._inner.release_lock("my-lock", lock_id)
-```
-
-
- The low-level API skips auto-extension and does not release on exception.
- Prefer the context manager (`lock()` / `alock()`) for production code.
-
-
-
- `owner_id` is a bearer token — whoever knows it can release or extend the
- lock. The low-level `get_lock_info` returns it raw; the high-level
- `queue.lock(name).info()` masks it to `"***"` for any caller that isn't the
- current holder, so prefer `info()` when surfacing lock status to other
- parties (e.g. a dashboard or another tenant).
-
diff --git a/docs/content/docs/python/guides/reliability/meta.json b/docs/content/docs/python/guides/reliability/meta.json
index 132c3c96..359ef199 100644
--- a/docs/content/docs/python/guides/reliability/meta.json
+++ b/docs/content/docs/python/guides/reliability/meta.json
@@ -7,6 +7,6 @@
"idempotency",
"rate-limiting",
"circuit-breakers",
- "locking"
+ "locks"
]
}
diff --git a/docs/content/docs/python/guides/reliability/retries.mdx b/docs/content/docs/python/guides/reliability/retries.mdx
deleted file mode 100644
index f8afcef4..00000000
--- a/docs/content/docs/python/guides/reliability/retries.mdx
+++ /dev/null
@@ -1,185 +0,0 @@
----
-title: Retries & Dead Letters
-description: "Automatic retries with exponential backoff, exception filtering, dead letter queue."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-taskito automatically retries failed tasks with exponential backoff and moves
-permanently failed jobs to a dead letter queue.
-
-## Retry policy
-
-Configure retries at the task level:
-
-```python
-@queue.task(max_retries=5, retry_backoff=2.0)
-def flaky_api_call(url):
- response = requests.get(url)
- response.raise_for_status()
- return response.json()
-```
-
-| Parameter | Default | Description |
-|---|---|---|
-| `max_retries` | `3` | Maximum retry attempts before DLQ |
-| `retry_backoff` | `1.0` | Base delay in seconds for exponential backoff |
-
-### Backoff formula
-
-```
-delay = min(max_delay, base_delay * 2^retry_count) + jitter
-```
-
-- `base_delay` = `retry_backoff` (in seconds)
-- `max_delay` = 300 seconds (5 minutes)
-- `jitter` = random 0–500ms to prevent thundering herd (many retries firing at the same instant)
-
-**Example with `retry_backoff=2.0`:**
-
-| Attempt | Delay |
-|---|---|
-| 1st retry | ~2s |
-| 2nd retry | ~4s |
-| 3rd retry | ~8s |
-| 4th retry | ~16s |
-| 5th retry | ~32s |
-
-## Exception filtering
-
-Control which exceptions trigger retries with `retry_on` (a whitelist) **or**
-`dont_retry_on` (a blacklist). Use one or the other — not both (see the note below).
-
-```python
-# Whitelist: retry only these exceptions; everything else skips straight to DLQ.
-@queue.task(max_retries=5, retry_on=[ConnectionError, TimeoutError])
-def fetch_data(url):
- response = requests.get(url)
- response.raise_for_status()
- return response.json()
-
-# Blacklist: retry everything except these exceptions.
-@queue.task(max_retries=5, dont_retry_on=[ValueError])
-def parse_data(raw):
- return json.loads(raw) # a ValueError here is permanent — don't retry it
-```
-
-| Parameter | Description |
-|---|---|
-| `retry_on` | Whitelist — only retry on these exception types. All others skip straight to DLQ. |
-| `dont_retry_on` | Blacklist — never retry on these exception types, even if retries remain. |
-
-If neither is set, all exceptions trigger retries (default behavior).
-
-
- `retry_on` and `dont_retry_on` are mutually exclusive in practice — if
- `retry_on` is set, only those exceptions are retried and `dont_retry_on` is
- ignored. Set one, not both.
-
-
-> **Coming from Celery:** `retry_backoff` here is a **float** — the base delay in
-> seconds — not Celery's boolean flag. Celery's `autoretry_for=(SomeError,)` maps
-> to taskito's `retry_on=[SomeError]`.
-
-## Retry flow
-
- B{Success?}
- B -->|Yes| C["Status: Complete Store result"]
- B -->|No| D["Record error in job_errors table"]
- D --> SR{"Exception passes retry_on / dont_retry_on?"}
- SR -->|No| I["Move to Dead Letter Queue Status: Dead"]
- SR -->|Yes| E{"retry_count < max_retries?"}
- E -->|Yes| F["Calculate backoff delay"]
- F --> G["Status: Pending retry_count += 1"]
- G --> H["Wait for scheduled time"]
- H --> A
- E -->|No| I`}
-/>
-
-## Dead letter queue
-
-Jobs that exhaust all retries are moved to the DLQ for inspection and manual replay.
-
-### Inspect dead letters
-
-```python
-# List the 10 most recent dead letters
-dead = queue.dead_letters(limit=10, offset=0)
-
-for d in dead:
- print(f"Job: {d['original_job_id']}")
- print(f"Task: {d['task_name']}")
- print(f"Error: {d['error']}")
- print(f"Retries: {d['retry_count']}")
- print()
-```
-
-### Replay dead letters
-
-```python
-# Re-enqueue a dead letter job (creates a new job)
-new_job_id = queue.retry_dead(dead[0]["id"])
-```
-
-
- Replayed jobs preserve the original job's `priority`, `max_retries`,
- `timeout`, and `result_ttl` settings. You don't need to re-specify them — the
- DLQ stores the full configuration.
-
-
-### Purge old dead letters
-
-```python
-# Delete dead letters older than 24 hours
-deleted = queue.purge_dead(older_than=86400)
-print(f"Purged {deleted} dead letter(s)")
-```
-
-## Error history
-
-Every failed attempt is recorded with the error message. Access the full
-history via `job.errors`:
-
-```python
-@queue.task(max_retries=3)
-def unreliable():
- raise ConnectionError("timeout")
-
-job = unreliable.delay()
-
-# After the job exhausts all retries...
-for error in job.errors:
- print(f"Attempt {error['attempt']}: {error['error']}")
- # Attempt 0: timeout
- # Attempt 1: timeout
- # Attempt 2: timeout
- # Attempt 3: timeout
-```
-
-`max_retries` counts retries **after** the first attempt — `max_retries=3`
-means the task runs up to 4 times total (the initial attempt plus 3 retries),
-recording 4 entries in `job.errors` (attempts 0–3).
-
-Each error entry contains:
-
-| Field | Type | Description |
-|---|---|---|
-| `id` | `str` | Unique error record ID |
-| `job_id` | `str` | The job this error belongs to |
-| `attempt` | `int` | Attempt number (0-indexed) |
-| `error` | `str` | Error message |
-| `failed_at` | `int` | Timestamp in milliseconds |
-
-## Timeout reaping
-
-If a task exceeds its `timeout`, the scheduler automatically detects it
-(checking every ~5 seconds) and treats it as a failure — triggering the
-retry/DLQ logic.
-
-```python
-@queue.task(timeout=10) # 10 second timeout
-def slow_task():
- time.sleep(60) # Will be reaped after 10s
-```
diff --git a/docs/content/docs/python/guides/resources/interception.mdx b/docs/content/docs/python/guides/resources/interception.mdx
deleted file mode 100644
index 40b3cac1..00000000
--- a/docs/content/docs/python/guides/resources/interception.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: Argument Interception
-description: "Classification modes and strategies — PASS / CONVERT / REDIRECT / PROXY / REJECT."
----
-
-Argument interception classifies every value passed to `.delay()` or
-`.apply_async()` before serialization. Enable it on the `Queue` constructor:
-
-```python
-queue = Queue(db_path="tasks.db", interception="strict")
-```
-
-Without interception, values are passed directly to the serializer. A
-SQLAlchemy session or file handle would either raise a serialization error
-at enqueue time or produce a broken payload that fails on the worker.
-
-## Modes
-
-| Mode | Behavior |
-|---|---|
-| `"off"` | Disabled (default). All arguments pass through to the serializer unchanged. |
-| `"strict"` | Raises `InterceptionError` immediately when a rejected type is detected. |
-| `"lenient"` | Logs a warning and drops the rejected argument instead of raising. |
-
-`"strict"` is recommended for production — it surfaces problems at call
-time rather than causing silent task failures.
-
-## Classification strategies
-
-Every argument gets one of five strategies:
-
-| Strategy | What happens | Examples |
-|---|---|---|
-| `PASS` | Sent as-is to the serializer | `int`, `str`, `bool`, `bytes` |
-| `CONVERT` | Transformed to a serializable form, reconstructed on the worker | `UUID`, `datetime`, `Decimal`, `Path`, `Enum`, Pydantic models, dataclasses |
-| `REDIRECT` | Replaced with a DI marker; the worker injects the named resource | SQLAlchemy sessions, Redis clients, MongoDB clients |
-| `PROXY` | Deconstructed to a recipe; reconstructed as a live object on the worker | File handles, loggers, `requests.Session`, `httpx.Client`, boto3 clients |
-| `REJECT` | Raises `InterceptionError` in strict mode, dropped in lenient mode | Thread locks, generators, coroutines, sockets |
-
-## Built-in CONVERT types
-
-These are converted automatically when interception is enabled:
-
-| Type | Notes |
-|---|---|
-| `uuid.UUID` | Stored as `"uuid:"` |
-| `datetime.datetime` / `date` / `time` / `timedelta` | ISO format |
-| `decimal.Decimal` | Stored as string to preserve precision |
-| `pathlib.Path` / `PurePath` | Stored as POSIX string |
-| `re.Pattern` | Pattern string + flags |
-| `collections.OrderedDict` | Preserves insertion order |
-| `pydantic.BaseModel` | Via `.model_dump()` (if pydantic is installed) |
-| `enum.Enum` subclasses | Class path + value |
-| Dataclasses | Auto-detected via `dataclasses.is_dataclass()` |
-| `NamedTuple` subclasses | Auto-detected |
-
-## Built-in REDIRECT types
-
-These connectors are automatically detected and replaced with a resource
-injection marker. The worker injects the named resource instead of
-attempting to deserialize a live connection object:
-
-| Type | Default resource name |
-|---|---|
-| `sqlalchemy.orm.Session` | `"db"` |
-| `sqlalchemy.ext.asyncio.AsyncSession` | `"db"` |
-| `sqlalchemy.engine.Engine` | `"db"` |
-| `sqlalchemy.ext.asyncio.AsyncEngine` | `"db"` |
-| `redis.Redis` | `"redis"` |
-| `redis.asyncio.Redis` | `"redis"` |
-| `pymongo.MongoClient` | `"mongo"` |
-| `motor.motor_asyncio.AsyncIOMotorClient` | `"mongo"` |
-| `psycopg2.extensions.connection` | `"db"` |
-| `asyncpg.connection.Connection` | `"db"` |
-| `django.db.backends.base.base.BaseDatabaseWrapper` | `"db"` |
-| `aiohttp.ClientSession` | `"aiohttp_session"` |
-
-The resource name is the key you use in `@queue.worker_resource("name")`.
-If your resource has a different name, register a custom redirect with
-`register_type()`.
-
-REDIRECT is a type shift, not just a value swap: the caller passes a live
-`Session` (or `Redis`, `MongoClient`, ...) to `.delay()`, but the worker's
-task function receives whatever the named resource's factory returns —
-for `"db"`, that's typically a `sessionmaker` you call to get a session, a
-different type than what the caller passed in. See
-[Dependency Injection](/python/guides/resources/dependency-injection) for how
-the factory return value is injected.
-
-## Built-in PROXY types
-
-These objects are deconstructed to a recipe dict and rebuilt by the worker:
-
-| Type | Handler name |
-|---|---|
-| `io.TextIOWrapper`, `io.BufferedReader`, `io.BufferedWriter`, `io.FileIO` | `"file"` |
-| `logging.Logger` | `"logger"` |
-| `requests.Session` | `"requests_session"` |
-| `httpx.Client` / `httpx.AsyncClient` | `"httpx_client"` |
-| boto3 clients (via `botocore.client.BaseClient`) | `"boto3_client"` |
-| `google.cloud.storage.Client` / `Bucket` / `Blob` | `"gcs_client"` |
-
-See [Resource Proxies](/python/guides/resources/proxies) for security options
-and handler details.
-
-## Built-in REJECT types
-
-These are always rejected because they cannot cross process or
-serialization boundaries:
-
-- Thread synchronization primitives (`Lock`, `RLock`, `Semaphore`, `Event`)
-- `socket.socket`
-- Generator objects
-- Coroutine objects
-- `subprocess.Popen`
-- `asyncio.Task` / `asyncio.Future`
-- `contextvars.Context`
-- Multiprocessing `Lock` and `Queue`
-
-Each rejection includes a message explaining why and suggests alternatives.
-
-## Registering custom types
-
-Add custom rules for types not covered by the built-ins:
-
-```python
-from myapp import MyDBClient, MoneyAmount, APIConnection
-
-# Treat a custom DB client as a worker resource (worker must have "my_db" registered)
-queue.register_type(MyDBClient, "redirect", resource="my_db")
-
-# Convert a custom value type to something serializable
-queue.register_type(
- MoneyAmount,
- "convert",
- converter=lambda m: {"__type__": "money", "value": str(m.value), "currency": m.currency},
- type_key="money",
-)
-
-# Reject with a helpful message
-queue.register_type(
- APIConnection,
- "reject",
- message="API connections are process-local. Register it as a worker resource instead.",
-)
-```
-
-`register_type()` requires interception to be enabled (`"strict"` or
-`"lenient"`). Calling it when interception is `"off"` raises `RuntimeError`.
-
-| Parameter | Description |
-|---|---|
-| `python_type` | The type to register. |
-| `strategy` | `"pass"`, `"convert"`, `"redirect"`, `"reject"`, or `"proxy"`. |
-| `resource` | Resource name for `"redirect"`. |
-| `message` | Rejection reason for `"reject"`. |
-| `converter` | Converter callable for `"convert"`. |
-| `type_key` | Dispatch key for the converter reconstructor. |
-| `proxy_handler` | Handler name for `"proxy"`. |
-
-## Constructor parameters
-
-| Parameter | Default | Description |
-|---|---|---|
-| `interception` | `"off"` | Interception mode: `"strict"`, `"lenient"`, or `"off"`. |
-| `max_intercept_depth` | `10` | Maximum depth the walker recurses into nested containers. |
-
-## Analyzing arguments
-
-Inspect how interception would classify arguments without actually
-transforming them:
-
-```python
-from myapp.tasks import queue
-
-report = queue._interceptor.analyze(
- args=(user_session, "Hello"),
- kwargs={"attachment": open("file.pdf", "rb")},
-)
-print(report)
-# Argument Analysis:
-# args[0] (Session) → REDIRECT (redirect to worker resource 'db')
-# args[1] (str) → PASS
-# kwargs.attachment (BufferedReader) → PROXY (handler=file)
-```
-
-`analyze()` is a development and debugging tool. It reads the registry but
-makes no changes to arguments. `_interceptor` is an internal accessor — it is
-not yet part of the stable public API and may change between releases.
-
-## Interception metrics
-
-```python
-stats = queue.interception_stats()
-# {
-# "total_intercepts": 1200,
-# "total_duration_ms": 216.0,
-# "avg_duration_ms": 0.18,
-# "strategy_counts": {"pass": 800, "convert": 250, "redirect": 100, "proxy": 50, "reject": 0},
-# "max_depth_reached": 3,
-# }
-```
-
-`total_intercepts` always equals the sum of `strategy_counts`.
-
-See [Observability](/python/guides/resources/observability) for Prometheus
-metrics and dashboard endpoints.
diff --git a/docs/content/docs/python/guides/workflows/canvas.mdx b/docs/content/docs/python/guides/workflows/canvas.mdx
deleted file mode 100644
index 6c4a8aaf..00000000
--- a/docs/content/docs/python/guides/workflows/canvas.mdx
+++ /dev/null
@@ -1,282 +0,0 @@
----
-title: Canvas Primitives
-description: "Lightweight chain / group / chord composition for simpler pipelines, with optional saga compensation."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-For simpler pipelines without DAG features, taskito provides **chain**,
-**group**, and **chord** — lightweight composition that doesn't require the
-workflow engine.
-
-## Coming from Celery
-
-taskito's canvas mirrors Celery's `chain` / `group` / `chord` / `.s()` / `.si()` /
-`chunks` / `starmap`, with a few deliberate differences:
-
-| Celery | taskito | Difference |
-|---|---|---|
-| `chain(...).apply_async()` | `chain(...).apply(queue)` | `.apply()` here **submits** the canvas; pass the `queue`. |
-| `chain(...).apply()` (eager) | — | taskito has **no** eager `.apply()`; it always enqueues for workers. |
-| `AsyncResult.get(timeout=)` | `job.result(timeout=)` | Same idea, different name. |
-| `group(...)` → `GroupResult` | `group(...).apply(queue)` → `list[JobResult]` | taskito returns a plain list of handles — iterate and call `.result()` on each. |
-| `.s()` / `.si()` | `.s()` / `.si()` | Identical (mutable vs immutable signature). |
-
-
- The biggest gotcha: in Celery, `.apply()` executes the canvas **locally and
- synchronously**. In taskito, `.apply(queue)` **enqueues** it for workers, and
- you await results with `.result()`. There is no eager local execution.
-
-
-## Signatures
-
-A `Signature` wraps a task call for deferred execution:
-
-```python
-from taskito import chain, group, chord
-
-sig = add.s(1, 2) # Mutable — receives previous result as first arg
-sig = add.si(1, 2) # Immutable — ignores previous result
-```
-
-## Chain
-
-Execute tasks sequentially, piping each result to the next:
-
-|result| S2["transform.s()"]
- S2 -->|result| S3["load.s()"]`}
-/>
-
-```python
-result = chain(
- extract.s("https://api.example.com/users"),
- transform.s(),
- load.s(),
-).apply(queue)
-
-print(result.result(timeout=30))
-```
-
-
- Use `.si()` when a step should **not** receive the previous result:
-
- ```python
- chain(
- step_a.s(input_data),
- step_b.si(independent_data),
- step_c.s(),
- ).apply(queue)
- ```
-
-
-## Group
-
-Execute tasks in parallel (fan-out):
-
-```python
-jobs = group(
- process.s(1),
- process.s(2),
- process.s(3),
-).apply(queue)
-
-results = [j.result(timeout=30) for j in jobs]
-```
-
-### Concurrency limits
-
-```python
-jobs = group(
- *[fetch.s(url) for url in urls],
- max_concurrency=5,
-).apply(queue)
-```
-
-## Chord
-
-Fan-out with a callback — run tasks in parallel, then pass all results to
-a final task:
-
-```python
-result = chord(
- group(
- fetch.s("https://api1.example.com"),
- fetch.s("https://api2.example.com"),
- fetch.s("https://api3.example.com"),
- ),
- merge.s(),
-).apply(queue)
-```
-
-## Saga compensation
-
-Canvas primitives support in-thread saga compensation via `.with_compensation()`.
-When a step fails, taskito dispatches the compensators for the steps that already
-succeeded — no workflow engine required.
-
-
- Canvas compensation is in-thread: compensators are enqueued synchronously
- during the `apply()` call that raised. For DAG-level sagas with full
- observability and sub-workflow propagation, see the
- [Sagas guide](/python/guides/workflows/sagas).
-
-
-### chain compensation
-
-Attach one compensator per chain step. `None` skips a slot. On failure,
-compensators for completed steps run in **reverse order**:
-
-```python
-from taskito import chain
-
-@queue.task()
-def step_a(x: int) -> int:
- return charge_payment(x)
-
-@queue.task()
-def step_b(x: int) -> str:
- return create_order(x)
-
-@queue.task()
-def refund(forward_args, forward_kwargs, forward_result):
- payment_id = forward_result
- payment_api.refund(payment_id)
-
-@queue.task()
-def cancel_order(forward_args, forward_kwargs, forward_result):
- order_id = forward_result
- order_api.cancel(order_id)
-
-result = chain(
- step_a.s(100),
- step_b.s(),
-).with_compensation([refund, cancel_order]).apply(queue)
-```
-
-`with_compensation` requires exactly one entry per chain step. If `step_b`
-fails after `step_a` succeeded, `cancel_order` then `refund` are enqueued —
-reverse order, one at a time.
-
-### group compensation
-
-One compensator per group member. On failure of any member, compensators for
-the **succeeded** members dispatch in parallel. The failed member is not
-compensated:
-
-```python
-from taskito import group
-
-g = group(
- debit_account.s(100),
- reserve_inventory.s("item-7"),
- notify_warehouse.s(42),
-).with_compensation([
- refund_account,
- release_inventory,
- cancel_notification,
-])
-
-g.apply(queue)
-```
-
-### chord compensation
-
-`chord.with_compensation` takes separate `group=` and `callback=` arguments:
-
-```python
-from taskito import chord, group
-
-ch = chord(
- group(fetch.s("url1"), fetch.s("url2")),
- merge.s(),
-).with_compensation(
- group=[rollback_fetch1, rollback_fetch2],
- callback=rollback_merge,
-)
-
-ch.apply(queue)
-```
-
-Compensation order:
-
-- Group member fails before callback runs → group compensators dispatch, callback compensator does **not** run.
-- Callback fails after group succeeded → callback compensator dispatches first, then group compensators in parallel.
-
-### Compensator contract
-
-Every compensator receives three positional args — the same contract as
-DAG-level saga compensators:
-
-```python
-@queue.task()
-def my_compensator(forward_args: tuple, forward_kwargs: dict, forward_result: object) -> None:
- # forward_result is the return value of the step being compensated.
- ...
-```
-
-### Idempotency
-
-Each compensator is enqueued with a deterministic unique key of the form
-`canvas_compensation:{run_id}:{slot}`. A second call to `apply()` on the
-same canvas object (e.g. inside a retry loop) never double-dispatches a
-compensator for the same `(run, slot)` pair.
-
-### Passing `None` to disable compensation
-
-```python
-# Disables all compensation (equivalent to not calling with_compensation).
-chain(a.si(), b.si()).with_compensation(None).apply(queue)
-
-# Disables compensation for one specific slot.
-chain(a.si(), b.si()).with_compensation([compensate_a, None]).apply(queue)
-```
-
-### Per-signature compensator
-
-A single `Signature` can also carry its own compensator:
-
-```python
-sig = step_a.si().with_compensation(refund)
-```
-
-This is useful when the same signature is reused across several chains with
-different compensators.
-
-## chunks / starmap
-
-```python
-from taskito import chunks, starmap
-
-# Batch processing — split 1000 items into groups of 100
-results = chunks(process_batch, items, chunk_size=100).apply(queue)
-
-# Map-reduce pattern
-result = chord(
- chunks(process_batch, items, chunk_size=100),
- merge_results.s(),
-).apply(queue)
-
-# Tuple unpacking
-results = starmap(add, [(1, 2), (3, 4), (5, 6)]).apply(queue)
-```
-
-## When to use canvas vs DAG workflows
-
-| Feature | Canvas | DAG Workflows |
-|---------|--------|---------------|
-| Setup | No imports needed | `from taskito.workflows import Workflow` |
-| Topology | Linear chains, flat groups | Arbitrary DAGs |
-| Fan-out | Static (known at build time) | Dynamic (from return values) |
-| Conditions | None | `on_success`, `on_failure`, `always`, callables |
-| Error handling | Per-task retries only | Workflow-level strategies |
-| Saga compensation | `chain` / `group` / `chord` | DAG-level, with sub-workflow propagation |
-| Approval gates | No | Yes |
-| Sub-workflows | No | Yes |
-| Incremental runs | No | Yes |
-| Status tracking | Per-job only | Per-workflow + per-node |
-| Visualization | No | Mermaid / DOT |
-
-Use canvas for quick one-off pipelines. Use DAG workflows for production
-pipelines that need monitoring, conditions, or complex topologies.
diff --git a/docs/content/docs/python/guides/workflows/gates.mdx b/docs/content/docs/python/guides/workflows/gates.mdx
deleted file mode 100644
index 29165763..00000000
--- a/docs/content/docs/python/guides/workflows/gates.mdx
+++ /dev/null
@@ -1,84 +0,0 @@
----
-title: Approval Gates
-description: "Pause workflows for human review — approve, reject, timeout, gate-reached events."
----
-
-Pause a workflow for human review. The gate enters `WAITING_APPROVAL`
-status until explicitly approved or rejected — or until a timeout fires.
-
- eval["evaluate ✓"]
- eval --> gate["approve ⏸"]:::gate
- gate -->|approved| deploy["deploy"]
- gate -->|rejected| skip["deploy ━ SKIPPED"]:::skipped`}
-/>
-
-## Adding a gate
-
-```python
-wf = Workflow(name="ml_deploy")
-wf.step("train", train_model)
-wf.step("evaluate", evaluate, after="train")
-wf.gate("approve", after="evaluate")
-wf.step("deploy", deploy, after="approve")
-```
-
-When the workflow reaches the gate, it pauses. Downstream steps won't
-execute until the gate is resolved.
-
-## Resolving a gate
-
-```python
-run = queue.submit_workflow(wf) # run.id is this workflow run's ID
-
-# Later, after review:
-queue.approve_gate(run.id, "approve") # → gate COMPLETED, deploy runs
-# or:
-queue.reject_gate(run.id, "approve") # → gate FAILED, deploy SKIPPED
-```
-
-## Timeout
-
-Auto-resolve after a deadline:
-
-```python
-wf.gate("approve", after="evaluate",
- timeout=86400, # 24 hours
- on_timeout="reject") # or "approve"
-```
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `timeout` | `float` | `None` | Seconds until auto-resolve. `None` waits forever |
-| `on_timeout` | `str` | `"reject"` | Action on expiry: `"approve"` or `"reject"` |
-| `message` | `str` | `None` | Human-readable message for approvers |
-
-## Gate with conditions
-
-Gates respect step conditions:
-
-```python
-wf.step("test", run_tests)
-wf.gate("approve", after="test", condition="on_success")
-wf.step("deploy", deploy, after="approve")
-```
-
-If `test` fails, the gate is skipped (condition not met), and `deploy` is
-also skipped.
-
-## Events
-
-When a gate enters `WAITING_APPROVAL`, a `WORKFLOW_GATE_REACHED` event fires.
-Subscribe with `queue.on_event()` (see the
-[events reference](/python/api-reference/queue/events) for the full list of
-event types):
-
-```python
-from taskito.events import EventType
-
-def notify_team(event_type, payload):
- send_slack(f"Workflow {payload['run_id']} needs approval at {payload['node_name']}")
-
-queue.on_event(EventType.WORKFLOW_GATE_REACHED, notify_team)
-```
diff --git a/docs/content/docs/python/guides/workflows/meta.json b/docs/content/docs/python/guides/workflows/meta.json
index 8ed05c7e..9839feb4 100644
--- a/docs/content/docs/python/guides/workflows/meta.json
+++ b/docs/content/docs/python/guides/workflows/meta.json
@@ -6,7 +6,7 @@
"conditions",
"gates",
"composition",
- "sagas",
+ "saga",
"caching",
"analysis",
"canvas"
diff --git a/docs/content/docs/python/guides/workflows/sagas.mdx b/docs/content/docs/python/guides/workflows/sagas.mdx
deleted file mode 100644
index 91167d77..00000000
--- a/docs/content/docs/python/guides/workflows/sagas.mdx
+++ /dev/null
@@ -1,152 +0,0 @@
----
-title: Sagas
-description: Reverse-order compensation when a workflow step fails
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-A saga is a workflow where each step has a registered **compensator** — a task that undoes the step's side effect. If a later step fails, taskito automatically runs the compensators of every already-completed step, in reverse topological order, so the system ends up in a consistent state.
-
-```python
-from taskito import Queue
-from taskito.workflows import Workflow
-
-queue = Queue()
-
-@queue.task()
-def refund(forward_args, forward_kwargs, forward_result):
- charge_id = forward_result # the id returned by charge()
- payment_api.refund(charge_id)
-
-@queue.task(compensates=refund)
-def charge(amount: int, customer_id: str) -> str:
- return payment_api.charge(amount, customer_id)
-
-@queue.task()
-def ship(order_id: int):
- shipping_api.ship(order_id)
-
-wf = Workflow(name="checkout")
-wf.step("charge", charge, args=(100, "c1"))
-wf.step("ship", ship, args=(42,), after="charge")
-
-run = queue.submit_workflow(wf)
-final = run.wait()
-```
-
-If `ship` fails after `charge` succeeded, taskito enqueues `refund` automatically. The final run state is `COMPENSATED` (every compensator succeeded) or `COMPENSATION_FAILED` (one or more failed).
-
-## Compensation contract
-
-Every compensator receives three positional args:
-
-```python
-def compensator(forward_args: tuple, forward_kwargs: dict, forward_result: Any) -> None:
- ...
-```
-
-That's the **only** signature taskito calls compensators with. It lets one compensator service multiple workflows without coupling to specific step shapes.
-
-
- `forward_args`, `forward_kwargs`, and `forward_result` are fully populated
- for both static and deferred workflow nodes. Use
- `current_compensation_context()` inside a compensator body to access the
- workflow run ID, node name, forward job ID, and forward result.
-
-
-## Declaring compensators
-
-There are two places you can declare a compensator. The step-level setting always wins.
-
-**Decorator-level** — the default compensator for this task wherever it's used:
-
-```python
-@queue.task(compensates=refund)
-def charge(amount: int, customer_id: str) -> str:
- ...
-```
-
-**Step-level** — override or add the compensator for one specific workflow step:
-
-```python
-wf.step("charge", charge, args=(100, "c1"), compensates=refund_v2)
-```
-
-To explicitly disable compensation for a specific step (even when the task has a decorator-level default):
-
-```python
-wf.step("charge", charge, args=(100, "c1"), compensates=None)
-```
-
-## Execution model
-
-When a workflow step's forward execution fails with retries exhausted:
-
-1. The run transitions to state `Compensating`. A `WORKFLOW_COMPENSATING` event fires.
-2. taskito collects every step that **completed successfully** AND has a registered compensator. Failed, skipped, pending, and ready nodes are not compensated.
-3. Those steps are grouped into **reverse topological waves**: leaves first, roots last. Within a wave, compensators run in parallel.
-4. Each compensator is enqueued with an idempotency key of `compensation:{run_id}:{node_name}` — re-triggering compensation (e.g. after a tracker restart) is safe.
-5. After a wave finishes:
- - All compensators succeeded → next wave starts.
- - Any failed → the run terminates as `CompensationFailed`. **Subsequent waves are not dispatched** because downstream rollback may depend on the failed step.
-
-## Compensating on `on_failure="continue"` workflows
-
-By default, `on_failure="continue"` workflows never trigger compensation —
-they collect failures and always complete. Set `compensate_on_continue=True`
-to opt in:
-
-```python
-wf = Workflow(
- name="resilient_checkout",
- on_failure="continue",
- compensate_on_continue=True,
-)
-wf.step("charge", charge, args=(100, "c1"))
-wf.step("ship", ship, args=(42,), after="charge")
-
-run = queue.submit_workflow(wf)
-```
-
-With `compensate_on_continue=True`:
-
-- The run completes all steps regardless of individual failures (continue semantics).
-- When the run terminates, if any steps failed, the run transitions to
- `CompletedWithFailures` before entering `Compensating`.
-- Compensators for every *succeeded* step then run in reverse topological order.
-- Final state is `Compensated` or `CompensationFailed` (same as fail-fast sagas).
-
-Without `compensate_on_continue=True`, a `continue`-mode run with failures
-ends in `CompletedWithFailures` and no compensation runs.
-
-## Final run states
-
-| State | Meaning |
-|---|---|
-| `COMPLETED` | Run finished normally — no compensation ran |
-| `FAILED` | Forward failure with no registered compensators (`fail_fast` mode) |
-| `COMPLETED_WITH_FAILURES` | `continue` mode completed with some step failures |
-| `COMPENSATED` | Every applicable compensator succeeded |
-| `COMPENSATION_FAILED` | One or more compensators failed during rollback |
-
-`WorkflowState.is_terminal()` returns `True` for all five.
-
-## Idempotency
-
-Compensators **must** be idempotent: taskito guarantees at-most-one compensation per `(run, node)` pair via the idempotency key, but the underlying network/database call can still be retried by the worker before reaching exhaustion. Treat the compensator like any other worker task — write it to be safe to retry.
-
-## Events
-
-Saga lifecycle emits dedicated events on the queue's event bus:
-
-| Event | Fires when |
-|----------------------------------------|---------------------------------------------------------|
-| `WORKFLOW_COMPLETED_WITH_FAILURES` | `continue`-mode run finished with at least one failure |
-| `WORKFLOW_COMPENSATING` | Run enters the compensation phase |
-| `WORKFLOW_COMPENSATED` | All compensators succeeded |
-| `WORKFLOW_COMPENSATION_FAILED` | At least one compensator failed |
-| `NODE_COMPENSATING` | A node's compensator was enqueued |
-| `NODE_COMPENSATED` | A node's compensator finished successfully |
-| `NODE_COMPENSATION_FAILED` | A node's compensator failed after retries |
-
-Subscribe via `queue.on_event(EventType.WORKFLOW_COMPENSATED, callback)` like any other workflow event.
diff --git a/docs/content/docs/python/more/examples/benchmark.mdx b/docs/content/docs/python/more/examples/benchmark.mdx
deleted file mode 100644
index f3401cbc..00000000
--- a/docs/content/docs/python/more/examples/benchmark.mdx
+++ /dev/null
@@ -1,230 +0,0 @@
----
-title: Benchmark
-description: "Throughput benchmark — enqueue / processing / latency, comparison with Celery and Dramatiq."
----
-
-import { Callout } from "fumadocs-ui/components/callout";
-
-Measure taskito's throughput by enqueuing and processing a large batch of
-tasks.
-
-## benchmark.py
-
-```python
-"""taskito throughput benchmark.
-
-Measures:
-1. Enqueue throughput (jobs/sec) using batch insert
-2. Processing throughput (jobs/sec) with N workers
-3. End-to-end latency
-"""
-
-import os
-import threading
-import time
-
-from taskito import Queue
-
-# ── Configuration ────────────────────────────────────────
-
-NUM_JOBS = 10_000
-NUM_WORKERS = os.cpu_count() or 4
-DB_PATH = ":memory:" # In-memory for pure speed test
-
-queue = Queue(db_path=DB_PATH, workers=NUM_WORKERS)
-
-@queue.task()
-def noop(x):
- """Minimal task — measures framework overhead."""
- return x
-
-@queue.task()
-def cpu_light(x):
- """Light CPU work — string formatting."""
- return f"processed-{x}-{'x' * 100}"
-
-# ── Benchmark Functions ──────────────────────────────────
-
-def bench_enqueue(task, n):
- """Measure batch enqueue throughput."""
- args_list = [(i,) for i in range(n)]
-
- start = time.perf_counter()
- jobs = task.map(args_list)
- elapsed = time.perf_counter() - start
-
- rate = n / elapsed
- print(f" Enqueued {n:,} jobs in {elapsed:.2f}s ({rate:,.0f} jobs/s)")
- return jobs
-
-def bench_process(jobs, timeout=120):
- """Measure processing throughput by waiting for all jobs."""
- n = len(jobs)
- start = time.perf_counter()
-
- last = jobs[-1]
- try:
- last.result(timeout=timeout, poll_interval=0.01, max_poll_interval=0.1)
- except TimeoutError:
- stats = queue.stats()
- print(f" Timed out! Stats: {stats}")
- return
-
- elapsed = time.perf_counter() - start
- rate = n / elapsed
- print(f" Processed {n:,} jobs in {elapsed:.2f}s ({rate:,.0f} jobs/s)")
-
-def bench_latency(task, samples=100):
- """Measure single-job round-trip latency."""
- latencies = []
- for i in range(samples):
- start = time.perf_counter()
- job = task.delay(i)
- job.result(timeout=10)
- latencies.append(time.perf_counter() - start)
-
- avg = sum(latencies) / len(latencies)
- p50 = sorted(latencies)[len(latencies) // 2]
- p99 = sorted(latencies)[int(len(latencies) * 0.99)]
- print(f" Latency (n={samples}): avg={avg*1000:.1f}ms p50={p50*1000:.1f}ms p99={p99*1000:.1f}ms")
-
-# ── Main ─────────────────────────────────────────────────
-
-def main():
- print(f"taskito benchmark")
- print(f" Workers: {NUM_WORKERS}")
- print(f" Jobs: {NUM_JOBS:,}")
- print(f" DB: {DB_PATH}")
- print()
-
- # Start worker in background
- worker_thread = threading.Thread(target=queue.run_worker, daemon=True)
- worker_thread.start()
- time.sleep(0.5)
-
- print("── noop task (framework overhead) ──")
- jobs = bench_enqueue(noop, NUM_JOBS)
- bench_process(jobs)
- print()
-
- print("── cpu_light task ──")
- jobs = bench_enqueue(cpu_light, NUM_JOBS)
- bench_process(jobs)
- print()
-
- print("── single-job latency ──")
- bench_latency(noop)
- print()
-
- stats = queue.stats()
- print(f"Final stats: {stats}")
-
-if __name__ == "__main__":
- main()
-```
-
-## Running
-
-```bash
-python benchmark.py
-```
-
-## Sample output
-
-```
-taskito benchmark
- Workers: 8
- Jobs: 10,000
- DB: :memory:
-
-── noop task (framework overhead) ──
- Enqueued 10,000 jobs in 0.18s (55,556 jobs/s)
- Processed 10,000 jobs in 2.41s (4,149 jobs/s)
-
-── cpu_light task ──
- Enqueued 10,000 jobs in 0.19s (52,632 jobs/s)
- Processed 10,000 jobs in 2.53s (3,953 jobs/s)
-
-── single-job latency ──
- Latency (n=100): avg=1.2ms p50=1.1ms p99=3.4ms
-
-Final stats: {'pending': 0, 'running': 0, 'completed': 20100, 'failed': 0, 'dead': 0, 'cancelled': 0}
-```
-
-
- Actual numbers depend on your hardware, Python version, and SQLite
- configuration. The numbers above are from an 8-core machine with Python
- 3.12.
-
-
-## What makes taskito fast
-
-| Component | How it helps |
-|---|---|
-| **Batch inserts** | `task.map()` inserts all jobs in a single SQLite transaction |
-| **WAL mode** | Concurrent reads while writing — workers don't block enqueue |
-| **Rust scheduler** | 50ms poll loop runs in native code, not Python |
-| **OS threads** | Workers are Rust `std::thread`, not Python threads |
-| **GIL per task** | GIL acquired only during Python task execution, released between tasks |
-| **tokio mpsc channels** | Bounded async dispatch to workers |
-| **r2d2 pool** | Up to 8 concurrent SQLite connections |
-| **Diesel ORM** | Compiled SQL queries, no runtime query building |
-
-## How it compares
-
-Rough directional comparison on the same hardware (8-core, single
-machine). These are not scientific benchmarks — run the script above on
-your own hardware for accurate numbers.
-
-| Metric | taskito (SQLite) | taskito (Postgres) | Celery + Redis | Dramatiq + Redis |
-|--------|-----------------|-------------------|---------------|-----------------|
-| Enqueue throughput | ~55,000/s | ~20,000/s | ~5,000/s | ~3,000/s |
-| Processing (noop, 8 workers) | ~4,000/s | ~3,500/s | ~2,000/s | ~1,500/s |
-| p50 latency | 1.1ms | 2.5ms | 5–10ms | 8–15ms |
-| p99 latency | 3.4ms | 8ms | 20–50ms | 30–80ms |
-| Memory (idle worker) | ~30 MB | ~35 MB | ~80 MB | ~60 MB |
-| Setup | `pip install taskito` | + Postgres | + Redis + Celery | + Redis + Dramatiq |
-| External services | 0 | 1 (Postgres) | 2 (Redis + result backend) | 1 (Redis) |
-
-
- Celery numbers are from public benchmarks and community reports. Your
- mileage will vary depending on workload, serializer, and broker
- configuration. Run your own benchmarks before making decisions.
-
-
-**Why is taskito faster?**
-
-- Rust scheduler avoids GIL contention — scheduling and dispatch never block Python
-- SQLite WAL mode with batch inserts — disk I/O is minimized
-- Direct DB polling — no broker hop (enqueue → DB → dequeue is one less network round-trip vs enqueue → Redis → dequeue)
-- OS thread pool with per-task GIL acquisition — no multiprocessing overhead for I/O-bound tasks
-
-## Tune for your workload
-
-| Symptom | Config to change | Why |
-|---------|-----------------|-----|
-| Low throughput (I/O tasks) | Increase `workers` | More threads = more concurrent I/O |
-| Low throughput (CPU tasks) | Use `pool="prefork"` | Each process gets its own GIL |
-| High latency | Decrease `scheduler_poll_interval_ms` | Scheduler checks for ready jobs more often |
-| Database too busy | Increase `scheduler_poll_interval_ms` | Less frequent polling reduces DB load |
-| Memory growing | Set `result_ttl` | Auto-cleanup old results and metrics |
-| Jobs timing out | Increase `default_timeout` | Give tasks more time to complete |
-| Jobs piling up | Add more workers or use Postgres | SQLite single-writer limit may bottleneck |
-
-## Tuning
-
-Adjust these for your workload:
-
-```python
-# More workers for I/O-bound tasks
-queue = Queue(workers=16)
-
-# Fewer workers for CPU-bound tasks (limited by GIL)
-queue = Queue(workers=4)
-
-# In-memory DB for maximum throughput (no persistence)
-queue = Queue(db_path=":memory:")
-
-# File DB for durability (slightly slower)
-queue = Queue(db_path="tasks.db")
-```
diff --git a/docs/content/docs/shared/getting-started/quickstart.mdx b/docs/content/docs/shared/getting-started/quickstart.mdx
new file mode 100644
index 00000000..31cb0fe4
--- /dev/null
+++ b/docs/content/docs/shared/getting-started/quickstart.mdx
@@ -0,0 +1,340 @@
+---
+title: Quickstart
+description: "Build your first task queue in 5 minutes."
+---
+
+Four pieces make up the core loop — a **task**, a **worker**, an enqueued
+**job**, and its **result** — plus how to check on the queue afterward. No
+broker to stand up: everything below talks to one embedded store.
+
+## 1. Define tasks
+
+A task is a named function registered on the queue. Producers enqueue by
+name; a worker executes the registered function and stores its return value
+as the result.
+
+
+
+
+Create a file called `tasks.py`:
+
+```python
+from taskito import Queue
+
+# Create a queue backed by SQLite
+queue = Queue(db_path="tasks.db")
+
+@queue.task()
+def add(a: int, b: int) -> int:
+ return a + b
+
+@queue.task(max_retries=3, retry_backoff=2.0)
+def send_email(to: str, subject: str, body: str) -> str:
+ # Your email sending logic here
+ print(f"Sending email to {to}: {subject}")
+ return f"sent to {to}"
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "taskito.db" });
+
+// Register a task with optional per-task config.
+queue.task("add", (a: number, b: number) => a + b, {
+ maxRetries: 3,
+ retryBackoff: { baseMs: 1000, maxMs: 60_000 },
+ timeoutMs: 30_000,
+ maxConcurrent: 4,
+});
+```
+
+
+
+
+```java
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.time.Duration;
+import java.util.Map;
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.task.Task;
+
+// A task is a name plus its payload type — the producer never needs the handler body.
+Task> add =
+ Task.of("add", new TypeReference>() {})
+ .retries(3)
+ .timeout(Duration.ofSeconds(30));
+
+Taskito taskito = Taskito.builder().sqlite("taskito.db").open();
+```
+
+
+
+
+## 2. Start a worker
+
+A worker polls storage, claims due jobs, and dispatches them to the
+registered functions above. Start one before you enqueue — or leave it
+running while you enqueue in the next step:
+
+
+
+
+
+
+
+```bash
+# Runs in its own terminal and blocks, processing jobs until you stop it (Ctrl+C)
+taskito worker --app tasks:queue
+```
+
+
+
+
+```python
+# Runs the worker in a background thread inside your own process
+import threading
+from tasks import queue
+
+t = threading.Thread(target=queue.run_worker, daemon=True)
+t.start()
+```
+
+
+
+
+```python
+import asyncio
+from tasks import queue
+
+async def main():
+ await queue.arun_worker()
+
+asyncio.run(main())
+```
+
+
+
+
+
+
+
+
+
+
+```bash
+# Loads the module that registers the tasks above and runs a worker over them
+taskito run ./app.js --queues default
+```
+
+
+
+
+```ts
+// Same process, or a separate one pointed at the same dbPath / DSN
+const worker = queue.runWorker({ queues: ["default"] });
+```
+
+
+
+
+
+
+
+```java
+import org.byteveda.taskito.worker.Worker;
+
+Worker worker = taskito.worker()
+ .handle(add, p -> p.get("a") + p.get("b"))
+ .start();
+// ... later: worker.close();
+```
+
+
+
+
+
+ Nothing executes until a worker is consuming the queue. Enqueued jobs sit in
+ `pending` until a worker claims them — so leave this worker running while you
+ enqueue jobs in the next step.
+
+
+## 3. Enqueue jobs and get results
+
+From another process (or anywhere in your app), enqueue a job and read its
+result. The worker from step 2 picks it up and runs it:
+
+
+
+
+```python
+from tasks import add
+
+# Enqueue — returns a JobResult handle immediately; the function does NOT run here
+job = add.delay(2, 3)
+print(f"Job ID: {job.id}") # 01936...
+print(f"Status: {job.status}") # "pending" — until a worker claims it
+
+# Block until the worker finishes the job (exponential-backoff polling)
+result = job.result(timeout=30)
+print(result) # 5
+
+# Async variant
+result = await job.aresult(timeout=30)
+```
+
+
+
+
+```ts
+// Enqueue a job (producer).
+const id = queue.enqueue("add", [2, 3], { priority: 5 });
+
+// Await the result — the worker from step 2 picks it up and runs it.
+const result = await queue.result(id); // 5
+
+worker.stop();
+```
+
+
+
+
+```java
+// Enqueue a job (producer).
+String id = taskito.enqueue(add, Map.of("a", 2, "b", 3));
+
+// Block until the job is terminal, then read its result.
+taskito.awaitJob(id, Duration.ofSeconds(20));
+int result = taskito.getResult(id, Integer.class).orElseThrow(); // 5
+
+worker.close();
+```
+
+
+
+
+## 4. Monitor
+
+Check queue health without leaving the terminal:
+
+
+
+
+```python
+from tasks import queue
+
+stats = queue.stats()
+print(stats)
+# {'pending': 0, 'running': 0, 'completed': 5, 'failed': 0, 'dead': 0, 'cancelled': 0}
+```
+
+Or the CLI:
+
+```bash
+taskito info --app tasks:queue
+```
+
+
+
+```bash
+# Live dashboard, refreshes every 2s
+taskito info --app tasks:queue --watch
+```
+
+
+
+
+
+
+```ts
+await queue.stats(); // { pending, running, completed, failed, dead, cancelled }
+```
+
+Or the CLI:
+
+```bash
+taskito --db taskito.db stats
+```
+
+
+
+
+```java
+QueueStats stats = taskito.stats(); // pending, running, completed, failed, dead, cancelled
+```
+
+Or the CLI:
+
+```bash
+taskito --url taskito.db stats
+```
+
+
+
+
+### Web dashboard
+
+For a full visual interface — job browsing, metrics charts, dead letter
+management, and queue controls — run the bundled dashboard. It ships in the
+package itself; no separate service or extra dependencies:
+
+
+
+
+```bash
+taskito dashboard --app tasks:queue
+```
+
+Open `http://localhost:8080` in your browser.
+
+
+
+[Dashboard guide →](/python/guides/dashboard)
+
+
+
+
+
+
+```bash
+pnpm build:dashboard # builds the SPA into static/dashboard (one-time)
+taskito --db taskito.db dashboard --port 8787
+```
+
+Open `http://localhost:8787` in your browser.
+
+
+
+[Dashboard guide →](/node/guides/operations/dashboard)
+
+
+
+
+
+
+```bash
+taskito --url taskito.db dashboard --port 8080 --token "$DASH_TOKEN"
+```
+
+Open `http://localhost:8080` in your browser.
+
+
+
+[Dashboard guide →](/java/guides/operations/dashboard)
+
+
+
+
+
+
+## Next steps
+
+- Concepts — the mental model behind queue, task, worker, and result
+- Tasks — registration options and per-task configuration
+- Workers — running workers, concurrency, and graceful shutdown
+- Retries — exponential backoff and the dead letter queue
+- Workflows — orchestrate multi-step DAGs of dependent tasks
+- Testing — unit-test tasks directly, or run them through a real worker in your suite
diff --git a/docs/content/docs/python/guides/core/predicates.mdx b/docs/content/docs/shared/guides/core/predicates.mdx
similarity index 50%
rename from docs/content/docs/python/guides/core/predicates.mdx
rename to docs/content/docs/shared/guides/core/predicates.mdx
index 30e6f03b..5242c3f7 100644
--- a/docs/content/docs/python/guides/core/predicates.mdx
+++ b/docs/content/docs/shared/guides/core/predicates.mdx
@@ -1,29 +1,78 @@
---
title: Predicates
-description: "A composable, serializable DSL for gating task execution. Authors compose predicates in Python; runtime evaluates them at enqueue and worker dispatch; persistence and dashboards see the same AST as JSON or string."
+description: "Composable gates that decide whether a job runs — from a simple boolean check to a serializable rule."
---
-A **predicate** is a serializable AST node that decides — at enqueue
-time and again at worker dispatch — whether a job runs, is deferred, or
-is cancelled. Predicates compose with `&` / `|` / `~`, round-trip
-through JSON and a small string DSL, and are inspectable from the
-dashboard.
+A **predicate** (also called a gate) is a small check evaluated before a job
+is allowed to proceed — a declarative way to say "only do this under
+condition X." What a denial does depends on the configured outcome: a reject
+or cancel outcome means no work runs, while a defer outcome schedules the job
+to try again later.
+Register one with the predicate= option on @queue.task.>} node={<>Register one with queue.gate().>} java={<>Register one with taskito.predicate(), or the richer taskito.gate().>} />
+
+## Quick start
+
+
+
```python
from taskito import Queue
-from taskito.predicates import is_business_hours, queue_paused, feature_flag
queue = Queue()
-@queue.task(
- predicate=is_business_hours(tz="US/Pacific")
- & ~queue_paused()
- & feature_flag("new_billing"),
- on_false="defer",
-)
-def send_invoice(tenant_id: str): ...
+@queue.task(predicate=lambda ctx: ctx.args[0] > 0, on_false="cancel")
+def charge(amount: float):
+ settle(amount)
+
+charge.delay(50) # ok
+charge.delay(-1) # raises PredicateRejectedError
+```
+
+
+
+
+```ts
+queue.task("charge", (amount: number) => settle(amount));
+
+queue.gate("charge", ({ args }) => args[0] > 0); // never enqueue a non-positive charge
+
+queue.enqueue("charge", [50]); // ok
+queue.enqueue("charge", [-1]); // throws PredicateRejectedError
+```
+
+
+
+
+```java
+Task charge = Task.of("charge", Integer.class);
+
+taskito.predicate("charge", ctx -> (Integer) ctx.payload() > 0);
+
+taskito.enqueue(charge, 50); // ok
+taskito.enqueue(charge, -1); // throws PredicateRejectedException
```
+
+
+
+
+
+The predicate receives the task name and the (already
+onEnqueue -rewritten)
+`args`, typed to the task's signature.
+
+
+
+
+
+The `PredicateContext` carries the task name and the payload — after
+`onEnqueue` middleware
+has run, so gates see the rewritten payload.
+
+
+
+
+
## What predicates *do not* do
Predicates are not the right place to rebuild taskito's atomic
@@ -41,13 +90,91 @@ enforcement primitives. Use the dedicated knobs:
Each is enforced atomically in the Rust scheduler. A Python predicate
that races against them is weaker and harder to reason about.
-Predicates are for **gates the Rust scheduler doesn't already provide**:
+Predicates are for **gates the scheduler doesn't already provide**:
time windows, payload-driven branching, feature flags, environment
config, and custom Python logic.
-## Outcomes
+
+
+## Composition
+
+
+
+
+```python
+allow = is_business_hours() & ~queue_paused()
+allow_or_urgent = allow | feature_flag("urgent_bypass")
+```
+
+
+
+
+```ts
+import { allOf, anyOf, not } from "@byteveda/taskito";
+
+const businessHours = () => {
+ const h = new Date().getHours();
+ return h >= 9 && h < 17;
+};
+const highPriority = ({ args }) => args[0].priority === "high";
+
+queue.gate("dispatch", anyOf(businessHours, highPriority));
+queue.gate("dispatch", not(isHoliday));
+```
+
+
+
+
+```java
+Predicate positive = ctx -> (Integer) ctx.payload() > 0;
+Predicate small = ctx -> (Integer) ctx.payload() <= 10_000;
+
+taskito.predicate("charge", Predicates.allOf(positive, small));
+taskito.predicate("dispatch", Predicates.not(isHoliday));
+```
+
+
+
+
+
+
+`@queue.task` accepts exactly one `predicate=` per task — compose
+everything you need with `&` / `|` / `~` before passing it in. `A & B`
+short-circuits if `A` denies; `A | B` tries both and, if neither
+allows, surfaces the more informative outcome; `~A` inverts a boolean
+result (see [Richer outcomes](#richer-outcomes-defer-cancel-skip) for
+how `Defer` / `Cancel` behave under composition).
+
+
+
+
+
+Registering more than one gate on the same task also composes them —
+they all must pass:
+
+```ts
+queue.gate("charge", ({ args }) => args[0] > 0);
+queue.gate("charge", ({ args }) => args[0] <= 10_000);
+```
+
+
+
+
+
+Registering more than one `predicate()` (or `gate()`) on the same task
+also composes them — they run in registration order and the first
+non-allow decision wins.
+
+
+
+## Richer outcomes: defer, cancel, skip
+
+A plain boolean allow/deny is the baseline every binding supports.
+
+
-`evaluate()` returns one of four values:
+`evaluate()` can also return `Defer` or `Cancel` instead of a plain
+boolean:
| Outcome | Meaning |
|---|---|
@@ -56,85 +183,102 @@ config, and custom Python logic.
| `Defer(seconds=N)` | Skip now, retry after `N` seconds. |
| `Cancel(reason="…")` | Permanently skip. Raises `PredicateRejectedError` at enqueue; cancels the job at dispatch. |
-A predicate that raises is treated as `False` (fail-closed). Errors are
-logged and counted on `PredicateMetrics`.
+A predicate that raises is treated as `False` (fail-closed). Errors
+are logged and counted on `PredicateMetrics`.
-## Composition
+Composition respects these richer outcomes: `A & B` short-circuits on
+the first `Defer`/`Cancel`/`False` from `A`; `A | B` evaluates both
+and, if neither allows, returns the most informative denial
+(`Cancel` > `Defer` > `False`); `~A` inverts `True`/`False` but passes
+`Defer` and `Cancel` through unchanged — they're terminal outcomes,
+not booleans.
```python
-allow = is_business_hours() & ~queue_paused()
-allow_or_urgent = allow | feature_flag("urgent_bypass")
-```
-
-* `A & B` — both must allow; short-circuits if `A` denies.
-* `A | B` — either may allow; if both deny, the most informative
- outcome wins (`Cancel` > `Defer` > `False`).
-* `~A` — inverts `True`/`False`. `Defer` and `Cancel` pass through
- unchanged (terminal outcomes, not booleans).
+from typing import Any
+from taskito import Queue
+from taskito.predicates import Predicate, PredicateContext
-## `@queue.task` options
+queue = Queue()
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `predicate` | `Predicate \| Callable \| None` | `None` | The gate. Plain callables receiving a `PredicateContext` are accepted but cannot be serialized. |
-| `on_false` | `"defer" \| "cancel"` | `"defer"` | What to do when the predicate returns plain `False`. |
-| `predicate_extras` | `dict \| None` | `None` | Static dict forwarded to the predicate via `ctx.extras`. |
-| `default_defer_seconds` | `float` | `60.0` | Default delay when `on_false="defer"` and the predicate returns plain `False`. |
+@queue.register_predicate("tenant_quota_under")
+class TenantQuotaUnder(Predicate):
+ OP = "tenant_quota_under"
-## The DSL surface
+ def __init__(self, limit: int = 0) -> None:
+ self.limit = limit
-Every predicate is a node in a closed, registered AST. Three equivalent
-authoring paths produce the same tree:
+ def evaluate(self, ctx: PredicateContext) -> bool:
+ tenant = ctx.kwargs.get("tenant")
+ return tenant is not None and quota_service.used(tenant) < self.limit
-### Python operators
+ def to_dict(self) -> dict[str, Any]:
+ return {"op": "tenant_quota_under", "limit": self.limit}
-```python
-p = is_business_hours(tz="US/Pacific") & ~queue_paused() | feature_flag("new")
+ @classmethod
+ def _from_kwargs(cls, kwargs: dict[str, Any]) -> "Predicate":
+ return cls(**kwargs)
```
-### JSON
+Once registered with `queue.register_predicate(op)`, a new op is
+usable in JSON, the string DSL, and operator composition exactly like
+a built-in recipe (see [The DSL surface](#the-dsl-surface)). Predicates
+may also be **async** — return a coroutine from `evaluate` and it will
+be awaited transparently.
-```python
-from taskito.predicates import Predicate
+
-p = Predicate.from_dict({
- "op": "or",
- "args": [
- {"op": "and", "args": [
- {"op": "is_business_hours", "tz": "US/Pacific"},
- {"op": "not", "arg": {"op": "queue_paused"}},
- ]},
- {"op": "feature_flag", "flag": "new", "provider": "env"},
- ],
-})
-```
+
-### String
+
+ Node predicates are plain booleans — there's no built-in defer/skip
+ outcome. To react once a job is already running, use
+ middleware or
+ conditions inside a
+ workflow.
+
-```python
-from taskito.predicates import parse
+
-p = parse(
- 'is_business_hours(tz="US/Pacific") & !queue_paused() '
- '| feature_flag(flag="new", provider="env")'
-)
+
+
+`gate(taskName, g)` registers an `EnqueueGate`, whose `EnqueueDecision`
+can do more than pass/fail. Gates run in registration order; the first
+non-allow decision wins.
+
+```java
+taskito.gate("charge", ctx -> switch (classify(ctx.payload())) {
+ case OK -> EnqueueDecision.allow();
+ case DUPLICATE -> EnqueueDecision.skip("already charged");
+ case TOO_EARLY -> EnqueueDecision.defer(Duration.ofHours(1));
+ case FRAUD -> EnqueueDecision.reject("fraud check failed");
+});
```
-The string DSL supports `&` / `|` / `!` and the long forms `and` / `or`
-/ `not`. Literals: strings, ints, floats, `true` / `false` / `null`,
-and `[lists, of, literals]`. Round-trip is stable:
+| Decision | `enqueue` | `tryEnqueue` |
+|---|---|---|
+| `allow()` | Job created. | Job id present. |
+| `skip(reason)` | Throws `EnqueueSkippedException`. | Returns empty. |
+| `defer(delay)` | Job created, scheduled `delay` from now (overrides any delay in the options). | Job id present. |
+| `deferUntil(instant)` | Job created, scheduled at the absolute `instant` (overrides any delay in the options). | Job id present. |
+| `reject(reason)` | Throws `PredicateRejectedException`. | Throws too. |
-```python
-from taskito.predicates import format_predicate, parse
+`tryEnqueue` is the gate-aware form — a skip becomes an empty
+`Optional` instead of an exception:
-p1 = is_business_hours() & ~queue_paused()
-s = format_predicate(p1) # 'is_business_hours() & !queue_paused()'
-p2 = parse(s)
-assert p1.to_dict() == p2.to_dict()
+```java
+Optional id = taskito.tryEnqueue(charge, amount);
```
+`predicate(taskName, p)` is sugar for a gate that only ever allows or
+rejects — use it for plain boolean checks and reach for `gate()` when
+you need skip/defer too.
+
+
+
## Built-in recipes
+
+
All recipes are registered AST ops. Import from `taskito.predicates`.
### Time
@@ -187,63 +331,108 @@ p = feature_flag("billing", provider="launchdarkly")
assert Predicate.from_dict(p.to_dict()).to_dict() == p.to_dict()
```
-## Where predicates run
+
-A predicate registered on a task is evaluated **twice**:
+
-1. **Enqueue time** — inside `Queue.enqueue()` / `enqueue_many()`.
- `Defer` adjusts the caller's `delay=`. `Cancel` (or `False` with
- `on_false="cancel"`) raises `PredicateRejectedError` and the job is
- never saved.
-2. **Worker-dispatch time** — just before the task body runs.
- `Cancel` raises `TaskCancelledError` (Rust marks the job cancelled).
- `Defer` re-enqueues a fresh job with the same payload + delay; the
- current job is cancelled.
+`Recipes` ships ready-made gates for common policies — time-based ones
+defer out-of-window enqueues to the next open moment; filters skip
+silently:
-`PredicateContext.job_id` is `None` at enqueue time, set at dispatch
-time — useful for custom predicates that need to behave differently
-across phases.
+```java
+taskito.gate("dispatch", Recipes.businessHours(ZoneId.of("America/New_York")));
+taskito.gate("digest", Recipes.dayOfWeek(zone, DayOfWeek.MONDAY, DayOfWeek.THURSDAY));
+taskito.gate("sync", Recipes.timeWindow(zone, LocalTime.of(22, 0), LocalTime.of(6, 0)));
+taskito.gate("beta", Recipes.featureFlag("beta-export", flags));
+taskito.gate("charge", Recipes.payloadMatches(p -> ((Order) p).amount() > 0));
+```
+
+
+
+
+
+There's no built-in recipe library yet — compose predicate functions
+directly, as in [Composition](#composition) above.
-## Custom predicates
+
+
+
+
+## The DSL surface
-Subclass `Predicate`, declare `OP`, and register through the queue:
+Every predicate is a node in a closed, registered AST. Three
+equivalent authoring paths produce the same tree — Python operators
+(above), JSON, and a small string DSL:
```python
-from typing import Any
-from taskito import Queue
-from taskito.predicates import Predicate, PredicateContext
+from taskito.predicates import Predicate
-queue = Queue()
+p = Predicate.from_dict({
+ "op": "or",
+ "args": [
+ {"op": "and", "args": [
+ {"op": "is_business_hours", "tz": "US/Pacific"},
+ {"op": "not", "arg": {"op": "queue_paused"}},
+ ]},
+ {"op": "feature_flag", "flag": "new", "provider": "env"},
+ ],
+})
+```
-@queue.register_predicate("tenant_quota_under")
-class TenantQuotaUnder(Predicate):
- OP = "tenant_quota_under"
+```python
+from taskito.predicates import parse
- def __init__(self, limit: int = 0) -> None:
- self.limit = limit
+p = parse(
+ 'is_business_hours(tz="US/Pacific") & !queue_paused() '
+ '| feature_flag(flag="new", provider="env")'
+)
+```
- def evaluate(self, ctx: PredicateContext) -> bool:
- tenant = ctx.kwargs.get("tenant")
- return tenant is not None and quota_service.used(tenant) < self.limit
+The string DSL supports `&` / `|` / `!` and the long forms `and` /
+`or` / `not`. Literals: strings, ints, floats, `true` / `false` /
+`null`, and `[lists, of, literals]`. Round-trip is stable:
- def to_dict(self) -> dict[str, Any]:
- return {"op": "tenant_quota_under", "limit": self.limit}
+```python
+from taskito.predicates import format_predicate, parse
- @classmethod
- def _from_kwargs(cls, kwargs: dict[str, Any]) -> "Predicate":
- return cls(**kwargs)
+p1 = is_business_hours() & ~queue_paused()
+s = format_predicate(p1) # 'is_business_hours() & !queue_paused()'
+p2 = parse(s)
+assert p1.to_dict() == p2.to_dict()
```
-Once registered, the new op is usable in JSON, the string DSL, and
-operator composition exactly like a built-in recipe.
+The string form is the right surface for ops-style editing — storing
+the gate in a config file, or letting an operator tweak it through a
+dashboard textbox without a code deploy:
-Predicates may be **async** — return a coroutine from `evaluate` and it
-will be awaited transparently.
+```python
+@queue.task(predicate=parse(
+ 'is_business_hours(tz="US/Pacific") & !queue_paused()'
+))
+def send_report(): ...
+```
+
+## Where predicates run
+
+A predicate registered on a task is evaluated **twice**:
+
+1. **Enqueue time** — inside `Queue.enqueue()` / `enqueue_many()`.
+ `Defer` adjusts the caller's `delay=`. `Cancel` (or `False` with
+ `on_false="cancel"`) raises `PredicateRejectedError` and the job is
+ never saved.
+2. **Worker-dispatch time** — just before the task body runs.
+ `Cancel` raises `TaskCancelledError` (the job is marked cancelled).
+ `Defer` re-enqueues a fresh job with the same payload and delay;
+ the current job is cancelled.
+
+`PredicateContext.job_id` is `None` at enqueue time, set at dispatch
+time — useful for custom predicates that need to behave differently
+across phases.
## Middleware filtering
-`TaskMiddleware` accepts `predicate=` to gate which jobs the middleware
-applies to. The legacy contrib `task_filter=Callable[[str], bool]`
+`TaskMiddleware` accepts `predicate=` to gate which jobs the
+middleware applies to. The legacy contrib `task_filter=Callable[[str], bool]`
kwarg still works and is translated internally to a predicate.
```python
@@ -258,6 +447,63 @@ queue = Queue(middleware=[
When the middleware predicate denies, **only that middleware** is
skipped for the current job — job dispatch is unaffected.
+
+
+
+
+Gates run once, on the **producer** side, at enqueue time — there's
+no worker-dispatch phase.
+
+
+
+
+
+Predicates and gates run once, synchronously, on the **producer**
+thread at enqueue time — there's no worker-dispatch phase. Keep them
+fast and side-effect-free.
+
+
+
+## Batches
+
+enqueue_many evaluates the task's predicate>} node={<>enqueueMany gates each entry>} java={<>enqueueMany gates each payload>} /> — a
+single rejection aborts the whole batch and no jobs are created.
+Pre-filter the batch yourself if you'd rather drop rejected entries
+silently.
+
+
+
+Each job in the batch is evaluated independently: a `Defer` outcome
+only adjusts that job's own delay and doesn't affect the rest of the
+batch. Only a `Cancel` (or `False` with `on_false="cancel"`) aborts
+the whole call, since it raises before any job is written.
+
+
+
+
+
+Inside `enqueueMany`, only an `Allow` decision is accepted per entry —
+a `Skip` or `Defer` also aborts the batch with
+`EnqueueSkippedException`, since the batch API can't schedule or drop
+individual entries. Use single `enqueue`/`tryEnqueue` calls for
+payloads that need skip/defer semantics.
+
+
+
+
+
+
+ Gates run on the **producer** side, so they decide whether work is
+ created at all. To react once a job is already running, use
+ middleware or
+ conditions inside
+ a workflow.
+
+
+
+
+
+
## Persistence & inspection
Every registered predicate is serialized once at decoration time. The
@@ -274,8 +520,9 @@ queue.predicate_for("app.send_invoice")
Bare callables (`predicate=lambda ctx: …`) appear in `list_predicates`
with `None` as their value, since callables have no stable schema.
-The dashboard reads `list_predicates()` to display "gated by: …" beside
-each task. To round-trip a predicate from the inspection output:
+The dashboard reads `list_predicates()` to display "gated by: …"
+beside each task. To round-trip a predicate from the inspection
+output:
```python
from taskito.predicates import Predicate
@@ -301,6 +548,20 @@ Three events fire on the queue's event bus:
* `EventType.PREDICATE_REJECTED` — enqueue-time rejections that raised
`PredicateRejectedError`.
+```python
+from taskito.events import EventType
+
+def on_predicate_event(event_type: EventType, payload: dict) -> None:
+ print(event_type.value, payload)
+
+for event in (
+ EventType.PREDICATE_DEFERRED,
+ EventType.PREDICATE_CANCELLED,
+ EventType.PREDICATE_REJECTED,
+):
+ queue.on_event(event, on_predicate_event)
+```
+
## Examples
### Business-hours-only report with urgent override
@@ -318,17 +579,6 @@ send_daily_report.delay("alpha") # defers to 09:00 PT
send_daily_report.delay("alpha", urgent=True) # runs now
```
-### Feature-flag rollout with a custom provider
-
-```python
-from taskito.predicates import feature_flag, register_feature_flag_provider
-
-register_feature_flag_provider("launchdarkly", my_ld_provider)
-
-@queue.task(predicate=feature_flag("new_billing", provider="launchdarkly"))
-def charge_card(tenant_id: str, amount_cents: int): ...
-```
-
### Tenant allowlist as a custom predicate
```python
@@ -386,33 +636,4 @@ queue.register_predicate("quota_available")(QuotaAvailable)
async def index_document(tenant: str, doc_id: str): ...
```
-### Editing a predicate as a string
-
-```python
-from taskito.predicates import parse
-
-@queue.task(predicate=parse(
- 'is_business_hours(tz="US/Pacific") & !queue_paused()'
-))
-def send_report(): ...
-```
-
-The string form is the right surface for ops-style editing (e.g.
-storing the gate in a config file or letting an operator tweak it
-through a dashboard textbox without code deploys).
-
-### Listening for predicate events
-
-```python
-from taskito.events import EventType
-
-def on_predicate_event(event_type: EventType, payload: dict) -> None:
- print(event_type.value, payload)
-
-for event in (
- EventType.PREDICATE_DEFERRED,
- EventType.PREDICATE_CANCELLED,
- EventType.PREDICATE_REJECTED,
-):
- queue.on_event(event, on_predicate_event)
-```
+
diff --git a/docs/content/docs/shared/guides/core/queues.mdx b/docs/content/docs/shared/guides/core/queues.mdx
new file mode 100644
index 00000000..2e2f3ae8
--- /dev/null
+++ b/docs/content/docs/shared/guides/core/queues.mdx
@@ -0,0 +1,388 @@
+---
+title: Queues & Priority
+description: "Named queues, priority ordering, pause/resume, and per-queue limits."
+---
+
+Every job belongs to a queue (default `"default"`). Queues are routing
+labels in shared storage — a worker chooses which queues it serves, and
+jobs dequeue in priority order within each queue.
+
+## Named queues and routing
+
+Route tasks to different queues to isolate workloads — separate I/O-bound
+tasks (API calls, emails) from CPU-bound ones (data processing, report
+generation), and run each on a dedicated worker process.
+
+
+
+
+```python
+@queue.task(queue="emails")
+def send_email(to, subject, body):
+ ...
+
+@queue.task() # goes to the "default" queue
+def process_data(data):
+ ...
+
+send_email.delay("user@example.com", "Welcome", "...")
+```
+
+
+
+
+```ts
+queue.task("sendEmail", (to: string, subject: string, body: string) => {
+ // ...
+});
+
+const id = queue.enqueue("sendEmail", [to, subject, body], { queue: "emails" });
+```
+
+
+
+
+```java
+Task sendEmail = Task.of("send_email", EmailPayload.class).queue("emails");
+
+String id = taskito.enqueue(sendEmail, payload);
+```
+
+
+
+
+
+
+Override the decorator's default at enqueue time:
+
+```python
+send_email.apply_async(args=(to, subject, body), queue="urgent-emails")
+```
+
+
+
+
+
+Routing only happens at enqueue time — the options object passed to
+`queue.task()` has no `queue` field, so there's no per-task default to set
+or override; every `enqueue()` call picks the queue independently.
+
+
+
+
+
+The queue can also be set per enqueue, overriding the task's default:
+
+```java
+taskito.enqueue(sendEmail, payload, EnqueueOptions.builder().queue("urgent-emails").build());
+```
+
+See enqueue options for
+the rest of what an enqueue call can override.
+
+
+
+## Worker queue subscription
+
+A worker only dequeues jobs from the queues it's told to serve — queues it
+isn't watching accumulate jobs untouched, no matter how many pile up.
+
+
+
+
+```python
+queue.run_worker(queues=["emails", "reports"])
+```
+
+
+
+
+```ts
+queue.runWorker({ queues: ["emails", "default"] }); // serve two queues
+```
+
+
+
+
+```java
+taskito.worker()
+ .handle(sendEmail, p -> deliver(p))
+ .queues("emails", "default") // serve two queues
+ .start();
+```
+
+
+
+
+
+
+Or from the CLI:
+
+```bash
+# Process only email tasks
+taskito worker --app myapp:queue --queues emails
+
+# Process multiple queues
+taskito worker --app myapp:queue --queues emails,reports
+
+# Process all registered queues (default)
+taskito worker --app myapp:queue
+```
+
+
+
+
+
+Or from the CLI:
+
+```bash
+taskito run ./app.js --queues emails,reports
+```
+
+
+
+
+
+
+ There's no worker subcommand on the bundled CLI — workers are code,
+ started with `taskito.worker()`. The CLI does have `pause`/`resume`
+ subcommands for queue control; see
+ CLI .
+
+
+
+
+
+ Separate I/O-bound tasks (API calls, emails) from CPU-bound tasks (data
+ processing, report generation) into different queues. Run them on
+ different worker processes for optimal resource usage.
+
+
+## Priority
+
+Higher-priority jobs dequeue first within a queue. Priority is a plain
+integer with no fixed scale — pick whatever range fits your app.
+
+
+
+
+```python
+# This specific job is extra urgent
+urgent_task.apply_async(args=(data,), priority=100) # ahead of priority=0 jobs
+```
+
+
+
+
+```ts
+queue.enqueue("report", [id], { priority: 10 }); // ahead of priority 0 jobs
+```
+
+
+
+
+```java
+taskito.enqueue(report, payload, EnqueueOptions.builder().priority(10).build());
+// ahead of priority 0 jobs
+```
+
+
+
+
+
+
+
+ Priority direction is **inverted**. BullMQ treats a *lower* `priority`
+ number as higher priority (`1` runs before `10`). taskito is the other way
+ round — a *higher* `priority` number dequeues first. Porting a BullMQ
+ priority scheme means inverting the numbers, not copying them.
+
+
+
+
+### Default priority
+
+
+
+Set a default at task registration; individual enqueues can still override it:
+
+```python
+@queue.task(priority=10)
+def urgent_task(data):
+ ...
+
+@queue.task(priority=0) # default
+def normal_task(data):
+ ...
+```
+
+
+
+
+
+Set a default on the task descriptor; a per-enqueue `EnqueueOptions.priority(...)`
+still overrides it:
+
+```java
+Task urgentTask = Task.of("urgent_task", OrderPayload.class).priority(10);
+```
+
+
+
+
+
+
+ There's no registration-time default — `priority` is enqueue-only, set per
+ call as shown above.
+
+
+
+
+### How it works
+
+- Higher-priority jobs go first.
+- Among jobs of equal priority, the one that became eligible earliest (the
+ oldest `scheduled_at`) goes first.
+- Each queue is ordered independently of the others.
+
+## Pause and resume
+
+Pausing stops dispatch for a queue without stopping the worker — in-flight
+jobs finish, new ones wait. Paused queues still accept new enqueues; they
+just won't be dequeued until resumed.
+
+
+
+
+```python
+queue.pause("emails")
+queue.paused_queues() # ["emails"]
+queue.resume("emails")
+```
+
+
+
+
+```ts
+queue.pauseQueue("emails");
+queue.listPausedQueues(); // ["emails"]
+queue.resumeQueue("emails");
+```
+
+
+
+
+```java
+Queue emails = taskito.queue("emails");
+emails.pause();
+emails.isPaused(); // true
+taskito.listPausedQueues(); // ["emails"]
+emails.resume();
+```
+
+
+
+
+
+
+See Job management
+for maintenance-window patterns, cancellation, and archival built on top of
+pause/resume.
+
+
+
+## Per-queue limits
+
+A rate limit or concurrency cap applied to an entire queue, independently of
+any per-task settings — checked in the scheduler before per-task limits, so
+it's the right knob for protecting a shared downstream resource (an API, a
+database) regardless of which task is hitting it.
+
+
+
+```python
+queue.set_queue_rate_limit("emails", "20/s") # max 20 emails per second
+queue.set_queue_concurrency("reports", 2) # heavy tasks: max 2 at a time
+```
+
+The format is the same as `rate_limit` on `@queue.task()`: `"N/s"`, `"N/m"`,
+or `"N/h"`. Both settings are read once when `run_worker()` starts — call
+them beforehand, or between worker restarts to change them.
+
+
+
+
+
+```ts
+queue.configureQueue("emails", {
+ rateLimit: "50/s",
+ maxConcurrent: 10,
+});
+```
+
+`rateLimit` follows the same `"/"` spec as a task's `rateLimit`
+option. Limits are applied when a worker calls `runWorker()` — set them
+before starting the worker.
+
+
+
+
+
+
+ The Java SDK has no queue-level rate limit or concurrency cap — throughput
+ is shaped per task instead: producer-side gates for rate limiting (see
+ Rate limiting ) and
+ worker thread-pool sizing for concurrency (see
+ Concurrency ).
+
+
+
+
+## Per-queue stats
+
+Scope job counts to a single queue, or get every queue's counts in one call.
+
+
+
+
+```python
+queue.stats_by_queue("emails") # {"pending": 3, "running": 1, ...}
+queue.stats_all_queues() # {"emails": {...}, "reports": {...}}
+```
+
+
+
+
+```ts
+await queue.statsByQueue("emails");
+await queue.statsAllQueues();
+```
+
+
+
+
+```java
+long backlog = taskito.statsByQueue("emails").pending;
+Map all = taskito.statsAllQueues();
+```
+
+
+
+
+
+
+## Queue-level defaults
+
+Configure defaults for every task at the `Queue` level; individual
+`@queue.task()` decorators override them:
+
+```python
+queue = Queue(
+ db_path="myapp.db",
+ default_priority=0, # default priority for all tasks
+ default_retry=3, # default max retries
+ default_timeout=300, # default timeout in seconds
+)
+```
+
+
diff --git a/docs/content/docs/shared/guides/core/scheduling.mdx b/docs/content/docs/shared/guides/core/scheduling.mdx
new file mode 100644
index 00000000..7fd6aae7
--- /dev/null
+++ b/docs/content/docs/shared/guides/core/scheduling.mdx
@@ -0,0 +1,409 @@
+---
+title: Scheduling
+description: "Delay a task to run once in the future, or register it on a recurring cron schedule."
+---
+
+taskito supports two ways to schedule work ahead of time: **delayed tasks**
+(run once, at a specific point in the future) and **periodic tasks** (run
+repeatedly on a cron expression). Both ride the same scheduler that
+dispatches ordinary jobs — a delayed job just isn't eligible until its
+scheduled time passes, and periodic tasks are enqueued by the scheduler's
+maintenance loop when they come due. No separate cron daemon or scheduler
+process is needed.
+
+## Delayed tasks
+
+Enqueue with a delay to run a task once, in the future:
+
+
+
+
+```python
+# Run 1 hour from now
+send_email.apply_async(
+ args=("user@example.com", "Reminder", "Don't forget!"),
+ delay=3600, # seconds
+)
+```
+
+
+
+
+```ts
+// Run 1 hour from now
+queue.enqueue("send_email", ["user@example.com", "Reminder", "Don't forget!"], {
+ delayMs: 60 * 60 * 1000,
+});
+```
+
+
+
+
+```java
+// Run 1 hour from now
+taskito.enqueue(sendEmail, payload, EnqueueOptions.builder()
+ .delay(Duration.ofHours(1))
+ .build());
+```
+
+
+
+
+The job is created immediately — it just sits `pending` until the delay
+elapses. A worker never claims it before then.
+
+
+
+See Tasks for the rest of what
+`apply_async` accepts alongside `delay`.
+
+
+
+
+
+See Enqueue
+options for the rest of what `delayMs` composes with.
+
+
+
+
+
+See Enqueue
+options for the rest of what `delay` composes with.
+
+
+
+## Periodic tasks
+
+Register a task on a cron expression once; from then on, every running
+worker's scheduler enqueues a fresh job for it each time the schedule fires:
+
+
+
+
+```python
+@queue.periodic(
+ name="daily-digest",
+ cron="0 0 9 * * *",
+ args=("2026-06-16",),
+ timezone="America/New_York",
+)
+def digest(date: str):
+ """Run every day at 9:00 AM Eastern."""
+ send_digest(date)
+```
+
+
+
+
+```ts
+queue.task("digest", (date: string) => sendDigest(date));
+
+// cron is 6/7-field, seconds first: sec min hour day-of-month month day-of-week
+queue.registerPeriodic("daily-digest", "digest", "0 0 9 * * *", {
+ args: ["2026-06-16"],
+ timezone: "America/New_York",
+});
+```
+
+
+
+
+```java
+long nextFire = taskito.registerPeriodic(
+ PeriodicTask.builder("daily-digest", "digest", "0 0 9 * * *")
+ .payload(Map.of("edition", "morning"))
+ .queue("emails")
+ .timezone("America/New_York")
+ .build());
+```
+
+
+
+
+
+
+`@queue.periodic()` registers the function as a normal task (it can still be
+enqueued manually) and records the schedule. The schedule itself isn't sent
+to the scheduler until `run_worker()`
+starts — every `@queue.periodic()`-decorated function is collected and
+registered as a batch at that point, upserted by name, so restarting a
+worker with the same decorators just re-applies the same schedule rather
+than duplicating it.
+
+
+
+
+
+`registerPeriodic(name, taskName, cron, options?)` talks to the scheduler
+immediately — there's no separate worker-startup step — and returns the
+computed next fire time (Unix ms). Re-registering the same `name` replaces
+the schedule (upsert by name).
+
+
+
+
+
+`registerPeriodic(PeriodicTask)` talks to the scheduler immediately and
+returns the next fire time (Unix ms). Re-registering the same name replaces
+the schedule (upsert by name).
+
+
+
+
+
+Several schedules can share one `@queue.periodic()`-decorated worker file:
+
+```python
+@queue.periodic(cron="0 */5 * * * *")
+def health_check():
+ """Run every 5 minutes."""
+ ping_services()
+
+@queue.periodic(cron="0 0 0 * * *")
+def daily_cleanup():
+ """Run at midnight every day."""
+ queue.purge_completed(older_than=86400)
+
+@queue.periodic(cron="0 0 9 * * 1", args=("weekly",))
+def weekly_report(report_type):
+ """Run every Monday at 9:00 AM."""
+ generate_report(report_type)
+```
+
+
+
+### Cron expression format
+
+taskito uses **6-field cron expressions** (with seconds) — a 7th, optional
+year field is also accepted:
+
+```
+┌─────────── second (0-59)
+│ ┌───────── minute (0-59)
+│ │ ┌─────── hour (0-23)
+│ │ │ ┌───── day of month (1-31)
+│ │ │ │ ┌─── month (1-12)
+│ │ │ │ │ ┌─ day of week (0-6, Sun=0)
+│ │ │ │ │ │
+* * * * * *
+```
+
+
+ The cron field order is **seconds first** (`sec min hour dom mon dow`).
+ `"0 0 9 * * *"` is 09:00:00 daily, not every minute.
+
+
+| Expression | Schedule |
+|---|---|
+| `*/30 * * * * *` | Every 30 seconds |
+| `0 */5 * * * *` | Every 5 minutes |
+| `0 0 * * * *` | Every hour |
+| `0 30 * * * *` | Every hour at :30 |
+| `0 0 */2 * * *` | Every 2 hours |
+| `0 0 0 * * *` | Every day at midnight |
+| `0 0 9 * * *` | Every day at 9:00 AM |
+| `0 0 9 * * 1-5` | Weekdays at 9:00 AM |
+| `0 30 9 * * 1-5` | Weekdays at 9:30 AM |
+| `0 0 0 1 * *` | First day of every month at midnight |
+| `0 0 0 * * 0` | Every Sunday at midnight |
+| `0 0 0 1 1 *` | January 1st at midnight (yearly) |
+
+
+
+> **Coming from Celery:** Celery's `crontab()` only maps to the last 5 fields
+> (minute, hour, day of month, month, day of week) — prepend a seconds field
+> to get taskito's 6-field format.
+
+
+
+
+
+> **Coming from BullMQ?** BullMQ attaches a repeat schedule to a job via
+> `add(name, data, { repeat: { pattern: cron } })` — the schedule lives
+> inside an enqueue call. taskito separates the two: `registerPeriodic(name,
+> taskName, cron, options?)` is its own call, independent of any single
+> `enqueue()`, and `deletePeriodic`/`pausePeriodic`/`resumePeriodic` manage it
+> by name afterward.
+
+
+
+### Registration options
+
+
+
+
+```python
+@queue.periodic(
+ cron="0 0 * * * *", # Required: cron expression
+ name="hourly-cleanup", # Optional: explicit name
+ args=(3600,), # Optional: positional args
+ kwargs={"force": True}, # Optional: keyword args
+ queue="maintenance", # Optional: target queue
+ timezone="America/New_York", # Optional: IANA timezone (default: UTC)
+)
+def cleanup(older_than, force=False):
+ ...
+```
+
+
+
+
+```ts
+queue.registerPeriodic("hourly-cleanup", "cleanup", "0 0 * * * *", {
+ args: [3600],
+ queue: "maintenance",
+ timezone: "America/New_York",
+ enabled: true, // register active immediately (default)
+});
+```
+
+
+
+
+```java
+taskito.registerPeriodic(
+ PeriodicTask.builder("hourly-cleanup", "cleanup", "0 0 * * * *")
+ .payload(Map.of("olderThan", 3600))
+ .queue("maintenance")
+ .timezone("America/New_York")
+ .enabled(true) // register active immediately (default)
+ .build());
+```
+
+
+
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `cron` | `str` | required | Cron expression (6-field, seconds first). |
+| `name` | `str \| None` | the function's `module.qualname` | Explicit schedule name. Re-registering the same name replaces it. |
+| `args` | `tuple` | `()` | Positional arguments passed to the task on each run. |
+| `kwargs` | `dict \| None` | `None` | Keyword arguments passed to the task on each run. |
+| `queue` | `str` | `"default"` | Named queue the enqueued job targets. |
+| `timezone` | `str \| None` | `None` (UTC) | IANA timezone the cron expression is evaluated in. |
+
+
+
+
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `args` | `unknown[]` | `[]` | Positional args passed to the task each time it fires. |
+| `queue` | `string` | `"default"` | Queue the periodic job is enqueued into. |
+| `timezone` | `string` | UTC | IANA timezone the cron expression is evaluated in. |
+| `enabled` | `boolean` | `true` | Register active immediately, or paused (won't fire until resumed). |
+
+
+
+
+
+| Builder method | Description |
+|---|---|
+| `payload(Object)` | Payload passed to the task on each fire. |
+| `queue(String)` | Queue to enqueue into. |
+| `timezone(String)` | IANA timezone for the cron expression (default UTC). |
+| `enabled(boolean)` | Register paused when `false` (default `true`). |
+
+
+
+### Timezone support
+
+Cron expressions are evaluated in **UTC** by default. Pass any IANA timezone
+name to schedule in local time instead — daylight saving transitions are
+handled automatically (via `chrono-tz` under the hood), so a schedule pinned
+to `"09:00 America/New_York"` keeps firing at 9 AM local time across the
+spring/fall clock changes.
+
+## Managing periodic tasks
+
+
+
+```ts
+queue.listPeriodic(); // every registered schedule, enabled or paused
+
+queue.pausePeriodic("daily-digest"); // stop firing, keep the registration
+queue.resumePeriodic("daily-digest");
+queue.deletePeriodic("daily-digest"); // unschedule; false if unknown
+```
+
+
+
+
+
+```java
+List all = taskito.listPeriodic(); // enabled and paused
+
+taskito.pausePeriodic("daily-digest"); // stop firing, keep the registration
+taskito.resumePeriodic("daily-digest");
+taskito.deletePeriodic("daily-digest"); // unschedule; false if unknown
+```
+
+
+
+
+
+There's no public API yet to list, pause, or delete a registered schedule —
+`@queue.periodic()` is the only entry point, and schedules are re-registered
+as a batch every time `run_worker()`
+starts. Removing a `@queue.periodic()` decorator from your code does **not**
+delete its row from storage — the schedule keeps firing and enqueuing jobs
+for a task name no running worker handles anymore, so retire a schedule
+deliberately rather than just deleting the decorator.
+
+
+
+## Overrun and multiple schedulers
+
+**A slow run can overlap its own next fire.** The scheduler only checks
+whether a schedule's next-run time has passed — it doesn't track whether the
+previous fire is still executing. If a periodic task's execution regularly
+outlasts its own cron interval, a new job can be enqueued and dispatched
+before the last one finishes.
+
+
+
+If overlap is unsafe for a task, cap it with `max_concurrent=1` on the
+underlying task (see
+per-task
+concurrency ) — the poller enforces that limit by task name, whether
+the job came from a periodic schedule or a normal enqueue.
+
+
+
+
+
+If overlap is unsafe for a task, cap it with `maxConcurrent: 1` (see
+concurrency ) — the
+scheduler enforces that limit by task name, whether the job came from a
+periodic schedule or a normal enqueue.
+
+
+
+
+
+The Java SDK doesn't expose a per-task concurrency cap in code yet. If
+overlapping runs would be unsafe, keep the cron interval comfortably longer
+than the task's worst-case execution time, or make the handler itself safe
+to run concurrently with itself.
+
+
+
+**Missed windows aren't backfilled.** If no worker's scheduler has been
+running — say, during a deploy — due schedules just accumulate. When a
+scheduler comes back, it enqueues only the single **next due** occurrence,
+not one job per missed interval.
+
+**Multiple workers sharing one schedule is safe in practice.** Every running
+worker's scheduler checks for due periodic tasks independently — there's no
+leader election. Each fire's job carries a `unique_key` derived from the
+schedule name and the exact fire timestamp, enforced by a partial unique
+index on active jobs, so two schedulers racing the same due window can't
+both insert a job for it.
+
+Periodic task state — the schedule, its cron expression, and its next/last
+fire time — is persisted in storage (the `periodic_tasks` table for
+SQLite/Postgres, or `periodic:*` keys for Redis), so it survives worker
+restarts.
diff --git a/docs/content/docs/shared/guides/core/tasks.mdx b/docs/content/docs/shared/guides/core/tasks.mdx
new file mode 100644
index 00000000..3b7f3698
--- /dev/null
+++ b/docs/content/docs/shared/guides/core/tasks.mdx
@@ -0,0 +1,756 @@
+---
+title: Tasks
+description: "Register task functions and attach per-task retry, timeout, and other execution options."
+---
+
+A task is a named function bound to a queue. The name is the contract —
+producers enqueue by name, and any worker with that name registered executes
+the function and stores its return value as the job's result.
+
+## Defining a task
+
+
+
+
+```python
+from taskito import Queue
+
+queue = Queue(db_path="myapp.db")
+
+@queue.task()
+def process_data(data: dict) -> str:
+ # Your logic here
+ return "done"
+```
+
+
+
+
+```ts
+queue.task("add", (a: number, b: number) => a + b);
+```
+
+
+
+
+```java
+Task sendEmail = Task.of("send_email", EmailPayload.class);
+```
+
+For generic payloads that a `Class` token can't express, use a Jackson
+`TypeReference` instead: `Task.of("send_email", new TypeReference>() {})`.
+
+
+
+
+
+
+### Binding a handler
+
+A `Task` is just a name and a payload type — it's a producer-side
+descriptor, not a function. A worker binds the actual logic separately, as a
+`TaskFunction` that receives the deserialized payload and returns a
+result (or `null`):
+
+```java
+try (Worker worker = taskito.worker()
+ .handle(sendEmail, payload -> deliver(payload)) // Task + TaskFunction
+ .handle("resize", ImageJob.class, payload -> resize(payload)) // name + payload type
+ .start()) { ... }
+```
+
+`Handler.of(task, fn)` pairs the two for registration via `register(handler)`
+or a `HandlerRegistry`. See Workers
+for the full worker lifecycle.
+
+### Typed tasks from `@TaskHandler`
+
+Annotate handler methods with `@TaskHandler`; the compile-time processor
+(`org.byteveda:taskito-processor`) generates a `Tasks` companion with a
+typed `Task` constant per method plus a `bind(...)` — name declared once, full
+generics, zero runtime reflection:
+
+```java
+class EmailTasks {
+ @TaskHandler("send_email") // explicit name
+ String send(EmailPayload p) { ... }
+
+ @TaskHandler // name defaults to "report"
+ Report report(List metrics) { ... }
+}
+
+// generated EmailTasksTasks:
+String id = taskito.enqueue(EmailTasksTasks.SEND, payload);
+
+taskito.worker()
+ .apply(b -> EmailTasksTasks.bind(b, new EmailTasks()))
+ .start();
+```
+
+The annotation also accepts `queue`, `maxRetries`, `timeoutMs`, `priority`,
+`idempotent`, and a `circuitBreakerThreshold` (plus
+`circuitBreakerWindowSeconds`/`circuitBreakerCooldownSeconds`/
+`circuitBreakerHalfOpenProbes`/`circuitBreakerHalfOpenSuccessRate`) for
+per-task defaults. See
+installation for the
+processor setup.
+
+
+ Register a handler for every task a worker's queues can carry — a job whose
+ task has no handler on the claiming worker fails with "no handler
+ registered". Re-registering a task name replaces the previous handler.
+
+
+
+
+## Task options
+
+Attach retry, timeout, priority, and other execution defaults when you
+register the task. Each SDK exposes them with its own names and shape:
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `name` | `str \| None` | Auto-generated | Explicit task name. Defaults to `module.qualname`. |
+| `max_retries` | `int` | `3` | Max retry attempts before moving to DLQ. |
+| `retry_backoff` | `float` | `1.0` | Base delay in seconds for exponential backoff. |
+| `retry_delays` | `list[float] \| None` | `None` | Per-attempt delays in seconds, overrides backoff. e.g. `[1, 5, 30]`. |
+| `max_retry_delay` | `int \| None` | `None` | Cap on backoff delay in seconds (default 300 s). |
+| `timeout` | `int` | `300` | Max execution time in seconds (hard timeout). |
+| `soft_timeout` | `float \| None` | `None` | Cooperative time limit; checked via `current_job.check_timeout()`. |
+| `priority` | `int` | `0` | Default priority (higher = more urgent). |
+| `rate_limit` | `str \| None` | `None` | Rate limit string, e.g. `"100/m"`. |
+| `queue` | `str` | `"default"` | Named queue to submit to. |
+| `circuit_breaker` | `dict \| None` | `None` | Circuit breaker config: `{"threshold": 5, "window": 60, "cooldown": 120}`. |
+| `middleware` | `list[TaskMiddleware] \| None` | `None` | Per-task middleware, applied in addition to queue-level middleware. |
+| `inject` | `list[str] \| None` | `None` | Worker resource names to inject as keyword arguments. See Resource System . |
+| `serializer` | `Serializer \| None` | `None` | Per-task serializer override. Falls back to the queue-level serializer. |
+| `max_concurrent` | `int \| None` | `None` | Max concurrent running instances of this task. `None` means no limit. |
+
+```python
+@queue.task(
+ name="emails.send",
+ max_retries=5,
+ retry_backoff=2.0,
+ max_retry_delay=60, # cap backoff at 60 s
+ timeout=60,
+ priority=10,
+ rate_limit="100/m",
+ queue="emails",
+ max_concurrent=10,
+)
+def send_email(to: str, subject: str, body: str):
+ ...
+```
+
+
+
+
+
+| Option | Description |
+|---|---|
+| `maxRetries` | Attempts before dead-lettering. See retries . |
+| `retryBackoff` | Exponential backoff bounds (`baseMs`, `maxMs`). |
+| `timeoutMs` | Per-attempt timeout . |
+| `maxConcurrent` | Max simultaneously-running jobs of this task (concurrency ). |
+| `rateLimit` | Rate limit string, `count/unit` (e.g. `"100/m"`). |
+| `circuitBreaker` | Trip after repeated failures (circuit breakers ). |
+
+```ts
+queue.task("add", (a: number, b: number) => a + b, {
+ maxRetries: 3,
+ retryBackoff: { baseMs: 1000, maxMs: 60_000 },
+ timeoutMs: 30_000,
+ maxConcurrent: 4,
+ rateLimit: "100/m",
+ circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 30_000 },
+});
+```
+
+
+ Register a task before enqueuing or running a worker for it. Per-task config
+ is applied to the scheduler at registration time — re-registering replaces
+ it. There's no task-level default for `queue` or `priority` — set those per
+ job with enqueue options
+ instead.
+
+
+Beyond per-task config, set defaults for an entire named queue:
+
+```ts
+queue.configureQueue("emails", { rateLimit: "50/s" });
+```
+
+
+ BullMQ splits a job into `queue.add(name, data)` on one side and a `new
+ Worker(queueName, processor)` callback on the other — the processor lives
+ wherever you construct the `Worker`. taskito ties the handler to the task
+ name once, via `queue.task(name, handler, options)`; `enqueue()` just
+ references that name, and `runWorker()` takes no processor argument. Also
+ note: `enqueue()` returns the job id (a `string`), not a rich `Job` object —
+ fetch a snapshot with `queue.getJob(id)` or block on
+ `queue.result(id)` .
+
+
+
+
+
+
+Fluent methods attach default enqueue options; each returns a new descriptor
+(the type is immutable):
+
+| Method | Description |
+|---|---|
+| `queue(name)` | Route jobs to a named queue (queues ). |
+| `priority(n)` | Higher dequeues first within a queue. |
+| `retries(n)` / `maxRetries(n)` | Attempts before dead-lettering. |
+| `timeout(duration)` / `timeoutMs(ms)` | Per-attempt timeout. |
+| `delay(duration)` / `delayMs(ms)` | Delay first execution. |
+| `retryPolicy(policy)` | Retry-backoff curve (below). |
+| `codecs(names...)` | Named payload codecs applied to this task's payload. |
+| `circuitBreaker(config)` | Guard with a `CircuitBreakerConfig` — trips after repeated failures (see [Circuit breakers](#circuit-breakers) below). |
+| `withOptions(options)` | Replace the defaults with an `EnqueueOptions` wholesale. |
+
+```java
+Task sendEmail = Task.of("send_email", EmailPayload.class)
+ .queue("emails")
+ .priority(5)
+ .retries(3)
+ .timeout(Duration.ofSeconds(30))
+ .delay(Duration.ofSeconds(10));
+```
+
+
+ There's no native per-task `rate_limit` field in the Java SDK — throughput is
+ shaped on the producer side with an enqueue gate instead. See
+ rate limiting .
+ Worker resources aren't a `Task` option either — pull them inside the
+ handler via `Resources.use("name")` or an `@Resource`-annotated parameter.
+ See dependency
+ injection .
+
+
+
+
+## Retries
+
+Retry scheduling lives in the shared core, so retries stay durable and
+survive worker crashes — no in-memory retry state to lose. Backoff is
+exponential by default: the delay roughly doubles each attempt, up to a
+configurable cap.
+
+
+
+
+```python
+@queue.task(max_retries=5, retry_backoff=2.0)
+def flaky_api_call(url):
+ ...
+```
+
+
+
+
+```ts
+queue.task("charge", chargeCard, {
+ maxRetries: 5,
+ retryBackoff: { baseMs: 1000, maxMs: 60_000 },
+});
+```
+
+
+
+
+```java
+Task charge = Task.of("charge", Order.class)
+ .maxRetries(5)
+ .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));
+```
+
+
+
+
+The cap on the exponential curve is `max_retry_delay` in Python,
+`retryBackoff.maxMs` in Node, and the `max` argument to
+`RetryPolicy.exponential` in Java.
+
+
+
+Use `retry_delays` for an exact per-attempt wait schedule instead of
+exponential backoff — the final value repeats for any further retries up to
+`max_retries`:
+
+```python
+@queue.task(retry_delays=[1, 5, 30]) # 1s after 1st fail, 5s after 2nd, 30s after 3rd
+def flaky_api_call():
+ ...
+```
+
+
+
+
+
+`retryBackoff` only shapes an exponential curve (`baseMs`, `maxMs`) — there's
+no option for an explicit per-attempt delay list.
+
+
+
+
+
+`RetryPolicy.delays(...)` is the explicit per-attempt equivalent — applied
+exactly, with no jitter. Supply at least as many delays as `maxRetries`; once
+the list is exhausted, further retries fire immediately:
+
+```java
+Task poll = Task.of("poll", Order.class)
+ .retries(3)
+ .retryPolicy(RetryPolicy.delays(
+ Duration.ofSeconds(5), Duration.ofSeconds(30), Duration.ofMinutes(2)));
+```
+
+
+
+See Retries for the full
+retry flow and dead-letter behavior.
+
+## Timeouts
+
+A timeout bounds a single execution attempt; exceeding it counts as a failure
+and consumes a retry.
+
+
+
+
+```python
+@queue.task(timeout=60)
+def long_running():
+ ...
+```
+
+
+
+
+```ts
+queue.task("scrape", scrape, { timeoutMs: 30_000 });
+```
+
+
+
+
+```java
+Task scrape = Task.of("scrape", String.class)
+ .timeout(Duration.ofSeconds(30));
+```
+
+
+
+
+
+
+### Soft timeouts
+
+A soft timeout raises `SoftTimeoutError` only when the task cooperatively
+checks — useful for a long loop that should stop cleanly instead of being
+killed mid-iteration:
+
+```python
+from taskito import current_job
+
+@queue.task(timeout=300, soft_timeout=60)
+def long_running(items):
+ for item in items:
+ current_job.check_timeout() # raises SoftTimeoutError if soft_timeout exceeded
+ process(item)
+```
+
+
+
+
+
+See Timeouts for how a
+timeout is surfaced to middleware and events, and how to make an `async` task
+actually stop work when one fires.
+
+
+
+
+
+See Timeouts — a timeout
+releases the job's bookkeeping, not the handler's thread, so long-running
+handlers should poll `queue.isCancelRequested(jobId)` to actually stop.
+
+
+
+## Circuit breakers
+
+Trip a task's circuit breaker after repeated failures so a failing downstream
+dependency gets time to recover instead of being hammered by retries. The
+breaker opens after `threshold` failures inside a rolling window, then
+half-opens after a cooldown to test recovery with a handful of probe jobs.
+
+
+
+
+```python
+@queue.task(circuit_breaker={"threshold": 5, "window": 60, "cooldown": 120})
+def call_external_api():
+ ...
+```
+
+
+
+
+```ts
+queue.task("call_flaky_api", callApi, {
+ circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 120_000 },
+});
+```
+
+
+
+
+```java
+Task callFlakyApi = Task.of("call_flaky_api", Response.class)
+ .circuitBreaker(CircuitBreakerConfig.builder(5)
+ .windowSeconds(60)
+ .cooldownSeconds(120)
+ .build());
+```
+
+
+
+
+Half-open recovery is tunable too: `half_open_probes` / `halfOpenMaxProbes` /
+`halfOpenProbes(int)` caps how many probe jobs run while half-open (default
+5), and `half_open_success_rate` / `halfOpenSuccessRate` /
+`halfOpenSuccessRate(double)` is the probe success rate required to close
+again (default 0.8).
+
+
+
+See Circuit
+breakers for the full state machine and how to inspect breaker
+state at runtime.
+
+
+
+
+
+See Circuit
+breakers — pair it with retries :
+retries absorb transient blips, the breaker stops spending that budget on a
+dependency that's actually down.
+
+
+
+## Concurrency
+
+
+
+`max_concurrent` caps how many instances of a task run at once, enforced by
+the scheduler across every worker on shared storage — not per worker:
+
+```python
+@queue.task(max_concurrent=3)
+def expensive_render():
+ ...
+# At most 3 instances of expensive_render run simultaneously across all workers.
+```
+
+
+
+
+
+`maxConcurrent` limits how many jobs of a task run at once across all
+workers — enforced by the scheduler against the live running count, so it
+holds even with many workers on shared storage:
+
+```ts
+queue.task("transcode", transcode, { maxConcurrent: 2 }); // never more than 2 at once
+```
+
+Apply it to a whole queue instead with `queue.configureQueue("video", {
+maxConcurrent: 4 })`. See
+Concurrency .
+
+
+
+
+
+Concurrency in the Java SDK is a **worker-level** setting, not a per-task
+field on `Task`: each worker runs its handlers on a thread pool, and the
+pool's size caps how many jobs that worker executes at once.
+
+```java
+Worker worker = queue.worker()
+ .handle(transcode, this::transcode)
+ .concurrency(2) // at most 2 handlers run at once on this worker
+ .start();
+```
+
+To cap a task globally, give it a dedicated queue and size a single worker to
+the limit, or serialize the critical section with a
+distributed lock . See
+Concurrency .
+
+
+
+
+
+## Per-task middleware
+
+Apply middleware to a specific task only, in addition to queue-level
+middleware:
+
+```python
+from taskito.contrib.sentry import SentryMiddleware
+
+@queue.task(middleware=[SentryMiddleware()])
+def important_task():
+ ...
+```
+
+## Per-task serializer
+
+Override the queue-level serializer for a specific task — the full
+round-trip: arguments are serialized with it at enqueue time and deserialized
+with it on the worker before the task function is called. Both the sync
+worker and the native async worker honor the per-task serializer, falling
+back to the queue-level serializer for tasks that have none registered.
+
+```python
+from taskito.serializers import JsonSerializer
+
+@queue.task(serializer=JsonSerializer())
+def api_event(payload: dict) -> dict:
+ ...
+```
+
+Useful when a task needs a different format (e.g., human-readable JSON for
+audit tasks) or when the payload isn't picklable. See
+Serializers .
+
+## Job expiration
+
+`expires` is an **enqueue-time** option (not a decorator option) — skip a job
+that wasn't started within the deadline:
+
+```python
+@queue.task()
+def time_sensitive():
+ ...
+
+# Skip if not started within 5 minutes
+time_sensitive.apply_async(args=(), expires=300)
+```
+
+## Task naming
+
+By default, tasks are named using `module.qualname`:
+
+```python
+# In myapp/tasks.py
+@queue.task()
+def process(): # Named: myapp.tasks.process
+ ...
+```
+
+Override with an explicit name:
+
+```python
+@queue.task(name="my-custom-name")
+def process(): # Named: my-custom-name
+ ...
+```
+
+
+
+## Enqueuing jobs
+
+
+
+
+Submit with the task's default options:
+
+```python
+job = send_email.delay("user@example.com", "Hello", "World")
+```
+
+
+
+
+```ts
+const id = queue.enqueue("sendEmail", ["user@example.com", "Hello", "World"]);
+```
+
+
+
+
+```java
+String id = taskito.enqueue(sendEmail, payload); // typed
+String id2 = taskito.enqueue("send_email", payload); // by name, default options
+```
+
+
+
+
+Override options at enqueue time:
+
+
+
+
+```python
+job = send_email.apply_async(
+ args=("user@example.com", "Hello", "World"),
+ priority=100, # Override priority
+ delay=3600, # Run 1 hour from now
+ queue="urgent-emails", # Override queue
+ max_retries=10, # Override retries
+ timeout=120, # Override timeout
+ unique_key="welcome-user@example.com", # Deduplicate
+ metadata='{"source": "signup"}', # Attach JSON metadata
+)
+```
+
+
+
+
+```ts
+const id = queue.enqueue("sendEmail", ["user@example.com", "Hello", "World"], {
+ priority: 100, // Override priority
+ delayMs: 3_600_000, // Run 1 hour from now
+ queue: "urgent-emails", // Override queue
+ maxRetries: 10, // Override retries
+ timeoutMs: 120_000, // Override timeout
+ uniqueKey: "welcome-user@example.com", // Deduplicate
+ metadata: JSON.stringify({ source: "signup" }), // Attach metadata
+});
+```
+
+
+
+
+```java
+String id = taskito.enqueue(sendEmail, payload, EnqueueOptions.builder()
+ .priority(100) // Override priority
+ .delay(Duration.ofHours(1)) // Run 1 hour from now
+ .queue("urgent-emails") // Override queue
+ .maxRetries(10) // Override retries
+ .timeout(Duration.ofSeconds(120)) // Override timeout
+ .uniqueKey("welcome-user@example.com") // Deduplicate
+ .metadata("{\"source\": \"signup\"}") // Attach metadata
+ .build());
+```
+
+
+
+
+
+
+### Direct call
+
+Calling a task directly runs it synchronously, bypassing the queue —
+useful in tests:
+
+```python
+result = send_email("user@example.com", "Hello", "World") # Runs immediately
+```
+
+
+
+## Batch enqueue
+
+Enqueue many jobs of one task in a single storage round-trip:
+
+
+
+
+```python
+jobs = send_email.map([
+ ("alice@example.com", "Hi", "Body"),
+ ("bob@example.com", "Hi", "Body"),
+])
+```
+
+
+
+
+```ts
+const ids = queue.enqueueMany("sendEmail", [
+ { args: ["alice@example.com", "Hi", "Body"] },
+ { args: ["bob@example.com", "Hi", "Body"], options: { priority: 5 } },
+]);
+```
+
+
+
+
+```java
+List ids = taskito.enqueueMany(sendEmail, List.of(emailA, emailB));
+```
+
+
+
+
+
+
+Each item can carry its own overrides. See
+Batch enqueue
+for `enqueue_many()`, per-item overrides, and per-item result tracking.
+
+
+
+
+
+Each entry has its own `args` and `options`. See
+Enqueue options for the
+full batch-enqueue contract.
+
+
+
+
+
+Unlike the per-item form above, one shared `EnqueueOptions` applies to the
+whole batch — not a list of per-job options. `enqueueAll` is an alias of
+`enqueueMany`. See Batching for
+the buffering `Batcher` helper built on top of it.
+
+
+
+## Metadata
+
+Attach an arbitrary string — commonly JSON — to a job at enqueue time. It's
+stored with the job and visible in dead letter queue entries:
+
+
+
+
+```python
+job = process.apply_async(
+ args=(data,),
+ metadata='{"user_id": 42, "source": "api"}',
+)
+```
+
+
+
+
+```ts
+queue.enqueue("process", [data], {
+ metadata: JSON.stringify({ userId: 42, source: "api" }),
+});
+```
+
+
+
+
+```java
+taskito.enqueue(process, data, EnqueueOptions.builder()
+ .metadata("{\"userId\": 42, \"source\": \"api\"}")
+ .build());
+```
+
+
+
diff --git a/docs/content/docs/shared/guides/core/workers.mdx b/docs/content/docs/shared/guides/core/workers.mdx
new file mode 100644
index 00000000..3c198f89
--- /dev/null
+++ b/docs/content/docs/shared/guides/core/workers.mdx
@@ -0,0 +1,460 @@
+---
+title: Workers
+description: "Start, size, and gracefully stop the workers that execute your tasks."
+---
+
+A worker polls storage, claims due jobs, dispatches them to your registered
+task functions, and writes results back.
+
+
+ Producers and workers only share storage — enqueue from a web process (or
+ script) and run workers from a separate process pointed at the same
+ database. Nothing else needs to be shared.
+
+
+## Starting a worker
+
+
+
+
+```bash
+taskito worker --app myapp.tasks:queue
+```
+
+```python
+# Programmatic — blocks the calling thread until shutdown
+queue.run_worker()
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+queue.task("process", (itemId: number) => {
+ // ...
+});
+
+const worker = queue.runWorker({ queues: ["default"] });
+// runWorker() returns immediately; the worker runs in the background.
+// ... later
+worker.stop();
+```
+
+
+
+
+```java
+Worker worker = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .queues("default")
+ .start();
+// start() returns immediately; scheduling runs in the background.
+// ... later
+worker.close();
+```
+
+
+
+
+
+
+`run_worker()` **blocks the calling thread** until the worker stops — that's
+why the CLI (`taskito worker --app ...`) is the recommended way to run one. To
+keep your own thread free, run it in a background thread or use the async
+variant:
+
+```python
+import threading
+
+t = threading.Thread(target=queue.run_worker, daemon=True)
+t.start()
+```
+
+```python
+import asyncio
+
+async def main():
+ await queue.arun_worker() # runs the worker loop in a thread executor
+
+asyncio.run(main())
+```
+
+
+
+
+
+`runWorker()` never blocks — it starts the worker on the Rust core and
+returns immediately. The bundled `taskito run ./app.js` CLI wraps this for
+you and wires `Ctrl+C` to `worker.stop()`; calling `runWorker()` from your
+own code needs no CLI at all.
+
+
+
+
+
+
+ `@Async` runs the call inline on a Spring-managed thread pool the moment
+ it's invoked — there's no separate producer/consumer step, and queued calls
+ are lost on restart. A `Worker` is a separate, explicitly started process
+ (or thread) that polls durable storage; nothing runs until one is up, and
+ work already enqueued survives a restart because it lives in the store, not
+ in a JVM executor.
+
+
+`start()` never blocks — scheduling runs in the background and handlers
+execute on the pool you configure. There's no CLI worker subcommand: workers
+are code, built with `taskito.worker()` in your application; wire
+`awaitShutdown()` (see [Graceful shutdown](#graceful-shutdown)) to keep the
+process alive.
+
+
+
+## Task registration
+
+
+
+`@queue.task()` registers a task on the `Queue` object the moment its module
+is imported. `run_worker()` serves whatever's registered on `self` at call
+time — make sure task modules are imported (directly, or via the `--app`
+path) before the worker starts.
+
+
+
+
+
+`queue.task(name, handler)` registers a task on the `Queue` instance
+immediately. `runWorker()` serves whatever's registered on that instance, so
+import your task modules before calling it.
+
+
+
+
+
+There's no decorator or global registry — each worker only handles what it
+explicitly binds with `.handle(task, fn)` (or `.register(...)` /
+`.apply(...)` for generated handler sets). A job for a task name the builder
+never bound fails immediately with `no handler registered for task '...'`,
+which also means two workers can bind different subsets of your tasks from
+the same `Taskito` client.
+
+
+
+## Selecting queues
+
+Workers can serve a subset of queues instead of everything registered —
+useful for routing heavy jobs to dedicated workers. Omit the option and a
+worker serves .
+
+
+
+
+```python
+queue.run_worker(queues=["emails", "reports"])
+```
+
+```bash
+taskito worker --app myapp.tasks:queue --queues emails,reports
+```
+
+
+
+
+```ts
+queue.runWorker({ queues: ["emails", "reports"] });
+```
+
+
+
+
+```java
+taskito.worker()
+ .handle(sendEmail, payload -> send(payload))
+ .queues("emails", "reports")
+ .start();
+```
+
+
+
+
+
+
+## Worker specialization
+
+Advertise capability tags when starting a worker:
+
+```python
+queue.run_worker(tags=["gpu", "heavy"])
+```
+
+Tags are stored with the worker's registration and show up in
+`queue.workers()` and the dashboard — a lightweight way to see which machines
+have which capabilities at a glance. They're informational: the scheduler
+doesn't filter dispatch by tag, so route GPU-only or heavy-workload jobs to
+the right machines with [dedicated queues](#selecting-queues) instead.
+
+
+
+## Worker concurrency
+
+How many jobs one worker process runs at once follows the SDK's own
+execution model — a thread pool, an event loop, or a JVM thread pool.
+Configure it where you start the worker:
+
+
+
+
+```python
+queue = Queue(db_path="myapp.db", workers=8) # OS threads (0 = auto-detect CPU count)
+queue.run_worker()
+```
+
+
+
+
+```ts
+queue.runWorker({ channelCapacity: 256, batchSize: 16 }); // dispatch buffer + poll batch, not a thread count
+```
+
+
+
+
+```java
+taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .concurrency(8) // fixed handler-thread pool; 0 (default) uses a cached pool
+ .start();
+```
+
+
+
+
+
+
+`workers` sets the OS-thread pool size (threads share a single GIL — only one
+runs Python bytecode at a time). Swap in the
+prefork pool for true
+CPU parallelism, or tune `async_concurrency` for `async def` tasks, which run
+on a dedicated event loop instead of the thread pool.
+
+
+
+
+
+There's no worker-level thread count — handlers run concurrently on the
+single event loop, each an independent async invocation. `channelCapacity`
+bounds in-flight dispatch and `batchSize` controls how many jobs are claimed
+per poll; real concurrency caps come from per-task/queue `maxConcurrent`. See
+Execution model .
+
+
+ BullMQ's `new Worker(queueName, processor, { concurrency: N })` runs `N`
+ jobs at once **per worker process** — concurrency scales with how many
+ workers you start. taskito's `runWorker()` takes no processor or
+ concurrency argument (handlers come from `queue.task()`); simultaneous
+ execution is bounded by per-task/per-queue
+ maxConcurrent ,
+ enforced by the scheduler across *all* workers on shared storage, not per
+ process.
+
+
+
+
+
+
+Jobs run on real JVM threads — no GIL, no event loop to block.
+`concurrency(n)` fixes the handler pool size; `autoscale(...)` resizes it
+with queue depth instead. See
+Execution model for
+CPU-bound sizing guidance.
+
+
+
+## Heartbeats and worker discovery
+
+Every worker registers itself in storage on startup and heartbeats while it
+runs, so it's visible to any process pointed at the same storage — including
+the dashboard. A worker that stops heartbeating is reaped after 30 seconds.
+
+
+
+
+```python
+for w in queue.workers():
+ print(f"{w['worker_id']} on {w['hostname']} (pid {w['pid']}, {w['status']})")
+```
+
+
+
+
+```ts
+const workers = await queue.listWorkers();
+// [{ workerId, hostname, pid, status, lastHeartbeat, ... }]
+```
+
+
+
+
+```java
+for (WorkerInfo w : taskito.listWorkers()) {
+ log.info(w.workerId + " on " + w.hostname + " (pid " + w.pid + ", " + w.status + ")");
+}
+```
+
+
+
+
+Field names follow each SDK's casing convention (`worker_id` in Python,
+`workerId` in Node and Java) but describe the same worker: hostname, pid,
+status (`active`, `draining`, or already reaped), the queues it serves, and
+the timestamp of its last heartbeat.
+
+
+
+The heartbeat runs on a background thread every 5 seconds and carries the
+current resource
+health snapshot, which is how `queue.resource_status()` sees health
+from a separate dashboard process.
+
+### Lifecycle events
+
+Subscribe to worker join/leave/health transitions:
+
+```python
+from taskito import EventType
+
+@queue.on_event(EventType.WORKER_ONLINE)
+def on_online(event_type, payload):
+ print(f"Worker {payload['worker_id']} joined")
+
+@queue.on_event(EventType.WORKER_OFFLINE)
+def on_offline(event_type, payload):
+ print(f"Worker {payload['worker_id']} went away")
+```
+
+| Event | Fires when |
+|---|---|
+| `WORKER_ONLINE` | Worker registered in storage |
+| `WORKER_OFFLINE` | Dead worker reaped (no heartbeat for 30s) |
+| `WORKER_UNHEALTHY` | A resource's health flips to unhealthy |
+
+
+
+
+
+The heartbeat fires every 5 seconds and carries the current resource-health
+snapshot, so worker
+resources going unhealthy shows up in `listWorkers()` too.
+
+
+
+
+
+Heartbeating is managed natively by the core — there's no user-facing
+interval to configure.
+
+
+
+## Graceful shutdown
+
+Stopping a worker unregisters it from storage and lets in-flight jobs finish
+before it exits; how you trigger that varies by SDK.
+
+
+
+`run_worker()` wires `Ctrl+C` (`SIGINT`/`SIGTERM`) for you: the first signal
+starts a **warm shutdown** — stop claiming new jobs, wait up to
+`drain_timeout` seconds for in-flight ones — and a second signal force-kills
+immediately.
+
+```python
+queue = Queue(db_path="myapp.db", drain_timeout=60) # wait up to 60s (default: 30)
+```
+
+```
+$ taskito worker --app myapp:queue
+[taskito] Starting worker...
+^C
+[taskito] Warm shutdown (waiting for running tasks to finish)...
+[taskito] Worker stopped.
+```
+
+
+
+
+
+`runWorker()` doesn't wire any signal handling itself — the
+`taskito run ./app.js` CLI does that for you (`SIGINT`/`SIGTERM` →
+`worker.stop()`). Calling `runWorker()` from your own code, wire it yourself:
+
+```ts
+process.on("SIGTERM", () => worker.stop());
+```
+
+
+
+
+
+There's no automatic signal handling either — wire a shutdown hook so the
+JVM calls `close()` on exit, and block `main` on `awaitShutdown()`:
+
+```java
+Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ worker.close();
+ taskito.close();
+}));
+worker.awaitShutdown();
+```
+
+
+ Don't put the worker in try-with-resources *and* call `awaitShutdown()`
+ inside the block — the block can't exit to trigger `close()`, so it
+ deadlocks. Use try-with-resources for bounded work (tests), the
+ shutdown-hook pattern for services.
+
+
+
+
+### Programmatic shutdown
+
+Trigger a shutdown from code — another thread, a signal handler, an admin
+endpoint:
+
+
+
+
+```python
+# From another thread or signal handler
+queue._inner.request_shutdown()
+```
+
+
+
+
+```ts
+worker.stop(); // in-flight results drain before background tasks exit
+```
+
+
+
+
+```java
+worker.close(); // drains in-flight handlers, then frees the native worker
+// worker.stop() only halts dispatch — no drain, no teardown
+```
+
+
+
+
+## Related
+
+- Tasks — defining and registering
+ the functions a worker runs.
+- Execution model — the
+ deep dive on how each SDK actually runs jobs concurrently.
+- Scheduling — how and when
+ jobs become claimable.
+- Mesh scheduling — gossip
+ discovery and work-stealing across a worker fleet.
diff --git a/docs/content/docs/shared/guides/extensibility/middleware.mdx b/docs/content/docs/shared/guides/extensibility/middleware.mdx
new file mode 100644
index 00000000..bd03651f
--- /dev/null
+++ b/docs/content/docs/shared/guides/extensibility/middleware.mdx
@@ -0,0 +1,438 @@
+---
+title: Middleware
+description: "Cross-cutting hooks around enqueue, task execution, and job outcomes — logging, metrics, tracing, and enqueue-time rewrites."
+---
+
+Middleware wraps task execution and reacts to job outcomes — the place for
+logging, metrics, tracing, and enqueue-time validation that would otherwise
+be copy-pasted into every task. Register it once and it applies across
+every task, or a specific one>} node={<>every task>} java={<>every task>} />; multiple middlewares compose in the order you
+register them.
+
+## Registering middleware
+
+
+
+
+```python
+from taskito import Queue, TaskMiddleware
+
+class LoggingMiddleware(TaskMiddleware):
+ def before(self, ctx):
+ print(f"[START] {ctx.task_name} (job {ctx.id})")
+
+ def after(self, ctx, result, error):
+ status = "OK" if error is None else f"FAILED: {error}"
+ print(f"[END] {ctx.task_name}: {status}")
+
+queue = Queue(middleware=[LoggingMiddleware()])
+```
+
+
+
+
+```ts
+queue.use({
+ before: (ctx) => log.info("start", ctx.taskName),
+ after: (ctx, result) => log.info("ok", ctx.taskName),
+ onError: (ctx, err) => log.error("threw", ctx.taskName, err),
+ onRetry: (e) => metrics.inc("retry", e.taskName),
+ onDeadLetter: (e) => alertOps(e),
+});
+```
+
+
+
+
+```java
+taskito.use(new Middleware() {
+ @Override
+ public void before(TaskContext context) {
+ log.info("start " + context.taskName);
+ }
+
+ @Override
+ public void onError(TaskContext context, Throwable error) {
+ log.error("threw " + context.taskName, error);
+ }
+
+ @Override
+ public void onDeadLetter(OutcomeEvent event) {
+ alertOps(event);
+ }
+});
+```
+
+
+
+
+
+
+Subclass `TaskMiddleware` and override only the hooks you need — unimplemented
+hooks default to a no-op on the base class.
+
+
+
+
+
+`Middleware` is a plain object whose hooks are all optional — implement only
+what you need.
+
+
+
+
+
+`Middleware` is an interface whose hooks are all no-op `default` methods —
+override only what you need.
+
+
+
+## Hooks
+
+
+
+`TaskMiddleware` exposes 7 hooks:
+
+| Hook | Called when |
+|---|---|
+| `before(ctx)` | Before task execution |
+| `after(ctx, result, error)` | After task execution (success or failure) |
+| `on_retry(ctx, error, retry_count)` | A job fails and will be retried |
+| `on_enqueue(task_name, args, kwargs, options)` | A job is about to be enqueued |
+| `on_dead_letter(ctx, error)` | A job exhausts all retries and moves to the dead letter queue |
+| `on_timeout(ctx)` | A job hits its hard timeout |
+| `on_cancel(ctx)` | A job is cancelled during execution |
+
+`ctx` is a `JobContext` — the same object as `current_job` — exposing
+`ctx.id`, `ctx.task_name`, `ctx.retry_count`, and `ctx.queue_name`.
+
+
+ `on_retry`, `on_dead_letter`, `on_timeout`, and `on_cancel` are called by
+ the Rust result handler after the scheduler records the outcome, firing
+ after `after()` and after the corresponding event is emitted on the event
+ bus. Exceptions raised inside these hooks are logged and do not affect job
+ processing.
+
+
+`on_timeout` fires when the maintenance reaper detects a stale job that
+exceeded its hard `timeout` — **before** `on_retry` (if it will be retried)
+or `on_dead_letter` (if retries are exhausted), so you can react to the
+timeout itself independently of the job's eventual fate.
+
+
+
+
+
+`Middleware` exposes 8 hooks:
+
+| Hook | When | Awaited |
+|---|---|---|
+| `onEnqueue(ctx)` | Producer-side, inside `enqueue`, before serialization. | no (sync) |
+| `before(ctx)` | Before each execution attempt. | yes |
+| `after(ctx, result)` | After a successful attempt. | yes |
+| `onError(ctx, err)` | When an attempt throws (before the retry/dead decision). | yes |
+| `onCompleted(e)` | After a job completes successfully. | no |
+| `onRetry(e)` | After the core schedules a retry. | no |
+| `onDeadLetter(e)` | After a job dead-letters. | no |
+| `onCancel(e)` | After a job is cancelled. | no |
+
+The execution hooks (`before`/`after`/`onError`) receive a `TaskContext` with
+`taskName`, `jobId`, and `args`. The outcome hooks receive an `OutcomeEvent`
+with `taskName`, `jobId`, `queue`, `retryCount`, and `timedOut` (which
+separates a timeout from other failures).
+
+
+
+
+
+`Middleware` exposes 8 hooks:
+
+| Hook | When |
+|---|---|
+| `onEnqueue(context)` | Producer-side, inside `enqueue`, before serialization. |
+| `before(context)` | Before each execution attempt. |
+| `after(context, result)` | After a successful attempt. |
+| `onError(context, error)` | When an attempt throws (before the retry/dead decision). |
+| `onCompleted(event)` | After a job completes successfully. |
+| `onRetry(event)` | After the core schedules a retry. |
+| `onDeadLetter(event)` | After a job dead-letters. |
+| `onCancel(event)` | After a job is cancelled. |
+
+The execution hooks receive a `TaskContext` with `taskName`, `jobId`, a
+mutable per-execution `attributes()` map shared across a job's hooks, and
+`job()` with lazily-loaded metadata. The outcome hooks receive an
+`OutcomeEvent` with `taskName`, `jobId`, `error`, `retryCount`, and
+`timedOut` (which separates a timeout from other failures).
+
+
+
+## Rewriting an enqueue
+
+on_enqueue>} node={<>onEnqueue>} java={<>onEnqueue>} /> is unique among the hooks: it fires
+before the job is written to storage, and the context it receives is
+**mutable** — use it to validate, redact, or reshape what actually gets
+enqueued.
+
+
+
+
+```python
+class PriorityBoostMiddleware(TaskMiddleware):
+ def on_enqueue(self, task_name, args, kwargs, options):
+ # Bump priority for urgent tasks during business hours
+ if task_name.startswith("alerts."):
+ options["priority"] = max(options.get("priority", 0), 50)
+```
+
+
+
+
+```ts
+queue.use({
+ onEnqueue: (ctx) => {
+ // Bump priority for urgent tasks
+ if (ctx.taskName.startsWith("alerts.")) {
+ ctx.options.priority = 50;
+ }
+ },
+});
+```
+
+
+
+
+```java
+taskito.use(new Middleware() {
+ @Override
+ public void onEnqueue(EnqueueContext context) {
+ // Bump priority for urgent tasks
+ if (context.taskName.startsWith("alerts.")) {
+ context.options(context.options().toBuilder().priority(50).build());
+ }
+ }
+});
+```
+
+
+
+
+
+
+Keys present in `options`: `priority`, `delay`, `queue`, `max_retries`,
+`timeout`, `unique_key`, `metadata`.
+
+
+
+
+
+`EnqueueContext` also exposes mutable `args`, and throwing from `onEnqueue`
+aborts the enqueue. This is the same producer-side seam as
+enqueue interceptors ,
+which run first and can convert, redirect, or reject the call outright before
+`onEnqueue` sees it.
+
+
+
+
+
+`EnqueueContext` also exposes `payload()`/`payload(...)` to replace the job's
+payload outright, and a mutable `metadata()` map that travels with the job
+(readable at execution via `context.job().metadata()`). Throwing from
+`onEnqueue` aborts the enqueue. For typed convert/redirect/reject decisions,
+use an interceptor
+instead — interceptors run before middleware `onEnqueue`.
+
+
+
+## Execution order
+
+
+
+1. **Global middleware** (registered via `Queue(middleware=[...])`) runs first
+2. **Per-task middleware** (via `@queue.task(middleware=[...])`) runs second
+3. Within each group, middleware runs in **registration order**
+4. `after()` and the Rust-dispatched outcome hooks run in that **same
+ forward order**, not reversed — `after()` only fires for middleware whose
+ `before()` didn't raise.
+
+
+
+
+
+Every hook — `before`, `after`, `onError`, and the outcome hooks — runs in
+the same registration order for every middleware; the Node SDK doesn't
+reverse `after` the way an "onion" middleware model would.
+
+
+
+
+
+Multiple middlewares run in registration order for every phase — `before`,
+`after`, `onError`, and the outcome hooks are not reversed.
+
+
+
+## Exception handling
+
+
+
+If a middleware hook raises an exception:
+
+- **`before()`**: the exception is logged, but subsequent middleware `before()` hooks still run. The task executes normally.
+- **`after()`**: the exception is logged. Other `after()` hooks still run.
+- **`on_retry()` / `on_dead_letter()` / `on_timeout()` / `on_cancel()`**: logged and swallowed — these are notification hooks, not control flow.
+
+Middleware exceptions never prevent task execution or result handling.
+
+
+
+
+
+The execution hooks (`before`/`after`/`onError`) are awaited in the hot path:
+a rejected `before` or `after` fails the attempt like a thrown handler error.
+`onError` itself is wrapped so a throwing `onError` never masks the original
+task failure. The outcome hooks (`onCompleted`/`onRetry`/`onDeadLetter`/
+`onCancel`) are isolated per middleware — one that throws or rejects is
+logged at `debug` and doesn't stop the rest from running.
+
+
+
+
+
+The execution hooks run inside the attempt — a `before` or `after` that
+throws fails the attempt like a handler error, and is caught by the same
+`onError` path. Outcome hooks are isolated — one that throws is caught and
+logged so it never starves the others.
+
+
+
+## Per-task middleware
+
+
+
+Apply middleware to a **specific task** using the `middleware` parameter on
+`@queue.task`:
+
+```python
+@queue.task(middleware=[MetricsMiddleware()])
+def process(data):
+ ...
+```
+
+Per-task middleware runs **after** global middleware, in registration order.
+
+
+
+
+
+There's no separate per-task registration — every middleware passed to
+`queue.use()` applies to every task. Scope a middleware's own hooks to
+specific tasks by checking `ctx.taskName` inside them, or by giving the
+middleware its own task-name filter (as the [OpenTelemetry](#combining-with-observability)
+integration does with `taskFilter`).
+
+
+
+
+
+There's no separate per-task registration — every middleware passed to
+`taskito.use()` applies to every task. Scope a middleware's own hooks to
+specific tasks by checking `context.taskName` inside them.
+
+
+
+
+
+## Middleware vs hooks
+
+Coming from Celery signals? Use these instead of connecting to `task_prerun` /
+`task_postrun` / `task_failure`: queue-level **hooks** for global behavior, or
+per-task **middleware** for scoped behavior.
+
+taskito has two systems for running code around tasks:
+
+| | Hooks (`@queue.on_failure`, etc.) | Middleware (`TaskMiddleware`) |
+|---|---|---|
+| **Scope** | Queue-level only | Queue-level or per-task |
+| **Interface** | Decorated functions | Class with up to 7 hooks |
+| **Context** | Receives `task_name, args, kwargs` | Receives `JobContext` |
+| **Enqueue hook** | No | Yes (`on_enqueue`, can mutate options) |
+| **Retry hook** | No | Yes (`on_retry`) |
+| **DLQ / timeout / cancel hooks** | No | Yes |
+| **Execution order** | After middleware | Before hooks |
+
+Middleware runs **inside** the task wrapper (closer to the task function),
+while hooks run **outside**. In practice, middleware `before()` fires
+first, then `before_task` hooks. On completion, `on_success`/`on_failure`
+hooks fire, then middleware `after()`.
+
+
+
+## Combining with observability
+
+Middleware is how the built-in observability integrations attach to task
+execution — compose them with your own middleware freely.
+
+
+
+
+```python
+from taskito import Queue
+from taskito.contrib.otel import OpenTelemetryMiddleware
+
+queue = Queue(middleware=[
+ OpenTelemetryMiddleware(),
+ LoggingMiddleware(),
+])
+```
+
+
+
+
+```ts
+import { otelMiddleware } from "@byteveda/taskito/contrib/otel";
+
+queue.use(otelMiddleware());
+queue.use({ before: (ctx) => log.info("start", ctx.taskName) }); // your own middleware
+```
+
+
+
+
+```java
+import org.byteveda.taskito.contrib.TaskitoObservation;
+
+taskito.use(new TaskitoObservation(registry));
+taskito.use(new Middleware() { // your own middleware
+ @Override
+ public void before(TaskContext context) {
+ log.info("start " + context.taskName);
+ }
+});
+```
+
+
+
+
+
+
+See the [OpenTelemetry guide](/python/guides/integrations/otel) for setup
+details.
+
+
+
+
+
+See the [OpenTelemetry](/node/guides/integrations/otel) and
+[Sentry](/node/guides/integrations/sentry) integration guides for setup
+details.
+
+
+
+
+
+See the [Micrometer](/java/guides/integrations/micrometer) and
+[Sentry](/java/guides/integrations/sentry) integration guides for setup
+details.
+
+
diff --git a/docs/content/docs/shared/guides/extensibility/serializers.mdx b/docs/content/docs/shared/guides/extensibility/serializers.mdx
new file mode 100644
index 00000000..995b669b
--- /dev/null
+++ b/docs/content/docs/shared/guides/extensibility/serializers.mdx
@@ -0,0 +1,576 @@
+---
+title: Pluggable Serializers
+description: "Built-in JSON, MessagePack, signed, and encrypted serializers, plus the cross-SDK payload-codec chain for compression and encryption."
+---
+
+Task arguments and results are serialized with a pluggable `Serializer`. The
+Rust core stores every payload as an opaque byte blob, so serialization is
+entirely a host-language concern — producers and workers just need to agree
+on the same serializer.
+
+
+ The serializer is not negotiated at runtime — it's configured once per
+ `Queue`. A job enqueued with one serializer can only be decoded by a worker
+ configured with a compatible one.
+
+
+
+
+The default is `SmartSerializer` — MessagePack for plain data (dicts, lists,
+strings, numbers, booleans, `None`, tuples), with an automatic
+`CloudpickleSerializer` fallback for anything MessagePack can't encode
+(lambdas, closures, custom class instances). No configuration needed.
+
+
+
+
+
+The default is `JsonSerializer` — human-readable and easy to debug. Other
+built-ins: `MsgpackSerializer`, `SignedSerializer`, `EncryptedSerializer`.
+
+
+
+
+
+The default is `JsonSerializer` — Jackson-backed, human-readable and easy to
+debug. Other built-ins: `MsgpackSerializer`, `SignedSerializer`,
+`EncryptedSerializer`.
+
+
+
+## Built-in serializers
+
+
+
+### SmartSerializer (default)
+
+Encodes plain data via MessagePack — fast and compact — and transparently
+falls back to `CloudpickleSerializer` for anything MessagePack can't encode.
+A one-byte tag on each payload records which codec produced it, so `loads()`
+always picks the right path.
+
+```python
+from taskito import Queue
+
+queue = Queue() # uses SmartSerializer
+```
+
+### CloudpickleSerializer
+
+Handles lambdas, closures, and complex Python objects unconditionally —
+every payload goes through cloudpickle, skipping `SmartSerializer`'s
+MessagePack fast path. Use it directly for that predictability, or as the
+inner serializer for `EncryptedSerializer` / `SignedSerializer`.
+
+```python
+from taskito import CloudpickleSerializer, Queue
+
+queue = Queue(serializer=CloudpickleSerializer())
+```
+
+
+
+### JsonSerializer
+
+Human-readable JSON payloads. Useful for debugging, cross-language interop,
+or when arguments and results are plain types.
+
+
+
+
+```python
+from taskito import JsonSerializer, Queue
+
+queue = Queue(serializer=JsonSerializer())
+```
+
+
+
+
+```ts
+import { Queue, JsonSerializer } from "@byteveda/taskito";
+
+new Queue({ dbPath: "taskito.db", serializer: new JsonSerializer() });
+```
+
+
+
+
+```java
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.serialization.JsonSerializer;
+
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .serializer(new JsonSerializer())
+ .open();
+```
+
+
+
+
+### MessagePack
+
+Compact binary serialization — smaller payloads than JSON, cross-language
+compatible.
+
+
+
+
+```python
+from taskito import MsgPackSerializer, Queue
+
+queue = Queue(serializer=MsgPackSerializer())
+```
+
+
+
+
+```ts
+import { Queue, MsgpackSerializer } from "@byteveda/taskito";
+
+new Queue({ dbPath: "taskito.db", serializer: new MsgpackSerializer() });
+```
+
+
+
+
+```java
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.serialization.MsgpackSerializer;
+
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .serializer(new MsgpackSerializer())
+ .open();
+```
+
+
+
+
+
+
+`msgpack` ships as a core taskito dependency, so no extra install is
+required — `pip install taskito[msgpack]` exists only for discoverability if
+you want to spell it out in your own requirements file.
+
+
+ `MsgPackSerializer` only handles basic types: dicts, lists, strings,
+ numbers, booleans, and `None`. It does not support lambdas, closures, or
+ arbitrary Python objects. Use `CloudpickleSerializer` when you need to pass
+ complex objects.
+
+
+
+
+
+
+`@msgpack/msgpack` ships as a core dependency — no extra install required.
+
+
+
+
+
+`MsgpackSerializer`'s `org.msgpack:jackson-dataformat-msgpack` dependency is
+`compileOnly` — add it to your build explicitly.
+
+
+
+### SignedSerializer
+
+HMAC-SHA256 integrity tag. Unlike `EncryptedSerializer`, it does **not** hide
+the payload — it authenticates it. A worker refuses to deserialize bytes
+that were not produced with the shared key, so an attacker who can write to
+the queue's storage cannot smuggle in a forged payload.
+
+
+
+
+```python
+import os
+from taskito import Queue, SignedSerializer, SmartSerializer
+
+key = os.urandom(32) # share this across producers and workers
+queue = Queue(serializer=SignedSerializer(SmartSerializer(), key))
+```
+
+
+
+
+```ts
+import { Queue, SignedSerializer } from "@byteveda/taskito";
+
+new Queue({
+ dbPath: "taskito.db",
+ serializer: new SignedSerializer(process.env.TASKITO_SECRET!),
+});
+```
+
+
+
+
+```java
+import java.nio.charset.StandardCharsets;
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.serialization.JsonSerializer;
+import org.byteveda.taskito.serialization.SignedSerializer;
+
+byte[] key = System.getenv("TASKITO_SIGNING_KEY").getBytes(StandardCharsets.UTF_8);
+
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .serializer(new SignedSerializer(new JsonSerializer(), key))
+ .open();
+```
+
+
+
+
+
+
+
+ The default serializer can execute code on load (cloudpickle handles
+ lambdas and arbitrary objects). Without signing, anyone able to write to
+ the backing store can achieve remote code execution on every worker that
+ dequeues a crafted job. `SignedSerializer` closes that path. The key must
+ be at least 32 bytes of CSPRNG output and identical on producers and
+ workers.
+
+
+
+
+
+
+The key is a plain `secret` string, hashed internally — producers and
+workers just need to share the same string.
+
+
+
+### EncryptedSerializer
+
+AES-256-GCM encryption. Payloads stored in the database are opaque
+ciphertext — only the key holder can read them. Provides confidentiality
+**and** integrity, a superset of `SignedSerializer`.
+
+
+
+
+```python
+import base64
+import os
+from taskito import CloudpickleSerializer, EncryptedSerializer, Queue
+
+key = base64.b64decode(os.environ["QUEUE_KEY"]) # 16, 24, or 32 raw bytes
+queue = Queue(serializer=EncryptedSerializer(CloudpickleSerializer(), key))
+```
+
+
+
+
+```ts
+import { Queue, EncryptedSerializer } from "@byteveda/taskito";
+
+new Queue({
+ dbPath: "taskito.db",
+ serializer: new EncryptedSerializer(process.env.TASKITO_SECRET!),
+});
+```
+
+
+
+
+```java
+import java.util.Base64;
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.serialization.JsonSerializer;
+import org.byteveda.taskito.serialization.EncryptedSerializer;
+
+byte[] key = Base64.getDecoder().decode(System.getenv("TASKITO_ENC_KEY")); // 16, 24, or 32 bytes
+
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .serializer(new EncryptedSerializer(new JsonSerializer(), key))
+ .open();
+```
+
+
+
+
+
+
+`EncryptedSerializer` always wraps an explicit inner serializer — there's no
+implicit default. The key must be exactly 16, 24, or 32 bytes (AES-128/192/256),
+base64-decoded if it came from a string. Generate one with:
+
+```bash
+python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
+```
+
+For both confidentiality **and** integrity, wrap one in the other:
+
+```python
+from taskito import EncryptedSerializer, Queue, SignedSerializer, SmartSerializer
+
+inner = EncryptedSerializer(SmartSerializer(), key=enc_key)
+queue = Queue(serializer=SignedSerializer(inner, sign_key))
+```
+
+
+
+
+
+The key is derived from `secret` via SHA-256, so producers and workers only
+need to share the string — not raw key bytes.
+
+
+
+## Custom serializers
+
+Implement the `Serializer` interface with two methods:
+
+
+
+
+```python
+import msgpack
+from taskito import Queue, Serializer
+
+
+class MyMsgpackSerializer:
+ def dumps(self, obj) -> bytes:
+ return msgpack.packb(obj)
+
+ def loads(self, data: bytes):
+ return msgpack.unpackb(data, raw=False)
+
+
+queue = Queue(serializer=MyMsgpackSerializer())
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+import type { Serializer } from "@byteveda/taskito";
+
+const mySerializer: Serializer = {
+ serialize: (value) => Buffer.from(JSON.stringify(value)),
+ deserialize: (bytes) => JSON.parse(Buffer.from(bytes).toString()),
+};
+
+new Queue({ dbPath: "taskito.db", serializer: mySerializer });
+```
+
+
+
+
+```java
+import org.byteveda.taskito.serialization.Serializer;
+
+public final class MySerializer implements Serializer {
+ @Override
+ public byte[] serialize(Object value) { /* ... */ }
+
+ @Override
+ public T deserialize(byte[] bytes, Class type) { /* ... */ }
+}
+```
+
+
+
+
+
+
+| Method | Signature | Description |
+|---|---|---|
+| `dumps` | `(obj: Any) -> bytes` | Serialize an object to bytes |
+| `loads` | `(data: bytes) -> Any` | Deserialize bytes back to an object |
+
+The serializer is used for both task arguments (the `(args, kwargs)` tuple)
+and return values.
+
+
+ `job.result()` uses the queue's configured serializer for deserialization.
+ If you're using `JsonSerializer` or a custom serializer, results are
+ correctly deserialized with that serializer — not hardcoded cloudpickle.
+
+
+
+
+
+
+The `Type` overload (`deserialize(byte[] bytes, Type type)`) is a default
+method supporting generic payloads from a `TypeReference`; the base
+implementation handles plain `Class` types and rejects generic ones —
+override it in a generics-aware implementation, as `JsonSerializer` does.
+
+
+
+
+
+## Choosing a serializer
+
+| | SmartSerializer | CloudpickleSerializer | JsonSerializer | MsgPackSerializer | EncryptedSerializer |
+|---|---|---|---|---|---|
+| **Complex objects** | Yes (cloudpickle fallback) | Yes | No | No | Depends on inner serializer |
+| **Debugging** | Binary payloads (opaque) | Binary payloads (opaque) | Human-readable JSON | Binary (opaque) | Ciphertext (opaque) |
+| **Cross-language** | Python only | Python only | Any language | Any language | Python only (by default) |
+| **Performance** | Best for plain data | Good | Good for simple types | Best | Adds encryption overhead |
+| **Security** | None | None | None | None | AES-256-GCM |
+| **Extra dependency** | No | No | No | No | `cryptography` (`taskito[encryption]`) |
+| **Default** | Yes | No | No | No | No |
+
+**Rule of thumb**: use the default `SmartSerializer` unless you have a
+specific reason to switch — it gets you compact MessagePack payloads for
+plain data with an automatic cloudpickle fallback for anything MessagePack
+can't encode. Use `EncryptedSerializer` when tasks carry sensitive data that
+must not be readable in the database. See
+[Architecture: Serialization](/architecture/serialization) for a shorter
+overview and what gets serialized where.
+
+
+
+## Payload codecs
+
+A `PayloadCodec` is a reversible byte-to-byte transform layered *around* a
+serializer — compression, encryption, signing — applied after serialization
+on the producer and reversed before deserialization on the worker. Wire
+formats are part of the cross-SDK wire contract, so a codec-framed payload
+decodes from any Taskito binding:
+
+| Codec | Wire format | Description |
+|---|---|---|
+| `GzipCodec` | standard gzip stream | Compression; decompression capped at 64 MiB by default (zip-bomb guard) |
+| `AesGcmCodec(key)` | `[12-byte nonce][ciphertext \|\| 16-byte GCM tag]` | AES-GCM encryption, fresh nonce per payload. Key must be 16, 24, or 32 bytes |
+| `HmacCodec(key)` | `[32-byte mac][body]` | HMAC-SHA256 signing, constant-time verification. Key must not be empty |
+
+A chain encodes in list order on the producer and decodes in reverse on the
+worker — put `GzipCodec` before a signing or encryption codec so integrity
+is verified before decompressing.
+
+### Global codec chain
+
+Apply a chain queue-wide — it wraps the queue serializer, so it covers every
+payload *and* result:
+
+
+
+
+```python
+from taskito import AesGcmCodec, GzipCodec, Queue
+
+queue = Queue(codec=[GzipCodec(), AesGcmCodec(key)]) # compress, then encrypt
+```
+
+
+
+
+```ts
+import { Queue, GzipCodec, AesGcmCodec } from "@byteveda/taskito";
+
+new Queue({
+ dbPath: "taskito.db",
+ codec: [new GzipCodec(), new AesGcmCodec(key)], // compress, then encrypt
+});
+```
+
+
+
+
+```java
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .codec(new GzipCodec(), new AesGcmCodec(key)) // compress, then encrypt
+ .open();
+```
+
+
+
+
+
+ Jobs written before a codec chain is turned on cannot be decoded through
+ it — drain the queue before turning codecs on, or before changing the
+ chain.
+
+
+### Named codecs (per task)
+
+Register codecs under a name and opt individual tasks in — payload only,
+results still use the plain queue serializer:
+
+
+
+
+```python
+from taskito import AesGcmCodec, Queue
+
+queue = Queue(codecs={"secret": AesGcmCodec(key)})
+
+@queue.task(codecs=["secret"])
+def export_report(report_id: int):
+ ...
+```
+
+
+
+
+```ts
+import { Queue, AesGcmCodec } from "@byteveda/taskito";
+
+const queue = new Queue({
+ dbPath: "taskito.db",
+ codecs: { secret: new AesGcmCodec(key) },
+});
+
+queue.task(
+ "exportReport",
+ (reportId: number) => {
+ // ...
+ },
+ { codecs: ["secret"] },
+);
+```
+
+
+
+
+```java
+Taskito taskito = Taskito.builder()
+ .sqlite("taskito.db")
+ .codec("secret", new AesGcmCodec(key))
+ .open();
+
+Task export = Task.of("export", Report.class).codecs("secret");
+```
+
+
+
+
+
+
+Handlers annotated `@Compressed` / `@Encrypted` get the `"compressed"` /
+`"encrypted"` codec names recorded on their generated task constants —
+register codecs under those names on both producers and workers. The key is
+supplied at runtime, never in the annotation.
+
+
+
+The same chain (or the same names) must be configured on producers and
+workers. Dedup keys for auto-derived idempotency hash the *pre-codec*
+payload, so a nondeterministic named codec (an AES-GCM nonce, for example)
+never breaks dedup — see
+Idempotency for the
+full precedence rules and the hash recipe.
+
+
+
+
+ The warning above is about per-task `codecs=[...]`. The queue-wide `codec=`
+ chain wraps the serializer itself, so a nondeterministic codec there (e.g.
+ `AesGcmCodec`) *does* make `idempotent=True` auto-keys nondeterministic
+ from one call to the next — pass an explicit `idempotency_key` instead when
+ a global codec chain is enabled.
+
+
+
diff --git a/docs/content/docs/shared/guides/integrations/prometheus.mdx b/docs/content/docs/shared/guides/integrations/prometheus.mdx
new file mode 100644
index 00000000..942a81c3
--- /dev/null
+++ b/docs/content/docs/shared/guides/integrations/prometheus.mdx
@@ -0,0 +1,422 @@
+---
+title: Prometheus Metrics
+description: "Per-task execution counters and a duration histogram from middleware, plus polled queue depth and dead-letter gauges."
+---
+
+Taskito exports Prometheus metrics through two pieces — a middleware that
+records per-task execution counters and a duration histogram, and a stats
+collector that polls queue depth and dead-letter size onto gauges on an
+interval. Both write into the same
+prometheus-client} node={prom-client} />
+registry, so one HTTP endpoint can serve everything.
+
+
+
+Java has no Prometheus contrib — metrics come from
+[Micrometer](/java/guides/integrations/micrometer) instead, which yields a
+timer (and a trace span) from one instrumentation.
+
+
+
+## Installation
+
+
+
+```bash
+pip install taskito[prometheus]
+```
+
+This installs `prometheus-client` as a dependency.
+
+
+
+
+
+Peer dependency: `prom-client`. Import from the `taskito/contrib/prometheus`
+subpath — it is not exported from the main barrel.
+
+
+
+## Metrics tracked
+
+
+
+| Metric | Type | Labels | Description |
+|---|---|---|---|
+| `taskito_jobs_total` | Counter | `task`, `status` | Total jobs processed (`status` is `completed` or `failed`) |
+| `taskito_job_duration_seconds` | Histogram | `task` | Job execution duration |
+| `taskito_active_workers` | Gauge | — | Number of currently executing workers |
+| `taskito_retries_total` | Counter | `task` | Total retry attempts |
+| `taskito_queue_depth` | Gauge | `queue` | Number of pending jobs, polled by `PrometheusStatsCollector` |
+| `taskito_dlq_size` | Gauge | — | Number of dead-letter jobs, polled |
+| `taskito_worker_utilization` | Gauge | `queue` | Ratio of running jobs to total workers for that queue (0.0–1.0), polled |
+
+The first four are written by `PrometheusMiddleware` as each job runs; the
+last three are polled by `PrometheusStatsCollector`. Metrics are created once
+per `namespace` and reused, so it's safe to construct the middleware and the
+collector independently as long as they share a namespace.
+
+
+
+
+
+| Metric | Type | Labels | Description |
+|---|---|---|---|
+| `taskito_jobs_total` | Counter | `task`, `status` | Finished executions by outcome (`status` is `completed` or `failed`) |
+| `taskito_job_duration_seconds` | Histogram | `task` | Execution duration |
+| `taskito_active_workers` | Gauge | — | Jobs currently executing |
+| `taskito_retries_total` | Counter | `task` | Retry attempts |
+| `taskito_queue_depth` | Gauge | `queue` | Pending jobs per queue, polled by `PrometheusStatsCollector` |
+| `taskito_dlq_size` | Gauge | — | Dead-letter queue size, polled |
+
+
+ There's no `worker_utilization` gauge on Node — `PrometheusStatsCollector`
+ only polls queue depth and DLQ size.
+
+
+
+
+## Instrumenting job execution
+
+
+
+Add `PrometheusMiddleware` to your queue to track per-task execution metrics:
+
+```python
+from taskito import Queue
+from taskito.contrib.prometheus import PrometheusMiddleware
+
+queue = Queue(db_path="myapp.db", middleware=[PrometheusMiddleware()])
+```
+
+```python
+PrometheusMiddleware(
+ namespace="myapp",
+ disabled_metrics={"resource", "proxy"},
+ task_filter=lambda name: not name.startswith("internal."),
+)
+```
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `namespace` | `str` | `"taskito"` | Prefix for all metric names. |
+| `extra_labels_fn` | `Callable[[JobContext], dict[str, str]] \| None` | `None` | Accepted for forward compatibility — not currently applied to any metric's labels. |
+| `disabled_metrics` | `set[str] \| None` | `None` | Metric groups or individual names to skip. Groups: `"jobs"`, `"queue"`, `"resource"`, `"proxy"`, `"intercept"`. |
+| `task_filter` | `Callable[[str], bool] \| None` | `None` | Predicate that receives a task name. Return `True` to export metrics for the task, `False` to skip it. `None` exports all tasks. |
+
+
+
+
+
+Register `prometheusMiddleware` to record per-job counters and the duration
+histogram:
+
+```ts
+import { prometheusMiddleware } from "@byteveda/taskito/contrib/prometheus";
+
+queue.use(prometheusMiddleware());
+```
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `namespace` | `string` | `"taskito"` | Metric name prefix |
+| `register` | `Registry` | global `register` | prom-client registry to register metrics into |
+| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task |
+| `buckets` | `number[]` | prom-client defaults | Histogram bucket boundaries (seconds) |
+
+
+
+## Queue-level metrics
+
+
+
+For queue-level metrics, use the stats collector. It polls `queue.stats()`
+(and resource/proxy/interception stats) on a background thread:
+
+```python
+from taskito.contrib.prometheus import PrometheusStatsCollector
+
+collector = PrometheusStatsCollector(queue, interval=10)
+collector.start()
+```
+
+```python
+PrometheusStatsCollector(
+ queue,
+ interval=10,
+ namespace="myapp",
+ disabled_metrics={"intercept"},
+)
+```
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `queue` | `Queue` | — | The Queue instance to poll. |
+| `interval` | `float` | `10.0` | Seconds between polls. |
+| `namespace` | `str` | `"taskito"` | Prefix for metric names. Must match `PrometheusMiddleware`'s namespace to share metric objects. |
+| `disabled_metrics` | `set[str] \| None` | `None` | Metric groups or names to skip. Same groups as `PrometheusMiddleware`. |
+
+Call `collector.stop()` on shutdown — it signals the background thread and
+joins it (up to 5s).
+
+
+
+
+
+`PrometheusStatsCollector` polls queue depth and DLQ size on an interval:
+
+```ts
+import { PrometheusStatsCollector } from "@byteveda/taskito/contrib/prometheus";
+
+const collector = new PrometheusStatsCollector(queue);
+collector.start();
+```
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `namespace` | `string` | `"taskito"` | Metric name prefix |
+| `register` | `Registry` | global `register` | Registry to write gauges into |
+| `intervalMs` | `number` | `10000` | Poll interval in milliseconds |
+
+The internal timer is unref'd, so it never keeps the process alive on its
+own. Call `.stop()` on graceful shutdown:
+
+```ts
+process.on("SIGTERM", () => {
+ collector.stop();
+ worker.stop();
+});
+```
+
+
+ Metrics for one namespace are built once per registry, so registering
+ multiple middlewares is safe. Pass the same `register` to the middleware and
+ the collector to keep everything in one registry.
+
+
+
+
+## Exposing metrics
+
+
+
+Start a standalone `/metrics` endpoint for Prometheus to scrape:
+
+```python
+from taskito.contrib.prometheus import start_metrics_server
+
+start_metrics_server(port=9090)
+```
+
+This uses `prometheus_client.start_http_server` under the hood.
+
+### This is a different `/metrics` than the dashboard's
+
+`start_metrics_server` opens its own dedicated HTTP server — it is not the
+same endpoint as the dashboard's `GET /metrics` (see
+REST API ). Both ultimately
+call `prometheus_client.generate_latest()`, but each only serves metrics
+registered in *its own process*:
+
+- Run `start_metrics_server` in the same process as your
+ `PrometheusMiddleware` / `PrometheusStatsCollector` (typically the worker
+ process) — this is the endpoint you'll usually scrape.
+- The dashboard's `GET /metrics` only shows something useful if the
+ dashboard process itself registered metrics (e.g. it also runs
+ `PrometheusMiddleware`). If your worker and dashboard run as separate
+ processes — the common deployment — scrape the worker's
+ `start_metrics_server` instead.
+
+
+
+
+
+There's no standalone metrics server built in — mount the registry on your
+own HTTP server:
+
+```ts
+import { register } from "prom-client";
+import express from "express";
+
+const app = express();
+app.get("/metrics", async (_req, res) => {
+ res.set("Content-Type", register.contentType);
+ res.end(await register.metrics());
+});
+```
+
+
+ The dashboard's own
+ `GET /api/metrics`
+ is a separate, JSON-based aggregation, not a Prometheus text-exposition
+ endpoint — scrape the server above instead.
+
+
+
+
+## Full example
+
+
+
+```python
+from taskito import Queue
+from taskito.contrib.prometheus import (
+ PrometheusMiddleware,
+ PrometheusStatsCollector,
+ start_metrics_server,
+)
+
+queue = Queue(db_path="myapp.db", middleware=[PrometheusMiddleware()])
+
+# Start metrics endpoint
+start_metrics_server(port=9090)
+
+# Start queue stats polling
+collector = PrometheusStatsCollector(queue, interval=10)
+collector.start()
+```
+
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+import {
+ prometheusMiddleware,
+ PrometheusStatsCollector,
+} from "@byteveda/taskito/contrib/prometheus";
+import { register } from "prom-client";
+import express from "express";
+
+const queue = new Queue({ dbPath: "myapp.db" });
+queue.use(prometheusMiddleware());
+
+const collector = new PrometheusStatsCollector(queue);
+collector.start();
+
+const app = express();
+app.get("/metrics", async (_req, res) => {
+ res.set("Content-Type", register.contentType);
+ res.end(await register.metrics());
+});
+app.listen(9090);
+```
+
+
+
+Prometheus scrape config for either SDK:
+
+```yaml
+scrape_configs:
+ - job_name: taskito
+ static_configs:
+ - targets: ["localhost:9090"]
+```
+
+## Grafana dashboard tips
+
+
+
+- **Throughput** — `rate(taskito_jobs_total[5m])` by `task` and `status`
+- **Duration p95** — `histogram_quantile(0.95, rate(taskito_job_duration_seconds_bucket[5m]))`
+- **Queue depth** — `taskito_queue_depth` by `queue`
+- **DLQ size** — `taskito_dlq_size` with an alert threshold
+- **Worker utilization** — `taskito_worker_utilization` by `queue`
+
+
+
+
+
+- **Throughput** — `rate(taskito_jobs_total[5m])` by `task` and `status`
+- **Duration p95** — `histogram_quantile(0.95, rate(taskito_job_duration_seconds_bucket[5m]))`
+- **Queue depth** — `taskito_queue_depth` by `queue`
+- **DLQ size** — `taskito_dlq_size` with an alert threshold
+
+
+
+## Alerting on high DLQ size
+
+Using the same setup as the [full example](#full-example) above, a couple of
+alerting rules worth adding — `taskito_dlq_size` and `taskito_jobs_total`
+carry the same names in the Python and Node exporters, so this rule file
+works unchanged for both:
+
+```yaml
+groups:
+ - name: taskito
+ rules:
+ - alert: HighDLQSize
+ expr: taskito_dlq_size > 10
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "taskito dead letter queue has {{ $value }} entries"
+ - alert: HighErrorRate
+ expr: rate(taskito_jobs_total{status="failed"}[5m]) > 0.1
+ for: 2m
+ labels:
+ severity: critical
+ annotations:
+ summary: "High task failure rate: {{ $value }} failures/sec"
+```
+
+These expressions assume the default `taskito` namespace. If you configured a
+custom one (`namespace: "myapp"` in the [Options](#options) above), rewrite
+the prefixes to match — `myapp_dlq_size`, `myapp_jobs_total` — or the alerts
+silently match no series.
+
+
+
+Java exports metrics through
+[Micrometer](/java/guides/integrations/micrometer), not this contrib — the
+metric names in these rules don't exist there. Build the equivalent alerts on
+the names your Micrometer→Prometheus registry emits instead.
+
+
+
+## Combining with other middleware
+
+
+
+`PrometheusMiddleware` composes with other middleware:
+
+```python
+from taskito.contrib.otel import OpenTelemetryMiddleware
+from taskito.contrib.sentry import SentryMiddleware
+
+queue = Queue(
+ db_path="myapp.db",
+ middleware=[
+ OpenTelemetryMiddleware(),
+ PrometheusMiddleware(),
+ SentryMiddleware(),
+ ],
+)
+```
+
+See the Middleware guide
+for more on combining middleware.
+
+
+
+
+
+`prometheusMiddleware` composes with other middleware — register each with
+its own `queue.use()` call, in the order you want their hooks to run:
+
+```ts
+import { otelMiddleware } from "@byteveda/taskito/contrib/otel";
+import { sentryMiddleware } from "@byteveda/taskito/contrib/sentry";
+import { prometheusMiddleware } from "@byteveda/taskito/contrib/prometheus";
+
+queue.use(otelMiddleware());
+queue.use(prometheusMiddleware());
+queue.use(sentryMiddleware());
+```
+
+See Middleware for
+more on hook ordering.
+
+
diff --git a/docs/content/docs/shared/guides/integrations/sentry.mdx b/docs/content/docs/shared/guides/integrations/sentry.mdx
new file mode 100644
index 00000000..313eb746
--- /dev/null
+++ b/docs/content/docs/shared/guides/integrations/sentry.mdx
@@ -0,0 +1,350 @@
+---
+title: Sentry Integration
+description: "Report task failures to Sentry, tagged by task name, job id, and queue."
+---
+
+taskito ships a Sentry middleware that reports task failures to
+[Sentry](https://sentry.io), tagging each event with the task name, job id,
+and queue so failures are groupable and searchable. Sentry itself is never
+initialized for you — call `Sentry.init(...)` in your own app before
+registering the middleware.
+
+## Installation
+
+
+
+Install the `sentry` extra, which pulls in `sentry-sdk`:
+
+```bash
+pip install taskito[sentry]
+```
+
+
+
+
+
+Peer dependency: install `@sentry/node` yourself and import the middleware
+from the `taskito/contrib/sentry` subpath.
+
+```bash
+npm install @sentry/node
+```
+
+
+
+
+
+Optional dependency: the SDK compiles against `io.sentry:sentry` as
+`compileOnly`, so nothing lands on your classpath until you add the runtime
+dependency yourself.
+
+```kotlin
+implementation("io.sentry:sentry:7.14.0")
+```
+
+
+
+Initialize Sentry, then register the middleware on the queue:
+
+
+
+
+```python
+import sentry_sdk
+from taskito import Queue
+from taskito.contrib.sentry import SentryMiddleware
+
+sentry_sdk.init(dsn="https://examplePublicKey@o0.ingest.sentry.io/0")
+
+queue = Queue(db_path="myapp.db", middleware=[SentryMiddleware()])
+```
+
+
+
+
+```ts
+import * as Sentry from "@sentry/node";
+import { sentryMiddleware } from "@byteveda/taskito/contrib/sentry";
+
+Sentry.init({ dsn: process.env.SENTRY_DSN });
+
+queue.use(sentryMiddleware());
+```
+
+
+
+
+```java
+import io.sentry.Sentry;
+import org.byteveda.taskito.contrib.SentryMiddleware;
+
+Sentry.init(options -> options.setDsn(System.getenv("SENTRY_DSN")));
+
+taskito.use(new SentryMiddleware());
+```
+
+
+
+
+
+ None of the three bindings call `Sentry.init` for you — configure the DSN,
+ environment, and sampling with the Sentry SDK itself. When Sentry hasn't
+ been initialized, the middleware's hooks are safe no-ops.
+
+
+## What it captures
+
+Each binding decides *when* to report a failure differently:
+
+
+
+Every task execution opens a Sentry scope tagged with the job's metadata
+(prefix customizable via `tag_prefix`):
+
+| Tag | Value |
+|-----|-------|
+| `taskito.task_name` | The registered task name |
+| `taskito.job_id` | The job ID |
+| `taskito.queue` | The queue name |
+| `taskito.retry_count` | Current retry attempt |
+
+The Sentry transaction name defaults to `taskito:` (customizable
+via `transaction_name_fn`). When a task raises, `SentryMiddleware` calls
+`sentry_sdk.capture_exception()` automatically — **every failed attempt** is
+reported, not just the terminal failure. When a task is retried, a
+breadcrumb is added (category `taskito` by default, level `warning`,
+message `Retrying (attempt ): `), so the final failure
+carries a trail of the retries leading up to it.
+
+
+
+
+
+The exception (with its stack trace) is captured internally in `onError`
+and held per job. It's only reported to Sentry once the job **dead-letters**
+— so each dead job produces a single event carrying the original stack plus
+the task/job/queue tags. Successful jobs and jobs that recover on retry are
+never reported. Set `captureRetries: true` to also report each intermediate
+retry as a `"warning"`-level event.
+
+Tags set on every event: `taskito.task_name`, `taskito.job_id`,
+`taskito.queue`, `taskito.retry_count`, and `taskito.timed_out` (when the
+job timed out). If a job dead-letters from a timeout without throwing, a
+synthetic error is captured so the failure is still recorded.
+
+
+
+
+
+Two kinds of events are reported:
+
+- **Every failed attempt** — the handler's exception is captured with its
+ stack trace as soon as it throws. A job that recovers on a later retry
+ still produces one event per failed attempt.
+- **Dead-letters** — when a job exhausts its retries, a `FATAL`-level
+ message (`task dead-lettered: `) marks the terminal failure.
+
+Both event kinds carry the tags `taskito.task` (task name) and
+`taskito.job` (job id).
+
+
+
+## Configuration
+
+
+
+
+```python
+SentryMiddleware(
+ tag_prefix="myapp",
+ transaction_name_fn=lambda ctx: f"task-{ctx.task_name}",
+ task_filter=lambda name: not name.startswith("internal."),
+ extra_tags_fn=lambda ctx: {"worker.host": socket.gethostname()},
+)
+```
+
+
+
+
+```ts
+sentryMiddleware({
+ tagPrefix: "myapp",
+ captureRetries: true,
+ level: "error",
+ extraTags: (event) => ({ "worker.host": os.hostname() }),
+ taskFilter: (taskName) => !taskName.startsWith("internal."),
+});
+```
+
+
+
+
+```java
+// The one-argument constructor takes a Predicate over the task
+// name; return false to skip a task entirely.
+new SentryMiddleware(task -> !task.startsWith("noisy."));
+```
+
+
+
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `tag_prefix` | `str` | `"taskito"` | Prefix for Sentry tag keys and breadcrumb category. |
+| `transaction_name_fn` | `Callable[[JobContext], str] \| None` | `None` | Custom transaction name builder. Receives `JobContext`. Defaults to `:`. |
+| `task_filter` | `Callable[[str], bool] \| None` | `None` | Predicate on task name. Return `True` to report, `False` to skip. `None` reports all tasks. |
+| `extra_tags_fn` | `Callable[[JobContext], dict[str, str]] \| None` | `None` | Returns extra Sentry tags to set. Receives `JobContext`. |
+
+
+
+
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `tagPrefix` | `string` | `"taskito"` | Prefix for Sentry tag keys. |
+| `captureRetries` | `boolean` | `false` | Also report each retried failure as a `"warning"` event. |
+| `level` | `SeverityLevel` | `"error"` | Severity for the terminal dead-letter event. |
+| `extraTags` | `(event) => Record` | — | Extra tags merged onto the event. |
+| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task. |
+
+
+
+
+
+`SentryMiddleware` has no other options beyond the task filter — DSN,
+environment, and sampling are all configured through the Sentry SDK itself.
+
+
+
+## Full example
+
+
+
+
+```python
+import sentry_sdk
+from taskito import Queue
+from taskito.contrib.sentry import SentryMiddleware
+
+# Initialize Sentry first
+sentry_sdk.init(
+ dsn="https://examplePublicKey@o0.ingest.sentry.io/0",
+ traces_sample_rate=1.0,
+)
+
+# Create queue with Sentry middleware
+queue = Queue(db_path="myapp.db", middleware=[SentryMiddleware()])
+
+@queue.task(max_retries=3)
+def process_payment(order_id: str, amount: float):
+ """Process a payment — errors are automatically reported to Sentry."""
+ result = payment_gateway.charge(order_id, amount)
+ if not result.success:
+ raise PaymentError(f"Payment failed: {result.error}")
+ return result.transaction_id
+```
+
+When `process_payment` fails, the error appears in Sentry tagged
+`taskito.task_name=myapp.tasks.process_payment`, `taskito.job_id=...`,
+`taskito.queue=default`. Each retry is recorded as a breadcrumb, so the
+final failure (after all retries) includes the full breadcrumb trail.
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+import * as Sentry from "@sentry/node";
+import { sentryMiddleware } from "@byteveda/taskito/contrib/sentry";
+
+// Initialize Sentry first
+Sentry.init({
+ dsn: process.env.SENTRY_DSN,
+ tracesSampleRate: 1.0,
+});
+
+// Create the queue and register the middleware
+const queue = new Queue({ dbPath: "myapp.db" });
+queue.use(sentryMiddleware());
+
+queue.task(
+ "processPayment",
+ async (orderId: string, amount: number) => {
+ const result = await paymentGateway.charge(orderId, amount);
+ if (!result.success) {
+ throw new Error(`Payment failed: ${result.error}`);
+ }
+ return result.transactionId;
+ },
+ { maxRetries: 3 },
+);
+```
+
+If `processPayment` keeps failing until it exhausts its retries, one event
+is sent to Sentry — carrying the original stack trace from the last failed
+attempt plus tags `taskito.task_name=processPayment`, `taskito.job_id=...`,
+`taskito.queue=default`, `taskito.retry_count=3`.
+
+
+
+
+```java
+import io.sentry.Sentry;
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.contrib.SentryMiddleware;
+import org.byteveda.taskito.task.Task;
+import org.byteveda.taskito.worker.Worker;
+
+// Initialize Sentry first
+Sentry.init(options -> {
+ options.setDsn(System.getenv("SENTRY_DSN"));
+ options.setTracesSampleRate(1.0);
+});
+
+Taskito taskito = Taskito.builder().sqlite("myapp.db").open();
+taskito.use(new SentryMiddleware());
+
+Task processPayment = Task.of("process_payment", PaymentRequest.class)
+ .maxRetries(3);
+
+try (Worker worker = taskito.worker()
+ .handle(processPayment, payload ->
+ paymentGateway.charge(payload.orderId(), payload.amount()))
+ .start()) {
+ worker.awaitShutdown();
+}
+```
+
+Each failed attempt of `process_payment` is captured immediately with tags
+`taskito.task=process_payment`, `taskito.job=`. Once retries are
+exhausted, a final `FATAL` dead-letter event marks the terminal failure.
+
+
+
+
+## Combining with other middleware
+
+Sentry middleware composes with any other middleware registered on the
+same queue or worker — see the
+Middleware guide for
+how hooks run across multiple middleware instances.
+
+
+
+```python
+from taskito.contrib.otel import OpenTelemetryMiddleware
+from taskito.contrib.prometheus import PrometheusMiddleware
+
+queue = Queue(
+ db_path="myapp.db",
+ middleware=[
+ OpenTelemetryMiddleware(),
+ PrometheusMiddleware(),
+ SentryMiddleware(),
+ ],
+)
+```
+
+
diff --git a/docs/content/docs/shared/guides/operations/deployment.mdx b/docs/content/docs/shared/guides/operations/deployment.mdx
new file mode 100644
index 00000000..f2ab0ef8
--- /dev/null
+++ b/docs/content/docs/shared/guides/operations/deployment.mdx
@@ -0,0 +1,1217 @@
+---
+title: Deployment
+description: "Process model, storage location, systemd/Docker, backups, monitoring, and sizing in production."
+---
+
+This guide covers running taskito in production.
+
+
+ Unlike Celery (which needs Redis or RabbitMQ as a message broker), taskito's
+ scheduler lives inside the worker process and reads directly from SQLite,
+ Postgres, or Redis. There's no separate broker process to run, monitor, or
+ fail over — one less moving part in production.
+
+
+## Process model
+
+Producers and workers are separate processes that share storage. A typical
+deploy has a web/API process that enqueues jobs, and one or more worker
+processes that claim and run them:
+
+
+
+
+```python
+# myapp.py
+from taskito import Queue
+
+queue = Queue(db_path="/var/lib/myapp/taskito.db")
+
+@queue.task()
+def send_email(to: str, subject: str):
+ ...
+```
+
+```bash
+taskito worker --app myapp:queue --queues default,emails
+```
+
+
+
+
+```ts
+// app.ts — the module `taskito run` loads
+import { Queue } from "@byteveda/taskito";
+
+export const queue = new Queue({ dbPath: "/var/lib/myapp/taskito.db" });
+
+queue.task("sendEmail", (to: string, subject: string) => {
+ // ...
+});
+```
+
+```bash
+taskito run ./app.js --queues default,emails
+```
+
+
+
+
+```java
+try (Taskito taskito = Taskito.builder().sqlite("/var/lib/myapp/taskito.db").open();
+ Worker worker = taskito.worker()
+ .handle(sendEmail, handlers::sendEmail)
+ .queues("default", "emails")
+ .concurrency(8) // fixed pool; 0 (default) = cached pool
+ .start()) {
+ Runtime.getRuntime().addShutdownHook(new Thread(worker::close));
+ worker.awaitShutdown();
+}
+```
+
+
+
+
+With SQLite, every producer and worker must share the same file (same host or
+volume). With Postgres or Redis, they can run on separate machines.
+
+
+
+See [Postgres deployment](#postgres-deployment) below.
+
+
+
+
+
+See Backends .
+
+
+
+
+
+See Backends .
+
+
+
+## SQLite file location
+
+Choose a persistent, backed-up location for the database:
+
+
+
+
+```python
+queue = Queue(db_path="/var/lib/myapp/taskito.db")
+```
+
+
+
+
+```ts
+const queue = new Queue({ dbPath: "/var/lib/myapp/taskito.db" });
+```
+
+
+
+
+```java
+Taskito taskito = Taskito.builder().sqlite("/var/lib/myapp/taskito.db").open();
+```
+
+
+
+
+SQLite runs in **WAL** (write-ahead logging) mode by default — see
+[WAL mode and backups](#wal-mode-and-backups) below — which appends writes to
+a separate log file instead of the main database file, letting many readers
+proceed concurrently with the one writer.
+
+**Best practices:**
+
+- Use an absolute path — relative paths depend on the working directory
+- Place the database on local storage (not NFS or network mounts) — SQLite file locking doesn't work reliably over network filesystems
+- Ensure the directory exists and the worker process has read/write permissions
+- The database file, WAL file (`taskito.db-wal`), and shared memory file (`taskito.db-shm`) must all be on the same filesystem
+
+## Running the worker as a service
+
+
+
+Create `/etc/systemd/system/taskito-worker.service`:
+
+```ini
+[Unit]
+Description=taskito worker
+After=network.target
+
+[Service]
+Type=simple
+User=myapp
+Group=myapp
+WorkingDirectory=/opt/myapp
+ExecStart=/opt/myapp/.venv/bin/taskito worker --app myapp:queue
+Restart=always
+RestartSec=5
+
+# Graceful shutdown — taskito handles SIGINT
+KillSignal=SIGINT
+TimeoutStopSec=35
+
+# Environment
+Environment=PYTHONPATH=/opt/myapp
+
+[Install]
+WantedBy=multi-user.target
+```
+
+
+ Set `TimeoutStopSec` to slightly longer than your longest task timeout
+ (`taskito worker --drain-timeout` defaults to 30s). This gives in-flight
+ tasks time to complete before systemd force-kills the process.
+
+
+
+
+
+
+Create `/etc/systemd/system/taskito-worker.service`:
+
+```ini
+[Unit]
+Description=taskito worker
+After=network.target
+
+[Service]
+Type=simple
+User=myapp
+Group=myapp
+WorkingDirectory=/opt/myapp
+ExecStart=/opt/myapp/node_modules/.bin/taskito run ./app.js --queues default,emails
+Restart=always
+RestartSec=5
+
+Environment=NODE_ENV=production
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`taskito run` installs its own `SIGINT`/`SIGTERM` handlers, so systemd's
+default `KillSignal=SIGTERM` works without overriding it.
+
+
+ On a stop signal, `taskito run` calls `worker.stop()`, waits a **fixed
+ 200ms**, then force-exits the process — it doesn't wait for long-running
+ handlers to actually finish. That's enough for typical short tasks, but if
+ your handlers can run for seconds, write your own entrypoint that calls
+ `queue.runWorker()` / `worker.stop()` directly and waits as long as you need
+ (e.g. until your own in-flight counter hits zero) before exiting.
+
+
+
+
+
+
+There's no worker CLI subcommand — workers are code. Wire the shutdown hook
+shown in [Process model](#process-model) above, then run the jar as a
+systemd service:
+
+```ini
+[Unit]
+Description=taskito worker
+After=network.target
+
+[Service]
+Type=simple
+User=myapp
+Group=myapp
+WorkingDirectory=/opt/myapp
+ExecStart=/usr/bin/java --enable-native-access=ALL-UNNAMED -jar /opt/myapp/app.jar
+Restart=always
+RestartSec=5
+TimeoutStopSec=65
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`worker.close()` (wired to the shutdown hook) drains in-flight handlers for up
+to 30 seconds, then interrupts and waits 30 more — set `TimeoutStopSec` a
+little past that so systemd doesn't force-kill first.
+
+
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable taskito-worker
+sudo systemctl start taskito-worker
+
+# Check logs
+journalctl -u taskito-worker -f
+```
+
+## Docker
+
+### Dockerfile
+
+
+
+```dockerfile
+FROM python:3.12-slim
+
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Store the database in a volume
+VOLUME /data
+
+CMD ["taskito", "worker", "--app", "myapp:queue"]
+```
+
+
+ The `taskito` CLI and the `Queue` constructor do not read a `TASKITO_DB_PATH`
+ environment variable — that env var name is only recognized by the Flask and
+ Django integrations. If your `myapp:queue` module builds the `Queue`
+ directly, read the path yourself (and keep the `ENV TASKITO_DB_PATH=...`
+ line in the Dockerfile above), as shown below.
+
+
+```python
+# myapp.py
+import os
+from taskito import Queue
+
+queue = Queue(db_path=os.environ.get("TASKITO_DB_PATH", "/data/taskito.db"))
+```
+
+
+
+
+
+```dockerfile
+FROM node:20-slim
+
+WORKDIR /app
+COPY package.json pnpm-lock.yaml ./
+RUN corepack enable && pnpm install --frozen-lockfile --prod
+
+COPY . .
+
+# Store the database in a volume
+VOLUME /data
+
+CMD ["node_modules/.bin/taskito", "run", "./app.js"]
+```
+
+
+ A prebuilt native addon ships for both glibc and musl Linux, so `node:20-slim`
+ and `node:20-alpine` both work with no extra build step — no Python or Rust
+ toolchain required in the image.
+
+
+
+
+
+
+Illustrative — `shadowJar` assumes the Gradle Shadow plugin builds a fat jar
+with your app's `main` class as the entry point; swap in whatever packaging
+task your build actually uses:
+
+```dockerfile
+FROM gradle:8-jdk21 AS build
+WORKDIR /src
+COPY . .
+RUN ./gradlew --no-daemon shadowJar
+
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+COPY --from=build /src/build/libs/app.jar app.jar
+
+VOLUME /data
+ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "app.jar"]
+```
+
+
+ The jar extracts the native engine to a per-user directory on first use.
+ Standard glibc-based images (`eclipse-temurin`, `debian`, `ubuntu`) work out
+ of the box. On a host where `/tmp` is `noexec`, set
+ `-Dtaskito.native.workdir=/path` to redirect extraction; set
+ `-Dtaskito.native.lib=/path/to/library` to skip extraction and load an
+ explicit binary (e.g. one built for musl).
+
+
+On JDK 22+, the hot byte operations automatically use a Java FFM (Panama) fast
+path packaged as a multi-release-jar overlay; older JDKs transparently fall
+back to JNI — same API either way. FFM calls are "restricted native access":
+`--enable-native-access=ALL-UNNAMED` (in the `ENTRYPOINT` above) grants access
+and silences the warning.
+
+
+
+### docker-compose.yml
+
+
+
+
+```yaml
+services:
+ worker:
+ build: .
+ volumes:
+ - taskito-data:/data
+ stop_signal: SIGINT
+ stop_grace_period: 35s
+
+ dashboard:
+ build: .
+ command: taskito dashboard --app myapp:queue --host 0.0.0.0
+ volumes:
+ - taskito-data:/data
+ ports:
+ - "8080:8080"
+
+volumes:
+ taskito-data:
+```
+
+
+
+
+```yaml
+services:
+ worker:
+ build: .
+ volumes:
+ - taskito-data:/data
+ stop_signal: SIGTERM
+ stop_grace_period: 10s
+
+ dashboard:
+ build: .
+ command: node_modules/.bin/taskito --db /data/taskito.db dashboard --host 0.0.0.0
+ volumes:
+ - taskito-data:/data
+ ports:
+ - "8787:8787"
+
+volumes:
+ taskito-data:
+```
+
+
+
+
+```yaml
+services:
+ worker:
+ build: .
+ volumes:
+ - taskito-data:/data
+ stop_signal: SIGTERM
+ stop_grace_period: 65s
+
+ dashboard:
+ build: .
+ command: java -cp app.jar org.byteveda.taskito.cli.Cli --url /data/taskito.db dashboard --port 8080
+ volumes:
+ - taskito-data:/data
+ ports:
+ - "8080:8080"
+
+volumes:
+ taskito-data:
+```
+
+
+
+
+
+ The worker and dashboard must access the **same SQLite file**. In Docker,
+ use a named volume shared between containers. Do not use bind mounts on
+ network storage.
+
+
+### Graceful shutdown in containers
+
+
+
+taskito handles `SIGINT` for graceful shutdown. Configure your container
+orchestrator to send `SIGINT` (not `SIGTERM`):
+
+- **Docker Compose**: `stop_signal: SIGINT`
+- **Kubernetes**: use a `preStop` hook or configure `STOPSIGNAL` in the Dockerfile:
+
+```dockerfile
+STOPSIGNAL SIGINT
+```
+
+For Kubernetes, set `terminationGracePeriodSeconds` to match your longest
+task timeout:
+
+```yaml
+spec:
+ terminationGracePeriodSeconds: 60
+ containers:
+ - name: worker
+ ...
+```
+
+
+
+
+
+`taskito run` already listens for both `SIGINT` and `SIGTERM`, so the default
+signal Docker and Kubernetes send (`SIGTERM`) works without changing
+`stop_signal`/`STOPSIGNAL`. Remember the fixed ~200ms grace window called out
+above — a short `stop_grace_period` / `terminationGracePeriodSeconds` (10s is
+plenty) is fine for the CLI, but a custom entrypoint with its own drain logic
+needs a longer one to match:
+
+```yaml
+spec:
+ terminationGracePeriodSeconds: 30
+ containers:
+ - name: worker
+ ...
+```
+
+
+
+
+
+The shutdown hook calls `worker.close()` on `SIGTERM`, Docker's and
+Kubernetes' default signal — no `STOPSIGNAL` override needed. `close()` drains
+for up to 30 seconds, then interrupts and waits 30 more, so give the
+orchestrator enough room:
+
+```yaml
+spec:
+ terminationGracePeriodSeconds: 65
+ containers:
+ - name: worker
+ ...
+```
+
+
+
+## WAL mode and backups
+
+taskito uses SQLite in WAL (write-ahead logging) mode for concurrent
+read/write access. This affects how you back up the database.
+
+**Do NOT** simply copy the `.db` file while the worker is running — you may
+get a corrupted backup if the WAL hasn't been checkpointed.
+
+**Safe backup methods**, both safe while the worker is running:
+
+```bash
+# Option 1: sqlite3 .backup command (safe, online)
+sqlite3 /var/lib/myapp/taskito.db ".backup /backups/taskito-$(date +%Y%m%d).db"
+
+# Option 2: SQLite VACUUM INTO command
+sqlite3 /var/lib/myapp/taskito.db "VACUUM INTO '/backups/taskito-$(date +%Y%m%d).db';"
+```
+
+## Postgres deployment
+
+
+
+If you're using the [Postgres backend](/python/guides/operations/postgres),
+deployment is simpler in several ways:
+
+
+
+
+
+Switching to Postgres or Redis
+removes SQLite's shared-file constraint:
+
+
+
+
+
+Switching to Postgres or Redis
+removes SQLite's shared-file constraint:
+
+
+
+- **No shared-file constraints** — workers connect over the network, no need for shared volumes or local storage
+- **Multi-machine workers** — run workers on separate hosts against the same database
+- **Standard backups** — use `pg_dump` instead of `sqlite3 .backup`
+
+
+
+
+```yaml
+services:
+ postgres:
+ image: postgres:16
+ environment:
+ POSTGRES_DB: myapp
+ POSTGRES_USER: taskito
+ POSTGRES_PASSWORD: secret
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ worker:
+ build: .
+ environment:
+ TASKITO_BACKEND: postgres
+ TASKITO_DB_URL: postgresql://taskito:secret@postgres:5432/myapp
+ depends_on:
+ - postgres
+ stop_signal: SIGINT
+ stop_grace_period: 35s
+
+volumes:
+ pgdata:
+```
+
+
+ `TASKITO_BACKEND`/`TASKITO_DB_URL` above are only read automatically by the
+ Flask and Django integrations — if `myapp:queue` builds the `Queue` directly,
+ read them yourself, the same way the [Dockerfile](#dockerfile) section above
+ does for `TASKITO_DB_PATH`.
+
+
+
+
+
+```yaml
+services:
+ postgres:
+ image: postgres:16
+ environment:
+ POSTGRES_DB: myapp
+ POSTGRES_USER: taskito
+ POSTGRES_PASSWORD: secret
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ worker:
+ build: .
+ environment:
+ TASKITO_PG_URL: postgresql://taskito:secret@postgres:5432/myapp
+ depends_on:
+ - postgres
+ stop_signal: SIGTERM
+ stop_grace_period: 10s
+
+volumes:
+ pgdata:
+```
+
+
+ `TASKITO_PG_URL` above is illustrative — read it in your own entrypoint
+ (`new Queue({ backend: "postgres", dsn: process.env.TASKITO_PG_URL })`); the
+ `taskito` CLI and `runWorker()` don't read it automatically.
+
+
+
+
+
+```yaml
+services:
+ postgres:
+ image: postgres:16
+ environment:
+ POSTGRES_DB: myapp
+ POSTGRES_USER: taskito
+ POSTGRES_PASSWORD: secret
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ worker:
+ build: .
+ environment:
+ TASKITO_PG_URL: postgresql://taskito:secret@postgres:5432/myapp
+ depends_on:
+ - postgres
+ stop_signal: SIGTERM
+ stop_grace_period: 65s
+
+volumes:
+ pgdata:
+```
+
+
+ `TASKITO_PG_URL` above is illustrative — read it yourself (e.g.
+ `Taskito.builder().postgres(System.getenv("TASKITO_PG_URL")).open()`); the
+ builder has no automatic env-var lookup.
+
+
+
+
+
+### Backups
+
+```bash
+# Dump the taskito schema
+pg_dump -h localhost -U taskito -d myapp -n taskito > backup.sql
+
+# Restore
+psql -h localhost -U taskito -d myapp < backup.sql
+```
+
+
+
+See the [Postgres Backend guide](/python/guides/operations/postgres) for full
+configuration details.
+
+
+
+
+
+See Backends for Redis, and
+for when to reach for Postgres over SQLite.
+
+
+
+
+
+See Backends for Redis, and
+for when to reach for Postgres over SQLite.
+
+
+
+## Database maintenance
+
+### Auto-cleanup
+
+
+
+Set `result_ttl` to automatically purge old completed jobs:
+
+```python
+queue = Queue(
+ db_path="/var/lib/myapp/taskito.db",
+ result_ttl=86400, # Purge completed/dead jobs older than 24 hours
+)
+```
+
+
+
+
+
+There's no built-in TTL option — run [manual cleanup](#manual-cleanup) on a
+schedule instead (a cron job, or a periodic task on the queue itself).
+
+
+
+
+
+There's no built-in TTL option — run [manual cleanup](#manual-cleanup) on a
+schedule instead (a cron job, or a periodic task on the queue itself).
+
+
+
+### Manual cleanup
+
+
+
+
+```python
+# Purge completed jobs older than 7 days (older_than is in seconds)
+queue.purge_completed(older_than=604800)
+
+# Purge dead letters older than 30 days
+queue.purge_dead(older_than=2592000)
+```
+
+
+
+
+```ts
+// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
+await queue.purgeCompleted(7 * 24 * 60 * 60 * 1000);
+
+// Purge dead letters older than 30 days
+await queue.purgeDead(30 * 24 * 60 * 60 * 1000);
+```
+
+
+
+
+```java
+// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
+taskito.purgeCompleted(Duration.ofDays(7).toMillis());
+
+// Purge dead letters older than 30 days
+taskito.purgeDead(Duration.ofDays(30).toMillis());
+```
+
+
+
+
+### Database size
+
+The database grows as jobs accumulate — roughly 1 KB per job for metadata and
+small payloads, more for large arguments or results. Without cleanup, expect
+steady growth; with regular purging (above) the database stays compact. You
+can also periodically run `VACUUM` to reclaim space:
+
+```bash
+sqlite3 /var/lib/myapp/taskito.db "VACUUM;"
+```
+
+
+ `VACUUM` rewrites the entire database and requires exclusive access. Run
+ it during low-traffic periods or during a maintenance window.
+
+
+## Monitoring in production
+
+### Dashboard
+
+
+
+
+```bash
+taskito dashboard --app myapp:queue --host 0.0.0.0 --port 8080
+```
+
+
+
+
+```bash
+taskito --db taskito.db dashboard --host 0.0.0.0 --port 8787
+```
+
+
+
+
+```bash
+taskito --url taskito.db dashboard --port 8080
+```
+
+
+
+
+
+
+The dashboard enforces session authentication once at least one admin exists;
+until then every `/api/*` route returns `503 setup_required`. Bootstrap the
+first admin with `TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`,
+or create one through the SPA's first-run setup. Serve it over TLS — see
+[Security: Dashboard](/python/guides/operations/security#dashboard) for the
+full auth, cookie, and role model.
+
+
+
+
+
+Auth runs **open** by default — anyone who can reach the port has full
+access. Pass a bearer token (`--token`, or `auth: { token }` to
+`serveDashboard`) for a quick gate, or mount the dashboard behind your own
+auth via the Express or
+[Fastify](/node/guides/integrations/fastify) helpers for real login/RBAC.
+Either way, put it behind TLS in production.
+
+
+
+
+
+The dashboard enforces session authentication (with OAuth/SSO support) by
+default; pass a `token` to `start(...)`/`dashboard(...)` for the legacy
+single-credential mode instead. Bootstrap the first admin with
+`TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`, or
+through the SPA's first-run setup. See
+[Dashboard: Auth](/java/guides/operations/dashboard#auth) for the full model.
+
+
+
+Every SDK's dashboard also exposes liveness/readiness probes outside the auth
+gate, for load balancer and orchestrator health checks:
+
+```bash
+curl http://localhost:8080/health # always public — liveness
+curl http://localhost:8080/readiness # storage/worker/resource readiness
+```
+
+### Programmatic stats
+
+Poll queue stats and export them to your monitoring system:
+
+
+
+
+```python
+import time
+
+def export_metrics():
+ while True:
+ stats = queue.stats()
+ # Export to Prometheus, Datadog, StatsD, etc.
+ gauge("taskito.pending", stats["pending"])
+ gauge("taskito.running", stats["running"])
+ gauge("taskito.dead", stats["dead"])
+ time.sleep(15)
+```
+
+
+
+
+```ts
+async function exportMetrics() {
+ for (;;) {
+ const stats = await queue.stats();
+ // Export to Prometheus, Datadog, StatsD, etc.
+ gauge("taskito.pending", stats.pending);
+ gauge("taskito.running", stats.running);
+ gauge("taskito.dead", stats.dead);
+ await new Promise((r) => setTimeout(r, 15_000));
+ }
+}
+```
+
+
+
+
+```java
+void exportMetrics() throws InterruptedException {
+ while (true) {
+ QueueStats stats = taskito.stats();
+ // Export to Prometheus, Datadog, StatsD, etc.
+ gauge("taskito.pending", stats.pending);
+ gauge("taskito.running", stats.running);
+ gauge("taskito.dead", stats.dead);
+ Thread.sleep(15_000);
+ }
+}
+```
+
+
+
+
+
+
+For a ready-made exporter instead of polling by hand, see
+[Prometheus](/python/guides/integrations/prometheus).
+
+
+
+
+
+For a ready-made exporter instead of polling by hand, see
+[Prometheus](/node/guides/integrations/prometheus).
+
+
+
+
+
+For a ready-made exporter instead of polling by hand, see
+[Micrometer](/java/guides/integrations/micrometer).
+
+
+
+### Alerting on failure
+
+
+
+
+```python
+@queue.on_failure
+def alert_on_failure(task_name, args, kwargs, error):
+ # Fires on every raised exception, including ones that still have
+ # retries left. Send to PagerDuty, Slack, email, etc.
+ notify(f"Task {task_name} failed: {error}")
+```
+
+
+
+
+```ts
+// Fires once a job exhausts its retries and dead-letters.
+queue.on("job.dead", (event) => {
+ notify(`Task ${event.taskName} dead-lettered: ${event.error}`);
+});
+```
+
+
+
+
+```java
+// Fires once a job exhausts its retries and dead-letters.
+taskito.worker()
+ .handle(sendEmail, handlers::sendEmail)
+ .on(EventName.DEAD, event -> notify("Task dead-lettered: " + event.error))
+ .start();
+```
+
+
+
+
+
+
+`on_failure` fires on every attempt that raises, whether or not a retry
+follows. To alert only once a job is permanently dead-lettered, use
+`queue.on_event(EventType.JOB_DEAD, ...)` instead — see
+[Events & Webhooks](/python/guides/extensibility/events-webhooks).
+
+
+
+
+
+See [Events](/node/guides/extensibility/events) for the full set
+(`job.completed`, `job.retrying`, `job.dead`, `job.cancelled`) and
+[Webhooks](/node/guides/extensibility/webhooks) to deliver them to an
+external HTTP endpoint instead of in-process.
+
+
+
+
+
+See [Events](/java/guides/extensibility/events) for the full set (`SUCCESS`,
+`RETRY`, `DEAD`, `CANCELLED`) and
+[Webhooks](/java/guides/extensibility/webhooks) to deliver them to an
+external HTTP endpoint instead of in-process.
+
+
+
+### Health check endpoint
+
+
+
+```python
+from fastapi import FastAPI
+from taskito.contrib.fastapi import TaskitoRouter
+
+app = FastAPI()
+app.include_router(TaskitoRouter(queue), prefix="/tasks")
+
+# GET /tasks/stats returns queue health
+# Use this as a health check endpoint in your load balancer
+```
+
+`TaskitoRouter` has no auth of its own — mount it behind your app's existing
+auth, or stick to the dashboard's public `/health`/`/readiness` probes above.
+
+
+
+
+
+```ts
+import express from "express";
+import { taskitoRouter } from "@byteveda/taskito/contrib/express";
+
+const app = express();
+app.use("/tasks", taskitoRouter(queue));
+
+// GET /tasks/stats returns queue health
+// Use this as a health check endpoint in your load balancer
+```
+
+`taskitoRouter` has no auth of its own — mount it behind your app's existing
+auth, or stick to the dashboard's public `/health`/`/readiness` probes above.
+
+
+
+
+
+There's no lightweight REST-framework helper — use the dashboard's `/health`
+and `/readiness` probes above (public by default; gate them with
+`TASKITO_DASHBOARD_METRICS_TOKEN`), or add a route in your own web framework
+that calls `taskito.stats()`.
+
+
+
+## Multiple workers
+
+
+
+taskito is designed as a **single-process** task queue when using SQLite.
+Running multiple worker processes against the same SQLite file is possible
+(WAL mode allows concurrent access), but:
+
+- Only one process can write at a time — this limits throughput
+- SQLite lock contention increases with more writers
+- There is no distributed coordination between workers
+
+For most single-machine workloads, one worker process with multiple threads
+(the default) is sufficient:
+
+```python
+queue = Queue(
+ db_path="myapp.db",
+ workers=8, # 8 OS threads in the worker pool
+)
+```
+
+If you need distributed workers across multiple machines, use the
+[Postgres backend](/python/guides/operations/postgres) which removes the
+single-writer constraint and supports multi-machine deployments.
+
+
+
+
+
+There's no thread-pool size to tune — a Node worker dispatches jobs as
+ordinary event-loop async invocations, bounded by `channelCapacity` and
+per-task/per-queue concurrency
+caps , not an OS thread count. Scaling out means running more worker
+**processes** (or hosts) against the same storage — the core claims each job
+for exactly one of them, so adding workers adds throughput without duplicate
+execution. See [Execution model](/node/guides/core/execution-model).
+
+Multiple processes against one SQLite file work (WAL mode allows concurrent
+access), but only one can write at a time — heavy concurrent writers surface
+as `SQLite database is locked` errors. Move to
+Postgres or Redis once that
+happens.
+
+
+
+
+
+Size the handler thread pool with `concurrency(n)`, or let
+[autoscale](/java/guides/operations/autoscaling) resize it between a min and
+max as queue depth changes:
+
+```java
+taskito.worker()
+ .handle(sendEmail, handlers::sendEmail)
+ .concurrency(8) // fixed pool; near the core count for CPU-bound work
+ .start();
+```
+
+Scaling out means running more worker **processes** (or hosts) against shared
+storage. Multiple processes against one SQLite file work (WAL mode allows
+concurrent access), but only one can write at a time — move to
+Postgres or Redis for
+heavy concurrent writers or true multi-machine deployment.
+
+
+
+## SQLite scaling limits
+
+taskito uses SQLite as its default storage backend. Understanding its
+limitations helps you plan for production.
+
+**Single-writer constraint.** SQLite allows only one write transaction at a
+time. WAL mode lets reads proceed concurrently with writes, but all writes
+are serialized — this is the primary throughput ceiling, regardless of SDK.
+
+
+
+**Expected throughput.** On modern hardware with an SSD, expect:
+
+- **1,000–5,000 jobs/second** for enqueue + dequeue cycles (small payloads)
+- Throughput decreases with larger payloads, complex queries, or spinning disks
+- The connection pool size is fixed at 8 for the SQLite backend — it is not
+ configurable from the Python `Queue` constructor. The `pool_size` argument
+ only takes effect on the
+ [Postgres backend](/python/guides/operations/postgres#connection-pooling)
+
+**When to upgrade to Postgres:**
+
+- You need multi-machine distributed workers
+- You consistently exceed ~5,000 jobs/second sustained throughput
+- Multiple processes contend heavily for writes (high lock wait times)
+- You need sub-millisecond dequeue latency under high load
+
+taskito's [Postgres backend](/python/guides/operations/postgres) addresses all
+of these limitations while keeping the same API.
+
+
+
+
+
+Unlike the Python SDK, the SQLite connection pool size is configurable here
+too (default 8) — `new Queue({ poolSize: 16 })` — though the single-writer
+constraint above still caps write throughput regardless of pool size. When
+lock contention or the need for multi-machine workers shows up, move to
+Postgres or Redis ; exact
+throughput numbers depend heavily on payload size and handler duration, so
+benchmark your own workload rather than relying on a generic figure.
+
+
+
+
+
+Unlike the Python SDK, the SQLite connection pool size is configurable here
+too (default 8) — `.poolSize(16)` on the builder — though the single-writer
+constraint above still caps write throughput regardless of pool size. When
+lock contention or the need for multi-machine workers shows up, move to
+Postgres or Redis ; exact
+throughput numbers depend heavily on payload size and handler duration, so
+benchmark your own workload rather than relying on a generic figure.
+
+
+
+## Sizing your deployment
+
+
+
+| Throughput | Backend | Workers | Pool | Notes |
+|-----------|---------|---------|------|-------|
+| < 100 jobs/s | SQLite | 4 | thread | Default config works fine |
+| 100–1K jobs/s | SQLite | 8–16 | thread or prefork | Increase `workers`, monitor WAL size |
+| 1K–5K jobs/s | SQLite | 16 | prefork | Prefork for CPU-bound; SQLite handles this well with WAL |
+| 5K–20K jobs/s | Postgres | 16–32 | prefork | Switch to Postgres for concurrent writers |
+| 20K–50K jobs/s | Postgres | 32+ | prefork | Multiple worker processes, tune `pool_size` |
+| > 50K jobs/s | — | — | — | Consider Celery + RabbitMQ for this scale |
+
+`Pool` is the worker execution pool: `thread` (default, one process with
+multiple OS threads) or `prefork` (multiple worker subprocesses, true CPU
+parallelism). See [Prefork Workers](/python/guides/advanced-execution/prefork)
+for when and how to switch.
+
+
+ These are rough guidelines for noop tasks. Real throughput depends on task
+ duration, payload size, and I/O patterns.
+
+
+
+
+
+
+There's no published throughput table for the Node SDK — the event-loop
+execution model (no thread-pool sizing to tune) makes it a poor fit for the
+kind of `workers`/`pool` matrix the Python SDK publishes. As a starting
+point:
+
+- CPU-bound handlers: offload to `worker_threads` and keep the event loop free
+- I/O-bound handlers: raise `channelCapacity`/`batchSize` and add worker processes
+- Once SQLite write contention shows up, move to
+ Postgres or Redis
+
+See [Execution model](/node/guides/core/execution-model) and
+[Concurrency](/node/guides/reliability/concurrency). Benchmark your own
+handlers rather than assuming generic numbers transfer.
+
+
+
+
+
+There's no published throughput table for the Java SDK — real-thread
+execution and `concurrency`/`autoscale` sizing don't map onto the Python
+SDK's `workers`/`pool` matrix. As a starting point:
+
+- CPU-bound handlers: size `concurrency(n)` near the core count
+- I/O-bound handlers: let the cached pool (default) or
+ [autoscale](/java/guides/operations/autoscaling) breathe with queue depth
+- Once SQLite write contention shows up, move to
+ Postgres or Redis
+
+See [Execution model](/java/guides/core/execution-model). Benchmark your own
+handlers rather than assuming generic numbers transfer.
+
+
+
+## Checklist
+
+- [ ] Use an absolute path for the SQLite file
+- [ ] Place SQLite on local (not network) storage
+- [ ] Purge old completed/dead jobs on a schedule
+ (result_ttl automates this>} node={<>no built-in TTL — schedule purgeCompleted/purgeDead>} java={<>no built-in TTL — schedule purgeCompleted/purgeDead>} />)
+- [ ] Set a timeout on tasks to recover from worker crashes
+ (timeout=} node={timeoutMs} java={.timeout(Duration...)} />)
+- [ ] Configure the process manager's stop signal correctly
+ (SIGINT} node={<>SIGINT/SIGTERM, mind the fixed 200ms CLI grace>} java={<>SIGTERM via a shutdown hook>} />)
+- [ ] Set up failure hooks or monitoring for alerting
+- [ ] Back up the database using `sqlite3 .backup` (not file copy), or `pg_dump` for Postgres
+- [ ] Put the dashboard behind TLS
+ ( )
diff --git a/docs/content/docs/shared/guides/operations/keda.mdx b/docs/content/docs/shared/guides/operations/keda.mdx
new file mode 100644
index 00000000..cc1ae0f0
--- /dev/null
+++ b/docs/content/docs/shared/guides/operations/keda.mdx
@@ -0,0 +1,559 @@
+---
+title: KEDA Autoscaling
+description: "Scale taskito worker replicas on Kubernetes by queue depth, using the bundled scaler server."
+---
+
+[KEDA](https://keda.sh) (Kubernetes Event-driven Autoscaling) scales a worker
+`Deployment` up and down based on queue depth. taskito ships a dedicated
+scaler server that KEDA's `metrics-api` trigger polls directly — no separate
+metrics pipeline required.
+
+
+ Requires a Kubernetes cluster with the [KEDA operator](https://keda.sh/docs/latest/deploy/)
+ installed.
+
+
+
+
+
+ Running on bare metal, Docker, or systemd without Kubernetes? Use the
+ bare-metal autoscaler
+ instead — it spawns and drains `taskito worker` subprocesses with the same
+ HPA-style formula.
+
+
+
+
+
+ The scaler's `/api/scaler` endpoint is unauthenticated by design — keep it
+ reachable only from inside the cluster (`ClusterIP`, never a public
+ `LoadBalancer` or `Ingress`).
+
+
+
+
+`taskito scaler` and `serve_scaler()` bind to `127.0.0.1` by default — see
+Security: Scaler
+bind address . Widen to `0.0.0.0` only behind a trusted network
+boundary (e.g. the ClusterIP Service below).
+
+
+
+
+
+`serveScaler` binds `0.0.0.0` by default, which is what lets a Kubernetes
+Service reach it out of the box — bind to `127.0.0.1` explicitly if you ever
+run it outside a container on a shared host.
+
+
+
+
+
+`ScalerOptions.defaults()` binds `0.0.0.0` by default, which is what lets a
+Kubernetes Service reach it out of the box — pass an explicit `host` if you
+ever run it outside a container on a shared host.
+
+
+
+## Scaler server
+
+
+
+
+```bash
+taskito scaler --app myapp:queue --port 9091
+```
+
+Or programmatically:
+
+```python
+from taskito.scaler import serve_scaler
+
+serve_scaler(queue, host="0.0.0.0", port=9091, target_queue_depth=10)
+```
+
+
+
+
+```ts
+import { Queue, serveScaler } from "@byteveda/taskito";
+
+const queue = new Queue({ backend: "postgres", dsn: process.env.DATABASE_URL });
+serveScaler(queue, { port: 9091, targetQueueDepth: 10 });
+```
+
+Or from the CLI, over a backend:
+
+```bash
+taskito --backend postgres --dsn "$DATABASE_URL" scaler --port 9091
+```
+
+
+
+
+```java
+try (Taskito taskito = Taskito.builder().postgres(System.getenv("DATABASE_URL")).open();
+ Scaler scaler = Scaler.start(taskito, ScalerOptions.onPort(9090))) {
+ Thread.currentThread().join(); // serve until the process is killed
+}
+```
+
+(`main` needs `throws InterruptedException` for the `join()`.)
+
+There's no bundled CLI for the scaler — embed `Scaler.start(...)` in a small
+entrypoint alongside (or instead of) your worker process.
+
+
+
+
+### Options
+
+
+
+| Flag | Default | Description |
+|---|---|---|
+| `--app` | — (required) | Python path to the `Queue` instance, e.g. `myapp.tasks:queue` |
+| `--host` | `127.0.0.1` | Bind address |
+| `--port` | `9091` | Bind port |
+| `--target-queue-depth` | `10` | Scaling target hint returned to KEDA |
+
+`serve_scaler(queue, host=, port=, target_queue_depth=)` takes the same
+options as keyword arguments.
+
+
+
+
+
+| Option | Default | Description |
+|---|---|---|
+| `port` | `9091` | Listen port |
+| `host` | `"0.0.0.0"` | Bind address |
+| `targetQueueDepth` | `10` | Target depth per replica, returned to KEDA |
+| `queue` | all queues | Restrict the metric to one queue; overridable per request via `?queue=` |
+
+The CLI mirrors these as `--port`, `--host`, `--target-queue-depth`, and
+`--queue`, plus the global `--backend`/`--dsn` connection flags.
+
+
+
+
+
+| `ScalerOptions` field | Default | Description |
+|---|---|---|
+| `port` | `9090` | Bind port. `0` picks an ephemeral port — `scaler.port()` reports it. |
+| `host` | `"0.0.0.0"` | Bind address |
+| `targetQueueDepth` | `10` | Target depth per replica; must be `> 0` |
+| `queue` | `null` (all queues) | Restrict the metric to one queue |
+
+`ScalerOptions.defaults()` sets all four; `ScalerOptions.onPort(port)` keeps
+the rest at their defaults.
+
+
+
+### Endpoints
+
+| Endpoint | Returns |
+|---|---|
+| `GET /api/scaler[?queue=]` | Current queue depth and scaling target |
+| `GET /health` | Liveness check — always `{"status": "ok"}` |
+
+
+
+Also serves `GET /metrics` in Prometheus text format when `prometheus-client`
+is installed (`501` otherwise).
+
+
+
+### Response shape
+
+KEDA only reads the field named by `valueLocation` in the trigger config
+(`metricValue` below) — everything else in the response is informational.
+
+
+
+
+```json
+{
+ "metricName": "taskito_queue_depth",
+ "metricValue": 42,
+ "isActive": true,
+ "liveWorkers": 3,
+ "totalCapacity": 12,
+ "targetQueueDepth": 10,
+ "workerUtilization": 0.583,
+ "perQueue": {
+ "default": { "pending": 42, "running": 7 }
+ }
+}
+```
+
+Filtering with `?queue=emails` narrows `metricValue`, `isActive`, and
+`metricName` (suffixed `taskito_queue_depth_emails`) to that queue —
+`perQueue` still reports every queue. There's no separate `queueName` field;
+the filter shows up in `metricName` instead.
+
+
+
+
+```json
+{ "metricValue": 42, "targetValue": 10, "queueName": "*" }
+```
+
+`metricValue` is the pending count for `queueName` (`"*"` for all queues, or
+the name passed via `?queue=`).
+
+
+
+
+```json
+{ "metricValue": 42, "targetValue": 10, "queueName": "all" }
+```
+
+`metricValue` is `pending + running` (outstanding work), not pending-only —
+account for that when tuning `targetValue` if you're porting a threshold from
+Python or Node.
+
+
+
+
+## Kubernetes deployment
+
+Deploy the scaler as its own `Deployment`, exposed as a `ClusterIP` Service:
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: taskito-scaler
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: taskito-scaler
+ template:
+ metadata:
+ labels:
+ app: taskito-scaler
+ spec:
+ containers:
+ - name: scaler
+ image: your-image:latest
+ command: [ ] # see "Container command" below
+ ports:
+ - containerPort: 9091
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: taskito-scaler
+spec:
+ selector:
+ app: taskito-scaler
+ ports:
+ - port: 9091
+ targetPort: 9091
+```
+
+
+ Run the scaler as its own small Deployment + Service (not in the worker
+ pod) so scaling to zero workers doesn't take the metric source down with
+ it.
+
+
+Container command:
+
+
+
+
+```yaml
+command: ["taskito", "scaler", "--app", "myapp:queue", "--port", "9091", "--host", "0.0.0.0"]
+```
+
+The explicit `--host 0.0.0.0` matters — `taskito scaler` binds to
+`127.0.0.1` by default, which the Service above can't reach.
+
+
+
+
+```yaml
+command: ["taskito", "--backend", "postgres", "--dsn", "$(DATABASE_URL)", "scaler", "--port", "9091"]
+env:
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: taskito-db
+ key: dsn
+```
+
+
+
+
+```yaml
+command: ["java", "-cp", "app.jar", "com.example.ScalerMain"]
+env:
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: taskito-db
+ key: dsn
+```
+
+with a small entrypoint packaged into `app.jar` — bound to `9091` here to
+match the Service above (Java's own default is `9090`, see [Options](#options)):
+
+```java
+public final class ScalerMain {
+ public static void main(String[] args) throws InterruptedException {
+ try (Taskito taskito = Taskito.builder().postgres(System.getenv("DATABASE_URL")).open();
+ Scaler scaler = Scaler.start(taskito, new ScalerOptions(9091, "0.0.0.0", 10, null))) {
+ Thread.currentThread().join(); // serve until the container is killed
+ }
+ }
+}
+```
+
+
+
+
+## ScaledObject (HTTP trigger)
+
+Scale a long-running worker `Deployment` based on pending job count. This
+config is identical regardless of which SDK built the scaler — it only talks
+to the HTTP endpoint above:
+
+```yaml
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+ name: taskito-worker
+ namespace: default
+spec:
+ scaleTargetRef:
+ name: taskito-worker # your worker Deployment name
+ pollingInterval: 15 # seconds between KEDA polls
+ cooldownPeriod: 60 # seconds before scaling to zero
+ minReplicaCount: 0 # scale to zero when idle
+ maxReplicaCount: 10
+ triggers:
+ - type: metrics-api
+ metadata:
+ url: "http://taskito-scaler:9091/api/scaler"
+ valueLocation: "metricValue"
+ targetValue: "10"
+```
+
+Filter to a specific queue name:
+
+```yaml
+ url: "http://taskito-scaler:9091/api/scaler?queue=emails"
+```
+
+
+
+Adjust the port to `9090` (the default for `ScalerOptions`) unless you
+overrode it when starting the scaler.
+
+
+
+## ScaledJob (ephemeral batch workers)
+
+For batch/ETL workloads, use `ScaledJob` to create short-lived Kubernetes
+Jobs — one pod per N pending tasks — instead of scaling a long-running
+`Deployment`:
+
+```yaml
+apiVersion: keda.sh/v1alpha1
+kind: ScaledJob
+metadata:
+ name: taskito-batch-worker
+ namespace: default
+spec:
+ jobTargetRef:
+ template:
+ spec:
+ containers:
+ - name: taskito-worker
+ image: your-image:latest
+ command: [ ] # see "Worker container command" below
+ restartPolicy: Never
+ pollingInterval: 15
+ successfulJobsHistoryLimit: 5
+ failedJobsHistoryLimit: 5
+ maxReplicaCount: 20
+ scalingStrategy:
+ strategy: default # or "accurate" for 1:1 job-to-pod mapping
+ triggers:
+ - type: metrics-api
+ metadata:
+ url: "http://taskito-scaler:9091/api/scaler"
+ valueLocation: "metricValue"
+ targetValue: "5" # one pod per 5 pending jobs
+```
+
+Worker container command:
+
+
+
+
+```yaml
+command: ["taskito", "worker", "--app", "myapp:queue"]
+```
+
+
+
+
+```yaml
+command: ["taskito", "run", "./worker.js"]
+```
+
+
+
+
+```yaml
+command: ["java", "-jar", "worker.jar"]
+```
+
+Package a `main()` that opens a `Taskito`, registers handlers with
+`taskito.worker()`, and calls `worker.awaitShutdown()` — see
+Deployment .
+
+
+
+
+## Scaling with Prometheus
+
+
+
+If you already have Prometheus scraping your workers, skip the scaler server
+and point KEDA's `prometheus` trigger at it directly:
+
+```yaml
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+ name: taskito-worker-prometheus
+ namespace: default
+spec:
+ scaleTargetRef:
+ name: taskito-worker
+ pollingInterval: 15
+ cooldownPeriod: 60
+ minReplicaCount: 0
+ maxReplicaCount: 10
+ triggers:
+ - type: prometheus
+ metadata:
+ serverAddress: "http://prometheus:9090"
+ metricName: taskito_queue_depth
+ query: sum(taskito_queue_depth{queue="default"})
+ threshold: "10"
+ - type: prometheus
+ metadata:
+ serverAddress: "http://prometheus:9090"
+ metricName: taskito_worker_utilization
+ query: taskito_worker_utilization{queue="default"}
+ threshold: "0.8"
+```
+
+Both metrics come from `PrometheusStatsCollector` — see the
+Prometheus integration .
+
+
+
+
+
+If you already have Prometheus scraping your workers, skip the scaler server
+and point KEDA's `prometheus` trigger at the queue-depth gauge directly:
+
+```yaml
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+ name: taskito-worker-prometheus
+ namespace: default
+spec:
+ scaleTargetRef:
+ name: taskito-worker
+ pollingInterval: 15
+ cooldownPeriod: 60
+ minReplicaCount: 0
+ maxReplicaCount: 10
+ triggers:
+ - type: prometheus
+ metadata:
+ serverAddress: "http://prometheus:9090"
+ metricName: taskito_queue_depth
+ query: sum(taskito_queue_depth{queue="default"})
+ threshold: "10"
+```
+
+This metric comes from `PrometheusStatsCollector` — see the
+Prometheus integration .
+There's no `taskito_worker_utilization` gauge yet, so a second trigger on
+utilization isn't available — queue depth alone is the supported signal.
+
+
+
+
+
+
+ There's no Prometheus contrib yet — use the
+ [ScaledObject](#scaledobject-http-trigger) or
+ [ScaledJob](#scaledjob-ephemeral-batch-workers) triggers above, which talk
+ to the scaler endpoint directly and need no separate metrics pipeline.
+
+
+
+
+## Deploy templates
+
+Ready-to-use YAML templates are included in the repository under
+`deploy/keda/`:
+
+| File | Purpose |
+|---|---|
+| `scaled-object.yaml` | `ScaledObject` using the HTTP scaler endpoint |
+| `scaled-object-prometheus.yaml` | `ScaledObject` using Prometheus metrics |
+| `scaled-job.yaml` | `ScaledJob` for ephemeral batch workers |
+
+## Multi-replica storage
+
+
+
+
+ When using SQLite, all worker replicas must share the same database
+ volume. For multi-replica Kubernetes deployments, use the
+ Postgres backend —
+ workers connect over the network and there's no shared-file constraint.
+
+
+
+
+
+
+
+ When using SQLite, all worker replicas must share the same database
+ volume. For multi-replica Kubernetes deployments, use a networked
+ Postgres or Redis backend
+ instead — workers connect over the network and there's no shared-file
+ constraint.
+
+
+
+
+
+
+
+ When using SQLite, all worker replicas must share the same database
+ volume. For multi-replica Kubernetes deployments, use a networked
+ Postgres or Redis backend
+ instead — workers connect over the network and there's no shared-file
+ constraint.
+
+
+
+
+See also mesh scheduling for
+within-process dispatch locality — it composes with KEDA rather than
+replacing it, since KEDA scales the number of worker processes and mesh only
+changes how quickly a claimed job reaches an idle one.
diff --git a/docs/content/docs/shared/guides/operations/mesh.mdx b/docs/content/docs/shared/guides/operations/mesh.mdx
new file mode 100644
index 00000000..00db8c5f
--- /dev/null
+++ b/docs/content/docs/shared/guides/operations/mesh.mdx
@@ -0,0 +1,1110 @@
+---
+title: Mesh Scheduling
+description: "Gossip-based worker discovery, consistent-hashing affinity, and work-stealing for distributed task dispatch."
+---
+
+Workers can form a decentralized **mesh** — SWIM gossip for peer discovery, a
+consistent-hash ring for task placement, and TCP work-stealing — so idle
+workers pull work from busy peers instead of every worker hammering
+[storage](/architecture/storage) on every poll.
+
+Storage remains the source of truth. The mesh only changes *how quickly* a
+claimed job reaches an idle worker — if gossip fails entirely, a worker just
+falls back to standard polling — so it's safe to add to any worker without
+changing dispatch guarantees.
+
+
+
+
+ The package published to PyPI does **not** include the `mesh` cargo
+ feature. `MeshWorker` is importable from it, but passing it to
+ `run_worker` has no effect. To use mesh scheduling you must build the
+ extension from source with the feature enabled:
+ `uv run maturin develop --features mesh,workflows`
+
+
+
+
+
+
+
+ `pnpm build:native` compiles the addon with `postgres,redis,workflows,mesh`
+ enabled, and published prebuilt binaries ship with it too — so `mesh` works
+ out of the box on install. If you build the native addon yourself with a
+ narrower `--features` list, drop `mesh` and the `mesh` option on the worker
+ is silently ignored.
+
+
+
+
+
+
+
+ The `mesh` cargo feature ships in the published native library, so
+ `MeshOptions` and the worker builder's `mesh(...)` work out of the box —
+ no separate build required.
+
+
+
+
+## Quick start
+
+First worker — no seeds needed, it becomes the initial cluster node:
+
+
+
+
+```python
+from taskito import Queue, MeshWorker
+
+queue = Queue(db_path="tasks.db")
+
+@queue.task()
+def process(item_id: int):
+ ...
+
+# First worker — no seeds needed, becomes the initial cluster node
+mesh = MeshWorker(port=7946)
+queue.run_worker(queues=["default"], mesh=mesh)
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+
+queue.task("process", (itemId: number) => {
+ // ...
+});
+
+// First worker — no seeds needed, becomes the initial cluster node.
+const worker = queue.runWorker({
+ queues: ["default"],
+ mesh: { port: 7946 },
+});
+```
+
+
+
+
+```java
+// First worker — no seeds needed, becomes the initial cluster node
+Worker worker = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .mesh(MeshOptions.builder().port(7946).build())
+ .start();
+```
+
+
+
+
+In a second process (or on another machine), join the cluster by seeding
+from the first worker:
+
+
+
+
+```python
+# Joins the cluster by seeding from the first worker
+mesh = MeshWorker(
+ port=7946,
+ seeds=["first-worker-host:7946"],
+)
+queue.run_worker(queues=["default"], mesh=mesh)
+```
+
+
+
+
+```ts
+// Joins the cluster by seeding from the first worker.
+const worker = queue.runWorker({
+ queues: ["default"],
+ mesh: { port: 7946, seeds: ["first-worker-host:7946"] },
+});
+```
+
+
+
+
+```java
+// Joins the cluster by seeding from the first worker
+Worker worker = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .mesh(MeshOptions.builder()
+ .port(7946)
+ .seed("first-worker-host:7946")
+ .build())
+ .start();
+```
+
+
+
+
+The second worker discovers the first via gossip. Add a third and it only
+needs to seed from *any* existing member — gossip propagates the full
+membership list from there.
+
+## How it works
+
+Mesh scheduling composes four primitives that work together:
+
+| Primitive | What it does |
+|---|---|
+| **SWIM gossip** | UDP protocol for peer discovery and failure detection (~1.5s vs a 30s DB heartbeat) |
+| **Consistent hashing** | Hash ring with virtual nodes maps each task name to a preferred worker (soft affinity) |
+| **Local deque** | In-memory buffer between DB polls, sorted by affinity — workers drain it before hitting [storage](/architecture/storage) again |
+| **Work-stealing** | TCP protocol lets idle workers pull jobs from busy peers' deques |
+
+ B[Worker B]
+ B <--> C[Worker C]
+ A <--> C
+ A -. steal .-> B
+ Storage[("SQLite / Postgres / Redis")]
+ A --- Storage
+ B --- Storage
+ C --- Storage`}
+/>
+
+The database remains the source of truth — an atomic claim is what actually
+grants a worker a job. Gossip and stealing are a dispatch-locality
+optimization layered on top of that. See
+[Architecture: Mesh Scheduling](/architecture/mesh) for the shared engine's
+internals, including adaptive prefetch sizing and poll jitter.
+
+## Configuration
+
+All mesh settings live in one place, alongside the worker configuration:
+
+
+
+
+```python
+mesh = MeshWorker(
+ port=7946, # gossip UDP port (steal port = port + 1)
+ seeds=["host:7946"], # seed nodes for cluster join
+ steal=True, # enable work-stealing
+ affinity_weight=0.7, # 0.0–1.0, how strongly tasks prefer their hashed worker
+ local_buffer=64, # local deque capacity
+ steal_batch=4, # max jobs stolen per request
+ steal_threshold=2, # steal when own deque ≤ this
+ virtual_nodes=150, # consistent-hash ring virtual nodes per worker
+ bind_addr="0.0.0.0", # network interface to bind
+ encryption_key=None, # base64-encoded key for gossip encryption
+ steal_rate_limit=10, # max steal requests per peer per second
+)
+```
+
+
+
+
+```ts
+queue.runWorker({
+ mesh: {
+ port: 7946, // gossip UDP port; steal TCP port is port + 1
+ bindAddr: "0.0.0.0",
+ seeds: ["10.0.0.2:7946"],
+ steal: true,
+ affinityWeight: 0.7,
+ localBuffer: 64,
+ stealBatch: 4,
+ stealThreshold: 2,
+ virtualNodes: 150,
+ advertiseAddr: undefined,
+ encryptionKey: undefined,
+ stealRateLimit: 10,
+ },
+});
+```
+
+
+
+
+```java
+MeshOptions.builder()
+ .port(7946) // gossip UDP port; steal TCP port is port + 1
+ .seed("10.0.0.2:7946") // repeatable; or seeds(List)
+ .bindAddr("0.0.0.0")
+ .advertiseAddr(null) // required when bindAddr is 0.0.0.0 across hosts
+ .enableStealing(true)
+ .affinityWeight(0.7)
+ .localBufferCapacity(64)
+ .maxStealBatch(4)
+ .stealThreshold(2)
+ .virtualNodes(150)
+ .stealRateLimit(10)
+ .encryptionKey(null)
+ .build();
+```
+
+
+
+
+### Parameter reference
+
+Each binding exposes these settings with its own names, defaults, and
+validation:
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `port` | `int` | `7946` | Gossip UDP port. Steal TCP port is `port + 1`. Must be 1024–65535. |
+| `seeds` | `list[str]` | `[]` | Addresses of existing cluster members (`host:port`). |
+| `steal` | `bool` | `True` | Whether this worker can steal from and be stolen from. |
+| `affinity_weight` | `float` | `0.7` | Task-to-worker affinity strength. `0.0` = no affinity, `1.0` = strong preference. |
+| `local_buffer` | `int` | `64` | Max jobs buffered locally before back-pressuring prefetch. |
+| `steal_batch` | `int` | `4` | Max jobs transferred per steal request. |
+| `steal_threshold` | `int` | `2` | Trigger stealing when own buffer drops to this level. |
+| `virtual_nodes` | `int` | `150` | Hash ring virtual nodes per worker. More = more even distribution. |
+| `bind_addr` | `str` | `"0.0.0.0"` | Network interface for gossip and steal servers. |
+| `encryption_key` | `str \| None` | `None` | Base64 key for XOR gossip encryption. All nodes must share the same key. |
+| `steal_rate_limit` | `int` | `10` | Max steal requests accepted per peer per second. `0` = unlimited. |
+
+
+
+
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `port` | `number` | — (required) | Gossip UDP port. Steal TCP port is `port + 1`. Must be 1–65534 — `runWorker` throws otherwise. |
+| `bindAddr` | `string` | `"0.0.0.0"` | Bind address for both the gossip and steal listeners. |
+| `seeds` | `string[]` | `[]` | Existing members to join, as `"host:gossipPort"` strings. Empty means this node starts a new cluster. |
+| `steal` | `boolean` | `true` | Whether this worker *initiates* steals when its deque runs low. See [Work-stealing](#work-stealing) — it does not stop this worker from donating jobs to peers. |
+| `affinityWeight` | `number` | `0.7` | Accepted for forward compatibility, but the current engine doesn't read it — affinity is a fixed owned/non-owned split (see [Task affinity](#task-affinity)), not a graduated weight. Leave it at the default. |
+| `localBuffer` | `number` | `64` | Local deque capacity before it stops accepting more prefetched jobs. |
+| `stealBatch` | `number` | `4` | Max jobs requested per steal, capped by however many the peer actually has. |
+| `stealThreshold` | `number` | `2` | This worker tries to steal once its own deque length drops to or below this. |
+| `virtualNodes` | `number` | `150` | Virtual points per worker on the hash ring. Higher spreads task ownership more evenly across workers. |
+| `advertiseAddr` | `string` | unset | IP address advertised to peers when this worker is behind NAT — a bare IP, not `host:port` (the advertised ports still come from `port`/`port + 1`). Falls back to `bindAddr`. |
+| `encryptionKey` | `string` | unset | Base64 key that XOR-encrypts gossip datagrams. See [Gossip encryption](#gossip-encryption). |
+| `stealRateLimit` | `number` | `10` | Max steal requests this worker will *answer* per peer per second. `0` disables the limit. |
+
+
+
+
+
+| Builder method | Type | Default | Description |
+|---|---|---|---|
+| `port(int)` | `int` | `7946` | Gossip UDP port. Steal TCP port is always `port + 1`. Must be `1..65534`. |
+| `seed(String)` / `seeds(List)` | `String` / `List` | none | `host:port` of an existing cluster member; call repeatedly to add more. |
+| `bindAddr(String)` | `String` | `"0.0.0.0"` | Listen address for both the gossip and steal servers. |
+| `advertiseAddr(String)` | `String` | `null` | Address advertised to peers; required behind NAT or when `bindAddr` is `0.0.0.0` and workers span hosts. |
+| `enableStealing(boolean)` | `boolean` | `true` | Whether this worker can steal from, and be stolen from by, peers. |
+| `affinityWeight(double)` | `double` | `0.7` | Hash-ring affinity strength. `0.0` ignores affinity, `1.0` is strict. |
+| `localBufferCapacity(int)` | `int` | `64` | Max jobs buffered in the local deque before back-pressuring prefetch. |
+| `maxStealBatch(int)` | `int` | `4` | Max jobs transferred to a thief per steal request. |
+| `stealThreshold(int)` | `int` | `2` | Trigger stealing once the local deque drops to this length. |
+| `virtualNodes(int)` | `int` | `150` | Hash-ring virtual nodes per worker; higher spreads placement more evenly. |
+| `stealRateLimit(int)` | `int` | `10` | Max steal requests served per peer per second; `0` disables the limit. |
+| `encryptionKey(String)` | `String` | `null` | Base64 32-byte key XOR-applied to gossip datagrams. Obfuscation, not encryption. |
+
+
+ SWIM protocol timing (500ms period, 3 indirect-probe peers, suspicion
+ multiplier 4) is fixed by the core engine and isn't exposed on
+ `MeshOptions.Builder`.
+
+
+
+
+## Cluster topology
+
+### Single-machine (development)
+
+Run multiple workers on one host with different ports — the steal port is
+always `port + 1`, so pick gossip ports far enough apart:
+
+
+
+
+```python
+# worker_a.py
+from taskito import Queue, MeshWorker
+
+queue = Queue(db_path="tasks.db")
+
+@queue.task()
+def send_email(to: str, subject: str):
+ ...
+
+mesh = MeshWorker(port=7946)
+queue.run_worker(mesh=mesh)
+```
+
+```python
+# worker_b.py — seeds from worker A
+from myapp import queue # same queue, different process
+
+mesh = MeshWorker(port=7948, seeds=["127.0.0.1:7946"])
+queue.run_worker(mesh=mesh)
+```
+
+
+
+
+```ts
+// worker-a.ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+queue.task("sendEmail", (to: string, subject: string) => {
+ /* ... */
+});
+
+queue.runWorker({ mesh: { port: 7946 } });
+```
+
+```ts
+// worker-b.ts — same db file, second process
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+queue.task("sendEmail", (to: string, subject: string) => {
+ /* ... */
+});
+
+queue.runWorker({ mesh: { port: 7948, seeds: ["127.0.0.1:7946"] } });
+```
+
+
+
+
+```java
+// Worker A — first node, no seeds
+Worker workerA = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .mesh(MeshOptions.builder().port(7946).build())
+ .start();
+
+// Worker B — same storage, seeds from A
+Worker workerB = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .mesh(MeshOptions.builder()
+ .port(7948) // steal port becomes 7949
+ .seed("127.0.0.1:7946")
+ .build())
+ .start();
+```
+
+
+
+
+### Multi-machine (production)
+
+Point seeds at a couple of known-stable nodes — you don't need to list every
+node, gossip propagates membership from there:
+
+
+
+
+```python
+import os
+
+SEEDS = ["scheduler-1.internal:7946", "scheduler-2.internal:7946"]
+
+mesh = MeshWorker(
+ port=7946,
+ seeds=SEEDS,
+ encryption_key=os.environ["MESH_ENCRYPTION_KEY"],
+ steal_rate_limit=20,
+)
+queue.run_worker(
+ queues=["default", "emails"],
+ mesh=mesh,
+)
+```
+
+
+
+
+```ts
+const SEEDS = ["scheduler-1.internal:7946", "scheduler-2.internal:7946"];
+
+queue.runWorker({
+ queues: ["default", "emails"],
+ mesh: {
+ port: 7946,
+ seeds: SEEDS,
+ encryptionKey: process.env.MESH_ENCRYPTION_KEY,
+ stealRateLimit: 20,
+ },
+});
+```
+
+
+
+
+```java
+List seeds = List.of(
+ "scheduler-1.internal:7946",
+ "scheduler-2.internal:7946");
+
+Worker worker = taskito.worker()
+ .handle(process, payload -> handle(payload))
+ .queues("default", "emails")
+ .mesh(MeshOptions.builder()
+ .seeds(seeds)
+ .advertiseAddr(System.getenv("HOST_ADDR") + ":7946")
+ .encryptionKey(System.getenv("MESH_KEY"))
+ .stealRateLimit(20)
+ .build())
+ .start();
+```
+
+
+
+
+
+
+Set `advertiseAddr` so peers dial back the right host when `bindAddr` stays
+`0.0.0.0`.
+
+
+
+
+ Workers need UDP (gossip) and TCP (steal) access to each other. Open both
+ the gossip port and the steal port (gossip + 1) between all mesh workers.
+
+
+
+
+
+ The `taskito run ./app.js` CLI command has no mesh options — mesh is only
+ enabled by passing `mesh` to `runWorker()` in your own code. Read the
+ config from the environment in your entrypoint instead:
+
+
+```ts
+// worker-entry.ts — container entrypoint, reads mesh config from env vars
+import { Queue, type MeshWorkerConfig } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+queue.task("sendEmail", (to: string, subject: string) => {
+ /* ... */
+});
+
+function meshFromEnv(): MeshWorkerConfig | undefined {
+ const port = process.env.MESH_PORT;
+ if (!port) return undefined;
+ const seeds = (process.env.MESH_SEEDS ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return { port: Number(port), seeds, encryptionKey: process.env.MESH_KEY };
+}
+
+queue.runWorker({ mesh: meshFromEnv() });
+```
+
+```bash
+node worker-entry.js # not `taskito run` — the CLI has no mesh flags
+```
+
+
+
+
+
+### Docker Compose example
+
+```yaml
+services:
+ worker-1:
+ build: .
+ command: taskito worker --app myapp:queue
+ environment:
+ MESH_PORT: "7946"
+ MESH_SEEDS: "" # first node, no seeds
+ MESH_KEY: ${MESH_ENCRYPTION_KEY}
+ ports:
+ - "7946:7946/udp" # gossip
+ - "7947:7947/tcp" # steal
+
+ worker-2:
+ build: .
+ command: taskito worker --app myapp:queue
+ environment:
+ MESH_PORT: "7946"
+ MESH_SEEDS: "worker-1:7946"
+ MESH_KEY: ${MESH_ENCRYPTION_KEY}
+ ports:
+ - "7948:7946/udp"
+ - "7949:7947/tcp"
+ depends_on:
+ - worker-1
+```
+
+
+ This compose file is illustrative, not runnable as-is: the `taskito worker`
+ CLI has no mesh flags and never reads `MESH_PORT`/`MESH_SEEDS`/`MESH_KEY`.
+ Mesh is only enabled by calling `queue.run_worker(mesh=...)` in your own
+ Python code. Replace `command: taskito worker --app myapp:queue` with
+ `command: python -m myapp`, where `myapp` is a module like the one below —
+ its `__main__` block starts the worker with the env-derived mesh config.
+
+
+```python
+# myapp.py — reads mesh config from environment
+import os
+from taskito import Queue, MeshWorker
+
+queue = Queue(db_path="tasks.db")
+
+def get_mesh() -> MeshWorker | None:
+ port = os.environ.get("MESH_PORT")
+ if not port:
+ return None
+ seeds_raw = os.environ.get("MESH_SEEDS", "")
+ seeds = [s.strip() for s in seeds_raw.split(",") if s.strip()]
+ return MeshWorker(
+ port=int(port),
+ seeds=seeds,
+ encryption_key=os.environ.get("MESH_KEY"),
+ )
+
+if __name__ == "__main__":
+ queue.run_worker(mesh=get_mesh())
+```
+
+
+
+## Task affinity
+
+The consistent-hash ring maps each task name to a preferred worker. When a
+worker prefetches a batch of jobs, it sorts them before pushing them into
+the local deque:
+
+- **Owned tasks** (hashed to this worker) go to the back of the deque —
+ popped first
+- **Non-owned tasks** go to the front — left there for peers to steal
+
+Any worker can still run any task — affinity only biases *which worker gets
+to it first*. This improves cache locality: when a task consistently lands
+on the same worker, its open connections, warm caches, and worker-scoped
+resources stay
+hot instead of being rebuilt everywhere.
+
+
+
+The `affinity_weight` parameter controls how aggressively the ring biases
+dispatch — `0.0` disables affinity entirely, `1.0` is a strict preference:
+
+```python
+# High affinity — tasks strongly prefer their hashed worker
+mesh = MeshWorker(affinity_weight=1.0)
+
+# No affinity — pure load-balancing, no task-to-worker preference
+mesh = MeshWorker(affinity_weight=0.0)
+```
+
+
+
+
+
+
+ `affinityWeight` on the mesh config is reserved but currently unused —
+ the sort above is a hard owned/non-owned split, not a tunable strength.
+ There's no option today to dial affinity up or down.
+
+
+
+
+
+
+`affinityWeight` controls the same tradeoff:
+
+```java
+// Strict — tasks strongly prefer their hashed worker
+MeshOptions.builder().affinityWeight(1.0).build();
+
+// None — pure load-balancing, no task-to-worker preference
+MeshOptions.builder().affinityWeight(0.0).build();
+```
+
+
+
+## Work-stealing
+
+When a worker's local buffer drops to its steal threshold, it looks at
+gossip-reported buffer lengths across the cluster, picks the busiest peer,
+and opens a TCP connection to that peer's steal port requesting a batch of
+jobs.
+
+The victim pops jobs from the cold end of its deque (non-owned tasks first)
+and sends them back. The thief executes them locally — no DB round-trip
+needed, since the jobs are already claimed as running in storage. Stealing
+is rate-limited per peer to prevent thundering-herd effects.
+
+
+
+The worker only steals from peers with enough surplus (steal threshold +
+steal batch). The connect attempt times out after 500ms and the response
+after 2s, so a stuck peer doesn't stall dispatch.
+
+
+
+Work-stealing is on by default — workers automatically balance load across
+the cluster. When one worker gets a burst of jobs, idle peers steal the
+overflow within milliseconds. Disabling it stops this worker from
+participating in steals:
+
+
+
+
+```python
+mesh = MeshWorker(steal=False)
+```
+
+Workers still benefit from gossip discovery, consistent hashing, and local
+deque prefetch — but won't steal from or donate jobs to peers. Useful when
+tasks have strong per-worker state dependencies.
+
+
+
+
+```ts
+queue.runWorker({ mesh: { port: 7946, steal: false } });
+```
+
+
+ Setting `steal: false` only stops *this* worker from initiating steals —
+ the TCP steal server always runs, so this worker will still hand jobs to
+ any peer that asks (subject to `stealRateLimit`). Use it on a worker you
+ don't want draining its own queue to help others, not to shield a worker
+ from ever donating work.
+
+
+
+
+
+```java
+MeshOptions.builder().enableStealing(false).build();
+```
+
+Gossip discovery, consistent hashing, and local-deque prefetch keep
+running — the worker just won't steal from or donate jobs to peers. Useful
+when tasks have strong per-worker state.
+
+
+
+
+### Tuning work-stealing
+
+
+
+| Scenario | Recommended settings |
+|---|---|
+| Bursty, short tasks | `steal_batch=8, steal_threshold=4` — steal more, earlier |
+| Long-running tasks | `steal_batch=1, steal_threshold=1` — steal conservatively |
+| Many workers (10+) | `steal_rate_limit=5` — prevent steal storms |
+| Few workers (2–3) | `steal_rate_limit=0` — unlimited, low contention |
+
+
+
+
+
+| Scenario | Recommended settings |
+|---|---|
+| Bursty, short tasks | `stealBatch: 8, stealThreshold: 4` — steal more, earlier |
+| Long-running tasks | `stealBatch: 1, stealThreshold: 1` — steal conservatively |
+| Many workers (10+) | `stealRateLimit: 5` — prevent steal storms |
+| Few workers (2–3) | `stealRateLimit: 0` — unlimited, contention is low anyway |
+
+
+
+
+
+| Scenario | Recommended settings |
+|---|---|
+| Bursty, short tasks | `maxStealBatch(8).stealThreshold(4)` — steal more, earlier |
+| Long-running tasks | `maxStealBatch(1).stealThreshold(1)` — steal conservatively |
+| Many workers (10+) | `stealRateLimit(5)` — avoid steal storms |
+| Few workers (2-3) | `stealRateLimit(0)` — unlimited, low contention |
+
+
+
+## Gossip encryption
+
+Enable gossip encryption with a shared, base64-encoded key:
+
+
+
+
+```python
+import base64
+import os
+
+# Generate a key (share this across all workers)
+key = base64.b64encode(os.urandom(32)).decode()
+print(key) # store in environment variable or secret manager
+
+mesh = MeshWorker(encryption_key=key)
+```
+
+
+
+
+```ts
+import { randomBytes } from "node:crypto";
+
+const key = randomBytes(32).toString("base64"); // share across every mesh worker
+queue.runWorker({ mesh: { port: 7946, encryptionKey: key } });
+```
+
+
+
+
+```java
+MeshOptions.builder()
+ .port(7946)
+ .seed("10.0.0.2:7946")
+ .encryptionKey(System.getenv("MESH_KEY")) // 32-byte key, base64-encoded
+ .build();
+```
+
+
+
+
+All nodes in the cluster must use the same key. Nodes with mismatched keys
+fail to decode each other's gossip messages and never join the cluster.
+
+
+ Gossip encryption XOR-obfuscates the UDP membership protocol only — it
+ deters casual sniffing, not a determined attacker. Work-stealing traffic
+ over TCP is never encrypted. For real transport security, put mesh traffic
+ on a private network (WireGuard, VPN, or a service mesh).
+
+
+## Monitoring
+
+
+
+Mesh activity appears in Rust log output at `debug` and `info` levels:
+
+```bash
+# See all mesh activity
+RUST_LOG=taskito_mesh=debug taskito worker --app myapp:queue
+
+# See only gossip membership changes
+RUST_LOG=taskito_mesh::swim=info taskito worker --app myapp:queue
+```
+
+Key log messages:
+
+| Level | Message | Meaning |
+|---|---|---|
+| `info` | `gossip listening on ...` | Gossip server started |
+| `info` | `discovered peer X at Y` | New cluster member joined |
+| `info` | `member X declared dead` | Failure detector confirmed crash |
+| `info` | `leave broadcast sent` | Graceful shutdown leave |
+| `debug` | `giving N jobs to thief X` | Responded to steal request |
+| `debug` | `ack from X resolved probe` | Healthy ping-ack cycle |
+
+If you use [Prometheus](/python/guides/integrations/prometheus), mesh
+metrics flow through the same worker observability pipeline.
+
+
+
+
+
+Mesh workers register and heartbeat exactly like any other worker, so they
+show up in `listWorkers()` and on the
+dashboard Workers panel:
+
+```ts
+await queue.listWorkers();
+// [{ workerId, hostname, pid, queues, status, lastHeartbeat, ... }]
+```
+
+
+ There's no separate JS-facing API for mesh-only signals yet (peer count,
+ steal counters, ring membership) — those live inside the Rust engine but
+ aren't exposed through the addon. See
+ [Architecture: Mesh Scheduling](/architecture/mesh) for what the engine
+ tracks internally.
+
+
+
+
+
+
+### Inspecting the cluster
+
+`Worker.meshClusterInfo()` returns an `Optional` — empty
+unless the worker was started with `.mesh(...)`:
+
+```java
+worker.meshClusterInfo().ifPresent(cluster ->
+ log.info("peers={} load={} buffered={} prefetch={}",
+ cluster.peerCount(), cluster.totalLoad(),
+ cluster.totalBuffered(), cluster.adaptivePrefetch()));
+```
+
+| `MeshClusterInfo` field | Description |
+|---|---|
+| `peerCount()` | Alive peers discovered via gossip (excludes this node). |
+| `totalCapacity()` | Summed advertised capacity across those peers. |
+| `totalLoad()` | Summed in-flight jobs across those peers. |
+| `totalBuffered()` | Summed local-deque length across those peers. |
+| `localBufferLen()` | This worker's own local-deque length. |
+| `adaptivePrefetch()` | This worker's current prefetch budget. |
+
+
+
+## Combining with other features
+
+Mesh composes with everything else a worker already does. Each mesh worker
+still manages its own resource pool and prefetch batch independently:
+
+
+
+
+```python
+from taskito import Queue, MeshWorker
+from taskito.resources import ResourceDefinition, Scope
+
+queue = Queue(db_path="tasks.db")
+
+# Worker resources work normally — each mesh worker manages its own pool
+@queue.worker_resource(scope=Scope.WORKER)
+def db_pool():
+ return create_connection_pool()
+
+# Rate limits and concurrency are per-task, enforced in the DB layer
+@queue.task(max_concurrent=5, rate_limit="100/m")
+def process_order(order_id: int, db_pool=None):
+ ...
+
+# Batch dequeue works with mesh — prefetch fills the local deque
+queue_config = Queue(db_path="tasks.db", scheduler_batch_size=10)
+
+mesh = MeshWorker(seeds=["peer:7946"])
+queue.run_worker(mesh=mesh)
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+
+// Worker resources work normally — each mesh worker builds its own pool.
+queue.resource("db", () => createConnectionPool());
+
+queue.task(
+ "processOrder",
+ async (orderId: string, deps: { db: Pool }) => {
+ await deps.db.query("...", [orderId]);
+ },
+ { inject: ["db"], maxConcurrent: 5, rateLimit: "100/m" },
+);
+
+queue.runWorker({
+ queues: ["default"],
+ batchSize: 10, // scheduler pulls more per poll; the mesh sorts the batch by affinity
+ mesh: { port: 7946, seeds: ["peer:7946"] },
+});
+```
+
+
+
+
+```java
+taskito.resource("db", ResourceScope.WORKER, ctx -> openPool(), pool -> pool.close());
+
+try (Worker worker = taskito.worker()
+ .handle(processOrder, orderId -> {
+ DataSource db = Resources.use("db");
+ return process(db, orderId);
+ })
+ .batchSize(10) // scheduler claims 10 jobs per poll; mesh drains them into the local deque
+ .mesh(MeshOptions.builder().seed("peer-a:7946").build())
+ .start()) {
+ worker.awaitShutdown();
+}
+```
+
+
+
+
+
+
+See also:
+- Per-task concurrency for `max_concurrent`
+- Rate limiting for `rate_limit`
+- Worker resources for DI into tasks
+- Batch dequeue for `scheduler_batch_size`
+
+
+
+
+
+See also dependency injection ,
+concurrency , and
+rate limiting .
+
+
+
+
+
+See also Resource System
+for worker-scoped dependency injection and
+Batching for `batchSize`.
+
+
+
+## Mixing mesh and non-mesh workers
+
+A mesh worker and a plain worker can consume the same storage at once — the
+atomic claim in storage is what guarantees a job dispatches exactly once,
+regardless of whether the worker that wins it is meshed:
+
+
+
+
+```python
+# Standard worker — no mesh, polls DB directly
+queue.run_worker(queues=["default"])
+
+# Mesh worker — same DB, but prefetches + steals
+mesh = MeshWorker(seeds=["other-mesh-node:7946"])
+queue.run_worker(queues=["default"], mesh=mesh)
+```
+
+
+
+
+```ts
+// Standard worker — polls storage directly, no gossip
+queue.runWorker({ queues: ["default"] });
+
+// Mesh worker — same storage, but prefetches into a local deque and can steal
+queue.runWorker({ queues: ["default"], mesh: { port: 7946, seeds: ["10.0.0.2:7946"] } });
+```
+
+
+
+
+```java
+// Standard worker — no mesh, polls storage directly
+taskito.worker().handle(process, this::handle).start();
+
+// Mesh worker — same storage, but prefetches + steals
+taskito.worker().handle(process, this::handle)
+ .mesh(MeshOptions.builder().seed("other-mesh-node:7946").build())
+ .start();
+```
+
+
+
+
+Non-mesh workers are invisible to the mesh (no gossip, no stealing) but
+still process jobs normally — roll mesh out to part of a fleet gradually,
+without downtime.
+
+## Graceful shutdown
+
+When a mesh worker shuts down, it broadcasts a `Leave` message to all known
+peers. They remove it from the ring immediately — no suspicion timeout
+needed.
+
+
+
+Triggered via `Ctrl+C`, `SIGTERM`, or
+programmatic
+shutdown .
+
+
+
+
+
+```ts
+process.on("SIGTERM", () => worker.stop());
+```
+
+`stop()` broadcasts the leave message before the gossip loop exits.
+
+
+
+
+
+`worker.close()` (and `stop()`) signal the mesh node before the native
+worker tears down, which broadcasts the leave message.
+
+
+
+If a worker crashes without leaving, the SWIM failure detector kicks in:
+direct ping fails → indirect ping via intermediaries → suspicion → declared
+dead after `suspicion_multiplier × ln(N+1) × protocol_period`. With default
+settings (multiplier `4`, 500ms period), a crashed worker in a 3-node
+cluster is typically detected in about `4 × ln(4) × 500ms ≈ 2.8s`. These
+SWIM timing knobs are fixed by the core engine and aren't exposed as
+configuration options.
+
+## When to use mesh
+
+
+
+**Good fit:**
+- Multiple workers processing the same queues (2+ workers)
+- High job throughput where DB polling is a bottleneck
+- Tasks that benefit from cache locality (imports, connections, warm state)
+- Environments where sub-second failure detection matters
+- Horizontal scaling where workers come and go frequently
+
+**Not needed:**
+- Single worker setups
+- Low throughput (< 100 jobs/second)
+- Workers processing completely different queues (no overlap = no stealing)
+- Already using [bare-metal autoscaler](/python/guides/operations/autoscaler)
+ or [KEDA](/python/guides/operations/keda) for process-level scaling (mesh
+ is *within-process* optimization, not a replacement for autoscaling)
+
+
+
+
+
+**Good fit:**
+- Two or more workers consuming the same queue(s)
+- High throughput where DB polling latency is the bottleneck
+- Tasks that benefit from cache locality — warm connections, open clients —
+ especially paired with worker-scoped
+ resources
+- Fleets that scale workers up and down often and need sub-second peer
+ detection
+
+**Not needed:**
+- A single worker process
+- Low throughput
+- Workers consuming entirely disjoint queues — little to steal without
+ overlap
+- You're already scaling worker processes up and down at the infrastructure
+ level — mesh optimizes dispatch within a running fleet, it isn't a
+ process autoscaler
+
+
+
+
+
+**Good fit:**
+- Multiple workers processing the same queues (2+ workers)
+- High job throughput where DB polling is a bottleneck
+- Tasks that benefit from cache locality (connections, warm state)
+- Environments where sub-second failure detection matters
+- Horizontal scaling where workers come and go frequently
+
+**Not needed:**
+- Single-worker setups
+- Low throughput (< 100 jobs/second)
+- Workers processing entirely disjoint queues (no overlap, no stealing)
+- Already scaling replicas with
+ [autoscaling / KEDA](/java/guides/operations/autoscaling) — mesh is a
+ *within-process* dispatch optimization, not a replacement for it
+
+
diff --git a/docs/content/docs/python/guides/operations/testing.mdx b/docs/content/docs/shared/guides/operations/testing.mdx
similarity index 60%
rename from docs/content/docs/python/guides/operations/testing.mdx
rename to docs/content/docs/shared/guides/operations/testing.mdx
index 89a8a2df..ce9141cd 100644
--- a/docs/content/docs/python/guides/operations/testing.mdx
+++ b/docs/content/docs/shared/guides/operations/testing.mdx
@@ -1,16 +1,44 @@
---
title: Testing
-description: "Test mode, TestResult, MockResource, fixtures, and workflow testing."
+description: "Unit-test tasks in isolation, then exercise the full enqueue → execute → result path without a production backend."
---
-import { Callout } from "fumadocs-ui/components/callout";
+ tasks are plain functions, so the fastest tests call them
+directly with no queue involved. For coverage of enqueue, dispatch, and
+result plumbing, each binding also gives you a way to run the whole path
+without standing up a production backend:
-taskito includes a built-in test mode that runs tasks **synchronously** in
-the calling thread — no worker, no Rust scheduler, no SQLite. This makes
-tests fast, deterministic, and easy to write.
+
+
+`test_mode()` patches `enqueue()` so every `.delay()` / `.apply_async()` call
+runs the task **synchronously** in the calling thread — no worker, no Rust
+scheduler, no SQLite. This makes tests fast, deterministic, and easy to
+write.
+
+
+
+
+
+There's no synchronous test mode — tests run a real
+worker against a throwaway
+SQLite database, so the whole enqueue → execute → result path runs
+in-process with no mocks of the core.
+
+
+
+
+
+The `taskito-test` artifact ships a pure-Java, in-memory `QueueBackend` — the
+full client API with no native library and no disk — ideal for fast unit
+tests of producers, handlers, retries, and dead-lettering.
+
+
## Quick example
+
+
+
```python
from taskito import Queue
@@ -29,6 +57,79 @@ def test_add():
assert results[0].succeeded
```
+
+
+
+```ts
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, expect, it } from "vitest";
+import { Queue, type Worker } from "@byteveda/taskito";
+
+let worker: Worker | undefined;
+afterEach(() => {
+ worker?.stop(); // always stop — a leaked worker keeps polling across tests
+ worker = undefined;
+});
+
+function newQueue(): Queue {
+ // a fresh temp DB per test keeps them independent
+ return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "test-")), "q.db") });
+}
+
+it("runs a task end to end", async () => {
+ const queue = newQueue();
+ queue.task("add", (a: number, b: number) => a + b);
+
+ const id = queue.enqueue("add", [2, 3]);
+ worker = queue.runWorker();
+
+ expect(await queue.result(id)).toBe(5);
+});
+```
+
+
+
+
+```kotlin
+testImplementation("org.byteveda:taskito-test:0.18.0")
+```
+
+```java
+import org.byteveda.taskito.test.InMemoryTaskito;
+
+@Test
+void runsATaskEndToEnd() throws Exception {
+ 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()) {
+ Job job = taskito.awaitJob(id, Duration.ofSeconds(5)).orElseThrow();
+ assertEquals(JobStatus.COMPLETE, job.status);
+ }
+
+ assertEquals(5, taskito.getResult(id, Integer.class).orElseThrow());
+ }
+}
+```
+
+
+
+
+
+
+`InMemoryTaskito.open(serializer)` swaps the JSON default. To construct the
+backend explicitly (e.g. to share one across a custom builder), pass it to
+`Taskito.builder().open(new InMemoryQueueBackend())`.
+
+
+
+
+
## How it works
When you enter `queue.test_mode()`, taskito patches the `enqueue()` method
@@ -53,7 +154,7 @@ with queue.test_mode(propagate_errors=False, resources=None) as results:
| Parameter | Type | Default | Description |
|---|---|---|---|
| `propagate_errors` | `bool` | `False` | If `True`, task exceptions are re-raised immediately instead of being captured in `TestResult.error` |
-| `resources` | `dict[str, Any] \| None` | `None` | Map of resource name → mock instance or `MockResource` for injection. See [Resource System](/python/guides/resources). |
+| `resources` | `dict[str, Any] \| None` | `None` | Map of resource name → mock instance or `MockResource` for injection. See Resource System . |
The context manager yields a `TestResults` list that accumulates results as
tasks execute.
@@ -238,59 +339,18 @@ def test_context():
assert ctx["queue_name"] == "default"
```
-## Pytest integration
-
-### Fixture pattern
+
-Create a reusable fixture for test mode:
+## Faking dependencies
-```python
-# conftest.py
-import pytest
-from myapp import queue
+Register a stub factory to swap a real dependency in tests — the worker
+injects whatever is registered by name, so tasks can't tell a fake from the
+real thing and no real connection is ever opened. See
+dependency injection
+for how resources are registered and scoped.
-@pytest.fixture
-def task_results():
- with queue.test_mode() as results:
- yield results
-
-# test_tasks.py
-def test_add(task_results):
- add.delay(2, 3)
- assert task_results[0].return_value == 5
-
-def test_email(task_results):
- send_email.delay("user@example.com", "Hello", "World")
- assert task_results[0].succeeded
-```
-
-### Fixture with error propagation
-
-```python
-@pytest.fixture
-def strict_tasks():
- with queue.test_mode(propagate_errors=True) as results:
- yield results
-```
-
-### Testing async code
-
-Test mode works with async test functions — the tasks still execute
-synchronously:
-
-```python
-import pytest
-
-@pytest.mark.asyncio
-async def test_async_enqueue(task_results):
- add.delay(1, 2)
- assert task_results[0].return_value == 3
-```
-
-## Testing with worker resources
-
-If your tasks use [worker resources](/python/guides/resources) (injected via
-`inject=` or `Inject["name"]`), pass mock instances through `resources=`:
+
+
```python
from unittest.mock import MagicMock
@@ -315,6 +375,31 @@ def test_create_user():
mock_db.return_value.add.assert_called_once()
```
+
+
+
+```ts
+import { useResource } from "@byteveda/taskito";
+
+queue.resource("db", () => fakeDb);
+queue.task("sync", async () => {
+ const db = await useResource("db");
+ // ...
+});
+```
+
+
+
+
+```java
+taskito.resource("db", context -> fakeDb);
+```
+
+
+
+
+
+
### `MockResource`
`MockResource` adds call tracking to a mock value:
@@ -390,6 +475,110 @@ mock_db.execute.assert_called_once()
tests don't fail due to missing files or network connections.
+
+
+
+
+### `mockResource()`
+
+`mockResource(value)` wraps a value as a factory and records how often it
+was built — register the factory and assert on `resolutions` to confirm a
+worker-scoped resource was built once, not once per job:
+
+```ts
+import { mockResource, Queue, useResource } from "@byteveda/taskito";
+
+const db = mockResource({ query: () => 7 });
+queue.resource("db", db.factory);
+
+queue.task("read", async () => {
+ const conn = await useResource<{ query: () => number }>("db");
+ return conn.query();
+});
+
+queue.enqueue("read");
+queue.enqueue("read");
+worker = queue.runWorker();
+
+// worker-scoped singleton, built once even though two jobs ran
+await waitFor(() => db.resolutions === 1);
+```
+
+
+
+
+
+### Asserting resource construction
+
+`InMemoryTaskito` paired with a counting factory confirms a resource was
+built the expected number of times, without touching a native backend:
+
+```java
+try (Taskito queue = InMemoryTaskito.open()) {
+ AtomicInteger built = new AtomicInteger();
+ queue.resource("db", ctx -> {
+ built.incrementAndGet();
+ return new FakeDatabase();
+ });
+ // ... run the task through a worker ...
+ assertEquals(1, built.get());
+ assertEquals(1, queue.resourceMetrics().get("db").created());
+}
+```
+
+
+
+
+
+## Pytest integration
+
+### Fixture pattern
+
+Create a reusable fixture for test mode:
+
+```python
+# conftest.py
+import pytest
+from myapp import queue
+
+@pytest.fixture
+def task_results():
+ with queue.test_mode() as results:
+ yield results
+
+# test_tasks.py
+def test_add(task_results):
+ add.delay(2, 3)
+ assert task_results[0].return_value == 5
+
+def test_email(task_results):
+ send_email.delay("user@example.com", "Hello", "World")
+ assert task_results[0].succeeded
+```
+
+### Fixture with error propagation
+
+```python
+@pytest.fixture
+def strict_tasks():
+ with queue.test_mode(propagate_errors=True) as results:
+ yield results
+```
+
+### Testing async code
+
+Test mode works with async test functions — the tasks still execute
+synchronously:
+
+```python
+import pytest
+
+@pytest.mark.asyncio
+async def test_async_enqueue(task_results):
+ add.delay(1, 2)
+ assert task_results[0].return_value == 3
+```
+
## What test mode does NOT cover
Test mode is designed for **unit and integration testing** of task logic.
@@ -460,3 +649,74 @@ To build with native async support:
```bash
uv run maturin develop --features native-async
```
+
+
+
+
+
+## Synchronization
+
+`awaitJob(id, timeout)` blocks until the job reaches a terminal state and is
+the simplest synchronization point — tests rarely need a sleep. Registering
+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();
+```
+
+## Integration tests
+
+For coverage of the real storage engine (scheduling, claims, locks), open a
+throwaway SQLite database — the whole enqueue → execute → result path runs
+in-process:
+
+```java
+Path dir = Files.createTempDirectory("taskito-test");
+try (Taskito taskito = Taskito.builder().sqlite(dir.resolve("q.db").toString()).open()) {
+ // real backend, no server
+}
+```
+
+Always close (or try-with-resources) workers — a leaked worker keeps
+polling across tests.
+
+
+ The in-memory backend covers the queue contract, not the native engine:
+ workflows are not supported in-memory, and storage-engine behavior (SQL
+ claims, cron timing) is only exercised against a real backend. Keep a few
+ SQLite-backed integration tests alongside the fast in-memory suite.
+
+
+
+
+
+
+## Polling for side effects
+
+When you assert on a side effect rather than a return value, poll until it
+settles instead of sleeping a fixed time:
+
+```ts
+async function waitFor(predicate: () => boolean, timeoutMs = 4000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (predicate()) return true;
+ await new Promise((r) => setTimeout(r, 20));
+ }
+ return false;
+}
+```
+
+
+ Awaiting `queue.result(id)` (shown in the quick example above) is the
+ simplest synchronization point — it resolves when the job reaches a
+ terminal state and rejects on failure, so most tests don't need
+ `waitFor` at all. Reach for it when you're asserting on a side effect
+ instead of a return value.
+
+
+
diff --git a/docs/content/docs/shared/guides/operations/troubleshooting.mdx b/docs/content/docs/shared/guides/operations/troubleshooting.mdx
new file mode 100644
index 00000000..c0fbb522
--- /dev/null
+++ b/docs/content/docs/shared/guides/operations/troubleshooting.mdx
@@ -0,0 +1,871 @@
+---
+title: Troubleshooting
+description: "Diagnose stuck jobs, unresponsive workers, growing databases, latency, and missing tasks."
+---
+
+Common issues and how to fix them, organized by symptom.
+
+
+
+Turn on debug logs first — they show the worker claiming, running, and
+settling jobs:
+
+```ts
+import { setLogLevel } from "@byteveda/taskito";
+setLogLevel("debug");
+// or: TASKITO_LOG_LEVEL=debug node app.js
+```
+
+
+
+
+
+Turn on debug logs first — they show the worker claiming, running, and
+settling jobs:
+
+```java
+TaskitoLogger.setLevel(LogLevel.DEBUG);
+// or: TASKITO_LOG_LEVEL=debug java -jar app.jar
+```
+
+
+
+
+
+Turn on debug logs first — the Rust core's own logging is wired through
+`RUST_LOG`, independent of Python's `logging` module:
+
+```bash
+RUST_LOG=taskito_core=debug,taskito_python=debug taskito worker --app myapp:queue
+```
+
+
+
+## A job stays pending and never runs
+
+A few checks, in order:
+
+- **No worker running** — a worker process must actually be started and
+ pointed at the same storage as the producer.
+- **Queue mismatch** — a worker only serves the queues it was started with
+ (default `"default"`); a job enqueued to a different queue is invisible to
+ it. Match them.
+- **Queue paused** — check for a paused queue and resume it.
+- **Producer and worker use different storage** — both must point at the
+ same database (same SQLite file path, Postgres DSN, or Redis URL). A
+ relative SQLite path resolves per working directory, so starting the two
+ processes from different directories silently splits them onto two
+ different files.
+
+
+
+
+```python
+queue.list_paused_queues()
+queue.resume_queue("default")
+```
+
+
+
+
+```ts
+queue.listPausedQueues();
+queue.resumeQueue("default");
+```
+
+
+
+
+```java
+taskito.listPausedQueues();
+taskito.queue("default").resume();
+```
+
+
+
+
+## Jobs stuck in `running`
+
+**Symptom**: jobs stay in `running` status long after they should have
+finished.
+
+**Diagnosis**:
+
+
+
+
+```python
+stats = queue.stats()
+print(stats) # {'running': 47, 'pending': 0, ...}
+
+stuck = queue.list_jobs(status="running", limit=20)
+for job in stuck:
+ d = job.to_dict()
+ print(f"{d['id']} | {d['task_name']} | started {d['started_at']}")
+```
+
+
+
+
+```ts
+const stats = await queue.stats();
+console.log(stats); // { running: 47, pending: 0, ... }
+
+const stuck = await queue.listJobs({ status: "running", limit: 20 });
+for (const job of stuck) {
+ console.log(`${job.id} | ${job.taskName} | started ${job.startedAt}`);
+}
+```
+
+
+
+
+```java
+QueueStats stats = taskito.stats();
+System.out.println(stats.running + " running, " + stats.pending + " pending");
+
+List stuck = taskito.listJobs(
+ JobFilter.builder().status(JobStatus.RUNNING).limit(20).build());
+for (Job job : stuck) {
+ System.out.println(job.id + " | " + job.taskName + " | started " + job.startedAt);
+}
+```
+
+
+
+
+**Cause**: the worker process that claimed the job crashed or hung before
+marking it complete. Two independent recovery mechanisms exist, and neither
+depends on the other:
+
+- **Timeout-based** — if the task has a timeout configured, the stale reaper
+ marks the job failed once the deadline passes (even if the worker process
+ is still alive) and retries it, or dead-letters it once retries are
+ exhausted.
+- **Dead-worker recovery** — if the worker that claimed the job crashed
+ outright, any surviving scheduler notices the stale heartbeat and reclaims
+ the job immediately — this doesn't need a timeout at all, it just needs
+ the owning worker to actually be gone.
+
+A job stuck because its worker is alive but hung — deadlocked, stuck in an
+infinite loop, blocked forever on I/O — has only the first mechanism to save
+it. Always set a timeout on production tasks:
+
+
+
+
+```python
+@queue.task(timeout=300) # 5 minutes max
+def process_data(payload):
+ ...
+```
+
+
+
+
+```ts
+queue.task("processData", (payload) => processData(payload), { timeoutMs: 300_000 });
+```
+
+
+
+
+```java
+Task PROCESS_DATA = Task.of("process_data", Payload.class)
+ .timeout(Duration.ofMinutes(5));
+```
+
+
+
+
+
+
+`timeout_ms` is the internal, database-stored deadline in milliseconds; the
+`timeout=` kwarg above takes **seconds** and taskito converts it under the
+hood.
+
+
+
+
+ A job without a timeout is only recovered automatically if its worker
+ process actually dies. A worker that's alive but hung holds the job
+ forever — nothing detects that case without a timeout.
+
+
+## Worker is unresponsive
+
+**Symptom**: the worker process is alive but not processing jobs; its
+heartbeat is stale.
+
+**Diagnosis**:
+
+
+
+
+```python
+workers = queue.workers()
+for w in workers:
+ print(f"{w['worker_id']}: {w['status']} (last seen: {w['last_heartbeat']})")
+```
+
+
+
+
+```ts
+const workers = await queue.listWorkers();
+for (const w of workers) {
+ console.log(`${w.workerId}: ${w.status} (last seen: ${w.lastHeartbeat})`);
+}
+```
+
+
+
+
+```java
+List workers = taskito.listWorkers();
+for (WorkerInfo w : workers) {
+ System.out.println(w.workerId + ": " + w.status + " (last seen: " + w.lastHeartbeat + ")");
+}
+```
+
+
+
+
+**Possible causes, not tied to any one runtime**:
+
+- **Deadlock** — a task is waiting on a resource held by another task in the
+ same worker. Check for circular waits in your task code.
+- **Infinite loop** — a task loops without returning. A timeout is the only
+ backstop; see [Jobs stuck in running](#jobs-stuck-in-running) above.
+
+
+
+**GIL-bound CPU task**: a long-running CPU task is holding the GIL (Global
+Interpreter Lock — only one thread runs Python bytecode at a time),
+blocking the scheduler thread from dispatching new jobs. The scheduler runs
+in Rust, but it still needs the GIL to call Python functions.
+
+Fix: switch to the [prefork pool](/python/guides/advanced-execution/prefork)
+for CPU-bound tasks — it runs tasks in separate subprocesses instead of
+threads, so one task's GIL usage can't block the scheduler.
+
+```bash
+taskito worker --app myapp:queue --pool prefork
+```
+
+
+
+
+
+Node handlers share one thread — the event loop. A long **synchronous**
+computation blocks *every* in-flight job, not just its own (see
+[Execution Models](/node/guides/core/execution-model)). Offload CPU-bound
+work to a `worker_threads` pool (or a native addon) and `await` it, or set a
+[`timeoutMs`](/node/guides/reliability/timeouts) so a stuck attempt is
+aborted — the timeout stops tracking the attempt, but a synchronous handler
+still won't yield the loop until it returns.
+
+
+
+
+
+Handlers run on real JVM threads (see
+[Execution Models](/java/guides/core/execution-model#concurrency)), so one
+hung handler doesn't stall the others under the default cached pool — it
+only ties up its own thread. If you sized the pool with `concurrency(n)`
+though, enough hung handlers exhaust it and everything else queues up
+behind them.
+
+`close()` drains in-flight handlers for 30 seconds, then interrupts and
+waits 30 more before closing the native worker — a stuck handler delays
+shutdown by up to a minute. Set a task timeout so a stuck attempt is reaped,
+and keep handlers interruptible.
+
+
+
+## Task not registered on the worker
+
+**Cause**: the task name recorded on a job at enqueue time doesn't match a
+name the worker has registered. Task functions never travel through the
+queue — only their names and arguments do — so the worker's registry has to
+be populated independently.
+
+
+
+Worker logs raise `KeyError: "task 'myapp.tasks.process' not registered"`.
+Task names default to `module.function_name`; if you enqueue from one
+import path and run the worker with a different one, the names won't match.
+
+```python
+# Check the task name stored in the job
+job = queue.get_job(job_id)
+print(job.to_dict()["task_name"]) # e.g. "myapp.tasks.process"
+
+# Check what the worker has registered
+print([t["name"] for t in queue.registered_tasks()])
+```
+
+**Fix**: use consistent import paths. If the task is `myapp/tasks.py:process`,
+always import it as `myapp.tasks.process` — not `tasks.process` (relative)
+or `src.myapp.tasks.process` (with an src prefix).
+
+You can also set an explicit name to decouple the task name from the module
+path:
+
+```python
+@queue.task(name="process-data")
+def process(payload):
+ ...
+```
+
+
+
+
+
+The worker dequeued a job whose task name was never registered on it —
+it throws `TaskNotRegisteredError` (`No task registered with name "..."`).
+Register the handler (`queue.task(name, fn)`) on the **worker** process.
+
+
+
+
+
+The worker dequeued a job whose task name has no handler on it — the job
+fails with `no handler registered for task '...'`. Register the handler
+(`handle(name, payloadType, fn)`) on the **worker** process.
+
+
+
+## `SQLite database is locked`
+
+SQLite allows many concurrent readers (WAL mode — write-ahead logging) but
+only one writer at a time. Under many concurrent workers writing to the
+same file, this surfaces as lock contention.
+
+
+
+```
+OperationalError: database is locked
+```
+
+taskito sets `busy_timeout=5000ms` to wait for locks, but heavy write loads
+can still cause contention.
+
+**Causes:**
+
+- Multiple processes writing to the same SQLite file simultaneously
+- Very large batch inserts blocking the writer
+- Long-running transactions from external tools (e.g. a DB browser) holding
+ locks
+
+**Fixes:**
+
+- Avoid opening the database file with other tools while the worker is
+ running
+- Use [`enqueue_many()` / `task.map()`](/python/guides/advanced-execution/batch-enqueue)
+ for batch inserts — they use a single transaction
+- If running multiple processes, ensure only one worker process writes at a
+ time, or move to [Postgres](/python/guides/operations/postgres) for
+ multi-machine deployments
+
+
+
+
+
+- Keep workers on one machine and moderate concurrency, or
+- Move to [Postgres or Redis](/node/guides/operations/backends) for
+ multi-process / multi-machine deployments
+
+
+
+
+
+- Keep workers on one machine and moderate concurrency, or
+- Move to [Postgres or Redis](/java/guides/operations/backends) for
+ multi-process / multi-machine deployments
+
+
+
+## Database growing too large
+
+**Symptom**: the SQLite file keeps growing, or the job tables never shrink
+on Postgres/Redis. Completed job records and their result payloads
+accumulate unless something purges them.
+
+**Fix**: purge old records.
+
+
+
+
+```python
+# Purge completed jobs older than 7 days (older_than is in seconds)
+queue.purge_completed(older_than=604800)
+
+# Purge dead-lettered jobs older than 30 days
+queue.purge_dead(older_than=2592000)
+```
+
+
+
+
+```ts
+// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
+await queue.purgeCompleted(7 * 24 * 60 * 60 * 1000);
+
+// Purge dead-lettered jobs older than 30 days
+await queue.purgeDead(30 * 24 * 60 * 60 * 1000);
+```
+
+
+
+
+```java
+// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
+taskito.purgeCompleted(Duration.ofDays(7).toMillis());
+
+// Purge dead-lettered jobs older than 30 days
+taskito.purgeDead(Duration.ofDays(30).toMillis());
+```
+
+
+
+
+
+
+Set `result_ttl` on the `Queue` to auto-purge going forward instead of
+purging manually every time:
+
+```python
+queue = Queue(
+ db_path="myapp.db",
+ result_ttl=86400, # Purge completed/dead jobs older than 24 hours
+)
+```
+
+
+
+
+
+There's no built-in TTL option — run the purge above on a schedule instead
+(a cron job, or a periodic task on the queue itself).
+
+
+
+
+
+There's no built-in TTL option — run the purge above on a schedule instead
+(a cron job, or a periodic task on the queue itself).
+
+
+
+After purging, reclaim disk space (SQLite only — Postgres reclaims space
+via autovacuum, Redis via key expiry):
+
+```bash
+sqlite3 myapp.db "VACUUM;"
+```
+
+
+ `VACUUM` rewrites the entire database and requires exclusive access. Run
+ it during low-traffic periods.
+
+
+## High job latency
+
+**Symptom**: jobs sit in `pending` for longer than expected before starting.
+
+**Diagnosis**: check the queue depth first.
+
+
+
+
+```python
+stats = queue.stats()
+print(f"Pending: {stats['pending']}, Running: {stats['running']}")
+```
+
+
+
+
+```ts
+const stats = await queue.stats();
+console.log(`Pending: ${stats.pending}, Running: ${stats.running}`);
+```
+
+
+
+
+```java
+QueueStats stats = taskito.stats();
+System.out.println("Pending: " + stats.pending + ", Running: " + stats.running);
+```
+
+
+
+
+
+
+**Possible causes and fixes**:
+
+1. **Scheduler poll interval too high**: default is 50ms. Jobs can wait up
+ to one poll interval before being picked up.
+
+ ```python
+ queue = Queue(scheduler_poll_interval_ms=10) # Poll every 10ms
+ ```
+
+ Lower values increase CPU/DB usage. Balance based on your latency
+ requirements.
+
+2. **Not enough worker threads**: all workers are busy. Increase the pool
+ size.
+
+ ```python
+ queue = Queue(workers=16)
+ ```
+
+3. **Rate limiting**: the task or queue has a rate limit active — rate-limited
+ jobs are rescheduled 1 second into the future.
+
+ ```python
+ pending = queue.list_jobs(status="pending", limit=10)
+ for job in pending:
+ print(job.to_dict()["scheduled_at"])
+ ```
+
+4. **Database performance**: slow dequeue queries. Check SQLite WAL size or
+ Postgres query plans.
+
+
+
+
+
+**Possible causes and fixes**:
+
+1. **Batch size too small**: raise `batchSize` and `channelCapacity` on
+ `runWorker` to claim and buffer more per poll — see the
+ [execution model](/node/guides/core/execution-model).
+
+ ```ts
+ queue.runWorker({ queues: ["default"], channelCapacity: 256, batchSize: 16 });
+ ```
+
+2. **Not enough worker processes**: add more worker processes against
+ shared storage.
+
+3. **Capped by a limit**: check you aren't capped by a per-task
+ `maxConcurrent` or [rate limit](/node/guides/reliability/rate-limiting).
+
+4. **Database performance**: slow dequeue queries. Check SQLite WAL size or
+ Postgres query plans.
+
+
+
+
+
+**Possible causes and fixes**:
+
+1. **Not enough concurrency**: raise `.concurrency(n)`, or let
+ `.autoscale(AutoscaleOptions.of(min, max))` grow the pool with queue
+ depth — see [Execution Models](/java/guides/core/execution-model#concurrency).
+
+2. **Batch size too small**: raise `.batchSize(n)` and `.channelCapacity(n)`
+ on the worker builder — see [Batching](/java/guides/core/batching).
+
+3. **Not enough worker processes**: add more worker processes against
+ shared storage.
+
+4. **A producer-side gate is deferring the enqueue**: the Java SDK throttles
+ on the *producer* side — a `queue.gate(...)` (including the built-in
+ `Recipes`) can defer a job's creation into the future, which looks like
+ latency but is actually delayed enqueue. Check for an active gate on the
+ task; see [Rate Limiting](/java/guides/reliability/rate-limiting).
+
+5. **Database performance**: slow dequeue queries. Check SQLite WAL size or
+ Postgres query plans.
+
+
+
+## Memory usage growing
+
+**Symptom**: worker process memory climbs over time.
+
+
+
+**Causes**:
+
+1. **Large result payloads**: task return values are stored in the database
+ but also held in the scheduler's result buffer briefly. If tasks return
+ large objects (images, dataframes), memory spikes.
+
+ Fix: return a reference (file path, object key) instead of the data
+ itself.
+
+ ```python
+ # Bad — large result stored in memory and DB
+ @queue.task()
+ def process_image(path: str) -> bytes:
+ return open(path, "rb").read()
+
+ # Good — return a path
+ @queue.task()
+ def process_image(path: str) -> str:
+ out = path + ".processed"
+ # ... write output to out ...
+ return out
+ ```
+
+2. **Accumulated job records**: without `result_ttl`, the database grows
+ unbounded. See [Database growing too large](#database-growing-too-large)
+ above.
+
+3. **Resource leaks in tasks**: a task opens a file or connection and never
+ closes it. Use context managers.
+
+
+
+
+
+**Causes**:
+
+1. **Large result payloads**: return values are serialized and written to
+ storage on every job. Returning large buffers or objects (images,
+ parsed documents) repeatedly pressures the heap — return a reference
+ (file path, object key) instead of the data itself.
+2. **Accumulated job records**: without periodic purging, storage keeps
+ growing. See [Database growing too large](#database-growing-too-large)
+ above.
+3. **Resource leaks in handlers**: a handler opens a file, socket, or
+ `worker_threads` worker and never closes it. Each leaked handle
+ accumulates for the life of the process — always release what you open.
+
+
+
+
+
+**Causes**:
+
+1. **Large result payloads**: return values are serialized and written to
+ storage on every job. Returning large objects repeatedly pressures the
+ JVM heap — return a reference (file path, object key) instead of the
+ data itself.
+2. **Accumulated job records**: without periodic purging, storage keeps
+ growing. See [Database growing too large](#database-growing-too-large)
+ above.
+3. **Resource leaks in handlers**: a handler opens a connection or file
+ directly instead of through a worker-scoped
+ [resource](/java/guides/resources/dependency-injection) and never closes
+ it. Prefer scoped resources — they're closed for you when the worker
+ shuts down.
+
+
+
+## Periodic task running twice
+
+**Symptom**: a periodic task fires more than once per interval, or appears
+to run on two workers simultaneously.
+
+**Behavior**: this is safe by design. Each worker's scheduler runs its own
+maintenance loop and checks for due periodic tasks independently — when one
+is due, every scheduler that notices tries to enqueue it, but a per-tick
+dedup key ensures only one enqueue actually creates a job. The rest resolve
+to that same job's existing id instead of inserting a duplicate.
+
+If you see two completed jobs for the same periodic task in the same
+interval, check for duplicates:
+
+
+
+
+```python
+jobs = queue.list_jobs(status="complete", task_name="daily_report", limit=50)
+for j in jobs:
+ print(j.to_dict()["completed_at"])
+```
+
+
+
+
+```ts
+const jobs = await queue.listJobs({ status: "complete", task: "dailyReport", limit: 50 });
+for (const job of jobs) {
+ console.log(job.completedAt);
+}
+```
+
+
+
+
+```java
+List jobs = taskito.listJobs(
+ JobFilter.builder().status(JobStatus.COMPLETE).task("daily_report").limit(50).build());
+for (Job job : jobs) {
+ System.out.println(job.completedAt);
+}
+```
+
+
+
+
+If you're genuinely seeing duplicate execution, ensure all workers use the
+same storage (same SQLite file path, same Postgres DSN, or same Redis URL).
+
+## Payload / codec errors
+
+A **named codec** must be registered under the same name on both the
+producer and the worker — a task opts into it by name, and if the worker's
+registry doesn't have that name, the job fails.
+
+
+
+
+```python
+from taskito import Queue
+from taskito.codecs import GzipCodec
+
+# Both the producer and the worker construct their Queue with the same
+# codec registered under the same name.
+queue = Queue(db_path="tasks.db", codecs={"gz": GzipCodec()})
+
+@queue.task(codecs=["gz"])
+def process_large_payload(data: bytes):
+ ...
+```
+
+
+
+
+```ts
+import { Queue, GzipCodec } from "@byteveda/taskito";
+
+// Both the producer and the worker construct their Queue with the same
+// codec registered under the same name.
+const queue = new Queue({ dbPath: "tasks.db", codecs: { gz: new GzipCodec() } });
+
+queue.task("processLargePayload", (data: Buffer) => handle(data), { codecs: ["gz"] });
+```
+
+
+
+
+```java
+import org.byteveda.taskito.serialization.GzipCodec;
+
+// Both the producer and the worker build their Taskito client with the
+// same codec registered under the same name.
+Taskito taskito = Taskito.builder()
+ .sqlite("tasks.db")
+ .codec("gz", new GzipCodec())
+ .open();
+
+Task PROCESS_LARGE_PAYLOAD = Task.of("process_large_payload", byte[].class)
+ .codecs("gz");
+```
+
+
+
+
+
+
+- **Serializer mismatch** — producer and worker must use the same
+ [serializer](/python/guides/extensibility/serializers) too, not just the
+ same codecs.
+- Exact error: `SerializationError: no codec registered named 'gz'` (at
+ dispatch time) or `ValueError: no codec registered named 'gz'` (at
+ enqueue time).
+
+
+
+
+
+- **Serializer mismatch** — producers and workers must use the same
+ serializer, not just the same codecs.
+- Exact error: `SerializationError: no codec registered named "gz"`.
+
+
+
+
+
+- **Serializer mismatch** — producers and workers must use the same
+ [serializer](/java/guides/extensibility/serializers) and codec chain.
+- **`no codec registered named '...'`** — the task was enqueued with named
+ codecs; register the same names with `Taskito.builder().codec(name, codec)`
+ on the worker.
+- **Generic payload types** — a non-Jackson serializer only supports plain
+ `Class` payloads; use `JsonSerializer`/`MsgpackSerializer` (or a
+ non-generic payload type) for `TypeReference` payloads.
+
+
+
+## Native addon or library fails to load
+
+
+
+```
+ModuleNotFoundError: No module named 'taskito._taskito'
+```
+
+The compiled Rust extension isn't present for this platform or Python
+version. Reinstall from a wheel that matches both
+(`pip install --force-reinstall taskito`), or rebuild it locally with
+`uv run maturin develop` if you're working from source.
+
+
+
+
+
+```
+Cannot find module '.../native/index.js'
+```
+
+The native addon isn't built. Run `pnpm build:native`. Workflows and mesh
+also require the addon to be built **with their cargo features** (included
+in `build:native`) — a "feature not enabled" error at `queue.workflows`
+means the addon was built without them.
+
+
+
+
+
+```
+UnsatisfiedLinkError: no bundled native library for platform '...'
+```
+
+The jar has no native binary for this OS/architecture. Build the native
+library for the platform and point `-Dtaskito.native.lib=/path/to/library`
+at it. On hardened hosts where `/tmp` is mounted `noexec`, extraction
+succeeds but loading fails — set
+`-Dtaskito.native.workdir=/path/on/exec/volume` instead.
+
+
+
+## Inspecting failures further
+
+
+
+
+ Inspect failures with `queue.job_errors(id)` and `queue.dead_letters()`;
+ see [job management](/python/guides/operations/job-management).
+
+
+
+
+
+
+
+ Inspect failures with `await queue.getJobErrors(id)` and
+ `await queue.deadLetters()`; see
+ [job management](/node/guides/operations/inspection).
+
+
+
+
+
+
+
+ Inspect failures with `taskito.jobErrors(id)` and
+ `taskito.listDead(50, 0)`; see
+ [job management](/java/guides/operations/inspection).
+
+
+
diff --git a/docs/content/docs/shared/guides/reliability/circuit-breakers.mdx b/docs/content/docs/shared/guides/reliability/circuit-breakers.mdx
new file mode 100644
index 00000000..33b5ff68
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/circuit-breakers.mdx
@@ -0,0 +1,401 @@
+---
+title: Circuit Breakers
+description: "Trip a task after repeated failures, then sample-probe before fully closing — protect a failing dependency from cascading load."
+---
+
+A circuit breaker stops a task from running once it fails too often within a
+time window, giving a failing downstream dependency — an external API, a
+database, a third-party service — time to recover instead of getting
+hammered by every retry. Enforcement lives entirely in the shared core
+scheduler; each SDK only supplies the configuration.
+
+
+
+> Celery has no built-in circuit breaker — this is taskito-native, configured
+> per task with `circuit_breaker={...}`.
+
+
+
+
+
+> **Coming from BullMQ?** BullMQ has no built-in circuit breaker either —
+> it's a taskito-specific addition. You'd otherwise reach for a separate
+> library (e.g. `opossum`) alongside a BullMQ `Worker`.
+
+
+
+## How it works
+
+A circuit breaker tracks failures within a time window and transitions
+through three states:
+
+ Closed
+ Closed --> Open: failures >= threshold within window
+ Open --> HalfOpen: cooldown elapsed
+ HalfOpen --> Closed: success rate >= threshold
+ HalfOpen --> Open: success rate impossible OR timeout`}
+/>
+
+- **Closed** — normal operation. Tasks execute as usual; each failure is
+ counted, and the count resets once a success comes in or the window
+ expires.
+- **Open** — too many failures. Jobs of that task are gated rather than
+ executed.
+- **Half-Open** — after the cooldown elapses, a limited number of probe jobs
+ are let through (default 5). Their successes and failures are tracked
+ separately from the closed-state counter. The circuit closes once the
+ success rate among completed probes meets the threshold (default 80%). If
+ enough probes fail that the threshold becomes mathematically impossible
+ before all of them complete, the circuit re-opens immediately. If the
+ probe quota is used up without the cooldown period completing a full
+ cycle, it also re-opens as a safety valve.
+
+
+ A gated job isn't dropped or dead-lettered — the poller reschedules it 5
+ seconds out and re-checks the breaker the next time it's due, so it runs
+ as soon as the breaker closes (or gets picked as a half-open probe). This
+ 5-second recheck interval is fixed by the core scheduler and isn't
+ configurable from any SDK.
+
+
+## Configuration
+
+A circuit breaker is configured per task, alongside its other resilience
+settings. It's only enforced once a worker registers that task's config at
+startup — enqueuing jobs before any such worker has started just queues
+them normally, since there's nothing yet to gate on.
+
+
+
+
+```python
+from taskito import Queue
+
+queue = Queue(db_path="tasks.db")
+
+@queue.task(
+ circuit_breaker={
+ "threshold": 5, # open after 5 failures
+ "window": 60, # within a 60-second window
+ "cooldown": 120, # stay open 2 minutes before half-open
+ }
+)
+def call_external_api(endpoint: str) -> dict:
+ return requests.get(endpoint).json()
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+
+queue.task("call_flaky_api", callApi, {
+ circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 120_000 },
+});
+```
+
+
+
+
+```java
+Task callFlakyApi = Task.of("call_flaky_api", Response.class)
+ .circuitBreaker(CircuitBreakerConfig.builder(5)
+ .windowSeconds(60)
+ .cooldownSeconds(120)
+ .build());
+```
+
+
+
+
+### Parameter reference
+
+Each binding exposes the same breaker with its own names, defaults, and
+required fields:
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `threshold` | `int` | `5` | Number of failures to trigger the breaker. |
+| `window` | `int` | `60` | Time window in seconds for counting failures. |
+| `cooldown` | `int` | `300` | Seconds to wait before allowing a half-open probe. |
+| `half_open_probes` | `int` | `5` | Number of probe requests allowed while half-open. |
+| `half_open_success_rate` | `float` | `0.8` | Required success rate (0.0–1.0) among probes to close from half-open. |
+
+
+
+
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `threshold` | `number` | required | Failures within `windowMs` that trip the breaker. |
+| `windowMs` | `number` | required | Rolling window for counting failures. |
+| `cooldownMs` | `number` | required | How long the breaker stays open before a half-open probe. |
+| `halfOpenMaxProbes` | `number` | `5` | Probe jobs let through while half-open. |
+| `halfOpenSuccessRate` | `number` | `0.8` | Success rate (0.0–1.0) among probes required to close. |
+
+
+
+
+
+| Builder method | Type | Default | Description |
+|---|---|---|---|
+| `CircuitBreakerConfig.builder(threshold)` / `.of(threshold)` | `int` | required, must be `> 0` | Failure count that trips the breaker. |
+| `.window(Duration)` / `.windowSeconds(long)` | `Duration` | `60s` | Rolling window for counting failures. |
+| `.cooldown(Duration)` / `.cooldownSeconds(long)` | `Duration` | `300s` | How long the breaker stays open before admitting half-open probes. |
+| `.halfOpenProbes(int)` | `int` | `5` | Probe runs admitted while half-open; must be `> 0`. |
+| `.halfOpenSuccessRate(double)` | `double` | `0.8` | Success rate (0.0–1.0) among probes required to re-close; must be within `[0.0, 1.0]`. |
+
+
+
+## Inspecting circuit breaker state
+
+Only tasks with a registered breaker show up here — plain tasks return
+nothing.
+
+
+
+
+```python
+breakers = queue.circuit_breakers()
+for cb in breakers:
+ print(f"{cb['task_name']}: {cb['state']} (failures: {cb['failure_count']})")
+```
+
+
+
+
+```ts
+const breakers = await queue.listCircuitBreakers();
+for (const cb of breakers) {
+ console.log(`${cb.taskName}: ${cb.state} (failures: ${cb.failureCount})`);
+}
+```
+
+
+
+
+```java
+List breakers = queue.listCircuitBreakers();
+for (CircuitBreakerState cb : breakers) {
+ log.info("{}: {} (failures: {})", cb.taskName, cb.state, cb.failureCount);
+}
+```
+
+
+
+
+### Dashboard API
+
+
+ These endpoints sit behind the dashboard's normal auth — see each SDK's
+ dashboard guide for setup and authentication details.
+
+
+
+
+
+```bash
+curl http://localhost:8080/api/circuit-breakers
+```
+
+```json
+[
+ {
+ "task_name": "myapp.tasks.call_external_api",
+ "state": "open",
+ "failure_count": 5,
+ "last_failure_at": 1700000010000,
+ "opened_at": 1700000010000,
+ "threshold": 5,
+ "window_ms": 60000,
+ "cooldown_ms": 120000
+ }
+]
+```
+
+
+
+
+```bash
+curl http://localhost:8787/api/circuit-breakers
+```
+
+```json
+[
+ {
+ "task_name": "call_flaky_api",
+ "state": "open",
+ "failure_count": 5,
+ "last_failure_at": 1700000010000,
+ "opened_at": 1700000010000,
+ "threshold": 5,
+ "window_ms": 60000,
+ "cooldown_ms": 120000
+ }
+]
+```
+
+
+
+
+```bash
+curl http://localhost:8080/api/circuit-breakers
+```
+
+```json
+[
+ {
+ "task_name": "call_flaky_api",
+ "state": "open",
+ "failure_count": 5,
+ "threshold": 5,
+ "window_ms": 60000,
+ "cooldown_ms": 120000,
+ "opened_at": 1700000010000,
+ "last_failure_at": 1700000010000,
+ "half_open_max_probes": 5,
+ "half_open_success_rate": 0.8
+ }
+]
+```
+
+
+ The Java contract includes `half_open_max_probes` and
+ `half_open_success_rate`; the Python and Node contracts don't expose those
+ two fields over the dashboard API today.
+
+
+
+
+
+## When to use
+
+Circuit breakers are most useful for tasks that interact with external
+systems:
+
+- **External API calls** — prevent hammering a service that's already down
+- **Database connections** — stop retrying against an unreachable database
+- **Third-party services** — email providers, payment gateways, and similar
+ dependencies
+
+For purely internal computation tasks, circuit breakers are usually
+unnecessary — plain retries with backoff are sufficient.
+
+## Combining with retries
+
+A task can have both. Retries and the breaker are independent counters over
+the same failures: **every** failed attempt is recorded against the
+breaker's threshold — including one that's about to be retried — while the
+retry budget separately decides whether that attempt gets another try. A
+sufficiently flaky dependency can trip the breaker mid-retry, before a job's
+own retry budget is exhausted. Once open, new jobs for that task are gated
+immediately, so the retry budget stops being spent on a dependency that's
+already down.
+
+
+
+
+```python
+@queue.task(
+ max_retries=3,
+ retry_backoff=2.0,
+ circuit_breaker={"threshold": 5, "window": 120, "cooldown": 600},
+)
+def send_email(to: str, subject: str, body: str):
+ smtp.send(to, subject, body)
+```
+
+
+
+
+```ts
+queue.task("send_email", sendEmail, {
+ maxRetries: 3,
+ retryBackoff: { baseMs: 2_000, maxMs: 60_000 },
+ circuitBreaker: { threshold: 5, windowMs: 120_000, cooldownMs: 600_000 },
+});
+```
+
+
+
+
+```java
+Task sendEmail = Task.of("send_email", EmailPayload.class)
+ .maxRetries(3)
+ .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(2), Duration.ofMinutes(1)))
+ .circuitBreaker(CircuitBreakerConfig.builder(5)
+ .windowSeconds(120)
+ .cooldownSeconds(600)
+ .build());
+```
+
+
+
+
+See Retries for the backoff
+curve and dead-letter behavior.
+
+## Monitoring state changes
+
+Poll `circuit_breakers()` / `listCircuitBreakers()` from a failure hook to
+alert when a breaker opens. The available hook differs by SDK: Python's
+`JOB_FAILED` event fires on every failed attempt, while Node's `job.dead`
+and Java's `EventName.DEAD` only fire once a job's retry budget is
+exhausted.
+
+
+
+
+```python
+from taskito.events import EventType
+
+def monitor_breakers(event_type: EventType, payload: dict) -> None:
+ # fires on every failed attempt, including ones that will be retried
+ open_breakers = [b for b in queue.circuit_breakers() if b["state"] == "open"]
+ if open_breakers:
+ names = ", ".join(b["task_name"] for b in open_breakers)
+ print(f"WARNING: open circuit breakers: {names}")
+
+queue.on_event(EventType.JOB_FAILED, monitor_breakers)
+```
+
+
+
+
+```ts
+queue.on("job.dead", async () => {
+ // fires once a job's retry budget is exhausted, not on every failed attempt
+ const open = (await queue.listCircuitBreakers()).filter((cb) => cb.state === "open");
+ if (open.length > 0) {
+ console.warn("open circuit breakers:", open.map((cb) => cb.taskName));
+ }
+});
+```
+
+
+
+
+```java
+Worker worker = queue.worker()
+ .handle(callFlakyApi, payload -> call(payload))
+ .on(EventName.DEAD, event -> {
+ // fires once a job's retry budget is exhausted, not on every failed attempt
+ List open = queue.listCircuitBreakers().stream()
+ .filter(CircuitBreakerState::isOpen)
+ .toList();
+ if (!open.isEmpty()) {
+ log.warn("open circuit breakers: {}", open);
+ }
+ })
+ .start();
+```
+
+
+
diff --git a/docs/content/docs/shared/guides/reliability/error-handling.mdx b/docs/content/docs/shared/guides/reliability/error-handling.mdx
new file mode 100644
index 00000000..ea1863c2
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/error-handling.mdx
@@ -0,0 +1,723 @@
+---
+title: Error Handling
+description: "What happens when a task fails — the retry, timeout, and dead-letter lifecycle, inspecting error history, and each SDK's exception hierarchy."
+---
+
+When a task raises an exception>} node={<>task throws (or its promise rejects)>} java={<>handler throws>} />, Taskito decides
+the job's fate from its retry budget. Each failing attempt is recorded,
+then:
+
+1. **Retry** — if attempts remain, the job is rescheduled with
+ backoff (a growing
+ delay between attempts, so retries don't hammer a struggling dependency).
+2. **Dead-letter** — once the budget is exhausted, the job moves to the
+ dead-letter queue, a holding area for permanently-failed jobs you can
+ inspect and replay.
+
+ B["Error recorded"]
+ B --> C{"Attempts left?"}
+ C -->|Yes| D["Reschedule with backoff"]
+ C -->|No| E["Move to dead-letter queue"]`}
+/>
+
+
+
+
+```python
+@queue.task(max_retries=3, retry_backoff=1.0, max_retry_delay=60)
+def fetch(url):
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+ return resp.json()
+```
+
+
+
+
+```ts
+queue.task("fetch", fetchUrl, {
+ maxRetries: 3,
+ retryBackoff: { baseMs: 1000, maxMs: 60_000 },
+});
+```
+
+
+
+
+```java
+Task FETCH = Task.of("fetch", String.class)
+ .maxRetries(3)
+ .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));
+```
+
+
+
+
+Returning normally is success; an uncaught throw is failure. There's no
+in-task "stop retrying" signal from inside a failing attempt — set retries
+to `0` for a fire-once task.
+
+
+
+`TaskFunction` declares `throws Exception`, so checked exceptions propagate
+without wrapping. `maxRetries` defaults to `0` — never retry — so a fresh
+task is fire-once until you opt into retries.
+
+
+
+## Timeouts
+
+A per-attempt timeout bounds a single execution attempt. When it elapses,
+the attempt is marked failed and timed-out — this consumes a retry like any
+other failure, and once the budget is exhausted the job dead-letters with
+the timed-out flag set on the outcome, so hooks and event listeners can tell
+a timeout apart from other errors.
+
+
+
+
+```python
+@queue.task(timeout=30) # 30 second timeout
+def long_task():
+ ...
+```
+
+
+
+
+```ts
+queue.task("slow", handler, { timeoutMs: 30_000 });
+```
+
+
+
+
+```java
+Task SLOW = Task.of("slow", String.class).timeout(Duration.ofSeconds(30));
+```
+
+
+
+
+
+
+The scheduler detects an exceeded timeout externally — its maintenance
+reaper checks roughly every 5 seconds — and marks the job failed, which
+triggers the retry/DLQ logic above.
+
+
+
+
+
+The dispatcher races the task against the timeout (`tokio::time::timeout`).
+For `async` tasks, the timeout aborts the task's
+`AbortSignal` — honor it
+(e.g. pass `signal` to `fetch`) so the underlying work actually stops, not
+just the bookkeeping.
+
+
+
+
+
+The dispatcher races the handler against the timeout. Unset (or `0`) means
+no limit.
+
+
+
+
+ Timeout reaping marks the job as failed, but it does **not** kill the
+ running : the code
+ keeps running until it returns, and the late result is discarded. Always
+ set a timeout on production tasks — without one, a wedged
+ can hold a worker
+ indefinitely.
+
+
+
+
+For CPU-bound synchronous work that might hang, honoring `AbortSignal` isn't
+enough on its own — see
+Troubleshooting: worker is
+unresponsive for offloading it.
+
+
+
+
+
+Make long-running handlers cooperative: poll
+`taskito.isCancelRequested(jobId)` or bound their own blocking calls, so the
+underlying work actually stops. See
+Cancellation .
+
+
+
+
+
+### Soft timeouts
+
+While the hard timeout above is enforced by the scheduler from the outside,
+a **soft timeout** lets the task itself react to time pressure — it's
+cooperative, so the task must call `check_timeout()` at safe points:
+
+```python
+from taskito import current_job
+
+@queue.task(soft_timeout=30)
+def long_task():
+ for chunk in data_chunks:
+ process(chunk)
+ current_job.check_timeout() # raises SoftTimeoutError after 30s
+```
+
+| Timeout type | Mechanism | Exception |
+|---|---|---|
+| Hard timeout (`timeout`) | Scheduler reaps the job externally | `TaskTimeoutError` (internal) |
+| Soft timeout (`soft_timeout`) | Task checks elapsed time via `check_timeout()` | `SoftTimeoutError` |
+
+
+
+## Inspecting failures
+
+Every attempt's error is recorded — one entry per failed attempt:
+
+
+
+
+```python
+queue.job_errors(job_id) # one entry per failed attempt
+queue.list_jobs(status="failed")
+queue.dead_letters() # exhausted jobs
+```
+
+
+
+
+```ts
+await queue.getJobErrors(id); // one entry per failed attempt
+await queue.listJobs({ status: "failed" });
+await queue.deadLetters(); // exhausted jobs
+```
+
+
+
+
+```java
+List errors = queue.jobErrors(jobId); // one entry per failed attempt
+List failed = queue.listJobs(JobFilter.builder().status(JobStatus.FAILED).build());
+List dead = queue.listDead(50, 0); // exhausted jobs
+```
+
+
+
+
+Each error entry carries an id, the job it belongs to, an attempt number
+(0-indexed), the error message, and a failure timestamp:
+
+| Field | Description |
+|---|---|
+| `id` | Unique error record ID |
+| job_id} node={jobId} java={jobId} /> | The job this error belongs to |
+| `attempt` | Attempt number (0-indexed) |
+| `error` | Error message |
+| failed_at} node={failedAt} java={failedAt} /> | Timestamp in milliseconds |
+
+
+
+A job handle also exposes its own history directly:
+
+```python
+job = unreliable_task.delay()
+
+for error in job.errors:
+ print(f"Attempt {error['attempt']}: {error['error']} at {error['failed_at']}")
+```
+
+
+
+### Querying the database directly
+
+For deep debugging, query the jobs table directly (SQLite shown; the same
+status codes apply on every backend):
+
+```bash
+sqlite3 myapp.db "SELECT id, task_name, status, error FROM jobs WHERE status = 3 LIMIT 10;"
+```
+
+Status codes: `0`=pending, `1`=running, `2`=complete, `3`=failed, `4`=dead,
+`5`=cancelled.
+
+## Diagnosing dead letters
+
+**Same error on every attempt** — the failure is deterministic (bad
+arguments, a missing dependency). Fix the root cause, then replay.
+**Intermittent errors** — the failure is transient (a network timeout, a
+flaky dependency); replaying will likely succeed.
+
+
+
+
+```python
+dead = queue.dead_letters(limit=20)
+
+new_job_id = queue.retry_dead(dead[0]["id"])
+```
+
+
+
+
+```ts
+const dead = await queue.deadLetters();
+
+queue.retryDead(dead[0].id); // re-enqueue (preserves notes/metadata)
+```
+
+
+
+
+```java
+List dead = queue.listDead(20, 0);
+
+String newJobId = queue.retryDead(dead.get(0).id); // re-enqueue (preserves metadata)
+```
+
+
+
+
+
+
+
+ Replayed jobs preserve the original job's `priority`, `max_retries`,
+ `timeout`, and `result_ttl` settings — no need to re-specify them.
+
+
+
+
+
+
+
+ A retried dead job re-enters as `pending` with a fresh retry budget. Notes
+ and metadata survive the DLQ round-trip. See
+ Dead-letter queue
+ for listing, deleting, and purging.
+
+
+
+
+
+
+
+ A retried dead job re-enters as `PENDING` with a fresh retry budget.
+ Metadata survives the DLQ round-trip. See
+ Dead-letter queue
+ for listing, deleting, and purging.
+
+
+
+
+
+
+See Retries for the full
+dead-letter queue API — listing, purging, and error history.
+
+
+
+## Cancellation
+
+Cancellation is cooperative — a running must check for it at safe points; nothing preempts it
+mid-execution. One that never checks runs to completion.
+
+
+
+
+```python
+from taskito import current_job
+
+@queue.task()
+def long_task(items):
+ for item in items:
+ process(item)
+ current_job.check_cancelled() # raises TaskCancelledError
+
+# From the caller:
+queue.cancel_running_job(job_id)
+```
+
+
+
+
+```ts
+import { currentJob } from "@byteveda/taskito";
+
+queue.task("download", async (url: string) => {
+ const { signal } = currentJob() ?? {};
+ const res = await fetch(url, { signal });
+ return res.text();
+});
+
+queue.requestCancel(jobId);
+```
+
+
+
+
+```java
+taskito.requestCancel(jobId); // caller side
+
+// handler side: poll between units of work
+for (Chunk chunk : chunks) {
+ if (taskito.isCancelRequested(jobId)) {
+ throw new InterruptedException("cancelled");
+ }
+ process(chunk);
+}
+```
+
+
+
+
+
+
+See Cancellation for
+cancelling a still-pending job and progress reporting.
+
+
+
+
+
+See Cancellation for
+cancelling a still-pending job, progress reporting, and getting the job id
+into a handler.
+
+
+
+## Reacting to outcomes
+
+Middleware and events fire as the core decides each outcome:
+
+
+
+
+```python
+from taskito import Queue, TaskMiddleware, EventType
+
+class AlertOnFailure(TaskMiddleware):
+ def after(self, ctx, result, error):
+ if error:
+ log.error(ctx.task_name, error) # each failing attempt
+
+ def on_retry(self, ctx, error, retry_count):
+ metrics.incr("retry", tags={"task": ctx.task_name})
+
+ def on_dead_letter(self, ctx, error):
+ alert_ops(ctx, error)
+
+queue = Queue(db_path="tasks.db", middleware=[AlertOnFailure()])
+queue.on_event(EventType.JOB_DEAD, lambda _type, payload: page_oncall(payload))
+```
+
+
+
+
+```ts
+queue.use({
+ onError: (ctx, err) => log.error(ctx.taskName, err), // each throwing attempt
+ onRetry: (e) => metrics.inc("retry", e.taskName),
+ onDeadLetter: (e) => alertOps(e),
+});
+queue.on("job.dead", (e) => pageOncall(e));
+```
+
+
+
+
+```java
+queue.use(new Middleware() {
+ @Override
+ public void onError(TaskContext context, Throwable error) {
+ log.error(context.taskName, error); // each throwing attempt
+ }
+
+ @Override
+ public void onRetry(OutcomeEvent event) {
+ metrics.increment("retry", event.taskName);
+ }
+
+ @Override
+ public void onDeadLetter(OutcomeEvent event) {
+ alertOps(event);
+ }
+});
+
+Worker worker = queue.worker()
+ .handle(FETCH, this::fetch)
+ .on(EventName.DEAD, event -> pageOncall(event))
+ .start();
+```
+
+
+
+
+
+
+See Middleware for
+the full hook lifecycle and execution order.
+
+
+
+
+
+See Middleware and
+Events for the full hook
+and event lists.
+
+
+
+
+
+See Middleware and
+Events for the full hook
+and event lists.
+
+
+
+
+ A throw fails only the current *attempt*. To fail fast with no retries,
+ set retries to `0`; to make failures safe to retry, keep
+
+ idempotent .
+
+
+## Cleanup on failure
+
+Use a `finally` block (or hooks) to release resources regardless of outcome:
+
+
+
+
+```python
+@queue.task()
+def process_file(path):
+ tmp = download_to_temp(path)
+ try:
+ return parse(tmp)
+ finally:
+ os.unlink(tmp)
+```
+
+
+
+
+```ts
+queue.task("processFile", async (path: string) => {
+ const tmp = await downloadToTemp(path);
+ try {
+ return await parse(tmp);
+ } finally {
+ await fs.unlink(tmp);
+ }
+});
+```
+
+
+
+
+```java
+Task PROCESS_FILE = Task.of("process_file", String.class);
+
+queue.worker().handle(PROCESS_FILE, path -> {
+ String tmp = downloadToTemp(path);
+ try {
+ return parse(tmp);
+ } finally {
+ Files.deleteIfExists(Path.of(tmp));
+ }
+});
+```
+
+
+
+
+
+
+## Serialization errors
+
+The default `SmartSerializer` uses MessagePack for common types and falls
+back to cloudpickle for anything else. The cloudpickle fallback fails on:
+
+- **Unpicklable objects**: open file handles, database connections, sockets,
+ thread locks
+- **Module-level objects that can't be resolved**: dynamically generated
+ classes without stable import paths
+- **Very large objects**: cloudpickle has no hard limit, but extremely large
+ payloads slow down SQLite writes
+
+**Fix**: pass simple, serializable data (strings, numbers, dicts, lists) as
+task arguments, and reconstruct complex objects inside the task:
+
+```python
+# Bad — passing a connection object
+@queue.task()
+def query(conn, sql): # conn can't be pickled
+ return conn.execute(sql)
+
+# Good — pass connection info, create inside the task
+@queue.task()
+def query(db_url, sql):
+ conn = create_connection(db_url)
+ return conn.execute(sql)
+```
+
+See [Serialization](/architecture/serialization) for the full serializer
+list, including `JsonSerializer` for cross-language or untrusted payloads.
+
+### Exception filtering
+
+Use `retry_on` and `dont_retry_on` to control which exceptions trigger
+retries:
+
+```python
+@queue.task(
+ max_retries=5,
+ retry_on=[ConnectionError, TimeoutError],
+ dont_retry_on=[ValueError],
+)
+def call_api(url):
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+ return resp.json()
+```
+
+See Retries — Exception
+Filtering for details.
+
+### Test mode for isolation
+
+Run tasks synchronously and inspect errors without a worker using
+test mode :
+
+```python
+with queue.test_mode() as results:
+ risky_task.delay()
+
+ if results[0].failed:
+ print(results[0].error)
+ print(results[0].traceback)
+```
+
+
+
+## Exception hierarchy
+
+
+
+All Taskito exceptions inherit from `TaskitoError`, so you can catch the
+base class for broad handling:
+
+```
+TaskitoError (base)
+├── TaskTimeoutError — hard timeout exceeded
+├── SoftTimeoutError — soft timeout exceeded (check_timeout)
+├── TaskCancelledError — task cancelled (check_cancelled)
+├── MaxRetriesExceededError — all retry attempts exhausted
+├── SerializationError — serialization/deserialization failure
+├── CircuitBreakerOpenError — circuit breaker is open
+├── RateLimitExceededError — rate limit exceeded
+├── JobNotFoundError — job ID not found (also a KeyError)
+└── QueueError — queue-level operational error
+```
+
+```python
+from taskito import TaskitoError, SoftTimeoutError, TaskCancelledError
+
+try:
+ result = job.result(timeout=30)
+except TaskitoError as e:
+ print(f"Taskito error: {e}")
+```
+
+Import any exception directly from the `taskito` package:
+
+```python
+from taskito import TaskCancelledError, SoftTimeoutError, SerializationError
+```
+
+
+
+
+
+Every SDK error extends `TaskitoError`, so you can catch the base class for
+broad handling:
+
+| Error | Thrown when |
+|---|---|
+| `TaskNotRegisteredError` | A worker dequeued a job whose task name was never registered. |
+| `JobFailedError` | An awaited job (`queue.result()`) failed or dead-lettered. |
+| `JobCancelledError` | An awaited job was cancelled. |
+| `ResultTimeoutError` | An awaited job didn't settle within the timeout. |
+| `QueueError` | A queue-level configuration or operational error. |
+| `LockNotAcquiredError` | A distributed lock is held by another owner. |
+| `LockLostError` | A held lock's lease was lost before the guarded section finished. |
+| `SerializationError` | A payload or result failed to (de)serialize. |
+| `CryptoError` | A codec failed to decrypt or verify a payload. Subtype of `SerializationError`. |
+| `NotesValidationError` | A `notes` object violates the structured-notes contract. |
+| `ResourceError` | Base class for resource errors. |
+| `ResourceNotFoundError` | Resolving a resource name that was never registered. Subtype of `ResourceError`. |
+| `ResourceScopeError` | A resource is resolved from a scope that outlives it. Subtype of `ResourceError`. |
+| `ResourceUnavailableError` | A pooled resource couldn't be checked out before its acquire timeout. Subtype of `ResourceError`. |
+| `WorkflowError` | A workflow definition, submission, or query error. |
+| `PredicateRejectedError` | An enqueue-time predicate gate rejected the submission. |
+| `ProxyError` | A proxy handler, signature, expiry, purpose, or allowlist failure. |
+| `InterceptionError` | An enqueue interceptor rejected, misbehaved, or redirected illegally. |
+
+```ts
+import { TaskitoError, JobFailedError } from "@byteveda/taskito";
+
+try {
+ const result = await queue.result(jobId, { timeoutMs: 30_000 });
+} catch (err) {
+ if (err instanceof TaskitoError) {
+ console.error("Taskito error:", err.message);
+ }
+}
+```
+
+
+
+
+
+Every SDK error is an unchecked `TaskitoException`; the `errors` package
+narrows it so callers can catch exactly what they care about:
+
+| Exception | Raised when |
+|---|---|
+| `ConfigurationException` | The client was misconfigured — missing connection URL, unusable storage directory. |
+| `SerializationException` | A payload or result failed to (de)serialize. |
+| `CryptoException` | A signing or encrypting serializer failed — HMAC mismatch, bad tag/IV. Subtype of `SerializationException`. |
+| `InterceptionException` | An interceptor rejected the enqueue; no job was created. |
+| `PredicateRejectedException` | An enqueue gate returned `Reject`; no job was created. |
+| `EnqueueSkippedException` | A gate returned `Skip` on `enqueue` — use `tryEnqueue` to get an empty `Optional` instead. |
+| `LockException` | A distributed lock operation failed or was interrupted. |
+| `ResourceException` | A worker resource could not be resolved — unknown name, scope violation, exhausted pool. |
+| `ProxyException` | A proxy ref failed to verify or reconstruct. |
+| `WorkflowException` | A workflow could not be driven to completion — run not found, missing payload or condition. |
+| `WebhookException` | A webhook could not be stored, loaded, signed, or its payload encoded. |
+
+```java
+try {
+ queue.enqueue(FETCH, url);
+} catch (TaskitoException e) {
+ log.error("Taskito error: {}", e.getMessage());
+}
+```
+
+
+
+## See also
+
+Troubleshooting
+covers symptom-based debugging — a task not registered on the worker,
+SQLite lock contention, jobs stuck in `running`, high latency, and growing
+databases.
diff --git a/docs/content/docs/shared/guides/reliability/guarantees.mdx b/docs/content/docs/shared/guides/reliability/guarantees.mdx
new file mode 100644
index 00000000..5dee2900
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/guarantees.mdx
@@ -0,0 +1,291 @@
+---
+title: Delivery Guarantees
+description: "Exactly-once dispatch, at-least-once execution, and how to design idempotent tasks."
+---
+
+Taskito gives **exactly-once dispatch**: an atomic claim in storage makes sure
+two workers never pick up the same job at once. But the system as a whole is
+**at-least-once execution** — if a worker crashes after it starts a task but
+before the result is recorded, the job is retried and the task body runs
+again. Design task code to tolerate that.
+
+## How a job is claimed
+
+Before dispatching a job, the scheduler makes an atomic claim on it — a
+guarded update only one caller can win (SQLite: `INSERT OR IGNORE`; Postgres:
+`INSERT ... ON CONFLICT DO NOTHING`; Redis: `SET NX`). If another scheduler
+instance already claimed the job, the claim fails and that instance skips it.
+
+- A job **will not be lost** — if a worker dies, the stale reaper detects the
+ abandoned claim and retries the job.
+- A job **may run twice** — if a worker crashes after starting the task but
+ before its result is recorded.
+- A job **will not be dispatched concurrently** — the atomic claim means only
+ one worker is ever handed it at a time. The narrow exception: when an
+ attempt times out, the reaper schedules a retry but cannot kill the
+ still-running task body, so the new attempt can briefly overlap the old
+ one. Idempotent task code covers this case too.
+
+
+
+
+ There's no manual ack/nack: a claimed job's redelivery is driven by the
+ claim lapsing, not by a consumer forgetting to acknowledge. You still get
+ the same at-least-once contract you'd design a JMS listener around.
+
+
+
+
+## Crash recovery
+
+>DB: dequeue + claim
+ S->>W: dispatch job
+ W->>W: execute task
+ Note over W: Worker crashes here
+ Note over S: timeout elapses...
+ S->>DB: stale reaper detects stuck job
+ S->>DB: mark failed + schedule retry
+ S->>W: dispatch again (new attempt)
+ W->>DB: complete + clear claim`}
+/>
+
+The claim prevents **duplicate dispatch** — two workers picking up the same
+job at once. It does not prevent **duplicate execution** after a crash: the
+claim lapses once the stale reaper detects the timeout, and the job is
+dispatched again as a new attempt.
+
+## Why not exactly-once?
+
+Exactly-once *delivery* is impossible in general: a worker can run a task's
+side effect and then crash before it acknowledges completion, so the job is
+retried and the side effect runs again. No protocol closes this gap — the
+effect and the acknowledgement can't be committed atomically across separate
+systems. What you *can* get is exactly-once *effect*, by making tasks
+idempotent (dedup keys, upserts) or routing writes through a transactional
+outbox/inbox. This matches Celery, SQS, and most production job systems:
+deliver at least once, design tasks to handle duplicates.
+
+## What's not guaranteed
+
+- **Exactly-once execution** — not offered anywhere in the system (see
+ above); pair at-least-once delivery with idempotent handlers instead.
+- **Strict ordering** — within a queue, jobs dequeue by priority then age,
+ but concurrent workers run them in parallel; don't rely on one finishing
+ before another. Use a workflow
+ when order matters.
+
+## Writing idempotent tasks
+
+Because a job may run more than once, design task code to be safe on
+re-execution:
+
+- Upsert or use a conditional write keyed by a stable id, instead of a blind
+ insert.
+- Dedupe external calls (payments, emails, webhooks) using the job id, or a
+ stable business id, as an idempotency key with the downstream provider.
+- Collapse duplicate *enqueues* into a single job with a unique key — see
+ [Deduplicating enqueues](#deduplicating-enqueues) below.
+
+
+
+
+```python
+@queue.task()
+def create_user(email, name):
+ # UPSERT — safe to run twice
+ db.execute(
+ "INSERT INTO users (email, name) VALUES (?, ?) "
+ "ON CONFLICT (email) DO UPDATE SET name = ?",
+ (email, name, name),
+ )
+```
+
+```python
+@queue.task()
+def charge_customer(order_id, amount):
+ # Check if already charged
+ if db.execute("SELECT 1 FROM charges WHERE order_id = ?", (order_id,)).fetchone():
+ return # Already processed
+
+ payment_provider.charge(amount, idempotency_key=f"order-{order_id}")
+ db.execute("INSERT INTO charges (order_id, amount) VALUES (?, ?)", (order_id, amount))
+```
+
+
+
+
+```ts
+import { currentJob } from "@byteveda/taskito";
+
+queue.task("charge", async (orderId: string) => {
+ const job = currentJob();
+ await payments.charge(orderId, { idempotencyKey: job?.jobId });
+});
+```
+
+
+
+
+```java
+Worker worker = queue.worker()
+ .handle(CHARGE, order -> payments.charge(order.total(), order.id()))
+ .start();
+```
+
+
+
+
+
+
+### Avoid unrecoverable side effects
+
+```python
+# Bad — sends duplicate emails on retry
+@queue.task()
+def notify(user_id):
+ send_email(user_id, "Your order shipped")
+
+# Good — atomically claim the send before doing it
+@queue.task()
+def notify(user_id):
+ claimed = db.execute(
+ "UPDATE orders SET notified = 1 WHERE user_id = ? AND notified = 0",
+ (user_id,),
+ )
+ if claimed.rowcount:
+ send_email(user_id, "Your order shipped")
+```
+
+The guarded `UPDATE` is atomic — two overlapping executions can't both win
+it, unlike a separate check-then-send. The trade-off: a crash between the
+claim and the send drops the email (at-most-once for that side effect). When
+the provider accepts an idempotency key, prefer that — it keeps the retry
+*and* dedupes the send, as in the `charge` example above.
+
+
+
+## Deduplicating enqueues
+
+A unique key coalesces duplicate enqueues into the same job while the first
+job for that key is still pending or running — the second call returns the
+existing job's id instead of creating a new one. It's backed by a partial
+unique index in storage, so the dedup is atomic across producers and
+processes.
+
+
+
+
+```python
+# Only one pending/running instance per key
+job1 = send_report.apply_async(args=(user_id,), unique_key=f"report-{user_id}")
+job2 = send_report.apply_async(args=(user_id,), unique_key=f"report-{user_id}")
+assert job2.id == job1.id # coalesced onto job1 — no duplicate created
+```
+
+
+
+
+```ts
+queue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` });
+queue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` }); // ignored
+```
+
+
+
+
+```java
+EnqueueOptions once = EnqueueOptions.builder()
+ .uniqueKey("welcome:" + user.id())
+ .build();
+
+String first = queue.enqueue(WELCOME, user.id(), once);
+String second = queue.enqueue(WELCOME, user.id(), once); // same id — no new job
+```
+
+
+
+
+The key frees up once the job finishes — a duplicate enqueue after that
+creates a fresh job. Only a *pending* or *running* job holds the key; one
+that completed, was dead-lettered, or was cancelled no longer blocks reuse.
+
+
+
+See Unique Tasks
+for the full `unique_key` reference.
+
+
+
+See Idempotency for more
+on designing dedup keys.
+
+## Framework vs. task responsibility
+
+| Concern | Who handles it |
+|---------|-----------------|
+| Job dispatch deduplication | Framework — the atomic claim |
+| Job enqueue deduplication | Framework — the unique key |
+| Crash recovery | Framework — the stale reaper |
+| Idempotent execution | You — task code |
+| Side-effect safety | You — task code |
+
+## Comparison with other queues
+
+| Guarantee | Taskito | Celery | SQS |
+|-----------|---------|--------|-----|
+| Delivery | At-least-once | At-least-once | At-least-once |
+| Duplicate prevention | Atomic claim (dispatch-level) | Visibility timeout | Visibility timeout |
+| Deduplication | Unique key (enqueue-level) | Manual | Message dedup ID |
+| Crash recovery | Stale reaper (timeout-based) | Worker ack timeout | Visibility timeout |
+
+## Inspecting results from another process
+
+A job's result is stored durably, so any process sharing the same storage
+can look it up by job id — not just the worker that produced it. Useful when
+a web backend enqueues a job and a different process needs to poll its
+outcome.
+
+
+
+```python
+job = queue.get_job(job_id)
+result = job.result(timeout=10) # blocks until the job settles
+```
+
+See `queue.get_job()` and
+JobResult .
+
+
+
+
+
+
+ A completed job's result is stored and can be awaited by id from any
+ process sharing the storage — see
+ result .
+
+
+> **Coming from BullMQ?** BullMQ's `QueueEvents` is a dedicated Redis Streams
+> consumer, so any process can listen for job outcomes. taskito's
+> `queue.on(event, handler)` fires inside the **worker process** handling the
+> job — it isn't a cross-process subscription. For visibility from another
+> process, poll `queue.getJob(id)` / `queue.stats()`, or use the
+> dashboard / REST API.
+
+
+
+
+
+
+ A completed job's result is stored and can be read by id from any process
+ sharing the storage — `queue.getResult(jobId, Receipt.class)`. In tests,
+ `queue.awaitJob(jobId, timeout)` blocks until the job settles.
+
+
+
diff --git a/docs/content/docs/shared/guides/reliability/idempotency.mdx b/docs/content/docs/shared/guides/reliability/idempotency.mdx
new file mode 100644
index 00000000..41b25d83
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/idempotency.mdx
@@ -0,0 +1,302 @@
+---
+title: Idempotency
+description: "How the enqueue dedup key is derived, prioritized, and overridden — and where the automation stops."
+---
+
+A **unique key** on an enqueue coalesces a duplicate call onto the existing
+job instead of creating a second one — see
+Delivery guarantees for
+the underlying mechanism (an atomic, storage-level dedup index) and for
+designing task bodies that are safe to re-run. This page is about the key
+itself: how to derive it, which value wins when more than one is set, and
+where the automation stops.
+
+## Deduping enqueues
+
+An explicit key works the same way in every binding — a second enqueue with
+the same key, while the first job is still pending or running, resolves to
+the **existing** job's id instead of creating a duplicate:
+
+
+
+
+```python
+job1 = charge_customer.apply_async(args=(42, 1000), unique_key="charge:42:1000")
+job2 = charge_customer.apply_async(args=(42, 1000), unique_key="charge:42:1000")
+
+assert job2.id == job1.id # coalesced onto job1 — no duplicate created
+```
+
+
+
+
+```ts
+const first = queue.enqueue("chargeCustomer", [42, 1000], { uniqueKey: "charge:42:1000" });
+const second = queue.enqueue("chargeCustomer", [42, 1000], { uniqueKey: "charge:42:1000" });
+
+// second === first — coalesced, no duplicate created
+```
+
+
+
+
+```java
+EnqueueOptions once = EnqueueOptions.builder().uniqueKey("charge:42:1000").build();
+
+String first = queue.enqueue(CHARGE_CUSTOMER, order, once);
+String second = queue.enqueue(CHARGE_CUSTOMER, order, once); // same id — no new job
+```
+
+
+
+
+The key frees up once that job leaves the pending/running state — completed,
+dead-lettered, or cancelled — so a later call with the same key creates a
+fresh job.
+
+## Auto-derived keys
+
+Python and Java can derive this key for you instead of you building the
+string by hand: mark a task idempotent and Taskito hashes the task name and
+arguments into a dedup key on every enqueue.
+
+
+
+```python
+@queue.task(idempotent=True)
+def charge_customer(customer_id: int, amount_cents: int) -> str:
+ return payment_provider.charge(customer_id, amount_cents)
+
+job1 = charge_customer.delay(42, 1000)
+job2 = charge_customer.delay(42, 1000)
+
+assert job1.id == job2.id # second call coalesced into the first
+```
+
+`idempotent=True` also works as a per-call override on `apply_async()` for a
+task that wasn't registered idempotent by default.
+
+
+
+
+
+```java
+public static final Task CHARGE_CUSTOMER =
+ Task.of("charge_customer", Order.class).idempotent(true);
+
+String first = queue.enqueue(CHARGE_CUSTOMER, order);
+String second = queue.enqueue(CHARGE_CUSTOMER, order); // same id — coalesced into the first
+```
+
+`EnqueueOptions.Builder.idempotent(boolean)` is the same toggle as a
+per-call override on a task that isn't idempotent by default.
+
+
+
+
+
+
+ There's no `idempotent` flag on the task or the enqueue options today —
+ build a stable key yourself from whatever makes two calls "the same":
+
+
+```ts
+queue.task("chargeCustomer", async (customerId: number, amountCents: number) => {
+ await paymentProvider.charge(customerId, amountCents);
+});
+
+function enqueueCharge(customerId: number, amountCents: number) {
+ const key = `charge:${customerId}:${amountCents}`;
+ return queue.enqueue("chargeCustomer", [customerId, amountCents], { uniqueKey: key });
+}
+
+enqueueCharge(42, 1_999);
+enqueueCharge(42, 1_999); // same key — coalesced into the first job
+```
+
+
+
+## How the key is derived
+
+The auto-derived key hashes the task name and the *serialized* arguments —
+not the pre-serialization values — so two calls only collide when the
+serializer would
+emit identical bytes for both:
+
+```
+auto:
+hex = sha256(utf8(task_name) + 0x00 + payload)[:32 hex chars]
+```
+
+The `0x00` separator is a single NUL byte, not a printable delimiter, so a
+task name can never be mistaken for the start of a payload. The hash runs
+over the **pre-codec** payload — the bytes before any payload codec — so a
+nondeterministic codec (an AES-GCM nonce, for example) can't change the
+dedup key from one call to the next.
+
+
+ Python and Java compute this exact same key for the same `(task_name,
+ payload)` pair, so idempotent enqueues from either binding dedupe against
+ each other when they share one queue.
+
+
+
+
+There's no `idempotent` flag to auto-derive this key for you (see above),
+but the recipe itself is public — build the same string yourself if you need
+a Node producer's key to collide with a Python or Java producer's.
+
+
+
+## Precedence and per-call overrides
+
+
+
+`apply_async()` and `enqueue_many()` accept three overlapping inputs; the
+first one set wins:
+
+| Parameter | Effect |
+|---|---|
+| `unique_key="…"` | The literal storage key. Highest precedence — wins over everything below. |
+| `idempotency_key="…"` | Same effect as `unique_key`, kept as the more descriptive name for new code. |
+| `idempotent=True` / `idempotent=False` | Force auto-derivation on or off for this call, overriding the task's `@queue.task(idempotent=True)` default. |
+
+Precedence, high to low: `unique_key` → `idempotency_key` → auto-derivation
+(when `idempotent` is `True`, or the task default is, and no explicit key
+was given).
+
+
+
+
+
+`EnqueueOptions` accepts the same three overlapping inputs, resolved with
+identical precedence before the enqueue call reaches native code —
+`idempotent`/`idempotencyKey` never cross the JNI boundary themselves, only
+the resolved `uniqueKey` does:
+
+| Builder method | Effect |
+|---|---|
+| `.uniqueKey("…")` (alias `.jobId("…")`) | The literal storage key. Highest precedence. |
+| `.idempotencyKey("…")` | Same effect as `.uniqueKey`, resolved locally before enqueue. |
+| `.idempotent(true)` / `.idempotent(false)` | Force auto-derivation on or off for this call, overriding `Task.idempotent(true)`. |
+
+Precedence, high to low: `uniqueKey` → `idempotencyKey` → auto-derivation
+(when `idempotent` is `true`, or the task default is, and no explicit key
+was given).
+
+
+
+
+
+There's only one input — `uniqueKey` — so there's no precedence to resolve.
+Omit the option to skip dedup for that call.
+
+
+
+## Bulk enqueues
+
+Batch enqueue trades the per-row dedup check for throughput, and the three
+bindings don't behave the same way when a batch contains a colliding key.
+
+
+
+`enqueueMany` routes any job carrying a key through the same check-then-insert
+path as a single `enqueue` — a duplicate inside the batch, or one that
+collides with an already-active job, resolves to the existing job's id
+instead of failing the batch:
+
+```java
+List ids = queue.enqueueMany(CHARGE_CUSTOMER, orders, once);
+// duplicates map to the already-enqueued job's id, in input order
+```
+
+
+
+
+
+
+ Unlike `apply_async()`, `enqueue_many()` inserts the whole batch in one
+ statement without a per-row existence check. A duplicate `unique_key` (or
+ auto-derived key) *within* the same call, or one that collides with an
+ already-active job, raises a `RuntimeError` and fails the whole batch
+ instead of coalescing.
+
+
+`enqueue_many()` still accepts `unique_keys`, `idempotency_keys`, and a
+uniform `idempotent=True` to resolve a key per row — just make sure the
+resulting keys are distinct, or don't rely on dedup for that call:
+
+```python
+queue.enqueue_many(
+ task_name="myapp.charge_customer",
+ args_list=[(42, 1000), (43, 2000)], # pre-deduplicated by the caller
+ idempotent=True,
+)
+```
+
+When collision resilience matters more than throughput, loop `apply_async()`
+calls instead — each one goes through the coalescing check-then-insert path.
+
+
+
+
+
+
+ `enqueueMany` is a plain bulk insert for throughput — it does not check
+ `uniqueKey` before inserting. A duplicate key in the batch, or one that
+ collides with an already-active job, is unsupported: don't pass `uniqueKey`
+ to a batch entry expecting it to dedupe.
+
+
+When collision resilience matters more than throughput, loop `enqueue()`
+calls instead — each one goes through the coalescing check-then-insert path.
+
+
+
+## Pitfalls
+
+
+
+- **Don't derive a key from a moving value.** `datetime.now()`, `uuid4()`,
+ and similar produce a different hash on every call, so auto-derived dedup
+ never triggers. Move them inside the task body or pass a stable identifier
+ instead.
+- **Argument order affects the hash.** `charge(42, 1000)` and
+ `charge(amount=1000, customer_id=42)` serialize differently and therefore
+ hash differently. Be consistent at the call site, or use an explicit
+ `idempotency_key`.
+- **Changing the serializer changes the hash.** The hash survives a codec
+ change (it's taken pre-codec) but not a *serializer* change between
+ releases — in-flight jobs from the old release won't dedupe against new
+ submissions. Use an explicit `idempotency_key` for keys that must survive
+ a deploy.
+- **`queue.test_mode()` skips dedup.** Tasks run synchronously without
+ storage in test mode, so idempotency is a no-op there.
+
+
+
+
+
+- **Don't derive a key from a moving value.** A timestamp, a random UUID, or
+ similar produces a different hash on every call, so auto-derived dedup
+ never triggers. Move it inside the handler or pass a stable identifier
+ instead.
+- **Argument order affects the hash.** The auto-key hashes the serialized
+ payload, so two payloads that are semantically equal but serialize
+ differently (field order in a hand-built JSON payload, for example) hash
+ differently. Use an explicit `.idempotencyKey(...)` when that risk exists.
+- **Changing the serializer changes the hash.** The hash survives a codec
+ change (it's taken pre-codec) but not a *serializer* change between
+ releases — in-flight jobs from the old release won't dedupe against new
+ submissions. Use an explicit `.idempotencyKey(...)` for keys that must
+ survive a deploy.
+
+
+
+
+
+Whatever you build `uniqueKey` from, keep it stable for the equality you
+want — a timestamp or a random id defeats dedup entirely, since every call
+then gets its own key.
+
+
diff --git a/docs/content/docs/shared/guides/reliability/locks.mdx b/docs/content/docs/shared/guides/reliability/locks.mdx
new file mode 100644
index 00000000..7a1e3c62
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/locks.mdx
@@ -0,0 +1,504 @@
+---
+title: Distributed Locking
+description: "TTL-bounded, owner-scoped locks backed by the queue's storage — mutual exclusion across workers and processes."
+---
+
+Coordinate across processes without a separate lock server. taskito's
+distributed locks are TTL-bounded and owner-scoped, backed by the same
+storage as the task queue — locks work across multiple worker processes on
+one machine, and across multiple machines when the backend is a shared
+server (Postgres or Redis) rather than a local SQLite file.
+
+Use a lock when multiple workers or processes must not run the same
+critical section at the same time — refreshing a shared cache, running a
+singleton periodic task, or calling an external API with a single-writer
+constraint.
+
+
+
+> Celery has no built-in lock — teams reach for `celery-once` or a Redis
+> lock. taskito's `queue.lock()` uses the queue database directly, so
+> there's nothing extra to run.
+
+
+
+
+
+> **Coming from BullMQ?** BullMQ's locking is internal — a `lockDuration`
+> per job that stops two workers double-processing it, not something you
+> can call from application code. taskito's `queue.lock()` / `withLock()`
+> is a general-purpose distributed lock you can use for *any* critical
+> section, job processing or not.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Quick start
+
+The scoped form acquires, runs, and releases in one call — the shortest
+path for a critical section:
+
+
+
+
+```python
+with queue.lock("cache-refresh"):
+ refresh_cache()
+```
+
+The lock is automatically released when the `with` block exits, even if an
+exception is raised.
+
+
+
+
+```ts
+await queue.withLock("report:2026-06", async () => {
+ await rebuildReport();
+});
+```
+
+`withLock` acquires, runs, and releases — throwing `LockNotAcquiredError`
+if the lock is held elsewhere.
+
+
+
+
+```java
+boolean ran = taskito.withLock("report:2026-06", 60_000, () -> rebuildReport());
+if (!ran) {
+ log.info("another worker holds the lock");
+}
+```
+
+`withLock` acquires, runs, and releases — returning whether the body ran.
+It never throws for a lock that's already held; it returns `false` instead.
+
+
+
+
+
+
+### Async context manager
+
+```python
+async with queue.alock("cache-refresh"):
+ await refresh_cache()
+```
+
+`alock()` accepts the same parameters as `lock()` and is safe to use inside
+async functions and FastAPI/Django async views.
+
+
+
+## Manual handle
+
+For cases that don't map to a single block — acquiring in one place and
+releasing in another — drive the lock explicitly:
+
+
+
+
+```python
+lock = queue.lock("resource", ttl=30)
+if lock.acquire():
+ try:
+ ... # critical section
+ finally:
+ lock.release()
+else:
+ ... # another process holds the lock
+```
+
+`lock.extend(ttl=None)` pushes the expiry out on demand, and `lock.info()`
+returns the current holder. Calling `acquire()` directly like this — instead
+of entering `with` — does **not** start the auto-extend background thread
+even when `auto_extend=True`; only the context manager does that. Prefer
+`with queue.lock(...)` for anything that maps to a single block.
+
+
+
+
+```ts
+using lock = queue.lock("resource", { ttlMs: 30_000 });
+if (lock.acquire()) {
+ // ... critical section
+} // released at block exit (via `using`)
+```
+
+`lock.extend(ms)`, `lock.info()`, and `lock.ownerId` round out the API.
+With `using`, the lock releases automatically when the block exits;
+otherwise call `lock.release()`.
+
+
+
+
+`Lock` is `AutoCloseable`, so try-with-resources releases it at block exit:
+
+```java
+try (Lock lock = taskito.lock("resource", 30_000)) {
+ if (lock.acquire()) {
+ // ... critical section
+ }
+} // released at block exit
+```
+
+| Method | Description |
+|---|---|
+| `acquire()` | Try once; `false` if another owner holds a live lock. |
+| `tryAcquire(Duration timeout)` | Retry every 50ms until obtained or the timeout elapses. |
+| `extend(long ttlMs)` | Push the expiry out if still held; `false` means the lock was lost. |
+| `release()` / `close()` | Give the lock up (no-op if not held). |
+
+`taskito.lock(name)` without a TTL defaults to 30 seconds.
+
+
+
+
+## Auto-extension
+
+Whether a held lock renews itself before its TTL expires differs by SDK:
+
+
+
+When `auto_extend=True` (the default), a background thread extends the
+lock's TTL at `ttl / 3` intervals. This prevents the lock from expiring
+during a long-running operation without requiring an artificially large
+TTL:
+
+```python
+# This lock stays alive for as long as the block runs, even if it takes
+# several minutes — the background thread re-extends it every ttl/3.
+with queue.lock("long-job", ttl=30, auto_extend=True):
+ run_slow_operation()
+```
+
+
+
+
+
+`autoExtend` defaults to `true` — while held, the lock renews itself at
+`ttlMs / 3`. If a renewal ever fails (the lock expired or was stolen),
+auto-extend stops rather than fighting for a lock this handle no longer
+holds. Pass `autoExtend: false` to manage extension yourself with
+`lock.extend()`:
+
+```ts
+using lock = queue.lock("long-job", { ttlMs: 30_000, autoExtend: false });
+```
+
+
+
+
+
+
+ Locks do not auto-extend. Choose a TTL comfortably longer than the
+ protected work's worst case, or call `extend()` at checkpoints — a failed
+ extend means the lock expired and another owner may now hold it, so stop
+ the critical section. Expiry is what keeps a crashed owner from holding a
+ lock forever.
+
+
+
+
+## Waiting for a lock
+
+By default, a failed acquisition doesn't wait — the caller finds out
+immediately:
+
+
+
+`lock()` raises `LockNotAcquiredError` immediately if the lock is held by
+another process. Pass `timeout` to retry every `retry_interval` seconds
+until it succeeds or the timeout elapses:
+
+```python
+from taskito.locks import LockNotAcquiredError
+
+try:
+ with queue.lock("resource", timeout=5.0):
+ do_work()
+except LockNotAcquiredError:
+ print("Could not acquire lock within 5 seconds")
+```
+
+
+
+
+
+
+ There's no built-in retry-with-timeout on `Lock` or `withLock` — both fail
+ fast. If you need to wait, poll `acquire()` in your own loop with a short
+ delay between attempts.
+
+
+
+
+
+
+`tryAcquire(Duration)` retries every 50ms until it succeeds or the timeout
+elapses:
+
+```java
+try (Lock lock = taskito.lock("resource", 30_000)) {
+ if (!lock.tryAcquire(Duration.ofSeconds(5))) {
+ log.info("could not acquire lock within 5 seconds");
+ return;
+ }
+ doWork();
+}
+```
+
+
+
+## Inspecting a lock
+
+Check who currently holds a lock — useful for a dashboard, a health check,
+or deciding whether to skip a queued retry:
+
+
+
+
+```python
+info = queue.lock("my-lock").info()
+# {"lock_name": "my-lock", "owner_id": "...", "acquired_at": ..., "expires_at": ...}
+```
+
+
+
+
+```ts
+const info = queue.lock("my-lock").info();
+// { lockName, ownerId, acquiredAt, expiresAt } | undefined
+```
+
+
+
+
+```java
+Optional info = taskito.lockInfo("my-lock");
+info.ifPresent(i -> log.info("held by {} until {}", i.ownerId, i.expiresAt));
+```
+
+
+
+
+
+
+
+ `owner_id` is a bearer token — whoever knows it can release or extend the
+ lock. The high-level `queue.lock(name).info()` masks it to `"***"` for
+ any caller that isn't the current holder, so prefer it over the
+ low-level `queue._inner.get_lock_info()` (which returns the raw token)
+ when surfacing lock status to other parties, e.g. a dashboard or another
+ tenant.
+
+
+
+
+## Handling a held lock
+
+
+
+
+```python
+from taskito.locks import LockNotAcquiredError
+
+try:
+ with queue.lock("my-lock", timeout=2.0):
+ critical_section()
+except LockNotAcquiredError:
+ # Another process holds the lock; handle gracefully
+ log.warning("skipping — another process is running the critical section")
+```
+
+
+
+
+```ts
+import { LockNotAcquiredError } from "@byteveda/taskito";
+
+try {
+ await queue.withLock("my-lock", async () => {
+ await criticalSection();
+ });
+} catch (error) {
+ if (error instanceof LockNotAcquiredError) {
+ // Another process holds the lock; handle gracefully
+ log.warn("skipping — another process is running the critical section");
+ } else {
+ throw error;
+ }
+}
+```
+
+
+
+
+```java
+boolean ran = taskito.withLock("my-lock", 30_000, () -> criticalSection());
+if (!ran) {
+ // Another process holds the lock; withLock doesn't throw for this case
+ log.warn("skipping — another process is running the critical section");
+}
+```
+
+
+
+
+
+
+
+ `withLock` throws a different error, `LockLostError`, if the lease was
+ lost while the guarded function was still running (the TTL expired
+ faster than auto-extend could renew it) — the critical section may have
+ run unprotected, so the result is discarded either way.
+
+
+
+
+## Cross-machine locking
+
+Because lock state lives in the queue's storage, locks are effective across
+multiple worker processes on the same machine. Locking across
+**different** machines works too, but only when they share a networked
+backend — SQLite is a local file, so cross-machine locking requires the
+Postgres or Redis backend:
+
+
+
+
+```python
+# Process A (machine 1)
+with queue.lock("billing-run"):
+ run_billing()
+
+# Process B (machine 2) — waits or raises while process A holds the lock
+with queue.lock("billing-run", timeout=30.0):
+ run_billing()
+```
+
+
+
+
+```ts
+// Process A (machine 1)
+await queue.withLock("billing-run", async () => {
+ await runBilling();
+});
+
+// Process B (machine 2) — throws LockNotAcquiredError while A holds it
+await queue.withLock("billing-run", async () => {
+ await runBilling();
+});
+```
+
+
+
+
+```java
+// Process A (machine 1)
+taskito.withLock("billing-run", 30_000, () -> runBilling());
+
+// Process B (machine 2) — returns false while A holds it; retry-wait instead:
+try (Lock lock = taskito.lock("billing-run", 30_000)) {
+ if (lock.tryAcquire(Duration.ofSeconds(30))) {
+ runBilling();
+ }
+}
+```
+
+
+
+
+
+ On SQLite, cross-process locking works via WAL mode and exclusive
+ transactions, but SQLite file locking is machine-local — it only works
+ across processes on the same machine. For multi-machine deployments, use
+ the PostgreSQL backend, where `SELECT FOR UPDATE SKIP LOCKED` guards lock
+ acquisition too, or the Redis backend, which supports the same
+ acquire/release/extend/info operations via Lua scripts.
+
+
+
+
+## Low-level API
+
+For advanced use cases, drive a lock without a `DistributedLock` handle,
+using the same primitives it's built on:
+
+```python
+import uuid
+
+owner_id = uuid.uuid4().hex
+
+# Acquire (ttl_ms, not seconds)
+acquired = queue._inner.acquire_lock("my-lock", owner_id, ttl_ms=30_000)
+
+# Extend
+queue._inner.extend_lock("my-lock", owner_id, ttl_ms=30_000)
+
+# Inspect
+info = queue._inner.get_lock_info("my-lock")
+# {"lock_name": "my-lock", "owner_id": "...", "acquired_at": ..., "expires_at": ...}
+
+# Release
+queue._inner.release_lock("my-lock", owner_id)
+```
+
+
+ The low-level API skips auto-extension and does not release on
+ exception. Prefer `queue.lock()` / `queue.alock()` for production code.
+
+
+
+
+## See also
+
+- Delivery guarantees —
+ locks complement at-least-once delivery for exactly-once-feeling critical
+ sections.
+- Idempotency —
+ dedup keys solve a related but different problem: duplicate enqueues, not
+ concurrent execution.
diff --git a/docs/content/docs/shared/guides/reliability/retries.mdx b/docs/content/docs/shared/guides/reliability/retries.mdx
new file mode 100644
index 00000000..d8bf6f6d
--- /dev/null
+++ b/docs/content/docs/shared/guides/reliability/retries.mdx
@@ -0,0 +1,436 @@
+---
+title: Retries
+description: "Automatic retries with exponential backoff before dead-lettering, and how exhausted jobs move to the dead-letter queue."
+---
+
+A task that fails is retried automatically before it dead-letters. The retry
+budget and backoff curve are enforced by the shared Rust scheduler — durable
+across worker crashes and identical in behavior across every binding — so a
+job that survives a worker restart mid-backoff still retries on schedule.
+
+## Retry policy
+
+Configure the retry budget and backoff on the task itself:
+
+
+
+
+```python
+@queue.task(max_retries=5, retry_backoff=2.0)
+def flaky_api_call(url):
+ response = requests.get(url)
+ response.raise_for_status()
+ return response.json()
+```
+
+
+
+
+```ts
+queue.task("charge", chargeCard, {
+ maxRetries: 5,
+ retryBackoff: { baseMs: 2000, maxMs: 300_000 },
+});
+```
+
+
+
+
+```java
+Task CHARGE = Task.of("charge", Order.class)
+ .maxRetries(5)
+ .retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(2), Duration.ofMinutes(5)));
+```
+
+
+
+
+### Parameter reference
+
+
+
+| Parameter | Default | Description |
+|---|---|---|
+| `max_retries` | `3` | Retry attempts after the first, before the job dead-letters. |
+| `retry_backoff` | `1.0` | Base delay in seconds for exponential backoff. |
+| `max_retry_delay` | `300` (5 min) | Cap on the backoff delay, in seconds. |
+
+
+
+
+
+| Option | Default | Description |
+|---|---|---|
+| `maxRetries` | `3` | Attempts after the first before dead-lettering. `0` = never retry. |
+| `retryBackoff.baseMs` | `1000` | First backoff; roughly doubles each attempt. |
+| `retryBackoff.maxMs` | `300000` (5 min) | Cap on the backoff delay. |
+
+> **Coming from BullMQ?** Watch the off-by-one: BullMQ's `attempts` counts the
+> *total* tries including the first (`attempts: 3` = 1 try + 2 retries).
+> taskito's `maxRetries` counts only the retries *after* the first
+> (`maxRetries: 3` = 1 try + 3 retries = 4 total). BullMQ's
+> `backoff: { type: "exponential", delay }` maps to `retryBackoff.baseMs`.
+
+
+
+
+
+| Option | Default | Description |
+|---|---|---|
+| `maxRetries` | `0` | Attempts after the first before dead-lettering. Defaults to never-retry, so set it on any task you want retried. |
+| `RetryPolicy.exponential(base, max)` | 1s / 5min if no policy is registered | Retry N waits about `base · 2^N`, capped at `max`, plus jitter up to `base`. |
+
+
+
+The retry *budget* travels with the job; the *backoff curve* is a setting
+registered with the worker when it starts (unset fields keep the core's
+built-in default). Override the budget per job at enqueue time — see
+[Per-job overrides](#per-job-overrides) below.
+
+## Backoff formula
+
+The scheduler computes each retry's delay from the task's base delay `B` and
+cap `M`:
+
+```
+delay = min(M, B * 2^retry_count) + jitter
+```
+
+`jitter` is a random value uniform between `0` and `B`, so a burst of
+identical failures doesn't retry in lockstep. With defaults (`B` = 1s, `M` =
+5 minutes) and a task configured with a 2-second base delay, the schedule
+looks like:
+
+| Attempt | Delay |
+|---|---|
+| 1st retry | ~2s |
+| 2nd retry | ~4s |
+| 3rd retry | ~8s |
+| 4th retry | ~16s |
+| 5th retry | ~32s |
+
+## Custom delay schedules
+
+
+
+`retry_delays` sets each attempt's base delay by index instead of computing
+it from the exponential curve:
+
+```python
+@queue.task(max_retries=5, retry_delays=[1.0, 5.0, 30.0])
+def flaky_api_call(url):
+ ...
+```
+
+Retry 0 waits ~1s, retry 1 waits ~5s, retry 2 waits ~30s — each still gets
+jitter up to `retry_backoff` seconds added on top (`retry_backoff` isn't
+overridden, just not used to *compute* the listed delays). Once the list
+runs out (retries 3 and 4 here), taskito falls back to the exponential
+formula above using `retry_backoff` and `max_retry_delay` as `B` and `M`.
+
+
+
+
+
+`RetryPolicy.delays(...)` replaces the exponential curve with explicit
+per-attempt delays, applied exactly with **no** jitter:
+
+```java
+Task CHARGE = Task.of("charge", Order.class)
+ .maxRetries(3)
+ .retryPolicy(RetryPolicy.delays(
+ Duration.ofSeconds(1),
+ Duration.ofSeconds(5),
+ Duration.ofSeconds(30)));
+```
+
+Supply at least `maxRetries` delays — once the list is exhausted, further
+retries fire immediately (no exponential fallback).
+
+
+
+## Exception filtering
+
+
+
+Control which exceptions trigger a retry with `retry_on` (a whitelist) **or**
+`dont_retry_on` (a blacklist) — set one, not both:
+
+```python
+# Whitelist: retry only these exceptions; everything else skips straight to DLQ.
+@queue.task(max_retries=5, retry_on=[ConnectionError, TimeoutError])
+def fetch_data(url):
+ response = requests.get(url)
+ response.raise_for_status()
+ return response.json()
+
+# Blacklist: retry everything except these exceptions.
+@queue.task(max_retries=5, dont_retry_on=[ValueError])
+def parse_data(raw):
+ return json.loads(raw) # a ValueError here is permanent — don't retry it
+```
+
+| Parameter | Description |
+|---|---|
+| `retry_on` | Whitelist — only retry on these exception types (and their subclasses). All others skip straight to DLQ. |
+| `dont_retry_on` | Blacklist — never retry on these exception types (and their subclasses), even if retries remain. |
+
+If neither is set, every exception triggers a retry. If both are set,
+`dont_retry_on` is checked first — a match there skips retry regardless of
+`retry_on` — and `retry_on` then acts as the whitelist for everything else.
+In practice, use one or the other.
+
+> **Coming from Celery:** `retry_backoff` here is a **float** — the base
+> delay in seconds — not Celery's boolean flag. Celery's
+> `autoretry_for=(SomeError,)` maps to taskito's `retry_on=[SomeError]`.
+
+
+
+
+
+There's no exception-type filter on the Node binding today — every error
+triggers a retry until the budget is exhausted. Distinguish terminal errors
+inside the task and either swallow them (return normally) or throw so the
+job dead-letters immediately by setting `maxRetries: 0` for that call:
+
+```ts
+queue.enqueue("parseData", [raw], { maxRetries: 0 }); // no retry for this job
+```
+
+
+
+
+
+There's no exception-type filter on the Java binding today — every thrown
+exception triggers a retry until the budget is exhausted. Distinguish
+terminal errors inside the handler, or set `maxRetries(0)` for calls that
+should dead-letter immediately on any failure.
+
+
+
+## Per-job overrides
+
+Override the retry budget for a single submission without changing the
+task's registered default:
+
+
+
+
+```python
+flaky_api_call.apply_async(args=("https://example.com",), max_retries=1)
+```
+
+
+
+
+```ts
+queue.enqueue("charge", [order], { maxRetries: 1 });
+```
+
+
+
+
+```java
+queue.enqueue(CHARGE, order, EnqueueOptions.builder().maxRetries(1).build());
+```
+
+
+
+
+The backoff curve itself is a worker-registration setting, not a per-job
+option — it can't be overridden at enqueue time.
+
+## Retry flow
+
+ B{Success?}
+ B -->|Yes| C["Status: Complete Store result"]
+ B -->|No| D["Record error in job_errors table"]
+ D --> SR{"Exception passes filter?"}
+ SR -->|No| I["Move to Dead Letter Queue Status: Dead"]
+ SR -->|Yes| E{"retry_count < max_retries?"}
+ E -->|Yes| F["Calculate backoff delay"]
+ F --> G["Status: Pending retry_count += 1"]
+ G --> H["Wait for scheduled time"]
+ H --> A
+ E -->|No| I`}
+/>
+
+
+
+The "Exception passes filter?" step only applies filtering when `retry_on`
+or `dont_retry_on` is set on the task — otherwise every exception passes
+straight through to the retry-budget check.
+
+
+
+## Timeouts count as retries
+
+A task that exceeds its timeout is treated as a failure by the same
+retry/DLQ engine — it consumes a retry like any other exception. The
+scheduler tags the outcome with a `timed_out` flag so your hooks can tell a
+timeout apart from a regular exception.
+
+
+
+The scheduler reaps timed-out jobs on a periodic sweep (checking every ~5
+seconds):
+
+```python
+@queue.task(timeout=10) # 10 second timeout
+def slow_task():
+ time.sleep(60) # Will be reaped after 10s
+```
+
+The `timed_out` flag drives the `on_timeout` middleware hook — it fires in
+addition to `on_retry` / `on_dead_letter` whenever the failure was a
+timeout, so you can alert on wedged tasks separately from ordinary errors.
+
+
+
+
+
+See Timeouts for
+`timeoutMs`, how the dispatcher enforces it, and the `timedOut: true` flag
+surfaced to the `onRetry` / `onDeadLetter` middleware hooks and
+`job.retrying` / `job.dead` events.
+
+
+
+
+
+See Timeouts for
+`.timeout(...)`, how the dispatcher enforces it, and the timed-out flag
+surfaced as `OutcomeEvent.timedOut` to event listeners and middleware
+outcome hooks.
+
+
+
+## Observing retries
+
+
+
+Via middleware, or the `job.retrying` / `job.dead` events:
+
+```python
+from taskito import EventType, TaskMiddleware
+
+class AlertOnRetry(TaskMiddleware):
+ def on_retry(self, ctx, error, retry_count):
+ log.warning(f"{ctx.task_name} retrying (attempt {retry_count}): {error}")
+
+ def on_dead_letter(self, ctx, error):
+ alert_ops(ctx.task_name, error)
+
+queue.add_middleware(AlertOnRetry())
+
+# Or, equivalently, as events:
+queue.on(EventType.JOB_RETRYING, lambda e: log.warning(f"retrying {e}"))
+queue.on(EventType.JOB_DEAD, lambda e: alert_ops(e))
+```
+
+
+
+
+
+The `job.retrying` event
+fires on each retry; once the budget is exhausted the job moves to the
+dead-letter queue and
+`job.dead` fires:
+
+```ts
+queue.on("job.retrying", (e) => log.warn(`retrying ${e.taskName}`));
+queue.on("job.dead", (e) => alertOps(e));
+```
+
+
+
+
+
+Observe retries as they happen via middleware `onRetry` or a worker event
+listener; once the budget is exhausted the job moves to the
+dead-letter queue and
+the `DEAD` event fires:
+
+```java
+Worker worker = queue.worker()
+ .handle(CHARGE, this::charge)
+ .on(EventName.RETRY, event -> log.info("retrying {}", event.taskName))
+ .on(EventName.DEAD, event -> alertOps(event))
+ .start();
+```
+
+
+
+
+ Retries re-run the whole task from the start, so design tasks to be
+ idempotent — a
+ crash after a side effect but before the result write will re-execute the
+ task.
+
+
+## Dead-letter queue
+
+Jobs that exhaust their retry budget move to the dead-letter queue (DLQ) for
+inspection and manual replay. The DLQ keeps the payload and full error
+history, and a replayed job re-enters as pending with a fresh retry budget.
+
+
+
+```python
+# List the 10 most recent dead letters
+dead = queue.dead_letters(limit=10, offset=0)
+
+for d in dead:
+ print(f"Job: {d['original_job_id']}")
+ print(f"Task: {d['task_name']}")
+ print(f"Error: {d['error']}")
+ print(f"Retries: {d['retry_count']}")
+
+# Re-enqueue a dead letter job (creates a new job)
+new_job_id = queue.retry_dead(dead[0]["id"])
+
+# Delete dead letters older than 24 hours
+deleted = queue.purge_dead(older_than=86400)
+```
+
+
+ Replayed jobs preserve the original job's `priority`, `max_retries`,
+ `timeout`, and `result_ttl` settings — the DLQ stores the full
+ configuration, so you don't need to re-specify them.
+
+
+Every failed attempt is recorded with its error message, accessible via
+`job.errors`:
+
+```python
+job = unreliable.delay()
+
+# After the job exhausts all retries...
+for error in job.errors:
+ print(f"Attempt {error['attempt']}: {error['error']}")
+```
+
+`max_retries` counts retries **after** the first attempt — `max_retries=3`
+means the task runs up to 4 times total, recording 4 entries in
+`job.errors` (attempts 0–3). Each entry has `id`, `job_id`, `attempt`
+(0-indexed), `error`, and `failed_at` (Unix ms).
+
+
+
+
+
+See Dead-letter queue
+for `deadLetters()`, `retryDead()`, `purgeDead()`, and per-attempt error
+history via `getJobErrors()`.
+
+
+
+
+
+See Dead-letter queue
+for `listDead()`, `retryDead()`, `purgeDead()`, and per-attempt error
+history via `jobErrors()`.
+
+
diff --git a/docs/content/docs/shared/guides/resources/interception.mdx b/docs/content/docs/shared/guides/resources/interception.mdx
new file mode 100644
index 00000000..99b48327
--- /dev/null
+++ b/docs/content/docs/shared/guides/resources/interception.mdx
@@ -0,0 +1,425 @@
+---
+title: Interception
+description: "Validate, redact, redirect, or reject data before it reaches the queue."
+---
+
+Interception hooks into the producer side of `enqueue` — before anything is
+serialized and written to storage. Use it to validate inputs, redact
+secrets, or reshape what actually gets enqueued, in one place instead of at
+every call site. Without it, a bad or unserializable value either raises at
+enqueue time in a way that's hard to centralize, or slips through and fails
+later on the worker.
+
+Each SDK hooks into this at a different point:
+
+
+
+This SDK classifies **every argument** passed to `.delay()` or
+`.apply_async()` by its Python type, automatically, against a built-in and
+user-extensible registry. See
+[Classifying arguments](#classifying-arguments) below.
+
+
+
+
+
+This SDK runs **enqueue interceptors** — functions you register with
+`queue.intercept()` that inspect the whole call (task name + args) and
+decide its fate. See [Enqueue interceptors](#enqueue-interceptors) below.
+
+
+
+
+
+This SDK runs **enqueue interceptors** — functions you register with
+`taskito.intercept()` that inspect the whole call (task name + payload) and
+decide its fate. See [The interceptor pipeline](#the-interceptor-pipeline)
+below.
+
+
+
+## Strategies
+
+Across the SDKs, an interceptor resolves to one of a handful of named
+strategies:
+
+| Strategy | Outcome |
+|---|---|
+| `PASS` | No change — the original value is used as-is. |
+| `CONVERT` | The original value is replaced with a substitute. |
+| `REDIRECT` | The original value isn't used directly — something else happens instead. |
+| `REJECT` | The enqueue is blocked and an error is raised. |
+
+
+
+These strategy names are shared vocabulary, not a shared mechanism.
+
+
+
+Here, `REDIRECT` swaps a single non-serializable **argument** for a
+dependency-injection marker — the task name and the rest of the call are
+untouched. There's also a fifth strategy, `PROXY`, that doesn't exist in the
+other SDKs. See [Classifying arguments](#classifying-arguments) below.
+
+
+
+
+
+Here, `REDIRECT` replaces the **entire enqueue call** with a different task
+and different args. There's no argument-level classification and no
+`PROXY` strategy. See [Enqueue interceptors](#enqueue-interceptors) below.
+
+
+
+
+
+Here, `REDIRECT` replaces the **entire enqueue call** with a different task
+and different payload. There's no argument-level classification and no
+`PROXY` strategy. See [The interceptor pipeline](#the-interceptor-pipeline)
+below.
+
+
+
+
+
+
+
+## Classifying arguments
+
+Enable interception on the `Queue` constructor:
+
+```python
+queue = Queue(db_path="tasks.db", interception="strict")
+```
+
+Without interception, values are passed directly to the serializer. A
+SQLAlchemy session or file handle would either raise a serialization error
+at enqueue time or produce a broken payload that fails on the worker.
+
+### Modes
+
+| Mode | Behavior |
+|---|---|
+| `"off"` | Disabled (default). All arguments pass through to the serializer unchanged. |
+| `"strict"` | Raises `InterceptionError` immediately when a rejected type is detected. |
+| `"lenient"` | Logs a warning and drops the rejected argument instead of raising. |
+
+`"strict"` is recommended for production — it surfaces problems at call
+time rather than causing silent task failures.
+
+### Classification strategies
+
+Every argument gets one of five strategies:
+
+| Strategy | What happens | Examples |
+|---|---|---|
+| `PASS` | Sent as-is to the serializer | `int`, `str`, `bool`, `bytes` |
+| `CONVERT` | Transformed to a serializable form, reconstructed on the worker | `UUID`, `datetime`, `Decimal`, `Path`, `Enum`, Pydantic models, dataclasses |
+| `REDIRECT` | Replaced with a DI marker; the worker injects the named resource | SQLAlchemy sessions, Redis clients, MongoDB clients |
+| `PROXY` | Deconstructed to a recipe; reconstructed as a live object on the worker | File handles, loggers, `requests.Session`, `httpx.Client`, boto3 clients |
+| `REJECT` | Raises `InterceptionError` in strict mode, dropped in lenient mode | Thread locks, generators, coroutines, sockets |
+
+### Built-in CONVERT types
+
+These are converted automatically when interception is enabled:
+
+| Type | Notes |
+|---|---|
+| `uuid.UUID` | Stored as `"uuid:"` |
+| `datetime.datetime` / `date` / `time` / `timedelta` | ISO format |
+| `decimal.Decimal` | Stored as string to preserve precision |
+| `pathlib.Path` / `PurePath` | Stored as POSIX string |
+| `re.Pattern` | Pattern string + flags |
+| `collections.OrderedDict` | Preserves insertion order |
+| `pydantic.BaseModel` | Via `.model_dump()` (if pydantic is installed) |
+| `enum.Enum` subclasses | Class path + value |
+| Dataclasses | Auto-detected via `dataclasses.is_dataclass()` |
+| `NamedTuple` subclasses | Auto-detected |
+
+### Built-in REDIRECT types
+
+These connectors are automatically detected and replaced with a resource
+injection marker. The worker injects the named resource instead of
+attempting to deserialize a live connection object:
+
+| Type | Default resource name |
+|---|---|
+| `sqlalchemy.orm.Session` | `"db"` |
+| `sqlalchemy.ext.asyncio.AsyncSession` | `"db"` |
+| `sqlalchemy.engine.Engine` | `"db"` |
+| `sqlalchemy.ext.asyncio.AsyncEngine` | `"db"` |
+| `redis.Redis` | `"redis"` |
+| `redis.asyncio.Redis` | `"redis"` |
+| `pymongo.MongoClient` | `"mongo"` |
+| `motor.motor_asyncio.AsyncIOMotorClient` | `"mongo"` |
+| `psycopg2.extensions.connection` | `"db"` |
+| `asyncpg.connection.Connection` | `"db"` |
+| `django.db.backends.base.base.BaseDatabaseWrapper` | `"db"` |
+| `aiohttp.ClientSession` | `"aiohttp_session"` |
+
+The resource name is the key you use in `@queue.worker_resource("name")`.
+If your resource has a different name, register a custom redirect with
+`register_type()`.
+
+REDIRECT is a type shift, not just a value swap: the caller passes a live
+`Session` (or `Redis`, `MongoClient`, ...) to `.delay()`, but the worker's
+task function receives whatever the named resource's factory returns —
+for `"db"`, that's typically a `sessionmaker` you call to get a session, a
+different type than what the caller passed in. See
+Dependency
+Injection for how the factory return value is injected.
+
+### Built-in PROXY types
+
+These objects are deconstructed to a recipe dict and rebuilt by the worker:
+
+| Type | Handler name |
+|---|---|
+| `io.TextIOWrapper`, `io.BufferedReader`, `io.BufferedWriter`, `io.FileIO` | `"file"` |
+| `logging.Logger` | `"logger"` |
+| `requests.Session` | `"requests_session"` |
+| `httpx.Client` / `httpx.AsyncClient` | `"httpx_client"` |
+| boto3 clients (via `botocore.client.BaseClient`) | `"boto3_client"` |
+| `google.cloud.storage.Client` / `Bucket` / `Blob` | `"gcs_client"` |
+
+See Resource Proxies for
+security options and handler details.
+
+### Built-in REJECT types
+
+These are always rejected because they cannot cross process or
+serialization boundaries:
+
+- Thread synchronization primitives (`Lock`, `RLock`, `Semaphore`, `Event`)
+- `socket.socket`
+- Generator objects
+- Coroutine objects
+- `subprocess.Popen`
+- `asyncio.Task` / `asyncio.Future`
+- `contextvars.Context`
+- Multiprocessing `Lock` and `Queue`
+
+Each rejection includes a message explaining why and suggests alternatives.
+
+### Registering custom types
+
+Add custom rules for types not covered by the built-ins:
+
+```python
+from myapp import MyDBClient, MoneyAmount, APIConnection
+
+# Treat a custom DB client as a worker resource (worker must have "my_db" registered)
+queue.register_type(MyDBClient, "redirect", resource="my_db")
+
+# Convert a custom value type to something serializable
+queue.register_type(
+ MoneyAmount,
+ "convert",
+ converter=lambda m: {"__type__": "money", "value": str(m.value), "currency": m.currency},
+ type_key="money",
+)
+
+# Reject with a helpful message
+queue.register_type(
+ APIConnection,
+ "reject",
+ message="API connections are process-local. Register it as a worker resource instead.",
+)
+```
+
+`register_type()` requires interception to be enabled (`"strict"` or
+`"lenient"`). Calling it when interception is `"off"` raises `RuntimeError`.
+
+| Parameter | Description |
+|---|---|
+| `python_type` | The type to register. |
+| `strategy` | `"pass"`, `"convert"`, `"redirect"`, `"reject"`, or `"proxy"`. |
+| `resource` | Resource name for `"redirect"`. |
+| `message` | Rejection reason for `"reject"`. |
+| `converter` | Converter callable for `"convert"`. |
+| `type_key` | Dispatch key for the converter reconstructor. |
+| `proxy_handler` | Handler name for `"proxy"`. |
+
+### Constructor parameters
+
+| Parameter | Default | Description |
+|---|---|---|
+| `interception` | `"off"` | Interception mode: `"strict"`, `"lenient"`, or `"off"`. |
+| `max_intercept_depth` | `10` | Maximum depth the walker recurses into nested containers. |
+
+### Analyzing arguments
+
+Inspect how interception would classify arguments without actually
+transforming them:
+
+```python
+from myapp.tasks import queue
+
+report = queue._interceptor.analyze(
+ args=(user_session, "Hello"),
+ kwargs={"attachment": open("file.pdf", "rb")},
+)
+print(report)
+# Argument Analysis:
+# args[0] (Session) → REDIRECT (redirect to worker resource 'db')
+# args[1] (str) → PASS
+# kwargs.attachment (BufferedReader) → PROXY (handler=file)
+```
+
+`analyze()` is a development and debugging tool. It reads the registry but
+makes no changes to arguments. `_interceptor` is an internal accessor — it is
+not yet part of the stable public API and may change between releases.
+
+### Interception metrics
+
+```python
+stats = queue.interception_stats()
+# {
+# "total_intercepts": 1200,
+# "total_duration_ms": 216.0,
+# "avg_duration_ms": 0.18,
+# "strategy_counts": {"pass": 800, "convert": 250, "redirect": 100, "proxy": 50, "reject": 0},
+# "max_depth_reached": 3,
+# }
+```
+
+`total_intercepts` always equals the sum of `strategy_counts`.
+
+See Observability for
+Prometheus metrics and dashboard endpoints.
+
+
+
+
+
+## Enqueue interceptors
+
+`queue.intercept(interceptor)` registers a function that runs at the very
+start of every enqueue — before per-task defaults, middleware, and gates —
+and decides what happens to the call:
+
+```ts
+import { Interception } from "@byteveda/taskito";
+
+queue.intercept((taskName, args) => {
+ if (taskName === "chargeCard" && (args[0] as number) < 0) {
+ return Interception.reject("charge amount must be non-negative");
+ }
+ return Interception.pass();
+});
+```
+
+An interceptor returns one of four outcomes:
+
+| Outcome | Effect |
+|---|---|
+| `Interception.pass()` | Enqueue unchanged. |
+| `Interception.convert(args)` | Replace the args; the task name stays the same. |
+| `Interception.redirect(taskName, args)` | Enqueue a different task with new args instead. |
+| `Interception.reject(reason)` | Block the enqueue — `enqueue`/`enqueueMany` throws `InterceptionError`. |
+
+Rejecting aborts the enqueue — the job is never created and the error
+propagates to the caller:
+
+```ts
+queue.enqueue("charge", [-5]); // throws — nothing is enqueued
+```
+
+Multiple interceptors chain in registration order, each seeing the previous
+one's (possibly redirected) task name and args. `redirect` is rejected for
+`enqueueMany` (a batch is stored under one task name) and for tasks
+registered with per-task codecs —
+the redirect target's codec chain can't be resolved from a bare name.
+
+### Interceptors vs. `onEnqueue`
+
+`queue.intercept()` is distinct from the `onEnqueue`
+middleware hook:
+`intercept` runs first and can redirect to a different task or reject
+outright; `onEnqueue` runs after and mutates the (possibly redirected)
+context in place — use it to rewrite enqueue *options* (metadata, priority,
+and so on) rather than the payload. Most apps need only one.
+
+### Why no proxies?
+
+Some SDKs also ship object *proxies* — a system for shipping
+non-serializable objects (file handles, sessions) through the queue. Node
+doesn't need it: a handler closes over its own resources, or pulls them from
+dependency
+injection . Keep non-serializable state on the worker and pass
+plain data through the queue.
+
+
+
+
+
+## The interceptor pipeline
+
+`taskito.intercept(interceptor)` registers an `Interceptor` — a functional
+interface that sees the task name and payload and returns one of four
+`Interception` strategies:
+
+```java
+taskito.intercept((taskName, payload) -> {
+ if (payload instanceof String s && s.startsWith("pw:")) {
+ return Interception.convert("***"); // redact before it reaches storage
+ }
+ return Interception.pass();
+});
+```
+
+| Strategy | Effect |
+|---|---|
+| `Interception.pass()` | Enqueue the payload unchanged. |
+| `Interception.convert(payload)` | Replace the payload (e.g. with a proxy ref ). |
+| `Interception.redirect(taskName, payload)` | Enqueue a different task (and payload) instead. |
+| `Interception.reject(reason)` | Block the enqueue — `InterceptionException` is thrown and no job is created. |
+
+Interceptors run synchronously in registration order; each sees the previous
+one's result. A `Redirect` retargets the rest of the pipeline — later
+interceptors, middleware, and gates all see the new task name and payload.
+
+### Validation
+
+Rejecting from an interceptor aborts the enqueue — the job is never created
+and the error propagates to the caller:
+
+```java
+taskito.intercept((taskName, payload) -> {
+ if (taskName.equals("charge") && ((Order) payload).total() < 0) {
+ return Interception.reject("charge amount must be non-negative");
+ }
+ return Interception.pass();
+});
+
+taskito.enqueue(CHARGE, invalidOrder); // throws InterceptionException — nothing enqueued
+```
+
+### Rewriting options — the `onEnqueue` hook
+
+Interceptors decide the payload's fate; to rewrite enqueue *options* or
+attach metadata, use middleware
+`onEnqueue`. It receives a mutable `EnqueueContext` after interceptors have
+run — every enqueue runs **interceptors → `onEnqueue` middleware → enqueue
+gates**, then serializes, codec-encodes, and submits. Gates see the payload
+that will actually be stored, after any rewrites.
+
+### Edge cases
+
+- **Batches** — `enqueueMany` runs each payload through the interceptors, so
+ a batch can't bypass the contract. `Convert` rewrites the item; `Reject`
+ fails the whole batch; `Redirect` is unsupported in a batch (it would move
+ an item out of the single-task batch) and throws.
+- **Redirect + payload codecs** — redirecting a task that has
+ payload codecs throws:
+ the source task's codec chain can't be applied to the target task without
+ corrupting the payload.
+- **Null result** — an interceptor returning `null` is a bug and throws
+ `InterceptionException`.
+
+
+ Keep interceptors fast and side-effect-free — they run on the producer
+ thread inside every `enqueue` call.
+
+
+
diff --git a/docs/content/docs/shared/guides/workflows/canvas.mdx b/docs/content/docs/shared/guides/workflows/canvas.mdx
new file mode 100644
index 00000000..72f4f707
--- /dev/null
+++ b/docs/content/docs/shared/guides/workflows/canvas.mdx
@@ -0,0 +1,584 @@
+---
+title: Canvas Primitives
+description: "chain / group / chord — shorthand composition for common task pipeline and workflow shapes."
+---
+
+taskito's canvas gives you three shorthand primitives for common pipeline
+shapes: **chain** (sequential), **group** (parallel), and **chord**
+(parallel with a callback) — composing task calls without hand-wiring
+dependencies yourself.
+
+
+
+
+ Canvas primitives are **in-thread orchestrators**, independent of
+ DAG workflows — no
+ persistent run row in storage, no workflow engine required. `chain`
+ enqueues each step and blocks on its result before dispatching the next;
+ `group` enqueues every member in parallel; `chord` runs a group then a
+ callback. See "Canvas vs DAG workflows" below for when to reach for each.
+
+
+
+
+
+
+`chain`, `group`, and `chord` are methods on the same
+workflow builder you use for
+everything else — they just add `.step()` calls with the `after` dependency
+wiring done for you. The result is a normal DAG workflow you `.submit()`
+like any other.
+
+
+
+
+
+`Canvas.chain`, `Canvas.group`, and `Canvas.chord` build a `Workflow` from a
+few `Canvas.link(name, task, payload)` steps — the same DAG a hand-written
+builder chain would
+produce. Submit the result like any other workflow, and keep adding steps to
+it with the regular builder if you need more.
+
+
+
+
+
+## Coming from Celery
+
+taskito's canvas mirrors Celery's `chain` / `group` / `chord` / `.s()` /
+`.si()` / `chunks` / `starmap`, with a few deliberate differences:
+
+| Celery | taskito | Difference |
+|---|---|---|
+| `chain(...).apply_async()` | `chain(...).apply(queue)` | `.apply()` here **submits** the canvas; pass the `queue`. |
+| `chain(...).apply()` (eager) | — | taskito has **no** eager `.apply()`; it always enqueues for workers. |
+| `AsyncResult.get(timeout=)` | `job.result(timeout=)` | Same idea, different name. |
+| `group(...)` → `GroupResult` | `group(...).apply(queue)` → `list[JobResult]` | taskito returns a plain list of handles — iterate and call `.result()` on each. |
+| `.s()` / `.si()` | `.s()` / `.si()` | Identical (mutable vs immutable signature). |
+
+
+ The biggest gotcha: in Celery, `.apply()` executes the canvas **locally and
+ synchronously**. In taskito, `.apply(queue)` **enqueues** it for workers,
+ and you await results with `.result()`. There is no eager local execution.
+
+
+
+
+## Building blocks
+
+Each primitive is composed from small step descriptors:
+
+
+
+
+A `Signature` wraps a task call for deferred execution:
+
+```python
+from taskito import chain, group, chord
+
+sig = add.s(1, 2) # Mutable — receives previous result as first arg
+sig = add.si(1, 2) # Immutable — ignores previous result
+```
+
+
+
+
+A `CanvasStep` names a step and its task; it accepts every `.step()` option
+except `after`, which the shorthand manages for you:
+
+```ts
+{ name: "extract", task: "extract" }
+{ name: "load", task: "load", maxRetries: 5 }
+```
+
+
+
+
+`Canvas.link(name, task, payload)` binds a step name to a registered task and
+its payload:
+
+```java
+Canvas.link("extract", extractTask, 1)
+Canvas.link("transform", transformTask, 2)
+```
+
+
+
+
+## Chain
+
+Run steps in sequence:
+
+ S2[transform] --> S3[load]`}
+/>
+
+
+
+
+```python
+result = chain(
+ extract.s("https://api.example.com/users"),
+ transform.s(),
+ load.s(),
+).apply(queue)
+
+print(result.result(timeout=30))
+```
+
+
+
+
+```ts
+queue.workflows
+ .define("etl")
+ .chain([
+ { name: "extract", task: "extract" },
+ { name: "transform", task: "transform" },
+ { name: "load", task: "load", maxRetries: 5 },
+ ])
+ .submit();
+// extract → transform → load
+```
+
+
+
+
+```java
+Workflow etl = Canvas.chain("etl",
+ Canvas.link("extract", extractTask, 1),
+ Canvas.link("transform", transformTask, 2),
+ Canvas.link("load", loadTask, 3));
+
+queue.submitWorkflow(etl);
+// extract → transform → load
+```
+
+
+
+
+
+
+Each step's return value flows automatically into the next via `.s()`. Use
+`.si()` when a step should **not** receive the previous result:
+
+```python
+chain(
+ step_a.s(input_data),
+ step_b.si(independent_data),
+ step_c.s(),
+).apply(queue)
+```
+
+
+
+
+
+
+ A chain step is an ordinary workflow step wired with `after` — it does
+ **not** automatically receive its predecessor's return value, and `args`
+ are fixed at build time. To act on an upstream result, aggregate it with
+ fan-out / fan-in instead
+ of a plain chain step.
+
+
+Pass `{ after }` to chain a canvas block onto an existing step.
+
+
+
+
+
+
+ Like any workflow step, a chain link does **not** automatically receive its
+ predecessor's return value — pass the payload explicitly, or read upstream
+ results via the workflow
+ context .
+
+
+
+
+## Group
+
+Run steps in parallel:
+
+
+
+
+```python
+jobs = group(
+ process.s(1),
+ process.s(2),
+ process.s(3),
+).apply(queue)
+
+results = [j.result(timeout=30) for j in jobs]
+```
+
+
+
+
+```ts
+queue.workflows
+ .define("notify")
+ .step("prepare", "prepare")
+ .group(
+ [
+ { name: "email", task: "sendEmail" },
+ { name: "sms", task: "sendSms" },
+ { name: "push", task: "sendPush" },
+ ],
+ { after: "prepare" },
+ )
+ .submit();
+// prepare → (email | sms | push)
+```
+
+
+
+
+```java
+Workflow notify = Canvas.group("notify",
+ Canvas.link("email", sendEmail, message),
+ Canvas.link("sms", sendSms, message),
+ Canvas.link("push", sendPush, message));
+
+queue.submitWorkflow(notify);
+// email | sms | push
+```
+
+
+
+
+
+
+### Concurrency limits
+
+```python
+jobs = group(
+ *[fetch.s(url) for url in urls],
+ max_concurrency=5,
+).apply(queue)
+```
+
+
+
+
+
+`Canvas.group` always starts group members as roots. To hang a parallel
+group off an existing step, add the steps with the builder instead —
+`Canvas` shortcuts always start from roots:
+
+```java
+Workflow wf = Workflow.named("notify")
+ .step("prepare", prepareTask, 1)
+ .step("email", sendEmail, message, "prepare")
+ .step("sms", sendSms, message, "prepare");
+// prepare → (email | sms)
+```
+
+
+
+## Chord
+
+Fan-out with a callback — run tasks in parallel, then run a final task once
+every member finishes (whether the callback sees the members' results is
+SDK-specific — see the notes below):
+
+
+
+
+```python
+result = chord(
+ group(
+ fetch.s("https://api1.example.com"),
+ fetch.s("https://api2.example.com"),
+ fetch.s("https://api3.example.com"),
+ ),
+ merge.s(),
+).apply(queue)
+```
+
+
+
+
+```ts
+queue.workflows
+ .define("report")
+ .chord(
+ [
+ { name: "q1", task: "queryRegion" },
+ { name: "q2", task: "queryRegion" },
+ ],
+ { name: "merge", task: "merge" },
+ )
+ .submit();
+// (q1 | q2) → merge
+```
+
+
+
+
+```java
+Workflow report = Canvas.chord("report",
+ Canvas.link("merge", mergeTask, 0), // callback
+ Canvas.link("q1", queryRegion, "east"), // group...
+ Canvas.link("q2", queryRegion, "west"));
+
+queue.submitWorkflow(report);
+// (q1 | q2) → merge
+```
+
+
+
+
+
+
+
+ The callback receives the list of member results as its first argument —
+ `merge` above is called with `[result1, result2, result3]`. Use an
+ immutable signature (`merge.si()`) to opt out.
+
+
+
+
+
+
+
+ `chord`'s callback runs *after* the group but receives its own `args` — the
+ members' results are not auto-passed. To aggregate results into one step,
+ use fan-out / fan-in ,
+ which collects child results into the combiner's argument.
+
+
+
+
+
+
+
+ A `chord`'s callback runs *after* the group but receives its own payload —
+ the members' results are not auto-passed. To aggregate results into one
+ step, use fan-out / fan-in ,
+ which collects child results into the combiner's payload.
+
+
+
+
+
+
+## Saga compensation
+
+Canvas primitives support in-thread saga compensation via
+`.with_compensation()`. When a step fails, taskito dispatches the
+compensators for the steps that already succeeded — no workflow engine
+required.
+
+
+ Canvas compensation is in-thread: compensators are enqueued synchronously
+ during the `apply()` call that raised. For DAG-level sagas with full
+ observability and sub-workflow propagation, see the
+ Sagas guide .
+
+
+### chain compensation
+
+Attach one compensator per chain step. `None` skips a slot. On failure,
+compensators for completed steps run in **reverse order**:
+
+```python
+from taskito import chain
+
+@queue.task()
+def step_a(x: int) -> int:
+ return charge_payment(x)
+
+@queue.task()
+def step_b(x: int) -> str:
+ return create_order(x)
+
+@queue.task()
+def refund(forward_args, forward_kwargs, forward_result):
+ payment_id = forward_result
+ payment_api.refund(payment_id)
+
+@queue.task()
+def cancel_order(forward_args, forward_kwargs, forward_result):
+ order_id = forward_result
+ order_api.cancel(order_id)
+
+result = chain(
+ step_a.s(100),
+ step_b.s(),
+).with_compensation([refund, cancel_order]).apply(queue)
+```
+
+`with_compensation` requires exactly one entry per chain step (a slot can be
+`None` to skip that step). Only steps that **completed** are compensated, in
+reverse order, one at a time — the failed step itself never ran to completion,
+so it has nothing to undo. If `step_b` fails after `step_a` succeeded, only
+`refund` (step_a's compensator) is enqueued; `cancel_order` would run only in
+a longer chain where `step_b` completed and a later step failed.
+
+### group compensation
+
+One compensator per group member. On failure of any member, compensators for
+the **succeeded** members dispatch in parallel. The failed member is not
+compensated:
+
+```python
+from taskito import group
+
+g = group(
+ debit_account.s(100),
+ reserve_inventory.s("item-7"),
+ notify_warehouse.s(42),
+).with_compensation([
+ refund_account,
+ release_inventory,
+ cancel_notification,
+])
+
+g.apply(queue)
+```
+
+### chord compensation
+
+`chord.with_compensation` takes separate `group=` and `callback=` arguments:
+
+```python
+from taskito import chord, group
+
+ch = chord(
+ group(fetch.s("url1"), fetch.s("url2")),
+ merge.s(),
+).with_compensation(
+ group=[rollback_fetch1, rollback_fetch2],
+ callback=rollback_merge,
+)
+
+ch.apply(queue)
+```
+
+Compensation order:
+
+- Group member fails before the callback runs → group compensators for the
+ succeeded members dispatch in parallel.
+- Callback fails after the group succeeded → group compensators dispatch in
+ parallel, same as above.
+
+
+ `callback=`'s compensator is accepted by `with_compensation()` but is not
+ currently dispatched on callback failure — only the group members are ever
+ rolled back. Handle callback-side rollback inside the callback task itself
+ until this gap closes.
+
+
+### Compensator contract
+
+Every compensator receives three positional args — the same contract as
+DAG-level saga compensators:
+
+```python
+@queue.task()
+def my_compensator(forward_args: tuple, forward_kwargs: dict, forward_result: object) -> None:
+ # forward_result is the return value of the step being compensated.
+ ...
+```
+
+### Idempotency
+
+Each compensator is enqueued with a deterministic unique key of the form
+`canvas_compensation:{run_id}:{slot}`, where `run_id` is a fresh UUID
+generated when `apply()` starts. The dedup guarantee is **per-run**: it stops
+one `apply()` call's failure handling from double-dispatching the same
+compensator slot. It does not carry across separate `apply()` calls — each
+call gets its own `run_id`, so retrying a whole canvas from scratch (e.g.
+calling `apply()` again after a crash) re-dispatches every compensator.
+
+### Passing `None` to disable compensation
+
+```python
+# Disables all compensation (equivalent to not calling with_compensation).
+chain(a.si(), b.si()).with_compensation(None).apply(queue)
+
+# Disables compensation for one specific slot.
+chain(a.si(), b.si()).with_compensation([compensate_a, None]).apply(queue)
+```
+
+
+
+
+
+## Saga compensation
+
+Canvas steps are ordinary workflow steps, so a `CanvasStep` accepts the same
+`compensate` option as `.step()`:
+
+```ts
+.chain([
+ { name: "reserve", task: "reserveInventory", compensate: "unreserveInventory" },
+ { name: "charge", task: "chargePayment", compensate: "refundPayment" },
+])
+```
+
+Rollback then follows the regular DAG-level saga rules — reverse-dependency
+order, driven by the workflow run's own state machine, not a canvas-specific
+mechanism. See Saga compensation .
+
+
+
+
+
+## Saga compensation
+
+`Canvas.link()` only takes a name, task, and payload — there's no
+`compensate` option on the shorthand itself. For rollback on a canvas-built
+step, mix in the regular builder: `Canvas.chain(...)` (and `group`/`chord`)
+return a plain `Workflow`, and `Workflow.named(...).step(...)` exposes a
+`.compensate(...)` builder method for
+saga compensation .
+
+
+
+
+
+## chunks / starmap
+
+```python
+from taskito import chunks, starmap
+
+# Batch processing — split 1000 items into groups of 100
+results = chunks(process_batch, items, chunk_size=100).apply(queue)
+
+# Map-reduce pattern
+result = chord(
+ chunks(process_batch, items, chunk_size=100),
+ merge_results.s(),
+).apply(queue)
+
+# Tuple unpacking
+results = starmap(add, [(1, 2), (3, 4), (5, 6)]).apply(queue)
+```
+
+## Canvas vs DAG workflows
+
+| Feature | Canvas | DAG Workflows |
+|---------|--------|---------------|
+| Setup | No imports needed | `from taskito.workflows import Workflow` |
+| Topology | Linear chains, flat groups | Arbitrary DAGs |
+| Fan-out | Static (known at build time) | Dynamic (from return values) |
+| Conditions | None | `on_success`, `on_failure`, `always`, callables |
+| Error handling | Per-task retries only | Workflow-level strategies |
+| Saga compensation | `chain` / `group` / `chord` | DAG-level, with sub-workflow propagation |
+| Approval gates | No | Yes |
+| Sub-workflows | No | Yes |
+| Incremental runs | No | Yes |
+| Status tracking | Per-job only | Per-workflow + per-node |
+| Visualization | No | Mermaid / DOT |
+
+Use canvas for quick one-off pipelines. Use
+DAG workflows for
+production pipelines that need monitoring, conditions, or complex
+topologies.
+
+
diff --git a/docs/content/docs/shared/guides/workflows/gates.mdx b/docs/content/docs/shared/guides/workflows/gates.mdx
new file mode 100644
index 00000000..8ec7ccdd
--- /dev/null
+++ b/docs/content/docs/shared/guides/workflows/gates.mdx
@@ -0,0 +1,480 @@
+---
+title: Approval Gates
+description: "Pause a workflow run until a human or external system approves or rejects it."
+---
+
+An approval gate pauses a workflow run until something external — a human
+reviewer or another system — resolves it. No task runs at the gate: the run
+stops there until it's approved, rejected, or a timeout fires, and only then
+do its successors run or get skipped.
+
+ gate["approve-deploy\n(waiting_approval)"]:::waiting
+ gate -- approved --> deploy:::run
+ gate -- rejected --> deploy_skip["deploy\n(skipped)"]:::skip`}
+/>
+
+## Adding a gate
+
+
+
+
+```python
+from taskito import Queue
+from taskito.workflows import Workflow
+
+queue = Queue(db_path="tasks.db")
+
+@queue.task()
+def build_task() -> str: ...
+
+@queue.task()
+def deploy_task() -> None: ...
+
+wf = Workflow(name="release_pipeline")
+wf.step("build", build_task)
+wf.gate(
+ "approve_deploy",
+ after="build",
+ message="Review build artifacts before deploying to production.",
+ timeout=86400, # 24 hours
+ on_timeout="reject",
+)
+wf.step("deploy", deploy_task, after="approve_deploy")
+
+run = queue.submit_workflow(wf)
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const queue = new Queue({ dbPath: "tasks.db" });
+
+const handle = queue.workflows
+ .define("release-pipeline")
+ .step("build", "buildTask")
+ .gate("approve-deploy", {
+ after: "build",
+ message: "Review build artifacts before deploying to production.",
+ timeoutMs: 24 * 60 * 60 * 1000, // 24 h
+ onTimeout: "reject",
+ })
+ .step("deploy", "deployTask", { after: "approve-deploy" })
+ .submit();
+
+queue.runWorker();
+```
+
+
+
+
+```java
+Workflow release = Workflow.named("release-pipeline")
+ .step("build", buildTask, artifact)
+ .gate("approve-deploy",
+ GateConfig.timeout(
+ Duration.ofHours(24),
+ GateAction.REJECT,
+ "Review build artifacts before deploying to production."),
+ "build")
+ .step("deploy", deployTask, artifact, "approve-deploy");
+
+WorkflowRun run = queue.submitWorkflow(release);
+
+try (Worker worker = queue.worker()
+ .handle(buildTask, p -> p)
+ .handle(deployTask, p -> p)
+ .trackWorkflows(release) // the tracker holds deploy's payload
+ .start()) {
+ // ...
+}
+```
+
+
+
+
+
+
+
+ A gated workflow must be registered on the tracking worker with
+ `trackWorkflows(workflow)`. The gate's downstream steps are deferred nodes —
+ their jobs are only created when the gate resolves, and the tracker supplies
+ their payloads from the registered definition.
+
+
+
+
+## Gate node status
+
+While a gate is pending, its node status is waiting-for-approval; the run
+itself keeps running. Downstream steps don't start until the gate resolves.
+
+
+
+
+```python
+run.node_status("approve_deploy") # NodeStatus.WAITING_APPROVAL
+run.status().state # WorkflowState.RUNNING — the run keeps going
+```
+
+
+
+
+```ts
+const nodes = handle.nodes();
+const gate = nodes.find(n => n.nodeName === "approve-deploy");
+console.log(gate?.status); // "waiting_approval"
+```
+
+
+
+
+```java
+WorkflowStatus status = run.status().orElseThrow();
+status.node("approve-deploy").orElseThrow().status; // WAITING_APPROVAL
+```
+
+
+
+
+## Resolving a gate
+
+Resolve a gate with the run ID and the gate's node name — approve to let its
+successors proceed, or reject (optionally with a reason) to fail the gate and
+skip them.
+
+
+
+
+```python
+# Call this on the same `queue` instance that is running the worker for
+# this run — see the note below.
+queue.approve_gate(run.id, "approve_deploy")
+
+# Reject, with an optional reason recorded as the gate's error.
+queue.reject_gate(run.id, "approve_deploy", error="Artifacts failed QA review.")
+```
+
+
+
+
+```ts
+// Approve — downstream steps are enqueued.
+queue.workflows.approveGate(runId, "approve-deploy");
+
+// Reject — downstream steps are skipped, run transitions to "failed".
+queue.workflows.rejectGate(runId, "approve-deploy");
+
+// Reject with a reason recorded in storage.
+queue.workflows.rejectGate(runId, "approve-deploy", "Artifacts failed QA review.");
+
+// Unified helper — pass a boolean.
+queue.workflows.resolveGate(runId, "approve-deploy", true); // approve
+queue.workflows.resolveGate(runId, "approve-deploy", false, "Reason."); // reject
+```
+
+
+
+
+```java
+// Approve — the gate completes and downstream steps are enqueued.
+worker.approveGate(run.runId(), "approve-deploy");
+
+// Reject with a reason — downstream steps are skipped, run transitions to FAILED.
+worker.rejectGate(run.runId(), "approve-deploy", "Artifacts failed QA review.");
+```
+
+
+
+
+
+
+
+ `approve_gate`/`reject_gate` only advance the run when called on the same
+ `Queue` instance that is running the worker for it — the tracker keeps each
+ run's plan and deferred-node payloads in memory, not in storage. Calling
+ either method on an unrelated `Queue()` still flips the gate's stored
+ status, but its successors won't be created until a call reaches the
+ tracker that owns the run. If you need to resolve gates from application
+ code (e.g. a request handler), run the worker in a background thread in
+ that same process — see
+ Building Workflows .
+
+
+
+
+
+
+Resolution works from any process that opens the same storage — the resolver
+does not need to be the worker process.
+
+
+
+
+
+Resolution goes through the `Worker` that is tracking workflows — calling
+either method on a worker built without `trackWorkflows()` throws a
+`WorkflowException`.
+
+
+
+## Timeout behaviour
+
+A gate with no timeout waits forever:
+
+
+
+
+```python
+wf.gate("approve_deploy", after="build") # timeout=None (default) — waits forever
+```
+
+
+
+
+```ts
+.gate("approve-deploy", { after: "build" }) // no timeoutMs — waits forever
+```
+
+
+
+
+```java
+GateConfig.manual(); // waits forever
+GateConfig.timeout(Duration.ofHours(24), GateAction.REJECT); // auto-reject after 24 h
+GateConfig.timeout(Duration.ofHours(24), GateAction.APPROVE, "Auto-ships unless stopped.");
+```
+
+
+
+
+Once the timeout elapses, the gate auto-resolves:
+
+
+
+| `on_timeout` | Effect |
+|---|---|
+| `"reject"` (default) | Same as calling `reject_gate` — downstream skipped, run `FAILED` |
+| `"approve"` | Same as calling `approve_gate` — downstream proceeds |
+
+
+
+
+
+| `onTimeout` | Effect |
+|---|---|
+| `"reject"` (default) | Same as calling `rejectGate` — downstream skipped, run `"failed"` |
+| `"approve"` | Same as calling `approveGate` — downstream proceeds |
+
+Setting `timeoutMs` without `onTimeout` defaults to `"reject"`.
+
+
+
+
+
+| `GateAction` | Effect |
+|---|---|
+| `REJECT` (default) | Same as `rejectGate` — downstream skipped, run `FAILED` |
+| `APPROVE` | Same as `approveGate` — downstream proceeds |
+
+The timeout must be positive; the timer is driven by the tracking worker and
+is cancelled if the gate is resolved manually first.
+
+
+
+## Gate options
+
+
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `after` | `str \| list[str]` | `None` | Predecessor step name(s) |
+| `timeout` | `float \| None` | `None` | Seconds until auto-resolve; `None` waits forever |
+| `on_timeout` | `str` | `"reject"` | Resolution on timeout: `"approve"` or `"reject"` |
+| `message` | `str \| None` | `None` | Human-readable message included in the `WORKFLOW_GATE_REACHED` event payload |
+
+
+
+
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `after` | `string \| string[]` | — | Predecessor step name(s) |
+| `message` | `string` | — | Human-readable description shown in the dashboard |
+| `timeoutMs` | `number` | — | Auto-resolve after this many milliseconds |
+| `onTimeout` | `"approve" \| "reject"` | `"reject"` | Resolution on timeout |
+
+
+
+
+
+| `GateConfig` component | Type | Default | Description |
+|---|---|---|---|
+| `timeout` | `Duration` | `null` (wait forever) | Auto-resolve after this long |
+| `onTimeout` | `GateAction` | `REJECT` | Resolution taken when the timeout elapses |
+| `message` | `String` | `null` | Human-readable description shown to the approver |
+
+
+
+
+
+## Gate conditions
+
+Gates respect the same `condition` DSL as regular steps, evaluated against
+the gate's predecessors *before* it's entered:
+
+```python
+wf.step("test", run_tests)
+wf.gate("approve", after="test", condition="on_success")
+wf.step("deploy", deploy_task, after="approve")
+```
+
+If `test` fails, the gate is skipped (its condition isn't met) — and
+`deploy`, the gate's successor, is skipped too.
+
+
+ Node's `.gate()` step options and Java's `GateConfig`/`Workflow.gate()`
+ don't expose a condition on the gate node itself — only Python's
+ `Workflow.gate()` takes one directly.
+
+
+
+
+## Rejection behaviour
+
+Rejecting a gate marks the gate node failed and skips its default
+(on-success) successors; the run ends in a failed state. Any on-failure or
+always successors still run — use those (see
+conditions ) for a
+rejection branch.
+
+
+
+
+ Calling `approveGate` or `rejectGate` on a gate that has already been
+ resolved (approved, rejected, or timed out) is a no-op — the tracker ignores
+ stale resolutions.
+
+
+
+
+
+
+
+ Calling `approveGate` or `rejectGate` on a gate that has already been
+ resolved (approved, rejected, or timed out) is a no-op — the tracker ignores
+ stale resolutions.
+
+
+
+
+
+
+
+ Python has no equivalent guard: calling `approve_gate`/`reject_gate` again
+ on an already-resolved gate re-runs the resolution instead of no-op'ing.
+
+
+
+
+
+
+## Events
+
+When a gate enters `WAITING_APPROVAL`, a `WORKFLOW_GATE_REACHED` event fires
+in the process that reached it — the same process whose tracker owns the run
+(see the note under "Resolving a gate"). Subscribe with `queue.on_event()`
+(see the events reference
+for the full list of event types):
+
+```python
+from taskito.events import EventType
+
+def notify_team(event_type, payload):
+ send_slack(f"Workflow {payload['run_id']} needs approval at {payload['node_name']}")
+
+queue.on_event(EventType.WORKFLOW_GATE_REACHED, notify_team)
+```
+
+The payload carries `run_id`, `node_name`, and `message` (the gate's
+`message`, or `None` if it wasn't set).
+
+
+
+## Webhooks and external triggers
+
+A common pattern is an HTTP endpoint that receives a webhook and resolves the
+gate — any external system can drive a gate this way:
+
+
+
+
+```python
+@app.post("/hooks/deploy-approval")
+def deploy_approval():
+ # Fail closed — resolving a gate is a privileged state transition.
+ verify_webhook_signature(request) # abort(401) on a bad signature
+ body = request.get_json()
+ authorize_run(request, body["run_id"]) # abort(403) unless the caller may resolve this run
+ if body["approved"]:
+ queue.approve_gate(body["run_id"], "approve_deploy")
+ else:
+ queue.reject_gate(body["run_id"], "approve_deploy", error=body.get("reason"))
+ return "", 204
+```
+
+
+
+
+```ts
+app.post("/hooks/deploy-approval", async (req, res) => {
+ // Fail closed — resolving a gate is a privileged state transition.
+ verifyWebhookSignature(req); // throw 401 on a bad signature
+ const { runId, approved, reason } = req.body;
+ await authorizeRun(req, runId); // throw 403 unless the caller may resolve this run
+ queue.workflows.resolveGate(runId, "approve-deploy", approved, reason);
+ res.sendStatus(204);
+});
+```
+
+
+
+
+```java
+// Fail closed — resolving a gate is a privileged state transition.
+void onDeployApproval(HttpRequest request) {
+ verifyWebhookSignature(request); // 401 on a bad signature
+ ApprovalPayload payload = parse(request);
+ authorizeRun(request, payload.runId()); // 403 unless the caller may resolve this run
+ if (payload.approved()) {
+ worker.approveGate(payload.runId(), "approve-deploy");
+ } else {
+ worker.rejectGate(payload.runId(), "approve-deploy", payload.reason());
+ }
+}
+```
+
+
+
+
+
+
+This endpoint must run in the same process as the worker for the constraint
+described under "Resolving a gate" — `queue` here is the same `Queue`
+instance passed to `run_worker()`.
+
+
+
+
+
+> **Coming from BullMQ?** `FlowProducer` has no pause/resume primitive — a
+> human-in-the-loop approval step is something you'd build yourself (e.g. a
+> job that polls a database flag). taskito's `.gate()` is a first-class step
+> kind: the run genuinely pauses, and `approveGate`/`rejectGate` resolve it
+> from any process.
+
+
diff --git a/docs/content/docs/shared/guides/workflows/saga.mdx b/docs/content/docs/shared/guides/workflows/saga.mdx
new file mode 100644
index 00000000..4349ccd9
--- /dev/null
+++ b/docs/content/docs/shared/guides/workflows/saga.mdx
@@ -0,0 +1,449 @@
+---
+title: Sagas
+description: "Roll back completed workflow steps in reverse order when a run fails — declare a compensator per step and taskito handles the rest."
+---
+
+A saga is a workflow where steps that succeed register a **compensator** —
+a task that undoes that step's side effect. If a later step fails, taskito
+automatically rolls back every already-completed step by running its
+compensator, in reverse-dependency order, so the run ends in a consistent
+state instead of a half-applied one. There's no separate "saga" API — any
+DAG workflow becomes a saga the moment one of its steps declares a
+compensator.
+
+## Basic example
+
+
+
+
+```python
+from taskito import Queue
+from taskito.workflows import Workflow
+
+queue = Queue()
+
+@queue.task()
+def unreserve_inventory(forward_args, forward_kwargs, forward_result):
+ inventory_api.release(forward_result["order_id"])
+
+@queue.task(compensates=unreserve_inventory)
+def reserve_inventory(order_id: str) -> dict:
+ inventory_api.reserve(order_id)
+ return {"reserved": True, "order_id": order_id}
+
+@queue.task()
+def refund_payment(forward_args, forward_kwargs, forward_result):
+ payment_api.refund(forward_result["charge_id"])
+
+@queue.task(compensates=refund_payment)
+def charge_payment(order_id: str) -> dict:
+ return {"order_id": order_id, "charge_id": payment_api.charge(order_id)}
+
+@queue.task()
+def ship_order(order_id: str):
+ raise RuntimeError("Warehouse offline")
+# No compensator for ship — if it never succeeded, there's nothing to undo.
+
+wf = Workflow(name="checkout")
+wf.step("reserve", reserve_inventory, args=("4212",))
+wf.step("charge", charge_payment, args=("4212",), after="reserve")
+wf.step("ship", ship_order, args=("4212",), after="charge")
+
+# A worker is already running elsewhere (queue.run_worker()).
+run = queue.submit_workflow(wf)
+status = run.wait()
+# ship fails → charge rolled back (refund_payment) → reserve rolled back (unreserve_inventory)
+# status.state is WorkflowState.COMPENSATED
+```
+
+
+
+
+```ts
+queue.task("reserveInventory", (orderId: string) => ({ reserved: true, orderId }));
+queue.task("unreserveInventory", (result: { reserved: boolean; orderId: string }) => { /* undo */ });
+
+queue.task("chargePayment", (orderId: string) => ({ charged: true, orderId }));
+queue.task("refundPayment", (result: { charged: boolean; orderId: string }) => { /* undo */ });
+
+queue.task("shipOrder", (orderId: string) => { throw new Error("Warehouse offline"); });
+// No compensate for ship — if it never succeeded, there's nothing to undo.
+
+const handle = queue.workflows
+ .define("checkout")
+ .step("reserve", "reserveInventory", { compensate: "unreserveInventory" })
+ .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" })
+ .step("ship", "shipOrder", { after: "charge" })
+ .submit();
+
+queue.runWorker();
+
+const run = await handle.wait();
+// ship fails → charge rolled back (refundPayment) → reserve rolled back (unreserveInventory)
+// run.state === "compensated"
+```
+
+
+
+
+```java
+Task reserve = Task.of("reserveInventory", Integer.class);
+Task unreserve = Task.of("unreserveInventory", Integer.class);
+Task charge = Task.of("chargePayment", Integer.class);
+Task refund = Task.of("refundPayment", Integer.class);
+Task ship = Task.of("shipOrder", Integer.class);
+// No compensator for ship — if it never succeeded, there's nothing to undo.
+
+int orderId = 4212; // the workflow input every step receives
+
+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").maxRetries(0).build());
+
+WorkflowRun run = queue.submitWorkflow(checkout);
+
+try (Worker worker = queue.worker()
+ .handle(reserve, id -> id)
+ .handle(charge, id -> id)
+ .handle(ship, id -> { throw new IllegalStateException("Warehouse offline"); })
+ .handle(refund, chargeResult -> chargeResult) // undo charge
+ .handle(unreserve, reserveResult -> reserveResult) // undo reserve
+ .trackWorkflows()
+ .start()) {
+ WorkflowStatus status = run.await(Duration.ofSeconds(30));
+ // ship fails → charge rolled back (refund) → reserve rolled back (unreserve)
+ System.out.println(status.state); // COMPENSATED
+ System.out.println(status.node("charge").orElseThrow().status); // COMPENSATED
+}
+```
+
+
+
+
+ charge --> ship
+ ship -. "run fails" .-> refund
+ refund --> unreserve`}
+/>
+
+## How it works
+
+1. A step's forward execution fails with its retries exhausted.
+2. taskito collects every step that **completed** and declared a
+ compensator — steps that are pending, ready, or skipped never ran, and a
+ step that itself failed has nothing to undo, so all three are left
+ alone.
+3. Those steps are grouped into **reverse-topological waves** — the most
+ downstream completed steps first, so a step is never rolled back before
+ whatever depends on it. Compensators within one wave run in parallel;
+ the next wave only starts once the current one fully drains.
+4. Each compensator is enqueued idempotently (see [Idempotency](#idempotency))
+ with the forward step's result available to it.
+5. Once a wave finishes: if every compensator in it succeeded, the next
+ wave starts. If any failed, rollback stops there — remaining steps are
+ left un-rolled-back, since further undo may depend on the failed step's
+ own undo — and the run ends `CompensationFailed` instead of
+ `Compensated`.
+
+
+
+
+ A [cache-hit](guides/workflows/caching) step is never compensated: its
+ forward task didn't actually run in this run, so there's no local side
+ effect to undo. Only a step whose status is `NodeStatus.COMPLETED` is
+ eligible — a `CACHE_HIT` is excluded even if it has a `compensate`.
+
+
+
+
+## Declaring compensators
+
+Each step names a rollback task; when the run fails, taskito calls it with
+the forward step's outcome:
+
+
+
+
+```python
+# The compensator receives exactly three positional args: the forward
+# task's args, kwargs, and return value.
+@queue.task()
+def refund_payment(forward_args: tuple, forward_kwargs: dict, forward_result: dict) -> None:
+ payment_api.refund(forward_result["charge_id"])
+
+@queue.task(compensates=refund_payment)
+def charge_payment(order_id: str) -> dict:
+ return {"order_id": order_id, "charge_id": payment_api.charge(order_id)}
+```
+
+
+
+
+```ts
+// The compensate task receives the forward step's return value as its
+// single positional argument.
+queue.task("chargePayment", async (orderId: string) => {
+ const charge = await stripe.charge(orderId);
+ return { chargeId: charge.id, orderId }; // compensator needs chargeId
+});
+
+queue.task("refundPayment", async (result: { chargeId: string; orderId: string }) => {
+ await stripe.refund(result.chargeId);
+});
+```
+
+
+
+
+```java
+// The compensation task receives the forward step's return value as its payload.
+record Charge(String chargeId, String orderId) {}
+
+Task refund = Task.of("refundPayment", Charge.class);
+
+worker.handle(refund, result -> {
+ stripe.refund(result.chargeId());
+ return null;
+});
+```
+
+
+
+
+
+
+### Decorator-level default
+
+`@queue.task(compensates=…)` sets a task's default compensator wherever
+it's used; `Workflow.step(…, compensates=…)` overrides it (or disables
+compensation) for one specific step:
+
+```python
+wf.step("charge", charge_payment, compensates=refund_v2) # override decorator default
+wf.step("charge", charge_payment) # inherit decorator default
+wf.step("charge", charge_payment, compensates=None) # disable compensation
+```
+
+
+ `forward_args`, `forward_kwargs`, and `forward_result` are fully populated
+ for both static and deferred workflow nodes. Call
+ `current_compensation_context()` inside a compensator body to also access
+ the workflow run ID, node name, and forward job ID.
+
+
+
+
+
+
+> **Coming from BullMQ?** There's no built-in saga/compensation concept in
+> BullMQ — you'd hand-roll rollback logic in your own failure handler.
+> taskito's `compensate` option and automatic reverse-order rollback are
+> taskito-only.
+
+
+
+## Steps without compensators
+
+A step that doesn't declare a compensator is skipped during rollback, and
+so is a step that never ran because it was downstream of the step that
+failed — only a **completed** step with a compensator is rolled back:
+
+
+
+
+```python
+wf = Workflow(name="partial-saga")
+wf.step("a", task_a, args=(1,), compensates=undo_a) # rolled back if the run fails after a completes
+wf.step("b", task_b, args=(2,), after="a") # no compensator — left as-is
+wf.step("c", task_c, args=(3,), after="b", compensates=undo_c)
+
+# If b fails: only a is rolled back (b never completed; c never ran).
+# If c fails: c has no completed result, so only a is rolled back.
+```
+
+
+
+
+```ts
+queue.workflows
+ .define("partial-saga")
+ .step("a", "taskA", { compensate: "undoA" }) // rolled back if run fails after a completes
+ .step("b", "taskB", { after: "a" }) // no compensate — left as-is
+ .step("c", "taskC", { after: "b", compensate: "undoC" })
+ .submit();
+
+// If b fails: only a is rolled back (b never completed; c never ran).
+// If c fails: c has no result yet, so only a is rolled back.
+```
+
+
+
+
+```java
+Workflow partial = Workflow.named("partial-saga")
+ .step(Step.of("a", taskA, 1).compensate(undoA).build()) // rolled back if run fails after a completes
+ .step("b", taskB, 2, "a") // no compensator — left as-is
+ .step(Step.of("c", taskC, 3).after("b").compensate(undoC).build());
+
+// If b fails: only a is rolled back (b never completed; c never ran).
+// If c fails: c has no completed result, so only a is rolled back.
+```
+
+
+
+
+## Final run states
+
+| State | Meaning |
+|---|---|
+| WorkflowState.COMPLETED} node={"completed"} java={WorkflowState.COMPLETED} /> | All steps succeeded — no compensation ran |
+| WorkflowState.FAILED} node={"failed"} java={WorkflowState.FAILED} /> | A step failed and no completed step had a compensator |
+| WorkflowState.COMPENSATED} node={"compensated"} java={WorkflowState.COMPENSATED} /> | A step failed; every applicable compensator succeeded |
+| WorkflowState.COMPENSATION_FAILED} node={"compensation_failed"} java={WorkflowState.COMPENSATION_FAILED} /> | A step failed; at least one compensator also failed |
+
+
+ `CompensationFailed` needs a human: inspect each step's status (
+ run.status().nodes} node={handle.nodes()} java={status.nodes} />
+ ) to find which rollback failed and whether data is left partially
+ consistent.
+
+
+
+
+
+ These four are a subset of `WorkflowState`'s six terminal states —
+ `is_terminal()` is also `True` for `CANCELLED` (compensation never runs
+ on a cancelled run) and `COMPLETED_WITH_FAILURES`, covered next.
+
+
+## Compensating on continue-mode workflows
+
+By default, `on_failure="continue"` workflows never trigger compensation —
+they run every step regardless of individual failures and always reach
+`COMPLETED_WITH_FAILURES` if any step failed. Opt in with
+`compensate_on_continue=True`:
+
+```python
+wf = Workflow(
+ name="resilient_checkout",
+ on_failure="continue",
+ compensate_on_continue=True,
+)
+wf.step("charge", charge_payment, args=("4212",))
+wf.step("ship", ship_order, args=("4212",), after="charge")
+
+run = queue.submit_workflow(wf)
+```
+
+With `compensate_on_continue=True`, once the run settles, a run with at
+least one failed step transitions from `COMPLETED_WITH_FAILURES` into
+`Compensating` — compensators for every *succeeded* step then run in
+reverse-topological order, same as a `fail_fast` saga, and the run ends
+`Compensated` or `CompensationFailed`. Without it, a `continue`-mode run
+with failures just stays `COMPLETED_WITH_FAILURES` and no compensation
+runs.
+
+
+
+## Idempotency
+
+Every compensation job is enqueued with a deterministic idempotency key —
+`compensation:{run_id}:{node_name}` — so a compensation that's
+re-triggered (for example after a worker or tracker restart mid-rollback)
+hits the existing dedup and becomes a no-op instead of running the
+rollback twice.
+
+That guarantee is at the *job* level, not the function body: a worker can
+still retry a compensator on a transient failure before its own retries
+are exhausted. Write compensator bodies to be safely re-runnable, the same
+way you would any other task.
+
+## Combining with conditions
+
+Conditions and compensation compose. An `on_failure` step runs first as
+the run settles toward failure; once the run is failed, compensation then
+rolls back the completed compensable steps:
+
+
+
+
+```python
+wf = Workflow(name="safe-checkout")
+wf.step("reserve", reserve_inventory, args=("4212",), compensates=unreserve_inventory)
+wf.step("charge", charge_payment, args=("4212",), after="reserve", compensates=refund_payment)
+wf.step("ship", ship_order, args=("4212",), after="charge")
+wf.step("notify_failure", send_failure_email, args=("4212",), after="ship", condition="on_failure")
+```
+
+
+
+
+```ts
+queue.workflows
+ .define("safe-checkout")
+ .step("reserve", "reserveInventory", { compensate: "unreserveInventory" })
+ .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" })
+ .step("ship", "shipOrder", { after: "charge" })
+ .step("notifyFailure", "sendFailureEmail", {
+ after: "ship",
+ condition: "on_failure",
+ })
+ .submit();
+```
+
+
+
+
+```java
+int orderId = 4212; // the workflow input every step receives
+
+Workflow safeCheckout = Workflow.named("safe-checkout")
+ .step(Step.of("reserve", reserve, orderId).compensate(unreserve).build())
+ .step(Step.of("charge", charge, orderId).after("reserve").compensate(refund).build())
+ .step("ship", ship, orderId, "charge")
+ .step(Step.of("notifyFailure", sendFailureEmail, orderId)
+ .onFailure()
+ .after("ship")
+ .build());
+```
+
+
+
+
+
+
+## Events
+
+Saga lifecycle emits dedicated events on the queue's event bus, subscribed
+via `queue.on_event(event_type, callback)`:
+
+| Event | Fires when |
+|---|---|
+| `WORKFLOW_COMPLETED_WITH_FAILURES` | A `continue`-mode run finished with at least one failure |
+| `WORKFLOW_COMPENSATING` | The run enters the compensation phase |
+| `WORKFLOW_COMPENSATED` | Every compensator succeeded |
+| `WORKFLOW_COMPENSATION_FAILED` | At least one compensator failed |
+| `NODE_COMPENSATING` | A step's compensator was enqueued |
+| `NODE_COMPENSATED` | A step's compensator finished successfully |
+| `NODE_COMPENSATION_FAILED` | A step's compensator failed after retries |
+
+
+
+## See also
+
+- Canvas primitives for
+ `chain` / `group` / `chord` shorthand pipelines
+- Fan-out / fan-in
+- Conditions & error handling
+ for the `on_failure` step condition used above
+- Approval gates
+- Saga Checkout example —
+ a complete, runnable version of the checkout flow above
diff --git a/docs/content/docs/shared/more/examples/benchmark.mdx b/docs/content/docs/shared/more/examples/benchmark.mdx
new file mode 100644
index 00000000..8ad545e8
--- /dev/null
+++ b/docs/content/docs/shared/more/examples/benchmark.mdx
@@ -0,0 +1,501 @@
+---
+title: Benchmark
+description: "A self-contained throughput benchmark — measure enqueue rate, processing rate, and end-to-end latency on your own hardware."
+---
+
+Numbers depend heavily on your machine, backend, and task body, so the only
+number that matters is the one you measure. Each SDK ships a self-contained
+benchmark that stages a batch of jobs, drains them with a worker, and reports
+the rates that bound a queue: enqueue throughput, processing throughput, and
+end-to-end latency.
+
+## The benchmark
+
+
+
+
+```python
+"""taskito throughput benchmark.
+
+Measures:
+1. Enqueue throughput (jobs/sec) using batch insert
+2. Processing throughput (jobs/sec) with N workers
+3. End-to-end latency
+"""
+
+import os
+import threading
+import time
+
+from taskito import Queue
+
+# ── Configuration ────────────────────────────────────────
+
+NUM_JOBS = 10_000
+NUM_WORKERS = os.cpu_count() or 4
+DB_PATH = ":memory:" # In-memory for pure speed test
+
+queue = Queue(db_path=DB_PATH, workers=NUM_WORKERS)
+
+@queue.task()
+def noop(x):
+ """Minimal task — measures framework overhead."""
+ return x
+
+@queue.task()
+def cpu_light(x):
+ """Light CPU work — string formatting."""
+ return f"processed-{x}-{'x' * 100}"
+
+# ── Benchmark Functions ──────────────────────────────────
+
+def bench_enqueue(task, n):
+ """Measure batch enqueue throughput."""
+ args_list = [(i,) for i in range(n)]
+
+ start = time.perf_counter()
+ task.map(args_list)
+ elapsed = time.perf_counter() - start
+
+ rate = n / elapsed
+ print(f" Enqueued {n:,} jobs in {elapsed:.2f}s ({rate:,.0f} jobs/s)")
+
+def bench_process(n, baseline, timeout=120):
+ """Measure processing throughput by polling stats until n jobs drain.
+
+ Waiting on a single job handle isn't enough — jobs don't finish in
+ enqueue order, so the counter (against a baseline from before this
+ round) is the only reliable "all done" signal.
+ """
+ start = time.perf_counter()
+ deadline = start + timeout
+ while time.perf_counter() < deadline:
+ stats = queue.stats()
+ if stats["failed"] or stats["dead"]:
+ print(f" Aborting — jobs failed during the run: {stats}")
+ return
+ if stats["completed"] - baseline >= n:
+ elapsed = time.perf_counter() - start
+ print(f" Processed {n:,} jobs in {elapsed:.2f}s ({n / elapsed:,.0f} jobs/s)")
+ return
+ time.sleep(0.02)
+ print(f" Timed out! Stats: {queue.stats()}")
+
+def bench_latency(task, samples=100):
+ """Measure single-job round-trip latency."""
+ latencies = []
+ for i in range(samples):
+ start = time.perf_counter()
+ job = task.delay(i)
+ job.result(timeout=10)
+ latencies.append(time.perf_counter() - start)
+
+ avg = sum(latencies) / len(latencies)
+ p50 = sorted(latencies)[len(latencies) // 2]
+ p99 = sorted(latencies)[int(len(latencies) * 0.99)]
+ print(f" Latency (n={samples}): avg={avg*1000:.1f}ms p50={p50*1000:.1f}ms p99={p99*1000:.1f}ms")
+
+# ── Main ─────────────────────────────────────────────────
+
+def main():
+ print(f"taskito benchmark")
+ print(f" Workers: {NUM_WORKERS}")
+ print(f" Jobs: {NUM_JOBS:,}")
+ print(f" DB: {DB_PATH}")
+ print()
+
+ # Stage the first batch BEFORE the worker starts, so the enqueue rate
+ # doesn't include worker/database contention — same methodology as the
+ # other-language benchmarks on this page.
+ print("── noop task (framework overhead) ──")
+ bench_enqueue(noop, NUM_JOBS)
+
+ worker_thread = threading.Thread(target=queue.run_worker, daemon=True)
+ worker_thread.start()
+ bench_process(NUM_JOBS, baseline=0)
+ print()
+
+ # The queue is fully drained here, so the running worker only
+ # idle-polls during this enqueue — negligible contention.
+ print("── cpu_light task ──")
+ baseline = queue.stats()["completed"]
+ bench_enqueue(cpu_light, NUM_JOBS)
+ bench_process(NUM_JOBS, baseline=baseline)
+ print()
+
+ print("── single-job latency ──")
+ bench_latency(noop)
+ print()
+
+ stats = queue.stats()
+ print(f"Final stats: {stats}")
+
+if __name__ == "__main__":
+ main()
+```
+
+
+
+
+```ts
+import { Queue } from "@byteveda/taskito";
+
+const N = 50_000;
+// Fresh database per run — leftover rows from a previous run would satisfy
+// the completed-count below early and skew the latency sample.
+const queue = new Queue({ dbPath: `bench-${process.pid}.db` });
+
+queue.task("noop", (_n: number) => undefined);
+
+// 1. Enqueue throughput — stage N jobs in batches of 1,000.
+const t0 = Date.now();
+for (let i = 0; i < N; i += 1_000) {
+ queue.enqueueMany("noop", Array.from({ length: 1_000 }, (_, k) => ({ args: [i + k] })));
+}
+const enqueueMs = Date.now() - t0;
+
+// 2. Processing throughput — drain the backlog, polling stats with a
+// deadline so a failed job or stuck worker can't hang the benchmark.
+const t1 = Date.now();
+const deadline = t1 + 120_000;
+const worker = queue.runWorker({ queues: ["default"], batchSize: 64, channelCapacity: 512 });
+let processMs = 0;
+try {
+ while (true) {
+ const stats = await queue.stats();
+ if (stats.failed > 0 || stats.dead > 0) {
+ throw new Error(`jobs failed during the run: ${JSON.stringify(stats)}`);
+ }
+ if (stats.completed >= N) {
+ processMs = Date.now() - t1;
+ break;
+ }
+ if (Date.now() > deadline) {
+ throw new Error(`drain timed out: ${JSON.stringify(stats)}`);
+ }
+ await new Promise((r) => setTimeout(r, 20));
+ }
+} finally {
+ worker.stop();
+}
+
+// 3. End-to-end latency — created → completed across a sample of jobs.
+const sample = await queue.listJobs({ status: "complete", limit: 1_000 });
+const latencies = sample
+ .filter((j) => j.completedAt)
+ .map((j) => (j.completedAt as number) - j.createdAt)
+ .sort((a, b) => a - b);
+const p50 = latencies[Math.floor(latencies.length * 0.5)];
+const p99 = latencies[Math.floor(latencies.length * 0.99)];
+
+console.log(`enqueue: ${Math.round(N / (enqueueMs / 1000))} jobs/s`);
+console.log(`process: ${Math.round(N / (processMs / 1000))} jobs/s`);
+console.log(`latency: p50 ${p50}ms p99 ${p99}ms`);
+```
+
+
+
+
+```java
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.byteveda.taskito.Taskito;
+import org.byteveda.taskito.model.Job;
+import org.byteveda.taskito.model.JobFilter;
+import org.byteveda.taskito.model.JobStatus;
+import org.byteveda.taskito.model.QueueStats;
+import org.byteveda.taskito.task.Task;
+import org.byteveda.taskito.worker.Worker;
+
+public final class Benchmark {
+ private static final int N = 50_000;
+ private static final Task NOOP = Task.of("noop", Integer.class);
+
+ public static void main(String[] args) throws Exception {
+ // Fresh database per run — a reused file skews stats with old rows.
+ Path dir = Files.createTempDirectory("taskito-bench");
+ try (Taskito taskito = Taskito.builder().sqlite(dir.resolve("bench.db").toString()).open()) {
+
+ // 1. Enqueue throughput — stage N jobs in batches of 1,000.
+ long t0 = System.currentTimeMillis();
+ for (int i = 0; i < N; i += 1_000) {
+ List batch = new ArrayList<>(1_000);
+ for (int k = 0; k < 1_000; k++) {
+ batch.add(i + k);
+ }
+ taskito.enqueueMany(NOOP, batch);
+ }
+ long enqueueMs = System.currentTimeMillis() - t0;
+
+ // 2. Processing throughput — drain the backlog, polling stats with a
+ // deadline so a failed job or stuck worker can't hang the benchmark.
+ long t1 = System.currentTimeMillis();
+ long deadline = t1 + 120_000;
+ long processMs;
+ try (Worker worker = taskito.worker()
+ .handle(NOOP, n -> null)
+ .concurrency(8)
+ .batchSize(64)
+ .channelCapacity(512)
+ .start()) {
+ QueueStats stats = taskito.stats();
+ while (stats.completed < N) {
+ if (stats.failed > 0 || stats.dead > 0) {
+ throw new IllegalStateException(
+ "jobs failed during the run: failed=" + stats.failed + " dead=" + stats.dead);
+ }
+ if (System.currentTimeMillis() > deadline) {
+ throw new IllegalStateException("drain timed out with " + stats.completed + "/" + N + " completed");
+ }
+ Thread.sleep(20);
+ stats = taskito.stats();
+ }
+ processMs = System.currentTimeMillis() - t1;
+ }
+
+ // 3. End-to-end latency — created → completed across a sample of jobs.
+ List latencies = taskito
+ .listJobs(JobFilter.builder().status(JobStatus.COMPLETE).limit(1_000).build())
+ .stream()
+ .filter(job -> job.completedAt != null)
+ .map(job -> job.completedAt - job.createdAt)
+ .sorted()
+ .toList();
+ long p50 = latencies.get((int) (latencies.size() * 0.5));
+ long p99 = latencies.get((int) (latencies.size() * 0.99));
+
+ System.out.printf("enqueue: %d jobs/s%n", Math.round(N / (enqueueMs / 1000.0)));
+ System.out.printf("process: %d jobs/s%n", Math.round(N / (processMs / 1000.0)));
+ System.out.printf("latency: p50 %dms p99 %dms%n", p50, p99);
+ }
+ }
+}
+```
+
+
+
+
+## Running it
+
+
+
+
+```bash
+python benchmark.py
+```
+
+
+
+
+```bash
+node benchmark.ts
+```
+
+
+
+
+```bash
+java -cp app.jar Benchmark
+```
+
+
+
+
+## Sample output
+
+Illustrative — measure your own:
+
+
+
+
+```
+taskito benchmark
+ Workers: 8
+ Jobs: 10,000
+ DB: :memory:
+
+── noop task (framework overhead) ──
+ Enqueued 10,000 jobs in 0.18s (55,556 jobs/s)
+ Processed 10,000 jobs in 2.41s (4,149 jobs/s)
+
+── cpu_light task ──
+ Enqueued 10,000 jobs in 0.19s (52,632 jobs/s)
+ Processed 10,000 jobs in 2.53s (3,953 jobs/s)
+
+── single-job latency ──
+ Latency (n=100): avg=1.2ms p50=1.1ms p99=3.4ms
+
+Final stats: {'pending': 0, 'running': 0, 'completed': 20100, 'failed': 0, 'dead': 0, 'cancelled': 0}
+```
+
+
+
+
+```
+enqueue: 120000 jobs/s
+process: 35000 jobs/s
+latency: p50 8ms p99 42ms
+```
+
+
+
+
+```
+enqueue: 110000 jobs/s
+process: 30000 jobs/s
+latency: p50 9ms p99 45ms
+```
+
+
+
+
+
+
+
+ Actual numbers depend on your hardware, Python version, and SQLite
+ configuration. The numbers above are from an 8-core machine with Python
+ 3.12.
+
+
+
+
+## What drives the numbers
+
+
+
+| Symptom | Config to change | Why |
+|---------|-----------------|-----|
+| Low throughput (I/O tasks) | Increase `workers` | More threads = more concurrent I/O |
+| Low throughput (CPU tasks) | Use `pool="prefork"` | Each process gets its own GIL |
+| High latency | Decrease `scheduler_poll_interval_ms` | Scheduler checks for ready jobs more often |
+| Database too busy | Increase `scheduler_poll_interval_ms` | Less frequent polling reduces DB load |
+| Memory growing | Set `result_ttl` | Auto-cleanup old results and metrics |
+| Jobs timing out | Increase `default_timeout` | Give tasks more time to complete |
+| Jobs piling up | Add more workers or use Postgres | SQLite single-writer limit may bottleneck |
+
+```python
+# More workers for I/O-bound tasks
+queue = Queue(workers=16)
+
+# Fewer workers for CPU-bound tasks (limited by GIL)
+queue = Queue(workers=4)
+
+# In-memory DB for maximum throughput (no persistence)
+queue = Queue(db_path=":memory:")
+
+# File DB for durability (slightly slower)
+queue = Queue(db_path="tasks.db")
+```
+
+
+
+
+
+| Lever | Effect |
+| --- | --- |
+| `enqueueMany` | one storage round-trip per batch instead of per job |
+| `batchSize` | jobs claimed per scheduler poll — fewer polls under load |
+| `channelCapacity` | in-flight buffer between the scheduler and the worker |
+| Backend | SQLite (WAL) for a single node; Postgres/Redis to scale out workers |
+| Task body | a real handler's I/O usually dominates — benchmark *your* task too |
+
+
+ The hot path is the Rust core — enqueue, dequeue, and result handling never
+ touch the JS event loop except to run your handler. Raise `batchSize` and
+ `channelCapacity` together to keep a busy worker saturated.
+
+
+
+
+
+
+| Lever | Effect |
+| --- | --- |
+| `enqueueMany` | one storage round-trip per batch instead of per job |
+| `batchSize` | jobs claimed per scheduler poll — fewer polls under load |
+| `channelCapacity` | in-flight buffer between the scheduler and the handler pool |
+| `concurrency` | handler threads draining the channel |
+| Backend | SQLite (WAL) for a single node; Postgres/Redis to scale out workers |
+| Task body | a real handler's I/O usually dominates — benchmark *your* task too |
+
+
+ The hot path is the Rust core — enqueue, dequeue, and result handling happen
+ natively; your JVM only runs the handler body. Raise `batchSize` and
+ `channelCapacity` together to keep a busy worker saturated.
+
+
+
+
+
+
+## Why it's fast
+
+| Component | How it helps |
+|---|---|
+| **Batch inserts** | `task.map()` inserts all jobs in a single SQLite transaction |
+| **WAL mode** | Concurrent reads while writing — workers don't block enqueue |
+| **Rust scheduler** | 50ms poll loop runs in native code, not Python |
+| **OS threads** | Workers are Rust `std::thread`, not Python threads |
+| **GIL per task** | GIL acquired only during Python task execution, released between tasks |
+| **tokio mpsc channels** | Bounded async dispatch to workers |
+| **r2d2 pool** | Up to 8 concurrent SQLite connections |
+| **Diesel ORM** | Compiled SQL queries, no runtime query building |
+
+## How it compares
+
+Rough directional comparison on the same hardware (8-core, single
+machine). These are not scientific benchmarks — run the benchmark above on
+your own hardware for accurate numbers.
+
+| Metric | taskito (SQLite) | taskito (Postgres) | Celery + Redis | Dramatiq + Redis |
+|--------|-----------------|-------------------|---------------|-----------------|
+| Enqueue throughput | ~55,000/s | ~20,000/s | ~5,000/s | ~3,000/s |
+| Processing (noop, 8 workers) | ~4,000/s | ~3,500/s | ~2,000/s | ~1,500/s |
+| p50 latency | 1.1ms | 2.5ms | 5–10ms | 8–15ms |
+| p99 latency | 3.4ms | 8ms | 20–50ms | 30–80ms |
+| Memory (idle worker) | ~30 MB | ~35 MB | ~80 MB | ~60 MB |
+| Setup | `pip install taskito` | + Postgres | + Redis + Celery | + Redis + Dramatiq |
+| External services | 0 | 1 (Postgres) | 2 (Redis + result backend) | 1 (Redis) |
+
+
+ Celery numbers are from public benchmarks and community reports. Your
+ mileage will vary depending on workload, serializer, and broker
+ configuration. Run your own benchmarks before making decisions.
+
+
+**Why is taskito faster?**
+
+- Rust scheduler avoids GIL contention — scheduling and dispatch never block Python
+- SQLite WAL mode with batch inserts — disk I/O is minimized
+- Direct DB polling — no broker hop (enqueue → DB → dequeue is one less network round-trip vs enqueue → Redis → dequeue)
+- OS thread pool with per-task GIL acquisition — no multiprocessing overhead for I/O-bound tasks
+
+
+
+
+
+## Key patterns
+
+| Pattern | Where |
+| --- | --- |
+| Batched staging | `queue.enqueueMany` |
+| Drain to a target | poll `await queue.stats()` for `.completed` |
+| Worker throughput knobs | `runWorker({ batchSize, channelCapacity })` |
+| Latency percentiles | `listJobs` + `completedAt - createdAt` |
+
+
+
+
+
+## Key patterns
+
+| Pattern | Where |
+| --- | --- |
+| Batched staging | `taskito.enqueueMany` |
+| Drain to a target | poll `taskito.stats().completed` |
+| Worker throughput knobs | `batchSize` + `channelCapacity` + `concurrency` |
+| Latency percentiles | `listJobs` + `completedAt − createdAt` |
+
+
diff --git a/docs/package.json b/docs/package.json
index 50a404d7..64845519 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -10,6 +10,7 @@
"start": "serve build/client",
"typecheck": "react-router typegen && tsc --noEmit",
"lint": "biome check",
+ "check:parity": "node scripts/parity/index.mjs",
"format": "biome format --write"
},
"dependencies": {
diff --git a/docs/scripts/parity/checks/code-tabs.mjs b/docs/scripts/parity/checks/code-tabs.mjs
new file mode 100644
index 00000000..d84e053f
--- /dev/null
+++ b/docs/scripts/parity/checks/code-tabs.mjs
@@ -0,0 +1,35 @@
+import { isSharedRelPath } from "../../../app/lib/doc-slugs.ts";
+import { SDK_IDS } from "../../../app/lib/sdk-registry.ts";
+
+// (a) Every block in a shared page must carry a for
+// every SDK — a missing tab renders as a silent hole for that SDK's readers.
+// Opt out per block with data-parity-exempt (e.g. a feature one SDK lacks).
+// Blocking: the shared tree starts empty, so there is no legacy debt.
+
+const BLOCK_RE = /]*>[\s\S]*?<\/CodeTabs>/g;
+const TAB_RE = /]*\ssdk="([a-z]+)"/g;
+
+export function checkCodeTabs(files) {
+ const errors = [];
+ for (const file of files) {
+ if (!isSharedRelPath(file.rel) || !file.rel.endsWith(".mdx")) {
+ continue;
+ }
+ for (const block of file.raw.match(BLOCK_RE) ?? []) {
+ if (block.includes("data-parity-exempt")) {
+ continue;
+ }
+ const present = new Set(
+ [...block.matchAll(TAB_RE)].map((match) => match[1]),
+ );
+ const missing = SDK_IDS.filter((sdk) => !present.has(sdk));
+ if (missing.length > 0) {
+ const heading = block.slice(0, 80).replace(/\s+/g, " ");
+ errors.push(
+ `${file.rel}: CodeTabs missing for [${missing.join(", ")}] near "${heading}…"`,
+ );
+ }
+ }
+ }
+ return { name: "CodeTabs SDK coverage (shared pages)", errors, report: [] };
+}
diff --git a/docs/scripts/parity/checks/collisions.mjs b/docs/scripts/parity/checks/collisions.mjs
new file mode 100644
index 00000000..6d2281ea
--- /dev/null
+++ b/docs/scripts/parity/checks/collisions.mjs
@@ -0,0 +1,37 @@
+import {
+ isSharedRelPath,
+ mountsForRelPath,
+} from "../../../app/lib/doc-slugs.ts";
+
+// (c) Two content files must never produce the same URL — a shared file and a
+// stale per-SDK file at one slug would silently shadow each other. The vite
+// manifest plugin enforces the same rule at build time; this duplicate gives a
+// friendlier pre-build message. Also warns on meta.json under shared/ (nav's
+// META glob would pick it up and confuse section resolution).
+
+export function checkCollisions(files) {
+ const errors = [];
+ const report = [];
+ const bySlug = new Map();
+ for (const file of files) {
+ if (!file.rel.endsWith(".mdx")) {
+ if (file.rel.endsWith("meta.json") && isSharedRelPath(file.rel)) {
+ report.push(
+ `WARN ${file.rel}: meta.json is not supported under shared/ — list shared pages in each SDK section's meta.json instead`,
+ );
+ }
+ continue;
+ }
+ for (const { slug } of mountsForRelPath(file.rel)) {
+ const existing = bySlug.get(slug);
+ if (existing) {
+ errors.push(
+ `slug ${slug} produced by both ${existing} and ${file.rel}`,
+ );
+ } else {
+ bySlug.set(slug, file.rel);
+ }
+ }
+ }
+ return { name: "Slug collisions", errors, report, slugs: bySlug };
+}
diff --git a/docs/scripts/parity/checks/drift.mjs b/docs/scripts/parity/checks/drift.mjs
new file mode 100644
index 00000000..ab7176d6
--- /dev/null
+++ b/docs/scripts/parity/checks/drift.mjs
@@ -0,0 +1,66 @@
+import { isSdk } from "../../../app/lib/sdk-registry.ts";
+import { wordCount } from "../content.mjs";
+
+// (b) Informational migration queue: the same topic living in ≥2 per-SDK trees
+// is drift-prone by construction. Report prose word counts and flag ratios
+// above DRIFT_RATIO as shared-file migration candidates, worst first. Never
+// fails the run — per-SDK topics are legitimate until migrated or exempted.
+
+const DRIFT_RATIO = 2;
+
+// Known filename mismatches for the same topic across SDK trees.
+const NAME_EQUIVALENTS = new Map([
+ ["guides/reliability/locking", "guides/reliability/locks"],
+ ["guides/workflows/sagas", "guides/workflows/saga"],
+ ["guides/operations/autoscaler", "guides/operations/autoscaling"],
+]);
+
+function topicOf(rel) {
+ const [head, ...rest] = rel.split("/");
+ if (!isSdk(head) || rest.length === 0) {
+ return null;
+ }
+ const topic = rest.join("/").replace(/\.mdx$/, "");
+ return { sdk: head, topic: NAME_EQUIVALENTS.get(topic) ?? topic };
+}
+
+export function checkDrift(files) {
+ const byTopic = new Map(); // topic → Map
+ for (const file of files) {
+ if (!file.rel.endsWith(".mdx")) {
+ continue;
+ }
+ const entry = topicOf(file.rel);
+ if (!entry) {
+ continue;
+ }
+ const perSdk = byTopic.get(entry.topic) ?? new Map();
+ perSdk.set(entry.sdk, wordCount(file.raw));
+ byTopic.set(entry.topic, perSdk);
+ }
+
+ const flagged = [];
+ for (const [topic, perSdk] of byTopic) {
+ if (perSdk.size < 2) {
+ continue;
+ }
+ const counts = [...perSdk.values()];
+ const ratio = Math.max(...counts) / Math.max(1, Math.min(...counts));
+ if (ratio > DRIFT_RATIO) {
+ const cells = [...perSdk]
+ .map(([sdk, words]) => `${sdk}:${words}`)
+ .join(" ");
+ flagged.push({ topic, ratio, line: `${topic} (${cells})` });
+ }
+ }
+ flagged.sort((a, b) => b.ratio - a.ratio);
+
+ const report =
+ flagged.length === 0
+ ? ["no drifted per-SDK topics — migration complete"]
+ : [
+ `${flagged.length} per-SDK topics exceed ${DRIFT_RATIO}x word-count drift (migration candidates, worst first):`,
+ ...flagged.map((f) => ` ${f.ratio.toFixed(1)}x ${f.line}`),
+ ];
+ return { name: "Drift report (informational)", errors: [], report };
+}
diff --git a/docs/scripts/parity/checks/redirect-shadowing.mjs b/docs/scripts/parity/checks/redirect-shadowing.mjs
new file mode 100644
index 00000000..eec128f6
--- /dev/null
+++ b/docs/scripts/parity/checks/redirect-shadowing.mjs
@@ -0,0 +1,18 @@
+import { REDIRECTS } from "../../../app/lib/redirects.ts";
+
+// (d) The docs route resolves redirects BEFORE page loaders, so a REDIRECTS
+// key that equals a real page slug makes that page silently unreachable.
+// This bites exactly during name normalization (new shared page + redirect
+// from the retired per-SDK name) — fail loudly instead.
+
+export function checkRedirectShadowing(slugs) {
+ const errors = [];
+ for (const from of Object.keys(REDIRECTS)) {
+ if (slugs.has(from)) {
+ errors.push(
+ `redirect source ${from} shadows the page from ${slugs.get(from)} (redirects win — the page is unreachable)`,
+ );
+ }
+ }
+ return { name: "Redirect shadowing", errors, report: [] };
+}
diff --git a/docs/scripts/parity/content.mjs b/docs/scripts/parity/content.mjs
new file mode 100644
index 00000000..f9fc246e
--- /dev/null
+++ b/docs/scripts/parity/content.mjs
@@ -0,0 +1,40 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+
+// Content-tree access shared by every parity check. Slug/fan-out logic is NOT
+// reimplemented here — checks import it from app/lib/doc-slugs.ts (Node strips
+// the erasable TS types at import time), so the CI checks and the site always
+// agree on how files map to URLs.
+
+export const CONTENT_DIR = fileURLToPath(
+ new URL("../../content/docs", import.meta.url),
+);
+
+/** One content file: posix content-relative path + raw text. */
+export function loadContentFiles() {
+ const files = [];
+ const walk = (dir) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (entry.name.endsWith(".mdx") || entry.name === "meta.json") {
+ files.push({
+ rel: relative(CONTENT_DIR, full).split(sep).join("/"),
+ raw: readFileSync(full, "utf8"),
+ });
+ }
+ }
+ };
+ walk(CONTENT_DIR);
+ return files;
+}
+
+/** Prose word count of an MDX body — frontmatter and code fences stripped. */
+export function wordCount(raw) {
+ const body = raw
+ .replace(/^---\n[\s\S]*?\n---\n/, "")
+ .replace(/```[\s\S]*?```/g, " ");
+ return body.split(/\s+/).filter(Boolean).length;
+}
diff --git a/docs/scripts/parity/index.mjs b/docs/scripts/parity/index.mjs
new file mode 100644
index 00000000..ce2d873a
--- /dev/null
+++ b/docs/scripts/parity/index.mjs
@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+import { checkCodeTabs } from "./checks/code-tabs.mjs";
+import { checkCollisions } from "./checks/collisions.mjs";
+import { checkDrift } from "./checks/drift.mjs";
+import { checkRedirectShadowing } from "./checks/redirect-shadowing.mjs";
+import { loadContentFiles } from "./content.mjs";
+
+// Content-parity gate for the docs site (run: pnpm check:parity).
+// Blocking: CodeTabs SDK coverage on shared pages, slug collisions, redirect
+// shadowing. Informational: per-SDK drift report (the migration queue).
+
+const files = loadContentFiles();
+const collisions = checkCollisions(files);
+const results = [
+ checkCodeTabs(files),
+ collisions,
+ checkRedirectShadowing(collisions.slugs),
+ checkDrift(files),
+];
+
+let failed = false;
+for (const { name, errors, report } of results) {
+ const status = errors.length > 0 ? "FAIL" : "ok";
+ console.log(`\n== ${name}: ${status}`);
+ for (const line of report) {
+ console.log(line);
+ }
+ for (const error of errors) {
+ console.error(`ERROR ${error}`);
+ failed = true;
+ }
+}
+
+process.exit(failed ? 1 : 0);
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
index d3533f57..3358faad 100644
--- a/docs/tsconfig.json
+++ b/docs/tsconfig.json
@@ -18,6 +18,7 @@
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
+ "allowImportingTsExtensions": true,
"allowJs": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
diff --git a/docs/vite-plugin-docs-manifest.ts b/docs/vite-plugin-docs-manifest.ts
index b5d766bd..f7accf65 100644
--- a/docs/vite-plugin-docs-manifest.ts
+++ b/docs/vite-plugin-docs-manifest.ts
@@ -2,10 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
+import { mountsForRelPath } from "./app/lib/doc-slugs.ts";
-// Build-time manifest of doc pages: { slug, title, description } only — NO compiled
-// components. nav/search/llms import this (via `virtual:docs-manifest`) instead of the
-// component glob, so they never pull the 128 shiki-inflated MDX modules into a chunk.
+// Build-time manifest of doc pages: { slug, title, description, canonical? } only —
+// NO compiled components. nav/search/llms import this (via `virtual:docs-manifest`)
+// instead of the component glob, so they never pull the shiki-inflated MDX modules
+// into a chunk. Shared files emit one entry per SDK mount (same frontmatter).
const CONTENT_DIR = fileURLToPath(new URL("./content/docs", import.meta.url));
const VIRTUAL_ID = "virtual:docs-manifest";
@@ -15,15 +17,8 @@ export interface DocMeta {
slug: string;
title: string;
description: string;
-}
-
-function slugOf(absFile: string): string {
- const rel = relative(CONTENT_DIR, absFile).replace(/\.mdx$/, "");
- const parts = rel.split(sep);
- if (parts[parts.length - 1] === "index") {
- parts.pop();
- }
- return `/${parts.join("/")}`.replace(/\/$/, "") || "/";
+ /** Default-SDK URL of a shared page; present only on fan-out mounts. */
+ canonical?: string;
}
function parseFrontmatter(raw: string): { title: string; description: string } {
@@ -48,15 +43,25 @@ function walk(dir: string, out: string[]): void {
function buildManifest(): DocMeta[] {
const files: string[] = [];
walk(CONTENT_DIR, files);
- return files
- .map((file) => {
- const { title, description } = parseFrontmatter(
- readFileSync(file, "utf8"),
- );
- const slug = slugOf(file);
- return { slug, title: title || slug, description };
- })
- .sort((a, b) => a.slug.localeCompare(b.slug));
+ const sources = new Map(); // slug → content-relative source file
+ const metas: DocMeta[] = [];
+ for (const file of files) {
+ const { title, description } = parseFrontmatter(readFileSync(file, "utf8"));
+ const rel = relative(CONTENT_DIR, file).split(sep).join("/");
+ for (const { slug, canonical } of mountsForRelPath(rel)) {
+ const existing = sources.get(slug);
+ if (existing) {
+ // A shared file and a per-SDK file at the same URL would silently
+ // shadow each other and reintroduce drift — fail the build instead.
+ throw new Error(
+ `docs-manifest: slug ${slug} is produced by both ${existing} and ${rel}`,
+ );
+ }
+ sources.set(slug, rel);
+ metas.push({ slug, title: title || slug, description, canonical });
+ }
+ }
+ return metas.sort((a, b) => a.slug.localeCompare(b.slug));
}
export function docsManifest(): Plugin {