The quickstart walks you through defining a task, enqueuing it, and
- watching the worker run it — in Python or Node, no Redis, no broker,
- no config.
+ watching the worker run it — in the SDK you already use, no Redis, no
+ broker, no config.
diff --git a/docs/app/lib/landing-content.ts b/docs/app/lib/landing-content.ts
index 508458d8e..70167a596 100644
--- a/docs/app/lib/landing-content.ts
+++ b/docs/app/lib/landing-content.ts
@@ -150,12 +150,12 @@ export const FEATURES: IconCard[] = [
icon: "M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3",
rect: true,
title: "Rust-powered",
- body: "The scheduler, dispatcher, and storage engine are all Rust. Tokio runtime, OS-thread worker pool; thin PyO3 and napi-rs boundaries keep Python and Node overhead negligible.",
+ body: "The scheduler, dispatcher, and storage engine are all Rust. Tokio runtime, OS-thread worker pool; thin PyO3, napi-rs and JNI boundaries keep per-SDK overhead negligible.",
},
{
icon: "M22 12h-4l-3 9L9 3l-3 9H2",
- title: "One core, two SDKs",
- body: "First-class
Python and
Node.js clients are peers over the same Rust core and store — enqueue in one runtime, run workers in the other. Zero cross-language dependency.",
+ title: "One core, native SDKs",
+ body: "First-class
Python ,
Node.js and
Java clients are peers over the same Rust core and store — enqueue in one runtime, run workers in another. Zero cross-language dependency.",
},
{
icon: "M6 3v12M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM15 6a9 9 0 0 0-9 9",
diff --git a/docs/content/docs/java/guides/operations/meta.json b/docs/content/docs/java/guides/operations/meta.json
index 66d2416bd..74c93d37a 100644
--- a/docs/content/docs/java/guides/operations/meta.json
+++ b/docs/content/docs/java/guides/operations/meta.json
@@ -1 +1 @@
-{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "sso", "mesh", "autoscaling", "keda", "cli", "testing", "security", "troubleshooting", "deployment", "graalvm"] }
+{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "sso", "mesh", "autoscaling", "keda", "cli", "testing", "security", "troubleshooting", "deployment", "executor", "graalvm"] }
diff --git a/docs/content/docs/node/guides/operations/meta.json b/docs/content/docs/node/guides/operations/meta.json
index fd9d6580d..eef4bb7f7 100644
--- a/docs/content/docs/node/guides/operations/meta.json
+++ b/docs/content/docs/node/guides/operations/meta.json
@@ -1 +1 @@
-{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "dashboard-api", "sso", "mesh", "keda", "autoscaling", "cli", "testing", "security", "troubleshooting", "deployment", "migration"] }
+{ "title": "Operations", "pages": ["backends", "inspection", "dashboard", "dashboard-api", "sso", "mesh", "keda", "autoscaling", "cli", "testing", "security", "troubleshooting", "deployment", "executor", "migration"] }
diff --git a/docs/content/docs/python/guides/operations/meta.json b/docs/content/docs/python/guides/operations/meta.json
index bfab38e25..8066eaeff 100644
--- a/docs/content/docs/python/guides/operations/meta.json
+++ b/docs/content/docs/python/guides/operations/meta.json
@@ -7,6 +7,7 @@
"troubleshooting",
"security",
"deployment",
+ "executor",
"mesh",
"autoscaling",
"keda",
diff --git a/docs/content/docs/shared/guides/operations/executor.mdx b/docs/content/docs/shared/guides/operations/executor.mdx
new file mode 100644
index 000000000..816320e5f
--- /dev/null
+++ b/docs/content/docs/shared/guides/operations/executor.mdx
@@ -0,0 +1,198 @@
+---
+title: Attached Executors
+description: "Run task bodies in your app container while a separate scheduler holds the database credentials."
+---
+
+An **attached executor** splits a worker in two. The scheduler — `taskito-server`
+— holds the database connection, claims jobs, and owns retries, the dead-letter
+queue and retention. Your app container runs `taskito executor`, which dials the
+scheduler, announces the tasks it can run, and executes whatever it is sent.
+
+The app image needs no database credentials and no inbound port.
+
+```
+┌────────────────────────┐ ┌──────────────────────────────┐
+│ taskito-server │◀────────│ app container (unchanged) │
+│ scheduler + dashboard │ dial │
│
+│ retry / DLQ / breaker │ out │ + taskito executor │
+│ retention / reaper │────────▶│ │
+└────────────────────────┘ jobs └──────────────────────────────┘
+ │
+ storage (source of truth)
+```
+
+
+ An in-process worker stays the default and the simplest deployment. Reach for
+ an executor when the app image is large enough that running a second copy of
+ it as a worker is the expensive part — an interpreter or JVM plus model
+ weights resident twice, and a multi-gigabyte image pull on every scale-up.
+
+
+## Running the scheduler
+
+`taskito-server` is configured entirely through the environment — there are no
+flags. At minimum it needs a DSN and an attach address:
+
+```bash
+TASKITO_DSN=postgres://user:pass@db/taskito \
+TASKITO_LISTEN=0.0.0.0:7749 \
+TASKITO_ATTACH_TOKEN=$(openssl rand -hex 32) \
+taskito-server
+```
+
+The scheduler starts lazily, on the **first attach**. With nothing attached it
+would claim jobs no executor advertises, and each would fail retryably once the
+placement timeout elapsed — a retry storm against an idle deployment.
+
+## Running an executor
+
+
+
+
+```bash
+TASKITO_ATTACH=scheduler:7749 \
+TASKITO_ATTACH_TOKEN=... \
+taskito executor --app myapp:queue --slots 4
+```
+
+
+
+
+```bash
+TASKITO_ATTACH=scheduler:7749 \
+TASKITO_ATTACH_TOKEN=... \
+taskito executor ./app.js --slots 4
+```
+
+
+
+
+```bash
+TASKITO_ATTACH=scheduler:7749 \
+TASKITO_ATTACH_TOKEN=... \
+java -cp app.jar org.byteveda.taskito.cli.Cli executor --slots 4
+```
+
+Handlers are discovered from the classpath through `META-INF/services`, so no
+application `main` has to run to register them.
+
+
+
+
+`--slots` is how many jobs run at once; the scheduler dispatches a task name
+only to executors that advertised it, so a handler missing from an executor is a
+job that never reaches it.
+
+
+ A secret in `argv` shows up in `ps` output and shell history. Every SDK reads
+ `TASKITO_ATTACH_TOKEN` from the environment and offers no flag for it.
+
+
+## Security
+
+The attach port dispatches code, so the defaults are strict:
+
+- A **non-loopback** `TASKITO_LISTEN` refuses to start without
+ `TASKITO_ATTACH_TOKEN`.
+- The shared secret is compared in constant time, and never logged — an attach
+ failure is logged with the executor id and peer only.
+- `hello` must arrive before any other frame; an unauthenticated socket is
+ dropped rather than queued, and never receives an acknowledgement.
+- Use a Unix socket (`unix:/run/taskito.sock`) for a same-pod sidecar; it needs
+ no token because the filesystem permissions are the boundary.
+
+
+The token is a bearer credential, not transport security. It proves who is
+attaching; it does not encrypt or integrity-protect the frames, and those frames
+carry task payloads on a port that dispatches code. On anything but loopback or
+a Unix socket, terminate mTLS in a proxy in front of the listener and treat the
+token as the second factor.
+
+`taskito-server` does not terminate TLS itself. Setting
+`TASKITO_LISTEN_TLS_CERT` or `TASKITO_LISTEN_TLS_KEY` fails at startup rather
+than being ignored, so a deployment cannot come up believing it is encrypted
+when it is not.
+
+
+## What a task can do on an executor
+
+A task body behaves the same as it does on an in-process worker. Progress, log
+lines and published partials all work — the executor has no storage, so it
+reports them to the scheduler, which applies them:
+
+
+
+
+```python
+@queue.task()
+def resize(path: str) -> str:
+ current_job.update_progress(50)
+ current_job.log("halfway")
+ current_job.publish({"stage": "halfway"})
+ return "done"
+```
+
+
+
+
+```ts
+queue.task("resize", async (path: string) => {
+ const job = currentJob();
+ job?.setProgress(50);
+ job?.publish({ stage: "halfway" });
+ return "done";
+});
+```
+
+
+
+
+```java
+@TaskHandler("resize")
+public String resize(String path) {
+ JobContext job = JobContext.current();
+ job.setProgress(50);
+ job.log("halfway");
+ job.publish(Map.of("stage", "halfway"));
+ return "done";
+}
+```
+
+
+
+
+Middleware disabled from the dashboard is honoured too. The executor cannot read
+settings, so the scheduler resolves the list and attaches it to each dispatch —
+a toggle still takes effect on the next job, with nothing to restart.
+
+### What is not available
+
+Anything that needs the database directly. An enqueue, a workflow submission or
+a queue inspection raises rather than silently doing nothing, because an enqueue
+that quietly vanished would be worse than one that failed.
+
+A handful of job fields also arrive as zeros and nulls, because a dispatch frame
+carries what running the task needs rather than the whole row: `created_at`,
+`scheduled_at`, `priority`, `unique_key` and `notes`. `metadata` is the
+exception — middleware reads it, so it rides the frame. A task that needs the
+rest wants a worker, not an executor.
+
+## Version skew
+
+The scheduler and its executors upgrade independently. The handshake carries a
+capability list rather than a version both sides must match, so a newer executor
+attached to an older scheduler never sends a frame that scheduler could not
+parse — it degrades instead.
+
+Concretely: a scheduler that does not advertise `side_channel` gets no progress
+or task-log frames, and those calls become no-ops (logged once). Everything else
+— dispatch, results, retries, cancellation — is unaffected. The executor logs
+which capabilities it negotiated at attach.
+
+## Trade-offs
+
+| Cost | Mitigation |
+|---|---|
+| CPU-heavy tasks compete with request latency in the app container | Cap `--slots`; or run a second replica set from the same image with the HTTP server off |
+| A network hop per job | Use a Unix socket for same-pod sidecars; attach is a poor fit for microsecond-scale tasks |
+| The attach port dispatches code | A Unix socket where the peer is same-pod; off loopback, proxy-terminated mTLS *and* the token, never the token alone |