Skip to content
HomeConsoleGet started

Do a piece of work once per run and share its result, hand work to worker processes, and let another worker take over when one crashes.

Ten agents can reach for the same piece of work. Only one should do it, the others should get its result, and if the one doing it disappears, someone else should finish. runstate covers this two ways:

  • once wraps a function so it runs once per run for the same arguments. Every caller, in any process, gets the same result.
  • queue hands work to worker processes. Producers call it like a function; workers run serve().

Both are built on leases, so work held by a dead process is picked up by someone else.

import runstate from 'runstate-sdk';
const research = runstate.once(researchCompany);
// Any number of agents can call this. The function runs once; they all get its result.
const report = await research('acme');

What happens on each call:

  1. The SDK hashes the arguments into a key, <name>:<hash>.
  2. If nobody has started that key in the run, this caller runs the function and records the result.
  3. If another caller is running it, this caller waits and gets the same result.
  4. If the key already has a result in the run, it comes back straight away.

Callers with different arguments don’t wait for each other. research('acme') and research('globex') run in parallel.

By default the key covers every argument. Arguments must be JSON values: strings, numbers, booleans, null, lists and plain objects (in Python also tuples, dataclasses and pydantic models). Anything else is a ConfigError.

When an argument shouldn’t count, such as a client object or a timestamp, pass key:

const research = runstate.once(
async (company: Company, http: HttpClient) => researchCompany(company, http),
{ name: 'research', key: (company) => company.domain },
);

name defaults to the function name. Give it explicitly when you wrap an anonymous function, or when a TypeScript process and a Python process should share results: both SDKs hash arguments the same way, so equal names and arguments give equal keys.

  • The result must be JSON-serializable and at most 64 KB. A larger result raises ResultTooLargeError. Store big outputs elsewhere, such as object storage, and return a reference.
  • Results belong to the run. After the run is stopped or finished, the next call starts a fresh run and runs the function again. See runs.

If the function throws, the error goes back to that caller and the next waiting caller tries again. After attempts failures (3 by default), every caller gets OnceFailedError with the last error message, until the run ends.

import runstate, { OnceFailedError } from 'runstate-sdk';
const research = runstate.once(researchCompany, { attempts: 5 });
try {
await research('acme');
} catch (err) {
if (err instanceof OnceFailedError) console.error(`gave up after ${err.attempts} attempts: ${err.message}`);
else throw err;
}

The caller running the function holds a lease (30 seconds by default, set with lease). The SDK renews it while the function runs. If the process crashes, the lease lapses and a waiting caller takes the work over.

If the first process was only slow and comes back after the takeover, runstate rejects its result. The SDK aborts context().signal and raises LeaseLostError. Check the signal in long work so it stops early:

const writeReport = runstate.once(async function writeReport(name: string) {
const { signal } = runstate.context()!;
const sections = [];
for (const part of parts) {
signal.throwIfAborted();
sections.push(await writeSection(name, part, { signal }));
}
return sections;
});

once runs the function in whichever process calls first. When the work should run on dedicated workers, for example machines with browsers or GPUs, use a queue.

Define the queue once, in a module both sides import:

research.ts
import runstate from 'runstate-sdk';
export const research = runstate.queue(
'research',
async (input: { company: string }) => researchCompany(input.company),
{ attempts: 5, lease: '60s', concurrency: 4 },
);

The handler takes one argument, the input, and returns the result. Both must be JSON-serializable and at most 64 KB.

import { research } from './research.ts';
// Submit and wait for the result.
const report = await research({ company: 'acme' });
// Or submit now and collect later.
const job = await research.send({ company: 'globex' });
console.log(`submitted ${job.id}`);
const later = await job.result();

Submitting the same input twice in a run returns the same job, so any number of producers can ask for the same work and all get its one result. The job’s key is a hash of the input. Pass key to the queue to decide what counts as the same work:

export const research = runstate.queue('research', researchHandler, {
key: (input: { company: string; requestedAt: string }) => input.company,
});

A finished job stays the answer for the rest of the run. Start a fresh run to do the work again.

worker.ts
import runstate from 'runstate-sdk';
import './research.ts';
await runstate.serve(); // until the run stops or the process gets SIGTERM

serve() runs every queue defined in the process:

  • takes jobs, up to concurrency at once, and renews each job’s lease while the handler runs;
  • records the handler’s return value as the job’s result;
  • if the handler throws, puts the job back with backoff. After attempts failures (5 by default), callers get TaskFailedError with the last error message;
  • when the run stops, or the process gets SIGTERM or SIGINT, takes no new work and waits up to 30 seconds for running handlers.
  1. The worker stops renewing its lease (the process crashed, was killed, or lost the network).
  2. When the lease lapses, the job goes back to the queue. The next worker to take it sees context().attempt incremented.
  3. If the old worker comes back and tries to record a result, the result is dropped quietly. No LeaseLostError reaches your code, and the job’s caller gets the result of the worker that finished it.

The lease (30 seconds by default) sets how quickly a dead worker’s job is picked up again.

A queue can name the slots, limit and budget each job needs. A worker takes the job and all of them in one step, so a job never holds a browser while it waits for a rate limit:

export const render = runstate.queue('render', async (input: { url: string }) => renderPage(input.url), {
slots: 'browser',
limit: { name: 'search-api', units: 1 },
budget: { name: 'model-usd', reserve: 0.4 },
});

The named resources must already exist. See Limits and slots and Spend budgets.