Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/app/components/landing/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ export function Footer() {
</span>
</span>
<p>
Rust-powered, brokerless task queue for Python and Node.js. No
Redis, no RabbitMQ.
A Rust-powered task queue with native SDKs. One engine, no broker
required — just SQLite, Postgres, or Redis.
</p>
</div>
{COLS.map((col) => (
Expand Down
20 changes: 15 additions & 5 deletions docs/app/components/landing/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
INTEGRATIONS,
USE_CASES,
} from "@/lib/landing-content";
import { VERSION } from "@/lib/version";

function Icon({ d, rect }: { d: string; rect?: boolean }) {
return (
Expand Down Expand Up @@ -140,7 +141,7 @@ export function HowItWorks() {
to result
</>
}
lead="Your Python or Node code enqueues a job. The Rust scheduler hands it to a worker. The result lands back in the shared store — same core, same queue, no broker in the middle."
lead="Your application code enqueues a job. The Rust scheduler hands it to a worker. The result lands back in the shared store — same core, same queue, no broker in the middle, whichever SDK you called it from."
/>
<div className="diagram reveal">
<div className="flowdiag">
Expand Down Expand Up @@ -373,11 +374,16 @@ export function Integrations() {
);
}

function InstallPill({ cmd }: { cmd: string }) {
/**
* `prompt` is the shell `$` by default. Pass `""` for something copied into a
* build file rather than run — a Gradle coordinate under a `$` would read as a
* command that does not exist.
*/
function InstallPill({ cmd, prompt = "$" }: { cmd: string; prompt?: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="install-pill">
<span className="pf">$</span>
{prompt ? <span className="pf">{prompt}</span> : null}
{cmd}
<button
type="button"
Expand Down Expand Up @@ -406,12 +412,16 @@ export function CTA() {
<h2>Five minutes from install to your first job.</h2>
<p>
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.
</p>
<div className="install-row">
<InstallPill cmd="pip install taskito" />
<InstallPill cmd="pnpm add @byteveda/taskito" />
<InstallPill
cmd={`implementation("org.byteveda:taskito:${VERSION}")`}
prompt=""
/>
</div>
<div className="btns">
<Link className="btn pri" to={`/${sdk}/getting-started/quickstart`}>
Expand Down
6 changes: 3 additions & 3 deletions docs/app/lib/landing-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b>Python</b> and <b>Node.js</b> 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 <b>Python</b>, <b>Node.js</b> and <b>Java</b> 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",
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/java/guides/operations/meta.json
Original file line number Diff line number Diff line change
@@ -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"] }
2 changes: 1 addition & 1 deletion docs/content/docs/node/guides/operations/meta.json
Original file line number Diff line number Diff line change
@@ -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"] }
1 change: 1 addition & 0 deletions docs/content/docs/python/guides/operations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"troubleshooting",
"security",
"deployment",
"executor",
"mesh",
"autoscaling",
"keda",
Expand Down
198 changes: 198 additions & 0 deletions docs/content/docs/shared/guides/operations/executor.mdx
Original file line number Diff line number Diff line change
@@ -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 │ <app entrypoint> │
│ retry / DLQ / breaker │ out │ + taskito executor │
│ retention / reaper │────────▶│ │
└────────────────────────┘ jobs └──────────────────────────────┘
storage (source of truth)
```

<Callout type="info" title="This is opt-in">
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.
</Callout>

## 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

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

<CodeTabs>
<Tab sdk="python">

```bash
TASKITO_ATTACH=scheduler:7749 \
TASKITO_ATTACH_TOKEN=... \
taskito executor --app myapp:queue --slots 4
```

</Tab>
<Tab sdk="node">

```bash
TASKITO_ATTACH=scheduler:7749 \
TASKITO_ATTACH_TOKEN=... \
taskito executor ./app.js --slots 4
```

</Tab>
<Tab sdk="java">

```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.

</Tab>
</CodeTabs>

`--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.

<Callout type="warn" title="The token is read from the environment, never a flag">
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.
</Callout>

## 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.
Comment thread
stromanni marked this conversation as resolved.

<Callout type="warn">
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.
</Callout>

## 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:

<CodeTabs>
<Tab sdk="python">

```python
@queue.task()
def resize(path: str) -> str:
current_job.update_progress(50)
current_job.log("halfway")
current_job.publish({"stage": "halfway"})
return "done"
```

</Tab>
<Tab sdk="node">

```ts
queue.task("resize", async (path: string) => {
const job = currentJob();
job?.setProgress(50);
job?.publish({ stage: "halfway" });
return "done";
});
```

</Tab>
<Tab sdk="java">

```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";
}
```

</Tab>
</CodeTabs>

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 |
Loading