Skip to main content
Alpha

Background jobs

A NetScript background job is a durable, KV-backed TypeScript handler that runs in its own thread-isolated worker, separate from your request-serving services. You author a job as one defineJobHandler(...) callable, give it an id, and the runtime takes care of registration, dispatch, retry, execution tracking, scheduling, and an HTTP API to enqueue and inspect runs. It is the unit you reach for whenever work should happen after a request returns — charging a payment, sending a welcome email, processing an upload — without blocking the caller.

An enqueue call (from a trigger, an HTTP POST to the workers API, or the scheduler) places a job on the durable queue; the worker runtime pulls it and runs the handler in one of three runner modes — in-process, web-worker (one V8 isolate per worker), or subprocess — then writes a JobResult to the KV-backed execution store, which streams updates back over SSE.
Enqueue → durable queue → worker runtime (in-process / web-worker / subprocess) → result store. The scheduler fires cron-defined jobs onto the same queue; graceful shutdown drains in-flight runs before the runner stops.

What it is

A job is plain TypeScript that runs in the worker process, not the request process. The headline surface — defineJobHandler, createSuccessResult, and createFailureResult from @netscript/plugin-workers-core — lets you write a typed handler over a ctx, do the work, and return a structured JobResult. Job dispatch and execution are instrumented with real OpenTelemetry spans that show up in the Aspire dashboard automatically, so a queued run is observable end to end without wiring. The runner mode (how the handler is isolated) is a user-tunable — see the runtime-mode table below — and the same queue and scheduler also drive polyglot tasks when the work is owned by another runtime. The why-behind-the-choreography lives in Durability model.

Learn → / Do →

Minimal example

Add the workers plugin to a published workspace with the public package dispatcher:

netscript plugin add @netscript/plugin-workers

For local-source contributor work inside this monorepo, use the maintainer binary when you need first-party samples:

deno run -A packages/cli/bin/netscript-dev.ts plugin add worker --name workers --samples

That local path lands real, compiling modules you can read and trigger immediately — including plugins/workers/jobs/health-check.ts (a job handler) and plugins/workers/tasks/validate-payload.ts (a polyglot task). The plugin's API service comes up on port 8091.

A job handler is an async callable over a ctx object that returns a JobResult. Parse the payload with a Zod schema, do the work, and return createSuccessResult(...) or createFailureResult(...). The job's identity is attached with Object.assign(handler, { id }).

// plugins/workers/jobs/process-payment.ts
import {
  createFailureResult,
  createSuccessResult,
  defineJobHandler,
} from '@netscript/plugin-workers-core';
import { z } from 'zod';

// Payload contract — parse ctx.payload before doing any work.
const ProcessPaymentPayloadSchema = z.object({
  orderId: z.string().min(1),
  amountCents: z.number().int().positive(),
});

const handler = defineJobHandler(async (ctx) => {
  const { orderId, amountCents } = ProcessPaymentPayloadSchema.parse(ctx.payload ?? {});

  // Optional progress callback — the runtime wires reportProgress on real runs.
  ctx.reportProgress?.(10, 'charging customer');

  const charge = await chargeCustomer(orderId, amountCents);
  if (!charge.ok) {
    // A failure result is recorded and feeds the retry policy.
    return createFailureResult(`charge declined: ${charge.reason}`);
  }

  ctx.reportProgress?.(100, 'charged');
  // The success payload (data) is persisted on the execution record.
  return createSuccessResult({ orderId, chargeId: charge.id, amountCents });
});

// The id is how the runtime registers, lists, and triggers the job.
export default Object.assign(handler, { id: 'process-payment' });

Once the workers API is up (Aspire first — see Production notes), enqueue a run by id:

# Enqueue the process-payment job (workers API on port 8091).
curl -X POST http://localhost:8091/api/v1/workers/jobs/process-payment/trigger \
  -H 'content-type: application/json' \
  -d '{"orderId":"o_42","amountCents":4999}'

# Watch it land in the KV-backed execution history.
curl http://localhost:8091/api/v1/workers/executions?limit=10

Key types first — JobHandlerContext & JobResult

A handler is (ctx: JobHandlerContext<TPayload>) => JobResult<TResult> | Promise<…>. These two shapes are the contract you write against; read them before the option tables.

JobHandlerContext — the ctx passed to defineJobHandler
NameTypeDescription
id string The execution id for this run. Stable per dispatched run.
job { id: string } | undefined The job definition reference (its id), when available.
payload TPayload The enqueued payload. Parse it with your Zod schema before use.
correlationId string | undefined Correlation id propagated across the dispatch for tracing.
traceparent string | undefined W3C traceparent of the dispatching span; child spans nest under it.
tracestate string | undefined W3C tracestate accompanying the traceparent.
reportProgress (percent, message?) => void | Promise Optional progress callback wired by the runtime on real runs; emits job.progress events.
JobResult — return createSuccessResult() or createFailureResult()
NameTypeDescription
success true | false Discriminant. createSuccessResult sets true; createFailureResult sets false. Branch on this.
data TResult | undefined The result payload, persisted on the execution record (present on success, optional on failure).
error string Failure message — required when success is false (the first arg to createFailureResult).

Worker runtime modes (WORKER_RUNTIMES)

How a handler is isolated from the API process is a tunable: WORKER_RUNTIMES enumerates the three runner modes the worker runtime supports. The scaffold default is web-worker, where each worker is its own V8 isolate sized by the WORKERS_CONCURRENCY env var. Pick the mode that matches your isolation, memory, and parallelism needs.

WORKER_RUNTIMES — runner isolation modes (WorkerRuntime type)
NameTypeDescription
in-process WorkerRuntime Runs the handler in the same process via the in-process runner (registry-first). Lowest overhead, no isolation — best for tests, compiled binaries, and single-tenant local composition.
web-worker WorkerRuntime Runs each worker in its own Web Worker / V8 isolate (~20-40 MB each). The scaffold default; WORKERS_CONCURRENCY sets the process pool size for parallel job execution. Keep it low to bound memory.
subprocess WorkerRuntime Runs the handler in a spawned subprocess. Strongest process isolation; only Deno tasks get permission sandboxing through .permissions(). Python, .NET, shell, PowerShell, and cmd inherit the worker process's OS permissions.
Deployment & scaling knobs (workers config)
NameTypeDescription
WORKERS_CONCURRENCY env (number) Runtime worker process pool size. The entrypoint reads this plural variable; current Aspire metadata also emits WORKER_CONCURRENCY, but the runtime does not consume it.
concurrency number Per-topic max concurrent workers (WorkersConfigData.concurrency / per-group scaling).
mode 'combined' | 'distributed' Per-topic deployment mode: one combined runner vs. distributed runners. Defaults to 'combined'.
queueProvider 'auto' | 'deno-kv' | 'redis' | 'postgres' | 'amqp' Queue backend. 'auto' resolves a provider; see Choose a queue provider.
jobsDir / tasksDir string Directories scanned for default-exported job and task modules.

Enqueue from a trigger

The HTTP …/trigger endpoint is one way in; the other is declaratively, from a trigger handler, which returns an enqueueJob(...) action. enqueueJob(job, options) comes from @netscript/plugin-triggers-core and binds an imported job definition to a payload, so an inbound webhook or a scheduled trigger drops work onto this same runtime.

// plugins/triggers/triggers/payment-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core';
// Import the job definition you want to dispatch (its default export carries the id).
import processPayment from '../../workers/jobs/process-payment.ts';

const handler = defineWebhook(
  // The handler returns trigger actions; enqueueJob is the most common one.
  (event) => [
    enqueueJob(processPayment, {
      payload: { orderId: event.payload.orderId, amountCents: event.payload.amountCents },
      idempotencyKey: event.payload.orderId, // optional: collapse duplicate deliveries
    }),
  ],
  { id: 'payment-webhook', path: '/webhooks/payment', verifier: 'hmac-sha256', secretEnv: 'WEBHOOK_SECRET' },
);

export default handler;
enqueueJob(job, options) — EnqueueJobOptions (from @netscript/plugin-triggers-core)
NameTypeDescription
payload TPayload The payload handed to the job's ctx.payload. Parse it with the job's Zod schema.
idempotencyKey string Collapses duplicate deliveries — at-most-once effect per key.
concurrencyKey string Serializes runs that share a key (e.g. per-order) so they do not overlap.
priority number Dispatch priority for this enqueue action.

Graceful shutdown

Background runners must drain in flight work before they exit, or a redeploy loses jobs mid-run. The @netscript/plugin-workers-core/shutdown subpath provides a ShutdownManager that registers stoppable resources and stops them in priority order, with a timeout, when a shutdown is requested.

// plugins/workers/bin/with-shutdown.ts
import { ShutdownManager } from '@netscript/plugin-workers-core/shutdown';
import { startWorkers } from '@netscript/plugin-workers-core';

const runtime = await startWorkers({ autoStart: true });
const shutdown = new ShutdownManager({ timeoutMs: 10_000 });

// Register the runtime so a drain stops it gracefully (lower priority stops first).
shutdown.register({ id: 'workers-runtime', priority: 100, stop: (reason) => runtime.stop(reason) });

// Tie OS signals to the drain, then report what stopped / failed / timed out.
Deno.addSignalListener('SIGTERM', async () => {
  const report = await shutdown.shutdown('SIGTERM');
  console.info('shutdown', report.state, { stopped: report.stopped, timedOut: report.timedOut });
  Deno.exit(0);
});
ShutdownManager — @netscript/plugin-workers-core/shutdown
NameTypeDescription
register(resource) void Register a ShutdownResource ({ id, stop(reason?), priority? }) to be stopped on drain.
unregister(id) void Remove a resource from the drain set.
shutdown(reason?, { timeoutMs }) Promise Stop registered resources in priority order; returns { state, stopped, failed, timedOut }.
waitForShutdown() Promise Resolves once shutdown has started — await it in long-running loops.
createAbortController() AbortController An AbortController that aborts when shutdown begins; pass its signal into in-flight async work.
state 'running' | 'shutting-down' | 'stopped' Current lifecycle state of the manager.

Workers API & where jobs come from

Once Aspire is up and the schema is wired, the workers API on :8091 registers your job by id and exposes HTTP endpoints to seed, trigger, and inspect runs. These are the endpoints the CLI E2E suite validates live.

Workers API — port 8091 (full generated surface in the workers reference)
NameTypeDescription
GET /health liveness Health probe for the workers API service.
GET /api/v1/workers/jobs list All registered job definitions (id, name, topic) discovered from the jobs directories.
POST /api/v1/workers/jobs/{id}/trigger enqueue Enqueue a run of the job with this id, passing a JSON payload body.
GET /api/v1/workers/executions?limit=10 history Recent executions and outcomes (KV-backed execution state).
GET /api/v1/workers/tasks list Task registry view (polyglot defineTask entries).
POST /api/v1/workers/seed seed Seed the workers store with the registered jobs.
GET /api/v1/workers/subscribe SSE Server-sent-events stream of execution updates (KV-watch).

Observability: real job traces out of the box

Job-level observability is not a stub. The workers runtime instruments the scheduler → queue → worker → subprocess path with real OpenTelemetry spans, and those traces appear in the Aspire dashboard automatically once Aspire is up.

What the worker runtime traces automatically (framework layer — real today)
NameTypeDescription
Job dispatch + execution span Each enqueued run gets a span with attributes, duration, status, and job.started / job.completed / job.failed / job.exception events.
Step + progress events event job.step.* and job.progress (current / total / percentage) events are emitted on real job runs.
Subprocess trace continuation context W3C traceparent / tracestate is propagated into the subprocess runner, so the child trace links back to the dispatching span.
Scheduler + cron spans span Scheduler-start, schedule-job, dispatch, and cron-run spans cover the timer/cron path that fires scheduled jobs.

For spans you author inside a handler, import directly from @netscript/telemetry (e.g. @netscript/telemetry/instrumentation for withChildSpan). These nest correctly under the automatic dispatch span. See Observability for the model and Add OpenTelemetry for the recipe.

Production notes

partial

Reference →

This hub is intentionally thin — the full generated API for @netscript/plugin-workers (defineJobHandler, the job/task builders, the runtime, the shutdown manager, and every exported type and subpath) lives in the reference.

workers