Worker threads and cluster
workersRun CPU-heavy work on a pool of worker threads or processes, and run one server instance per core, declared in main.yaml and carrying the tenant.
parallelismFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
workers runs CPU-heavy JavaScript on a pool of worker threads so requests keep flowing, and cluster runs one server instance per core. Both are declared in main.yaml, run in the app and are free.
At a glance
workers:
size: auto
queue: 1000
timeout: 30s
memory: 256MB
idle: 30s
env: [TZ]
modules: ["src/workers/*.worker.ts"]
builtin: on
pools:
pdf: { size: 2, isolation: process, memory: 1GB, modules: [src/workers/pdf.worker.ts] }
cluster:
processes: auto
start-timeout: 30s
respawn: { backoff: 1s, max: 30s, crashes: 5, window: 60s }import { workers } from "mainsdk";
// runs on a worker thread; the module must match workers.modules
const pages = await workers.run("./src/workers/parse.worker.ts", { fileId }, { timeout: "20s" });import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({ yaml: "schema: 1\nproject: t\nworkers:\n modules: [src/workers/*.worker.ts]\n", workers: "threads" });
const total = await app.workers.run("./src/workers/sum.worker.ts", [1, 2, 3]);Calls
workers.run(module, input, opts)workers.snapshot()worker(fn)cluster.run(main)cluster.restart(opts)cluster.snapshot()cluster.role()cluster(object: one instance per core)parseWorkers(section, ctx)validateWorkers(config, ctx)
Guarantees
Whatever the driver.
- Only declared worker modules run
- Pools are sized from the cores the process may use, cgroup quota included
- Every task has a timeout and a signal; a stuck thread is terminated and replaced
- The tenant, trace and audit trail cross into every task; secrets and the environment don't
- Cluster instances share nothing but the port, the local store and broadcasts, so limits stay correct
- Rolling restarts and shutdown go through the health drain
- Free on every plan
Drivers
1 drivers. Select one to see its line in main.yaml.
workers:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-WORKER-0001 | Checkmainsdk check, before deploy | node:worker_threads, node:cluster or child_process.fork used outside the SDK |
MAIN-WORKER-0009 | Checkmainsdk check, before deploy | A workers or cluster value is invalid: a size, processes or queue out of range, a memory that isn't a size, a module glob that matches no file, a module in two pools, or an env name that is a secret's name |
MAIN-WORKER-0010 | Checkmainsdk check, before deploy | Workers or cluster on a target that can't run them: cluster with more than one process on serverless, edge or Deno fails; workers on a runtime without threads warn that tasks run inline |
MAIN-WORKER-0016 | Checkmainsdk check, before deploy | A worker module or cluster.run found in the code that main.yaml doesn't declare |
MAIN-WORKER-0002 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0003 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0004 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0005 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0006 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0007 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0008 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0011 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0012 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0013 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0014 | RuntimeError responses and logs | See the spec |
MAIN-WORKER-0015 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| workers and cluster sections with core-based sizing | Phase 1 | 0.4 | parallelism#contract |
| workers.run on a lazy thread pool with a bounded queue | Phase 1 | 0.4 | parallelism#pool |
| Structured clone and transfer lists | Phase 1 | 0.4 | parallelism#data |
| Timeouts, deadlines and signals that terminate stuck threads | Phase 1 | 0.4 | parallelism#stop |
| Context in, secrets out, logs through redaction | Phase 1 | 0.4 | parallelism#boundary |
| Process pools for code that may crash | Phase 1 | 0.4 | parallelism#process-pool |
| Inline fallback and inline test mode | Phase 1 | 0.4 | parallelism#fallbacks |
| Documents, imports and the seal boot check on the built-in pool | Phase 1 | 0.4 | parallelism#builtin |
| cluster.run with one instance per core on a shared port | Phase 1 | 0.4 | parallelism#cluster-run |
| Rolling restarts through the drain | Phase 1 | 0.4 | parallelism#rolling |
| Crash respawn with backoff and crash-loop stop | Phase 1 | 0.4 | parallelism#supervise |
| Local store and broadcasts relayed by the primary | Phase 1 | 0.4 | parallelism#shared-state |
| Cluster-wide resource pressure | Phase 1 | 0.4 | parallelism#pressure |
Quick start
workers:
size: auto
modules: ["src/workers/*.worker.ts"]import { worker } from "mainsdk/workers";
export default worker(async (input: number[], ctx) => {
ctx.log.info("summing", { count: input.length });
return input.reduce((a, b) => a + b, 0);
});import { workers } from "mainsdk";
export async function total(numbers: number[]) {
return workers.run<number[], number>("./src/workers/sum.worker.ts", numbers, { timeout: "5s" });
}Configuration
workers:
size: auto # 1 to 64
queue: 1000 # tasks waiting per pool; beyond it MAIN-WORKER-0007
timeout: 30s
memory: 256MB # heap per thread, or per process with isolation: process
idle: 30s # an idle thread exits; the pool starts empty
env: [TZ, LANG] # names copied into each thread; never a secret's name
modules: ["src/workers/*.worker.ts"]
builtin: on
pools:
pdf: { size: 2, isolation: process, memory: 1GB, timeout: 2m, modules: [src/workers/pdf.worker.ts] }
cluster:
processes: auto # 1 means no cluster
start-timeout: 30s
respawn: { backoff: 1s, max: 30s, crashes: 5, window: 60s }Cores are the machine's, cut to the cgroup CPU quota rounded up, at least 1. size: auto is max(1, floor(cores / processes) - 1); processes: auto is one per core. A module matches the pool whose modules lists it (named pools first), and only declared modules run (MAIN-WORKER-0002; in permissions.mode: observe it runs and is logged as a would-refuse).
Calls
workers.run(module, input, opts) runs a declared module and returns its result. opts takes transfer (buffers moved, not copied), timeout (at most the pool's timeout, also cut to the caller's deadline) and signal. workers.snapshot() reports each pool: size, threads, busy, queued, completed, failed, replaced.
import { workers } from "mainsdk";
const buf = new Uint8Array(1024 * 1024);
const out = await workers.run("./src/workers/resize.worker.ts", { buf }, { transfer: [buf.buffer] });
console.log(workers.snapshot().pools);worker(fn) defines a worker module's default export. fn(input, ctx) gets ctx.signal (aborts at the timeout, the deadline or the caller's signal), ctx.context (tenant, user, agent, trace, region, env, deadline), ctx.log and ctx.transfer(value, list) to move buffers back with the result.
import { worker } from "mainsdk/workers";
export default worker((input: { n: number }, ctx) => {
const out = new Uint8Array(input.n);
return ctx.transfer({ out }, [out.buffer]);
});cluster.run(main) forks one instance per core in the primary and runs main in each instance. cluster.restart(opts) replaces instances one at a time and rejects with MAIN-WORKER-0013 if a new instance is not ready. cluster.snapshot() lists the instances and the aggregate. cluster.role() is primary, instance or single.
import { cluster } from "mainsdk";
declare function startServer(): Promise<void>; // your HTTP server
await cluster.run(() => startServer());
console.log(cluster.role(), cluster.snapshot().instances.length);
await cluster.restart({ reason: "deploy" });What you get
- Worker threads for CPU-heavy work such as parsing a large PDF, resizing in pure JavaScript, scoring or compressing, with
workers.runand a timeout, queue and memory limit per pool. - Process pools (
isolation: process) for native modules and code that may crash or leak. cluster: one instance of your HTTP server per core on one shared port, with rolling restarts.- Declared-only modules: anything
main.yamldoes not declare is refused. - The tenant, trace and audit trail cross every thread and process; secrets never do.
- Built-in modules for document parsing and rendering, and optional sanitizing of large inputs.
What crosses
- Data. Input and result are cloned. A value that cannot be cloned fails with
MAIN-WORKER-0003naming its path, such asinput.items[3].fn, before anything is sent. Transfer lists detach the buffer in the caller for thread pools. For process pools they are copied and never detached. - Context in. The caller's tenant, user, agent, trace, region, env and deadline are in
ctx.context. Records the worker writes carry the task'strace_id, tenant and module name. - Logs out. Log and audit records go to the main thread, are redacted and are written by its pipeline before the task's result arrives. A worker has no sink of its own.
- Secrets out. A worker starts with an empty environment plus the names in
workers.env. Input that holds a resolved secret fails withMAIN-WORKER-0004, and the value is never echoed. An error message passes through log redaction. - No other calls. Inside a worker only
log,s,schema,parse,sanitize, the core units and WebCrypto work. Any other MainSDK call fails withMAIN-WORKER-0008naming it.
Timeouts, crashes and process pools
A task's limit is the smallest of opts.timeout, the pool's timeout and the time to the caller's deadline. At the limit, or when signal aborts, ctx.signal aborts; 100 ms later a thread that has not settled is terminated and replaced, and a timed-out task fails with MAIN-WORKER-0005. A thread that runs out of memory, calls process.exit or crashes fails its task with MAIN-WORKER-0006 and is replaced; the main thread keeps serving. A full queue fails at once with MAIN-WORKER-0007 (503, retryable).
A pool with isolation: process runs each member as a forked child with SIGKILL in place of thread termination, for native modules and code that may crash or leak. Never use workers for untrusted code: that is the sandbox interface.
Built-in modules
The built-in pool has one thread (two with workers.pools.builtin.size: 2) and needs no workers.modules entry.
| Module | Used by | Default |
|---|---|---|
mainsdk:documents.parse, mainsdk:documents.render | the built-in documents reader and renderer | on |
mainsdk:imports.xlsx | the XLSX reader of imports | on, when imports ships it |
mainsdk:seal.hash | the seal boot check | on, when seal ships it |
mainsdk:sanitize | workers.run("mainsdk:sanitize", { html, profile, maxBytes }) for inputs too big for a request | optional |
Runtimes
| Runtime | Worker threads | isolation: process | Cluster |
|---|---|---|---|
| Node (long-running) | yes | yes | yes |
| Bun | yes | yes | Linux only; elsewhere one process |
| Deno | yes | runs as threads, logged | one process |
| Serverless Node | yes | runs as threads, logged | one process |
| workerd, Vercel Edge | inline | inline | one process |
| Browser | MAIN-PORT-0002 | MAIN-PORT-0002 | MAIN-PORT-0002 |
mainsdk dev | yes; changed modules reload | yes | one process |
createTestApp() | inline by default | inline by default | one process |
Where threads are missing, tasks run inline in the same process after cloning the input, with the same declaration and refusals, and MAIN-WORKER-0014 is logged once. The timeout cannot preempt inline code; it is checked when the task awaits. Inline code shares your environment, so the empty-environment rule holds only on real threads.
Events
Each task is a mainsdk:workers.run event with module, pool, isolation, queuedMs, durationMs and code, never the input or result. Pool changes publish mainsdk:workers.pool (start, exit, replace, terminate) and every run publishes a kind: "worker" decision on mainsdk:permission.decision.
Errors and checks
mainsdk check reports MAIN-WORKER-0001 for node:worker_threads, node:cluster or child_process.fork in app code, MAIN-WORKER-0009 for an invalid workers or cluster value, MAIN-WORKER-0010 for a target that cannot run them, and MAIN-WORKER-0016 for a worker module or cluster.run that main.yaml does not declare. mainsdk discover lists both with file and line.
Runtime refusals are MAIN-WORKER-0002 to 0008 (see above), 0011 to 0015 for the cluster and MAIN-PORT-0002 in the browser. Each code's cause and fix is in check codes.
Swapping drivers
workers has no driver. Where threads are missing the runtime changes how tasks run: inline after cloning the input, or one process for cluster, with the same declaration and refusals (see the runtimes table). A pool's isolation (thread or process) is set in main.yaml.
Testing
createTestApp() runs tasks inline. Pass workers: "threads" for real threads, or workers: { threads: false, cores: { available: () => 8 } } to fake the runtime and the core count.
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({ yaml: "schema: 1\nproject: t\nworkers:\n modules: [src/workers/*.worker.ts]\n", workers: "threads" });
const total = await app.workers.run("./src/workers/sum.worker.ts", [1, 2, 3]);Helpers for wiring and tests
| Call | What it does |
|---|---|
cluster(object) | cluster runs one instance per core: cluster.run, cluster.restart, cluster.snapshot and cluster.role. |
parseWorkers(section, ctx) | Read the workers section of main.yaml into settings and issues. |
validateWorkers(config, ctx) | Check parsed workers settings against the app and report issues. |
Next
- Sandbox runs untrusted code, which workers must not.
- Compute covers where the app runs.
- Testing kit has
createTestApp().
Was this page helpful?