treadle 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timileyin Pelumi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # Treadle
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Background jobs and step workflows for Bun and Postgres. Jobs live in one table and commit inside your own transaction, so you can run a queue on the database you already have.
4
+
5
+ bun add treadle
6
+
7
+ Needs Bun 1.3 or later and Postgres 12 or later. Uses Bun's built in SQL client; there are no runtime dependencies.
8
+
9
+ ## Example
10
+
11
+ ```ts
12
+ import { SQL } from "bun";
13
+ import { dashboard, migrate, Treadle, Worker } from "treadle";
14
+
15
+ const sql = new SQL(process.env.DATABASE_URL!);
16
+ await migrate(sql);
17
+ const treadle = new Treadle(sql);
18
+
19
+ // Enqueue inside your own transaction. The job commits with your data or not at all.
20
+ await sql.begin(async (tx) => {
21
+ await tx`insert into withdrawals (id, amount) values (${id}, ${amount})`;
22
+ await treadle.enqueue(tx, "notify", { id }, { idempotencyKey: `notify:${id}` });
23
+ await treadle.startWorkflow(tx, "withdraw", { id });
24
+ });
25
+
26
+ const worker = new Worker(sql, { queues: ["default"], concurrency: 10 });
27
+ worker.register("notify", async ({ id }) => { /* send the email */ });
28
+ worker.registerWorkflow("withdraw", [
29
+ { name: "reserve", run: async ({ id }) => ({ holdId: await reserve(id) }) },
30
+ { name: "send", run: async ({ id }, results) => ({ ref: await send(id, results.reserve.holdId) }) },
31
+ { name: "settle", run: async ({ id }, results) => { await settle(id, results.send.ref); } },
32
+ ]);
33
+ await worker.start();
34
+
35
+ Bun.serve({ port: 3000, fetch: dashboard(sql, { basePath: "/admin/jobs" }) });
36
+ ```
37
+
38
+ ## Documentation
39
+
40
+ - [Getting started](docs/getting-started.md)
41
+ - [Concepts](docs/concepts.md): the guarantees and what they ask of your handlers
42
+ - [Use cases](docs/use-cases/README.md): signup emails, webhooks, withdrawals, order fulfilment, periodic jobs, media, bulk import, rate-limited APIs, multi-tenant work, each with a runnable example
43
+ - [Operations](docs/operations.md): running workers in production
44
+ - [API reference](docs/api.md)
45
+ - [When to use it, and when not to](docs/comparison.md)
46
+ - [Changelog](CHANGELOG.md)
47
+
48
+ ## What it guarantees
49
+
50
+ - A job enqueued inside a transaction runs at least once if that transaction commits, and never if it rolls back. The jobs table is the transactional outbox.
51
+ - A workflow that crashes resumes at the first step without a persisted result. Completed steps are not run again.
52
+ - Two enqueues with the same idempotency key produce one job, under any concurrency. A thousand simultaneous enqueues of one key yield one row.
53
+ - A worker that dies mid-job loses at most one lease duration before another worker picks the job up.
54
+
55
+ Execution is at least once. A handler can run twice if the process dies between the handler finishing and the completion write. Make side effects idempotent, or key them on `ctx.jobId` and `ctx.attempt`.
56
+
57
+ ## How it works
58
+
59
+ Workers claim jobs with `select ... for update skip locked` in one statement that also sets a lease and a worker id, so any number of workers pull from one table without blocking or double-claiming. A heartbeat extends the lease while a handler runs; a rescuer returns expired leases to the queue. Every write after the claim is guarded by the worker id, so a worker that lost its lease cannot overwrite what a rescuer or another worker did. Failed jobs back off exponentially with jitter and are discarded after `maxAttempts`. Finishing a job, including re-enqueueing a periodic job or the next workflow step, is a single statement, so it commits atomically without a transaction round trip. The claim index is partial on claimable states, so completed rows do not slow claims down.
60
+
61
+ ## Numbers
62
+
63
+ Measured on a laptop with Postgres on the same machine, one process per worker, empty handlers. Relative figures transfer; absolute ones depend on hardware.
64
+
65
+ | Measurement | Result |
66
+ |---|---|
67
+ | 1 worker, concurrency 10 | 863 jobs/s |
68
+ | 8 workers, concurrency 10 | 4750 jobs/s |
69
+ | Claim latency, 10k rows | p50 2.6 ms, p99 6.4 ms |
70
+ | Claim latency, 1M rows, 999k completed | p50 2.1 ms, p99 8.2 ms |
71
+ | Claim latency, 1M rows all waiting | p50 2.4 ms, p99 7.3 ms |
72
+ | Recovery after a worker is killed, 5 s lease | 5.1 s |
73
+ | 1000 concurrent enqueues of one idempotency key | 1 row, 167 ms |
74
+
75
+ One known slow case: a large backlog of jobs a worker has no handler for, in the same queue it claims from, makes each claim walk past them. Use a queue per worker type.
76
+
77
+ ## API
78
+
79
+ - `migrate(sql)` creates or updates the `treadle` schema. Safe to call from every process at startup.
80
+ - `new Treadle(sql)` with `enqueue(tx, name, args, options)`, `startWorkflow(tx, name, input, options)`, `cancel(jobId)`, `retry(jobId)`, `cancelWorkflow(runId)`.
81
+ - Enqueue options: `queue`, `priority`, `runAt`, `maxAttempts`, `idempotencyKey`, `every` (milliseconds, for periodic jobs).
82
+ - `new Worker(sql, options)` with `register(name, handler)`, `registerWorkflow(name, steps)`, `start()`, `stop()`. Options: `queues`, `concurrency`, `pollIntervalMs`, `leaseMs`, `heartbeatMs`, `rescueIntervalMs`, `stopTimeoutMs`, `backoff`, `onError`, `onEvent`.
83
+ - Handlers receive `(args, ctx)` with `ctx.jobId`, `ctx.attempt`, `ctx.signal` (aborted on cancel or lost lease), and `ctx.heartbeat()`.
84
+ - Steps receive `(input, results, ctx)` where `results` holds earlier steps' return values keyed by step name.
85
+ - `dashboard(sql, { basePath })` returns a fetch handler. Mount it behind your own auth. `#run=<id>` in the URL opens that run.
86
+
87
+ Job ids and run ids are strings, because Postgres bigint does not fit in a JavaScript number.
88
+
89
+ ## Benchmarks
90
+
91
+ WORKERS=4 JOBS=10000 bun run load
92
+ ROWS=1000000 SAMPLES=1000 bun run claim-latency
93
+ LEASE_MS=5000 bun run recovery
94
+ N=1000 bun run storm
95
+
96
+ All use the `treadle_load` database on the same Postgres and drop the treadle schema there first.
97
+
98
+ ## Develop
99
+
100
+ Needs a Postgres reachable at `DATABASE_URL`, default `postgres://postgres:postgres@127.0.0.1:5432/treadle_test`. `docker compose up -d` provides one.
101
+
102
+ bun install
103
+ bun test
104
+
105
+ Tests run against the real database. Five of them kill a child worker process with SIGKILL at chosen points and check what recovery does.
106
+
107
+ ## Licence
108
+
109
+ MIT
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "treadle",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Background jobs and step workflows for Bun and Postgres",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "types": "./src/index.ts",
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "timileyinpelumi",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/timileyinpelumi/treadle.git"
20
+ },
21
+ "homepage": "https://github.com/timileyinpelumi/treadle",
22
+ "keywords": [
23
+ "bun",
24
+ "postgres",
25
+ "job-queue",
26
+ "background-jobs",
27
+ "workflow",
28
+ "outbox",
29
+ "skip-locked"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.3.0"
33
+ },
34
+ "scripts": {
35
+ "test": "bun test --timeout 20000",
36
+ "typecheck": "bunx tsc --noEmit",
37
+ "load": "bun scripts/load.ts",
38
+ "claim-latency": "bun scripts/claim-latency.ts",
39
+ "recovery": "bun scripts/recovery.ts",
40
+ "storm": "bun scripts/storm.ts",
41
+ "prepublishOnly": "bun test && bun run typecheck"
42
+ },
43
+ "devDependencies": {
44
+ "@types/bun": "^1.3.14",
45
+ "typescript": "^5.6.0"
46
+ }
47
+ }
package/src/backoff.ts ADDED
@@ -0,0 +1,6 @@
1
+ const MAX_MS = 3_600_000;
2
+
3
+ export function defaultBackoff(attempt: number): number {
4
+ const base = 2 ** attempt * 1000;
5
+ return Math.min(base + Math.random() * 1000, MAX_MS);
6
+ }
@@ -0,0 +1,129 @@
1
+ export const PAGE = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Treadle</title>
7
+ <style>
8
+ :root { --ink: #16181d; --mute: #6b7078; --rule: #d9dce2; --ground: #f6f7f9; --bad: #9b2c2c; --good: #2f6b4f; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; background: var(--ground); color: var(--ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
11
+ main { max-width: 72rem; margin: 0 auto; padding: 1.5rem; }
12
+ header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
13
+ h1 { margin: 0; font-size: 1.25rem; font-weight: 600; }
14
+ header span { color: var(--mute); font-size: 0.85rem; }
15
+ h2 { margin: 2rem 0 0.5rem; font-size: 0.95rem; font-weight: 600; }
16
+ .states { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid var(--ink); }
17
+ .states div { padding: 0.75rem 0; border-bottom: 1px solid var(--rule); }
18
+ .states b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.1; }
19
+ .states span { color: var(--mute); font-size: 0.85rem; }
20
+ table { width: 100%; border-collapse: collapse; border-top: 1px solid var(--ink); }
21
+ th, td { text-align: left; padding: 0.5rem 0.5rem 0.5rem 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
22
+ th { font-weight: 500; color: var(--mute); font-size: 0.85rem; }
23
+ td.num, th.num { text-align: right; padding-right: 1rem; }
24
+ .err { color: var(--bad); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; white-space: pre-wrap; max-width: 40rem; }
25
+ button { font: inherit; font-size: 0.85rem; color: var(--ink); background: none; border: 1px solid var(--rule); border-radius: 3px; padding: 0.15rem 0.5rem; cursor: pointer; margin-right: 0.25rem; }
26
+ button:hover { border-color: var(--ink); }
27
+ .minutes { display: flex; align-items: flex-end; gap: 2px; height: 3rem; border-bottom: 1px solid var(--rule); padding-bottom: 2px; }
28
+ .minutes div { flex: 1; display: flex; flex-direction: column-reverse; height: 100%; }
29
+ .minutes i { display: block; background: var(--good); }
30
+ .minutes i.bad { background: var(--bad); }
31
+ .steps { margin: 0.25rem 0 0.5rem 1rem; }
32
+ .steps div { padding: 0.25rem 0; border-bottom: 1px dashed var(--rule); }
33
+ .state-completed { color: var(--good); } .state-discarded, .state-failed { color: var(--bad); } .state-cancelled { color: var(--mute); }
34
+ .empty { color: var(--mute); padding: 0.75rem 0; }
35
+ a.run { cursor: pointer; text-decoration: underline; }
36
+ @media (max-width: 640px) { .states { grid-template-columns: repeat(3, 1fr); } }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <main>
41
+ <header><h1>Treadle</h1><span id="updated">Loading</span></header>
42
+ <div class="states" id="states"></div>
43
+ <div id="queues"></div>
44
+ <h2>Finished per minute, last hour</h2>
45
+ <div class="minutes" id="minutes"></div>
46
+ <h2>Recent failures</h2>
47
+ <div id="failures"></div>
48
+ <h2>Workflow runs</h2>
49
+ <div id="runs"></div>
50
+ </main>
51
+ <script>
52
+ const STATES = ["available", "running", "retryable", "completed", "discarded", "cancelled"];
53
+ const open = new Set();
54
+ if (location.hash.startsWith("#run=")) open.add(location.hash.slice(5));
55
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
56
+ const el = (id) => document.getElementById(id);
57
+
58
+ async function act(path) {
59
+ const r = await fetch("api/" + path, { method: "POST" });
60
+ if (!r.ok) el("updated").textContent = "Action did not apply";
61
+ load();
62
+ }
63
+
64
+ function renderStates(counts) {
65
+ const total = {};
66
+ for (const c of counts) total[c.state] = (total[c.state] || 0) + c.n;
67
+ el("states").innerHTML = STATES.map((s) => "<div><b>" + (total[s] || 0) + "</b><span>" + s + "</span></div>").join("");
68
+ const queues = [...new Set(counts.map((c) => c.queue))];
69
+ if (queues.length < 2) { el("queues").innerHTML = ""; return; }
70
+ el("queues").innerHTML = "<table><tr><th>Queue</th>" + STATES.map((s) => '<th class="num">' + s + "</th>").join("") + "</tr>" +
71
+ queues.map((q) => "<tr><td>" + esc(q) + "</td>" + STATES.map((s) => '<td class="num">' + (counts.find((c) => c.queue === q && c.state === s)?.n || 0) + "</td>").join("") + "</tr>").join("") + "</table>";
72
+ }
73
+
74
+ function renderMinutes(rows) {
75
+ const now = Date.now();
76
+ const bins = [];
77
+ for (let i = 59; i >= 0; i--) {
78
+ const t = new Date(Math.floor(now / 60000) * 60000 - i * 60000).toISOString().slice(0, 17) + "00Z";
79
+ bins.push({ t, completed: 0, discarded: 0 });
80
+ }
81
+ for (const r of rows) { const b = bins.find((x) => x.t === r.minute); if (b) b[r.state] = r.n; }
82
+ const max = Math.max(1, ...bins.map((b) => b.completed + b.discarded));
83
+ el("minutes").innerHTML = bins.map((b) =>
84
+ '<div title="' + b.t.slice(11, 16) + ": " + b.completed + " completed, " + b.discarded + ' discarded">' +
85
+ '<i style="height:' + (b.completed / max * 100) + '%"></i><i class="bad" style="height:' + (b.discarded / max * 100) + '%"></i></div>').join("");
86
+ }
87
+
88
+ function renderFailures(rows) {
89
+ if (!rows.length) { el("failures").innerHTML = '<div class="empty">No failures on record.</div>'; return; }
90
+ el("failures").innerHTML = "<table><tr><th>Job</th><th>Queue</th><th>State</th><th class=\\"num\\">Attempt</th><th>Error</th><th></th></tr>" +
91
+ rows.map((r) => "<tr><td>" + esc(r.name) + " <span style=\\"color:var(--mute)\\">#" + r.id + "</span></td><td>" + esc(r.queue) + "</td>" +
92
+ '<td class="state-' + r.state + '">' + r.state + '</td><td class="num">' + r.attempt + " / " + r.max_attempts + "</td>" +
93
+ '<td class="err">' + esc(r.last_error.split("\\n")[0]) + "</td>" +
94
+ '<td><button onclick="act(\\'jobs/' + r.id + '/retry\\')">Retry now</button><button onclick="act(\\'jobs/' + r.id + '/cancel\\')">Cancel</button></td></tr>').join("") + "</table>";
95
+ }
96
+
97
+ async function renderRuns(rows) {
98
+ if (!rows.length) { el("runs").innerHTML = '<div class="empty">No workflow runs yet.</div>'; return; }
99
+ let html = "<table><tr><th>Run</th><th>State</th><th class=\\"num\\">Step</th><th>Started</th><th></th></tr>";
100
+ for (const r of rows) {
101
+ html += '<tr><td><a class="run" onclick="toggle(\\'' + r.id + '\\')">' + esc(r.name) + " #" + r.id + "</a></td>" +
102
+ '<td class="state-' + r.state + '">' + r.state + '</td><td class="num">' + r.current_step + "</td><td>" + esc(r.created_at.slice(0, 19)) + "</td>" +
103
+ "<td>" + (r.state === "running" ? '<button onclick="act(\\'workflows/' + r.id + '/cancel\\')">Cancel</button>' : "") + "</td></tr>";
104
+ if (open.has(r.id)) {
105
+ const d = await (await fetch("api/workflows/" + r.id)).json();
106
+ html += '<tr><td colspan="5"><div class="steps">' + d.steps.map((s) =>
107
+ "<div>Step " + s.step_index + ' <span class="state-' + s.state + '">' + s.state + "</span>, attempt " + s.attempt +
108
+ (s.has_result ? " <span style=\\"color:var(--mute)\\">result " + esc(JSON.stringify(s.result)) + "</span>" : "") +
109
+ (s.last_error ? '<div class="err">' + esc(s.last_error.split("\\n")[0]) + "</div>" : "") +
110
+ (s.state === "discarded" ? ' <button onclick="act(\\'jobs/' + s.job_id + '/retry\\')">Retry step</button>' : "") + "</div>").join("") + "</div></td></tr>";
111
+ }
112
+ }
113
+ el("runs").innerHTML = html + "</table>";
114
+ }
115
+
116
+ function toggle(id) { open.has(id) ? open.delete(id) : open.add(id); load(); }
117
+
118
+ async function load() {
119
+ try {
120
+ const d = await (await fetch("api/overview")).json();
121
+ renderStates(d.counts); renderMinutes(d.perMinute); renderFailures(d.failures); await renderRuns(d.runs);
122
+ el("updated").textContent = "Updated " + new Date().toLocaleTimeString();
123
+ } catch (e) { el("updated").textContent = "Could not reach the API"; }
124
+ }
125
+ load();
126
+ setInterval(() => { if (!document.hidden) load(); }, 3000);
127
+ </script>
128
+ </body>
129
+ </html>`;
@@ -0,0 +1,62 @@
1
+ import type { Sql } from "./sql";
2
+
3
+ export interface CountRow { queue: string; state: string; n: number }
4
+ export interface MinuteRow { minute: string; state: string; n: number }
5
+ export interface FailureRow {
6
+ id: string; queue: string; name: string; state: string; attempt: number;
7
+ max_attempts: number; last_error: string; at: string;
8
+ }
9
+ export interface RunRow {
10
+ id: string; name: string; state: string; current_step: number; created_at: string; finished_at: string | null;
11
+ }
12
+ export interface StepRow {
13
+ step_index: number; job_id: string; state: string; attempt: number; last_error: string | null;
14
+ result: unknown; has_result: boolean;
15
+ }
16
+
17
+ export async function overview(sql: Sql): Promise<{ counts: CountRow[]; perMinute: MinuteRow[] }> {
18
+ const counts = await sql`
19
+ select queue, state, count(*)::int as n from treadle.jobs
20
+ group by queue, state order by queue, state`;
21
+ const perMinute = await sql`
22
+ select to_char(date_trunc('minute', finished_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:MI:00"Z"') as minute, state, count(*)::int as n
23
+ from treadle.jobs
24
+ where finished_at > now() - interval '1 hour' and state in ('completed', 'discarded')
25
+ group by 1, 2 order by 1, 2`;
26
+ return { counts: counts as CountRow[], perMinute: perMinute as MinuteRow[] };
27
+ }
28
+
29
+ export async function recentFailures(sql: Sql, limit = 50): Promise<FailureRow[]> {
30
+ return (await sql`
31
+ select id::text as id, queue, name, state, attempt, max_attempts, last_error,
32
+ coalesce(finished_at, run_at)::text as at
33
+ from treadle.jobs
34
+ where last_error is not null and state in ('retryable', 'discarded')
35
+ order by coalesce(finished_at, run_at) desc, id desc
36
+ limit ${limit}`) as FailureRow[];
37
+ }
38
+
39
+ export async function workflowRuns(sql: Sql, limit = 50): Promise<RunRow[]> {
40
+ return (await sql`
41
+ select id::text as id, name, state, current_step, created_at::text as created_at, finished_at::text as finished_at
42
+ from treadle.workflow_runs order by id desc limit ${limit}`) as RunRow[];
43
+ }
44
+
45
+ export async function workflowRun(
46
+ sql: Sql,
47
+ id: string,
48
+ ): Promise<{ run: RunRow & { input: unknown }; steps: StepRow[] } | null> {
49
+ if (!/^\d+$/.test(id)) return null;
50
+ const [run] = await sql`
51
+ select id::text as id, name, state, current_step, input, created_at::text as created_at, finished_at::text as finished_at
52
+ from treadle.workflow_runs where id = ${id}::bigint`;
53
+ if (!run) return null;
54
+ const steps = await sql`
55
+ select j.step_index, j.id::text as job_id, j.state, j.attempt, j.last_error,
56
+ r.result, (r.workflow_run_id is not null) as has_result
57
+ from treadle.jobs j
58
+ left join treadle.step_results r on r.workflow_run_id = j.workflow_run_id and r.step_index = j.step_index
59
+ where j.workflow_run_id = ${id}::bigint
60
+ order by j.step_index`;
61
+ return { run: run as RunRow & { input: unknown }, steps: steps as StepRow[] };
62
+ }
@@ -0,0 +1,51 @@
1
+ import { PAGE } from "./dashboard-page";
2
+ import { overview, recentFailures, workflowRun, workflowRuns } from "./dashboard-queries";
3
+ import type { Sql } from "./sql";
4
+ import { Treadle } from "./treadle";
5
+
6
+ export interface DashboardOptions {
7
+ basePath?: string;
8
+ }
9
+
10
+ const json = (body: unknown, status = 200): Response =>
11
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
12
+
13
+ export function dashboard(sql: Sql, options: DashboardOptions = {}) {
14
+ const base = (options.basePath ?? "").replace(/\/+$/, "");
15
+ const treadle = new Treadle(sql);
16
+
17
+ return async (req: Request): Promise<Response> => {
18
+ const url = new URL(req.url);
19
+ if (base && !url.pathname.startsWith(base)) return new Response("Not found", { status: 404 });
20
+ const path = url.pathname.slice(base.length);
21
+
22
+ if (path === "") return Response.redirect(`${base}/`, 308);
23
+ if (path === "/") {
24
+ return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
25
+ }
26
+
27
+ if (path === "/api/overview") {
28
+ if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
29
+ const [o, failures, runs] = await Promise.all([overview(sql), recentFailures(sql), workflowRuns(sql)]);
30
+ return json({ ...o, failures, runs });
31
+ }
32
+
33
+ let m: RegExpMatchArray | null;
34
+ if ((m = path.match(/^\/api\/workflows\/([^/]+)$/))) {
35
+ if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
36
+ const detail = await workflowRun(sql, m[1]!);
37
+ return detail ? json(detail) : json({ error: "not found" }, 404);
38
+ }
39
+ if ((m = path.match(/^\/api\/(jobs|workflows)\/(\d+)\/(retry|cancel)$/))) {
40
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
41
+ const [, kind, id, action] = m;
42
+ let ok = false;
43
+ if (kind === "jobs" && action === "retry") ok = await treadle.retry(id!);
44
+ else if (kind === "jobs" && action === "cancel") ok = await treadle.cancel(id!);
45
+ else if (kind === "workflows" && action === "cancel") ok = await treadle.cancelWorkflow(id!);
46
+ else return json({ error: "not found" }, 404);
47
+ return ok ? json({ ok: true }) : json({ ok: false }, 404);
48
+ }
49
+ return new Response("Not found", { status: 404 });
50
+ };
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type { Sql } from "./sql";
2
+ export type { EnqueueOptions, Handler, Job, JobContext, JobState, StartWorkflowOptions, Step, WorkerEvent, WorkerOptions } from "./types";
3
+ export { migrate } from "./migrate";
4
+ export { Treadle } from "./treadle";
5
+ export { Worker } from "./worker";
6
+ export { defaultBackoff } from "./backoff";
7
+ export { workflowJobName } from "./workflows";
8
+ export { dashboard } from "./dashboard";
9
+ export type { DashboardOptions } from "./dashboard";
package/src/migrate.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Sql } from "./sql";
2
+ import { migrations } from "./schema";
3
+
4
+ export async function migrate(sql: Sql): Promise<void> {
5
+ await sql`create schema if not exists treadle`;
6
+ await sql`
7
+ create table if not exists treadle.migrations (
8
+ version integer primary key,
9
+ applied_at timestamptz not null default now()
10
+ )`;
11
+
12
+ await sql.begin(async (tx) => {
13
+ // Serialise concurrent migrate calls from several workers starting at once.
14
+ await tx`select pg_advisory_xact_lock(7231001)`;
15
+ const applied = new Set(
16
+ (await tx`select version from treadle.migrations`).map((r: { version: number }) => r.version),
17
+ );
18
+ for (const m of migrations) {
19
+ if (applied.has(m.version)) continue;
20
+ await tx.unsafe(m.sql);
21
+ await tx`insert into treadle.migrations (version) values (${m.version})`;
22
+ }
23
+ });
24
+ }
package/src/queries.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { Sql } from "./sql";
2
+ import type { Job } from "./types";
3
+
4
+ const columns = `
5
+ j.id::text as id, j.queue, j.name, j.args, j.state, j.priority, j.run_at,
6
+ j.attempt, j.max_attempts, j.lease_until, j.worker_id, j.idempotency_key,
7
+ j.every_ms, j.workflow_run_id::text as workflow_run_id, j.step_index,
8
+ j.last_error, j.created_at, j.started_at, j.finished_at`;
9
+
10
+ // One statement per queue. With queue = any(array) Postgres cannot use the claim
11
+ // index for ordering and falls back to a seq scan plus sort over the whole backlog.
12
+ export async function claimJobs(
13
+ sql: Sql,
14
+ args: { queues: string[]; names: string[]; limit: number; leaseMs: number; workerId: string },
15
+ ): Promise<Job[]> {
16
+ if (args.limit < 1 || args.queues.length === 0 || args.names.length === 0) return [];
17
+ const claimed: Job[] = [];
18
+ for (const queue of args.queues) {
19
+ const remaining = args.limit - claimed.length;
20
+ if (remaining < 1) break;
21
+ const rows: Job[] = await sql.unsafe(
22
+ `with next as (
23
+ select id from treadle.jobs
24
+ where state in ('available', 'retryable')
25
+ and queue = $1
26
+ and name = any($2::text[])
27
+ and run_at <= now()
28
+ order by priority, run_at
29
+ limit $3
30
+ for update skip locked
31
+ )
32
+ update treadle.jobs j
33
+ set state = 'running',
34
+ attempt = j.attempt + 1,
35
+ lease_until = now() + ($4 * interval '1 millisecond'),
36
+ worker_id = $5,
37
+ started_at = coalesce(j.started_at, now())
38
+ from next
39
+ where j.id = next.id
40
+ returning ${columns}`,
41
+ [queue, toPgArray(args.names), remaining, args.leaseMs, args.workerId],
42
+ );
43
+ claimed.push(...rows);
44
+ }
45
+ return claimed;
46
+ }
47
+
48
+ // Bun sends JS arrays as a bare "a,b" literal, which Postgres rejects. Build the {} form ourselves.
49
+ function toPgArray(items: string[]): string {
50
+ return "{" + items.map((s) => '"' + s.replace(/(["\\])/g, "\\$1") + '"').join(",") + "}";
51
+ }
52
+
53
+ export type FinalState = "completed" | "retryable" | "discarded" | "cancelled";
54
+
55
+ // Finishing is one statement: the update and, for periodic jobs, the insert of the
56
+ // next occurrence run as data-modifying CTEs and commit together. Wrapping these in
57
+ // sql.begin instead made Bun's pool hand one caller another caller's rows under load.
58
+ export async function completeJob(sql: Sql, jobId: string, workerId: string): Promise<FinalState | null> {
59
+ const rows = await sql`
60
+ with done as (
61
+ update treadle.jobs
62
+ set state = case when cancel_requested then 'cancelled' else 'completed' end,
63
+ finished_at = now(), lease_until = null, worker_id = null
64
+ where id = ${jobId} and state = 'running' and worker_id = ${workerId}
65
+ returning state, every_ms, queue, name, args, priority, max_attempts, workflow_run_id
66
+ ), next as (
67
+ insert into treadle.jobs (queue, name, args, priority, max_attempts, every_ms, run_at)
68
+ select queue, name, args, priority, max_attempts, every_ms,
69
+ now() + (every_ms * interval '1 millisecond')
70
+ from done where every_ms is not null and state = 'completed'
71
+ ), run as (
72
+ update treadle.workflow_runs w
73
+ set state = 'cancelled', finished_at = now()
74
+ from done
75
+ where w.id = done.workflow_run_id and done.state = 'cancelled' and w.state = 'running'
76
+ )
77
+ select state from done`;
78
+ return (rows[0]?.state as FinalState | undefined) ?? null;
79
+ }
80
+
81
+ export async function failJob(
82
+ sql: Sql,
83
+ jobId: string,
84
+ workerId: string,
85
+ error: string,
86
+ backoffMs: number,
87
+ ): Promise<FinalState | null> {
88
+ const rows = await sql`
89
+ with done as (
90
+ update treadle.jobs
91
+ set state = case
92
+ when cancel_requested then 'cancelled'
93
+ when attempt >= max_attempts then 'discarded'
94
+ else 'retryable' end,
95
+ last_error = ${error},
96
+ run_at = case
97
+ when cancel_requested or attempt >= max_attempts then run_at
98
+ else now() + (${backoffMs} * interval '1 millisecond') end,
99
+ finished_at = case
100
+ when cancel_requested or attempt >= max_attempts then now()
101
+ else null end,
102
+ lease_until = null, worker_id = null
103
+ where id = ${jobId} and state = 'running' and worker_id = ${workerId}
104
+ returning state, every_ms, queue, name, args, priority, max_attempts, workflow_run_id
105
+ ), next as (
106
+ insert into treadle.jobs (queue, name, args, priority, max_attempts, every_ms, run_at)
107
+ select queue, name, args, priority, max_attempts, every_ms,
108
+ now() + (every_ms * interval '1 millisecond')
109
+ from done where every_ms is not null and state = 'discarded'
110
+ ), run as (
111
+ update treadle.workflow_runs w
112
+ set state = case done.state when 'discarded' then 'failed' else 'cancelled' end,
113
+ finished_at = now()
114
+ from done
115
+ where w.id = done.workflow_run_id and done.state in ('discarded', 'cancelled') and w.state = 'running'
116
+ )
117
+ select state from done`;
118
+ return (rows[0]?.state as FinalState | undefined) ?? null;
119
+ }
120
+
121
+ export async function extendLease(
122
+ sql: Sql,
123
+ jobId: string,
124
+ workerId: string,
125
+ leaseMs: number,
126
+ ): Promise<{ held: boolean; cancelRequested: boolean }> {
127
+ const rows = await sql`
128
+ update treadle.jobs
129
+ set lease_until = now() + (${leaseMs} * interval '1 millisecond')
130
+ where id = ${jobId} and state = 'running' and worker_id = ${workerId}
131
+ returning cancel_requested`;
132
+ const row = rows[0] as { cancel_requested: boolean } | undefined;
133
+ return { held: row !== undefined, cancelRequested: row?.cancel_requested ?? false };
134
+ }
135
+
136
+ export async function rescueExpired(sql: Sql): Promise<number> {
137
+ const rows = await sql`
138
+ with done as (
139
+ update treadle.jobs
140
+ set state = case
141
+ when cancel_requested then 'cancelled'
142
+ when attempt >= max_attempts then 'discarded'
143
+ else 'retryable' end,
144
+ finished_at = case
145
+ when cancel_requested or attempt >= max_attempts then now()
146
+ else finished_at end,
147
+ -- the worker died, the job did not fail, so no backoff
148
+ run_at = now(),
149
+ lease_until = null, worker_id = null, last_error = 'lease expired'
150
+ where state = 'running' and lease_until < now()
151
+ returning state, every_ms, queue, name, args, priority, max_attempts, workflow_run_id
152
+ ), next as (
153
+ insert into treadle.jobs (queue, name, args, priority, max_attempts, every_ms, run_at)
154
+ select queue, name, args, priority, max_attempts, every_ms,
155
+ now() + (every_ms * interval '1 millisecond')
156
+ from done where every_ms is not null and state = 'discarded'
157
+ ), run as (
158
+ update treadle.workflow_runs w
159
+ set state = case done.state when 'discarded' then 'failed' else 'cancelled' end,
160
+ finished_at = now()
161
+ from done
162
+ where w.id = done.workflow_run_id and done.state in ('discarded', 'cancelled') and w.state = 'running'
163
+ )
164
+ select count(*)::int as n from done`;
165
+ return (rows[0]?.n as number) ?? 0;
166
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,58 @@
1
+ export const migrations: ReadonlyArray<{ version: number; sql: string }> = [
2
+ {
3
+ version: 1,
4
+ sql: `
5
+ create table treadle.jobs (
6
+ id bigint generated always as identity primary key,
7
+ queue text not null default 'default',
8
+ name text not null,
9
+ args jsonb not null default '{}'::jsonb,
10
+ state text not null default 'available'
11
+ check (state in ('available','running','completed','retryable','discarded','cancelled')),
12
+ priority smallint not null default 0,
13
+ run_at timestamptz not null default now(),
14
+ attempt smallint not null default 0,
15
+ max_attempts smallint not null default 25,
16
+ lease_until timestamptz,
17
+ worker_id text,
18
+ idempotency_key text,
19
+ every_ms integer,
20
+ workflow_run_id bigint,
21
+ step_index smallint,
22
+ last_error text,
23
+ cancel_requested boolean not null default false,
24
+ created_at timestamptz not null default now(),
25
+ started_at timestamptz,
26
+ finished_at timestamptz
27
+ );
28
+
29
+ create index jobs_claim_idx on treadle.jobs (queue, priority, run_at)
30
+ where state in ('available', 'retryable');
31
+
32
+ create index jobs_rescue_idx on treadle.jobs (lease_until)
33
+ where state = 'running';
34
+
35
+ create unique index jobs_idempotency_key_idx on treadle.jobs (idempotency_key)
36
+ where idempotency_key is not null;
37
+
38
+ create table treadle.workflow_runs (
39
+ id bigint generated always as identity primary key,
40
+ name text not null,
41
+ input jsonb not null default '{}'::jsonb,
42
+ state text not null default 'running'
43
+ check (state in ('running','completed','failed','cancelled')),
44
+ current_step smallint not null default 0,
45
+ created_at timestamptz not null default now(),
46
+ finished_at timestamptz
47
+ );
48
+
49
+ create table treadle.step_results (
50
+ workflow_run_id bigint not null references treadle.workflow_runs (id) on delete cascade,
51
+ step_index smallint not null,
52
+ result jsonb,
53
+ finished_at timestamptz not null default now(),
54
+ primary key (workflow_run_id, step_index)
55
+ );
56
+ `,
57
+ },
58
+ ];
package/src/sql.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { SQL } from "bun";
2
+
3
+ export type Sql = SQL;
package/src/treadle.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Sql } from "./sql";
2
+ import type { EnqueueOptions, StartWorkflowOptions } from "./types";
3
+ import { cancelWorkflowRun, startWorkflowRun } from "./workflows";
4
+
5
+ export class Treadle {
6
+ constructor(readonly sql: Sql) {}
7
+
8
+ async enqueue(
9
+ tx: Sql,
10
+ name: string,
11
+ args: unknown = {},
12
+ options: EnqueueOptions = {},
13
+ ): Promise<string> {
14
+ if (!name) throw new Error("enqueue: name is required");
15
+ const maxAttempts = options.maxAttempts ?? 25;
16
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
17
+ throw new Error("enqueue: maxAttempts must be a positive integer");
18
+ }
19
+ if (args !== null && typeof args !== "object") {
20
+ throw new Error("enqueue: args must be an object, an array, or null");
21
+ }
22
+ if (options.every !== undefined && (!Number.isInteger(options.every) || options.every < 1)) {
23
+ throw new Error("enqueue: every must be a positive integer of milliseconds");
24
+ }
25
+
26
+ const rows = await tx`
27
+ insert into treadle.jobs
28
+ (queue, name, args, priority, run_at, max_attempts, idempotency_key, every_ms)
29
+ values (
30
+ ${options.queue ?? "default"},
31
+ ${name},
32
+ ${args ?? {}}::jsonb,
33
+ ${options.priority ?? 0},
34
+ ${options.runAt ?? new Date()},
35
+ ${maxAttempts},
36
+ ${options.idempotencyKey ?? null},
37
+ ${options.every ?? null}
38
+ )
39
+ on conflict (idempotency_key) where idempotency_key is not null do nothing
40
+ returning id::text as id`;
41
+
42
+ if (rows.length > 0) return rows[0]!.id as string;
43
+
44
+ const existing = await tx`
45
+ select id::text as id from treadle.jobs
46
+ where idempotency_key = ${options.idempotencyKey!}`;
47
+ return existing[0]!.id as string;
48
+ }
49
+
50
+ async cancel(jobId: string): Promise<boolean> {
51
+ const rows = await this.sql`
52
+ update treadle.jobs
53
+ set state = case when state = 'running' then state else 'cancelled' end,
54
+ cancel_requested = case when state = 'running' then true else cancel_requested end,
55
+ finished_at = case when state = 'running' then finished_at else now() end
56
+ where id = ${jobId} and state in ('available', 'retryable', 'running')
57
+ returning id`;
58
+ return rows.length === 1;
59
+ }
60
+
61
+ async retry(jobId: string): Promise<boolean> {
62
+ const rows = await this.sql`
63
+ with j as (
64
+ update treadle.jobs
65
+ set state = 'available', attempt = 0, run_at = now(), cancel_requested = false,
66
+ finished_at = null, lease_until = null, worker_id = null
67
+ where id = ${jobId} and state in ('discarded', 'cancelled', 'retryable')
68
+ returning id, workflow_run_id
69
+ ), run as (
70
+ update treadle.workflow_runs w
71
+ set state = 'running', finished_at = null
72
+ from j
73
+ where w.id = j.workflow_run_id and w.state in ('failed', 'cancelled')
74
+ )
75
+ select id from j`;
76
+ return rows.length === 1;
77
+ }
78
+
79
+ async startWorkflow(
80
+ tx: Sql,
81
+ name: string,
82
+ input: unknown = {},
83
+ options: StartWorkflowOptions = {},
84
+ ): Promise<string> {
85
+ if (!name) throw new Error("startWorkflow: name is required");
86
+ if (input !== null && typeof input !== "object") {
87
+ throw new Error("startWorkflow: input must be an object, an array, or null");
88
+ }
89
+ return startWorkflowRun(tx, name, input, options);
90
+ }
91
+
92
+ async cancelWorkflow(runId: string): Promise<boolean> {
93
+ return cancelWorkflowRun(this.sql, runId);
94
+ }
95
+ }
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ export type JobState =
2
+ | "available"
3
+ | "running"
4
+ | "completed"
5
+ | "retryable"
6
+ | "discarded"
7
+ | "cancelled";
8
+
9
+ export interface EnqueueOptions {
10
+ queue?: string;
11
+ priority?: number;
12
+ runAt?: Date;
13
+ maxAttempts?: number;
14
+ idempotencyKey?: string;
15
+ every?: number;
16
+ }
17
+
18
+ export interface Job {
19
+ id: string;
20
+ queue: string;
21
+ name: string;
22
+ args: unknown;
23
+ state: JobState;
24
+ priority: number;
25
+ run_at: Date;
26
+ attempt: number;
27
+ max_attempts: number;
28
+ lease_until: Date | null;
29
+ worker_id: string | null;
30
+ idempotency_key: string | null;
31
+ every_ms: number | null;
32
+ workflow_run_id: string | null;
33
+ step_index: number | null;
34
+ last_error: string | null;
35
+ created_at: Date;
36
+ started_at: Date | null;
37
+ finished_at: Date | null;
38
+ }
39
+
40
+ export interface JobContext {
41
+ jobId: string;
42
+ name: string;
43
+ queue: string;
44
+ attempt: number;
45
+ signal: AbortSignal;
46
+ heartbeat(): Promise<void>;
47
+ }
48
+
49
+ export type Handler<A = any> = (args: A, ctx: JobContext) => Promise<unknown> | unknown;
50
+
51
+ export interface WorkerOptions {
52
+ queues?: string[];
53
+ concurrency?: number;
54
+ pollIntervalMs?: number;
55
+ leaseMs?: number;
56
+ heartbeatMs?: number;
57
+ rescueIntervalMs?: number;
58
+ stopTimeoutMs?: number;
59
+ workerId?: string;
60
+ backoff?: (attempt: number) => number;
61
+ onError?: (error: unknown, job?: Job) => void;
62
+ onEvent?: (event: WorkerEvent, job: Job) => void;
63
+ }
64
+
65
+ export interface StartWorkflowOptions {
66
+ queue?: string;
67
+ priority?: number;
68
+ maxAttempts?: number;
69
+ idempotencyKey?: string;
70
+ }
71
+
72
+ export interface Step<I = any> {
73
+ name: string;
74
+ run: (input: I, results: Record<string, any>, ctx: JobContext) => Promise<unknown> | unknown;
75
+ }
76
+
77
+ export type WorkerEvent = "claimed" | "finishing" | "completed" | "failed" | "stepFinished" | "leaseLost";
package/src/worker.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { hostname } from "node:os";
2
+ import { defaultBackoff } from "./backoff";
3
+ import { claimJobs, completeJob, extendLease, failJob, rescueExpired } from "./queries";
4
+ import type { Sql } from "./sql";
5
+ import type { Handler, Job, JobContext, Step, WorkerEvent, WorkerOptions } from "./types";
6
+ import { loadRun, stepDone, workflowJobName } from "./workflows";
7
+
8
+ type InternalHandler = (job: Job, ctx: JobContext) => Promise<unknown>;
9
+
10
+ export class Worker {
11
+ readonly id: string;
12
+ readonly options: Required<Omit<WorkerOptions, "workerId" | "onError" | "backoff" | "onEvent">> & {
13
+ backoff: (attempt: number) => number;
14
+ onError: (error: unknown, job?: Job) => void;
15
+ onEvent: (event: WorkerEvent, job: Job) => void;
16
+ };
17
+
18
+ private handlers = new Map<string, InternalHandler>();
19
+ private inflight = new Map<string, Promise<void>>();
20
+ private running = false;
21
+ private loop: Promise<void> | null = null;
22
+ private rescueTimer: Timer | null = null;
23
+ private wake: (() => void) | null = null;
24
+
25
+ constructor(readonly sql: Sql, options: WorkerOptions = {}) {
26
+ this.id = options.workerId ?? `${hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 8)}`;
27
+ this.options = {
28
+ queues: options.queues ?? ["default"],
29
+ concurrency: options.concurrency ?? 10,
30
+ pollIntervalMs: options.pollIntervalMs ?? 1000,
31
+ leaseMs: options.leaseMs ?? 30_000,
32
+ heartbeatMs: options.heartbeatMs ?? 10_000,
33
+ rescueIntervalMs: options.rescueIntervalMs ?? 15_000,
34
+ stopTimeoutMs: options.stopTimeoutMs ?? 30_000,
35
+ backoff: options.backoff ?? defaultBackoff,
36
+ onEvent: options.onEvent ?? (() => {}),
37
+ onError: options.onError ?? ((error, job) => console.error("treadle worker error", { jobId: job?.id, error })),
38
+ };
39
+ }
40
+
41
+ register(name: string, handler: Handler): this {
42
+ this.assertNotRunning();
43
+ this.handlers.set(name, async (job, ctx) => handler(job.args, ctx));
44
+ return this;
45
+ }
46
+
47
+ registerWorkflow(name: string, steps: Step[]): this {
48
+ this.assertNotRunning();
49
+ if (steps.length === 0) throw new Error("registerWorkflow: a workflow needs at least one step");
50
+ if (new Set(steps.map((s) => s.name)).size !== steps.length) {
51
+ throw new Error("registerWorkflow: step names must be unique");
52
+ }
53
+ this.handlers.set(workflowJobName(name), (job, ctx) => this.runStep(steps, job, ctx));
54
+ return this;
55
+ }
56
+
57
+ private assertNotRunning(): void {
58
+ if (this.running) throw new Error("cannot register handlers after start");
59
+ }
60
+
61
+ private async runStep(steps: Step[], job: Job, ctx: JobContext): Promise<void> {
62
+ const runId = job.workflow_run_id!;
63
+ const index = job.step_index!;
64
+ const step = steps[index];
65
+ if (!step) throw new Error(`workflow step ${index} is not defined`);
66
+ const run = await loadRun(this.sql, runId);
67
+ if (!run) throw new Error(`workflow run ${runId} not found`);
68
+ if (run.state !== "running") return;
69
+
70
+ let result: unknown;
71
+ if (run.results.has(index)) {
72
+ // The step finished before a crash but its job was never marked done. Do not run it again.
73
+ result = run.results.get(index);
74
+ } else {
75
+ const results: Record<string, unknown> = {};
76
+ for (let i = 0; i < index; i++) results[steps[i]!.name] = run.results.get(i);
77
+ result = await step.run(run.input, results, ctx);
78
+ }
79
+ await stepDone(this.sql, {
80
+ runId,
81
+ jobId: job.id,
82
+ stepIndex: index,
83
+ result: result === undefined ? null : result,
84
+ isLast: index === steps.length - 1,
85
+ });
86
+ this.emit("stepFinished", job);
87
+ }
88
+
89
+ private emit(event: WorkerEvent, job: Job): void {
90
+ try {
91
+ this.options.onEvent(event, job);
92
+ } catch (e) {
93
+ this.options.onError(e, job);
94
+ }
95
+ }
96
+
97
+ async start(): Promise<void> {
98
+ if (this.running) return;
99
+ this.running = true;
100
+ this.rescueTimer = setInterval(() => {
101
+ rescueExpired(this.sql).catch((e) => this.options.onError(e));
102
+ }, this.options.rescueIntervalMs);
103
+ this.rescueTimer.unref();
104
+ this.loop = this.runLoop();
105
+ }
106
+
107
+ async stop(): Promise<void> {
108
+ if (!this.running) return;
109
+ this.running = false;
110
+ if (this.rescueTimer) clearInterval(this.rescueTimer);
111
+ this.wakeUp();
112
+ await this.loop;
113
+ const drained = Promise.allSettled([...this.inflight.values()]);
114
+ await Promise.race([drained, Bun.sleep(this.options.stopTimeoutMs)]);
115
+ }
116
+
117
+ private async runLoop(): Promise<void> {
118
+ while (this.running) {
119
+ const free = this.options.concurrency - this.inflight.size;
120
+ let claimed = 0;
121
+ if (free > 0) {
122
+ try {
123
+ const jobs = await claimJobs(this.sql, {
124
+ queues: this.options.queues,
125
+ names: [...this.handlers.keys()],
126
+ limit: free,
127
+ leaseMs: this.options.leaseMs,
128
+ workerId: this.id,
129
+ });
130
+ claimed = jobs.length;
131
+ for (const job of jobs) {
132
+ this.emit("claimed", job);
133
+ this.track(job);
134
+ }
135
+ } catch (e) {
136
+ this.options.onError(e);
137
+ }
138
+ }
139
+ // A full batch means more may be waiting; poll again at once. Otherwise
140
+ // sleep until the interval passes, a slot frees up, or stop() is called.
141
+ if (claimed < free || free === 0) await this.sleep(this.options.pollIntervalMs);
142
+ }
143
+ }
144
+
145
+ private track(job: Job): void {
146
+ const p = this.run(job).finally(() => {
147
+ this.inflight.delete(job.id);
148
+ this.wakeUp();
149
+ });
150
+ this.inflight.set(job.id, p);
151
+ }
152
+
153
+ private async run(job: Job): Promise<void> {
154
+ const handler = this.handlers.get(job.name)!;
155
+ const abort = new AbortController();
156
+ const ctx: JobContext = {
157
+ jobId: job.id,
158
+ name: job.name,
159
+ queue: job.queue,
160
+ attempt: job.attempt,
161
+ signal: abort.signal,
162
+ heartbeat: () => this.heartbeat(job, abort),
163
+ };
164
+ const beat = setInterval(() => {
165
+ ctx.heartbeat().catch((e) => this.options.onError(e, job));
166
+ }, this.options.heartbeatMs);
167
+ try {
168
+ await handler(job, ctx);
169
+ this.emit("finishing", job);
170
+ const state = await completeJob(this.sql, job.id, this.id);
171
+ if (state === null) {
172
+ this.emit("leaseLost", job);
173
+ this.options.onError(new Error("lease lost before completion"), job);
174
+ } else {
175
+ this.emit("completed", job);
176
+ }
177
+ } catch (error) {
178
+ this.options.onError(error, job);
179
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
180
+ await failJob(this.sql, job.id, this.id, message, this.options.backoff(job.attempt))
181
+ .then(() => this.emit("failed", job))
182
+ .catch((e) => this.options.onError(e, job));
183
+ } finally {
184
+ clearInterval(beat);
185
+ }
186
+ }
187
+
188
+ private async heartbeat(job: Job, abort: AbortController): Promise<void> {
189
+ const { held, cancelRequested } = await extendLease(this.sql, job.id, this.id, this.options.leaseMs);
190
+ if (abort.signal.aborted) return;
191
+ if (!held) abort.abort(new Error("lease lost"));
192
+ else if (cancelRequested) abort.abort(new Error("cancelled"));
193
+ }
194
+
195
+ private sleep(ms: number): Promise<void> {
196
+ return new Promise((resolve) => {
197
+ const timer = setTimeout(() => { this.wake = null; resolve(); }, ms);
198
+ this.wake = () => { clearTimeout(timer); this.wake = null; resolve(); };
199
+ });
200
+ }
201
+
202
+ private wakeUp(): void {
203
+ this.wake?.();
204
+ }
205
+ }
@@ -0,0 +1,104 @@
1
+ import type { Sql } from "./sql";
2
+ import type { StartWorkflowOptions } from "./types";
3
+
4
+ export function workflowJobName(name: string): string {
5
+ return `workflow:${name}`;
6
+ }
7
+
8
+ export function stepKey(runId: string, stepIndex: number): string {
9
+ return `wf:${runId}:${stepIndex}`;
10
+ }
11
+
12
+ export async function startWorkflowRun(
13
+ tx: Sql,
14
+ name: string,
15
+ input: unknown,
16
+ options: StartWorkflowOptions,
17
+ ): Promise<string> {
18
+ if (options.idempotencyKey) {
19
+ const existing = await tx`
20
+ select workflow_run_id::text as run from treadle.jobs
21
+ where idempotency_key = ${options.idempotencyKey}`;
22
+ if (existing[0]) return existing[0].run as string;
23
+ }
24
+ const [run] = await tx`
25
+ insert into treadle.workflow_runs (name, input)
26
+ values (${name}, ${input ?? {}}::jsonb)
27
+ returning id::text as id`;
28
+ const runId = run!.id as string;
29
+ const rows = await tx`
30
+ insert into treadle.jobs
31
+ (queue, name, args, priority, max_attempts, workflow_run_id, step_index, idempotency_key)
32
+ values (${options.queue ?? "default"}, ${workflowJobName(name)}, '{}'::jsonb,
33
+ ${options.priority ?? 0}, ${options.maxAttempts ?? 25}, ${runId}::bigint, 0,
34
+ ${options.idempotencyKey ?? stepKey(runId, 0)})
35
+ on conflict (idempotency_key) where idempotency_key is not null do nothing
36
+ returning id`;
37
+ if (rows.length === 0) {
38
+ // Lost a race on the idempotency key between the select above and this insert.
39
+ await tx`delete from treadle.workflow_runs where id = ${runId}::bigint`;
40
+ const [winner] = await tx`
41
+ select workflow_run_id::text as run from treadle.jobs
42
+ where idempotency_key = ${options.idempotencyKey!}`;
43
+ return winner!.run as string;
44
+ }
45
+ return runId;
46
+ }
47
+
48
+ export async function loadRun(
49
+ sql: Sql,
50
+ runId: string,
51
+ ): Promise<{ input: unknown; state: string; results: Map<number, unknown> } | null> {
52
+ const [run] = await sql`select input, state from treadle.workflow_runs where id = ${runId}::bigint`;
53
+ if (!run) return null;
54
+ const rows = await sql`
55
+ select step_index, result from treadle.step_results
56
+ where workflow_run_id = ${runId}::bigint order by step_index`;
57
+ const results = new Map<number, unknown>();
58
+ for (const r of rows as { step_index: number; result: unknown }[]) results.set(r.step_index, r.result);
59
+ return { input: run.input, state: run.state as string, results };
60
+ }
61
+
62
+ export async function stepDone(
63
+ sql: Sql,
64
+ args: { runId: string; jobId: string; stepIndex: number; result: unknown; isLast: boolean },
65
+ ): Promise<void> {
66
+ const next = args.stepIndex + 1;
67
+ await sql`
68
+ with saved as (
69
+ insert into treadle.step_results (workflow_run_id, step_index, result)
70
+ values (${args.runId}::bigint, ${args.stepIndex}::int, ${JSON.stringify(args.result ?? null)}::text::jsonb)
71
+ on conflict do nothing
72
+ ), enqueued as (
73
+ insert into treadle.jobs
74
+ (queue, name, args, priority, max_attempts, workflow_run_id, step_index, idempotency_key)
75
+ select j.queue, j.name, '{}'::jsonb, j.priority, j.max_attempts, j.workflow_run_id,
76
+ ${next}::int, ${stepKey(args.runId, next)}
77
+ from treadle.jobs j
78
+ where j.id = ${args.jobId}::bigint and ${args.isLast}::boolean = false
79
+ on conflict (idempotency_key) where idempotency_key is not null do nothing
80
+ )
81
+ update treadle.workflow_runs
82
+ set current_step = case when ${args.isLast}::boolean then current_step else ${next}::int end,
83
+ state = case when ${args.isLast}::boolean and state = 'running' then 'completed' else state end,
84
+ finished_at = case when ${args.isLast}::boolean and state = 'running' then now() else finished_at end
85
+ where id = ${args.runId}::bigint`;
86
+ }
87
+
88
+ export async function cancelWorkflowRun(sql: Sql, runId: string): Promise<boolean> {
89
+ const rows = await sql`
90
+ with j as (
91
+ update treadle.jobs
92
+ set state = case when state = 'running' then state else 'cancelled' end,
93
+ cancel_requested = case when state = 'running' then true else cancel_requested end,
94
+ finished_at = case when state = 'running' then finished_at else now() end
95
+ where workflow_run_id = ${runId}::bigint and state in ('available', 'retryable', 'running')
96
+ returning state
97
+ )
98
+ update treadle.workflow_runs w
99
+ set state = case when exists (select 1 from j where j.state = 'running') then w.state else 'cancelled' end,
100
+ finished_at = case when exists (select 1 from j where j.state = 'running') then w.finished_at else now() end
101
+ where w.id = ${runId}::bigint and w.state = 'running'
102
+ returning id`;
103
+ return rows.length === 1;
104
+ }