@sigx/actors
v0.2.0
Published
Virtual actors for SignalX — addressable, single-threaded, persistent server state
Maintainers
Readme
@sigx/actors
Virtual actors for SignalX: addressable, single-threaded,
persistent server objects that integrate with sigx's server layer — actor
calls ride the same wire protocol, codec, and security posture as serverFn.
// cart.actor.ts
import { defineActor } from '@sigx/actors';
import { requireUser } from './guards.server';
export const CartActor = defineActor({
type: 'Cart',
authorize: [requireUser],
state: () => ({ items: [] as Item[] }),
methods: (ctx) => ({
async addItem(item: Item) {
ctx.state.items.push(item); // deep signal — just mutate
await ctx.save(); // explicit persistence
return ctx.state.items.length;
}
})
});// Anywhere — browser, serverFn, SSR render. Same expression.
import { actor } from '@sigx/actors';
const count = await actor(CartActor, cartId).addItem(item);The actor need not exist: the first call activates it (loading its state from storage), and idle actors deactivate automatically. No create, no destroy, no connection management — that is the virtual-actor model.
The model, in five guarantees
- Addressable. An actor is
(type, key).actor(CartActor, 'user-42')always reaches theuser-42cart — one activation per key. - Single-threaded. One turn (method call) at a time per activation.
Plain mutation on
ctx.stateis race-free; no locks, ever. (reentrant: 'always'andmethodReentrancyopt out per actor or per method — see Reentrancy & interleaving.) awaitdoes not yield the actor. A turn ends when the method's promise settles — an awaitedfetchinside a method blocks every queued call to that actor until it resolves. This is the non-reentrant default and the model's core trade: state safety over intra-actor concurrency. (Dev builds warn when a turn exceedsslowTurnMs.)- Persistent.
ctx.save()writes state through the pluggableActorStorage(etag optimistic concurrency); activation loads it back. A conflicting writer faults the stale activation (ActorStateConflictError) — the next call loads the winning state. - Deadlock-detected. Every call carries its chain;
A → B → Ainto a non-reentrant actor throwsActorDeadlockErrorimmediately with the full chain, instead of hanging until a timeout.reentrant: trueallows call-chain re-entry (the cycle runs inline against your own turn);reentrant: 'always'interleaves everything, making the deadlock impossible by construction.
Turns
Guarantees 2 and 3 are one mechanism, and most surprises trace back to it.
A turn is one method invocation on one activation, from the moment it starts running to the moment its promise settles. Every activation runs its turns one at a time, in the order they arrived.
That is the whole reason ctx.state.count++ is safe with no lock — while
your turn is running, nothing else on that actor is. It is also why await
inside a turn costs more than it does in ordinary JS: the turn has not ended,
so every later call to that actor waits. Slow I/O inside a turn is a queue
for that actor, and metrics() splits the two halves so you can tell them
apart — queueMs (waiting for a turn; high means the actor is hot) versus
turnMs (holding the activation; high means the turn itself is slow).
Three things deliberately run outside a turn, so they cannot block one:
guards, task bodies (ctx.tasks) and stream iteration. Each can re-enter
with ctx.turn(...) when it needs to touch state safely.
reentrant: 'always' lets an actor's own turns overlap instead.
Setup
pnpm add @sigx/actorsVite app — add the plugin; it runs a dev host for you and mounts the
endpoint at /_sigx/actor:
// vite.config.ts
import { sigxActors } from '@sigx/actors/vite';
export default { plugins: [sigxActors({ app: '/src/actors.app.ts' })] };Actors live in *.actor.ts modules. The build swaps them wholesale for
typed client stubs — implementations never reach the browser (values are
swapped, types are not, so the client proxy is fully typed).
sigxActors({ app }) points dev at the same app module your production
entry imports, so storage, placement, codec handlers, defaults and every
plugin are identical in both. The app module gets its registry from
virtual:sigx-actors; add its types once, next to your other Vite types:
// src/env.d.ts
/// <reference types="@sigx/actors/vite-client" />The app module leaves out its registry, so it imports nothing Vite-specific
and loads under any runtime — which is what lets a plain-Node entry share
it, and what makes it safe for actor modules to import the bound
defineActor from it:
// src/actors.app.ts — no `actors`, no virtual import
export const app = defineActorApp({ storage: fileStorage({ dir: '.actors' }) });
export const { defineActor } = app;Under Vite the plugin supplies the registry it already builds (its loaders go through the module runner, so HMR keeps working). Anywhere else, name the actors:
// server.mjs — the SAME app module
const host = await app.withActors([Counter]).start();withActors throws if the app already declared actors, so a host can
never silently replace what the author configured. examples/counter runs
exactly this shape in dev and in production.
Production server entry — explicit composition, the sigx idiom:
import { createHost } from '@sigx/actors/host';
import { handleActorRequest, matchesActorRequest } from '@sigx/actors/server';
import { actors } from './dist/server/sigx-actors.js'; // build-emitted registry
const host = createHost({ actors, storage });
await host.start();
export default {
async fetch(request: Request): Promise<Response> {
if (matchesActorRequest(request)) return handleActorRequest(request, { host });
if (matchesServerFn(request)) return handleServerFnRequest(request, { resolve });
return documentHandler(request);
}
};Node servers use createActorHandler / attachSignalHandlers from
@sigx/actors/node (see examples/counter/server.mjs).
The app: one config, many runtimes
createHost stays the low-level primitive, but it takes exactly ONE
placement and ONE storage, and ActorPlacement.bind() is its only
lifecycle-hook shape — so two things that both want beforeActivate cannot
coexist. defineActorApp is the composition root that fixes that: it folds
every plugin's contributions into the single placement, storage and context
createHost already understands.
// src/actors.app.ts — one typed source of truth
import { defineActorApp } from '@sigx/actors/host';
import { fileStorage } from '@sigx/actors/node';
export const app = defineActorApp({
actors,
storage: fileStorage({ dir: '.actors' }),
defaults: { idleAfterMs: 60_000 }
}).use(metrics());
/** Bound to this app's plugin set — import it from your actor modules. */
export const { defineActor } = app;const host = await app.start(); // builds the host, starts it, runs onStart
await app.stop(); // drains the host, then onStop in reverseThe app is an inert description until start(), which is what lets the
same module be started by a Node entry, the Vite dev server, or a Worker.
A started app is single-use: start() is idempotent while running, but
after stop() it refuses to restart (a plugin placement mints its identity
per run — a cluster host id is gone once its membership entry is), so build a
new app instead. A start that fails is the exception: the rejection is not
cached, so fixing the cause and calling start() again really retries.
Writing a plugin
setup() receives a registry. Everything composes across plugins except
setPlacement, which is exclusive by nature — a second claim throws, naming
both plugins.
import type { ActorPlugin } from '@sigx/actors/host';
interface Logger { info(message: string): void }
export function logging(logger: Logger): ActorPlugin<{ log: Logger }> {
return {
name: 'logging',
setup(registry) {
registry.extendContext(() => ({ log: logger }));
registry.onBeforeActivate((ref) => logger.info(`activating ${ref.type}/${ref.key}`));
registry.useDispatch((next) => ({
dispatch: async (ref, method, args, call) => {
logger.info(`${ref.type}#${method}`);
return next.dispatch(ref, method, args, call);
},
// Forward streaming — `dispatchStream` is optional, so a
// middleware that omits it silently breaks every
// `streams:` method. Dev-warns if you forget.
...(next.dispatchStream && {
dispatchStream: (ref, method, args, call) =>
next.dispatchStream!(ref, method, args, call)
})
}));
}
};
}The ActorPlugin<{ log: Logger }> type argument is what makes ctx.log
typed inside every actor that imports the app-bound defineActor — no
global declaration merging, so the additions stay per-app:
// src/counter.actor.ts
import { defineActor } from './actors.app';
export const Counter = defineActor({
type: 'Counter',
allowAnonymous: true,
state: () => ({ count: 0 }),
methods: (ctx) => ({
increment(by: number) {
ctx.log.info('increment'); // typed, contributed by .use(logging(...))
return (ctx.state.count += by);
}
})
});| Registry hook | Composes? | Notes |
|---|---|---|
| addTypeHandlers | concatenated | codec handlers for state persistence |
| decorateStorage | chained | last registered is outermost |
| setPlacement | exclusive | a factory, run once the host exists; a second claim throws, naming both plugins |
| onBeforeActivate | in order | throwing refuses the activation |
| onAfterDeactivate | reverse order | errors caught per hook and dev-logged |
| useDispatch | outside-in | first registered is outermost; must forward dispatchStream |
| onStart / onStop | in order / reverse | onStop runs after the drain |
| route | collected | exposed as app.routes for adapters |
| extendContext | merged | never overwrites a built-in ctx member |
A placement's own hooks bracket the plugins': its beforeActivate (a
cluster's directory claim) runs first and its afterDeactivate (the release)
runs last, so plugin hooks always observe an activation the placement already
owns.
Custom placement
Choosing between the shipped policies? See Which placement policy should you use? — the short answer is that the default is right unless your load balancer hashes the routing token.
Two independent axes:
- The backend — who hosts an actor at all: the local host, a cluster,
Durable Objects. One per app, claimed with
setPlacement. - The strategy — which host a new activation goes to. That is
PlacementPolicyfrom@sigx/actors/cluster; ship your ownchoose(ref, view, self)alongside the built-inrandomPlacementPolicy(),consistentHashPolicy(),preferLocalPolicy()andactivationCountPolicy().
A strategy can be declared on the actor, a per-type placement
attribute that beats the central typePolicies map:
export const Session = defineActor({
type: 'Session',
placement: preferLocalPolicy(), // pin hot session-shaped types local
// ...
});setPlacement takes a factory, not an instance, precisely so a backend
can read those declarations — it runs once the host exists, so the context
can resolve definitions:
registry.setPlacement((ctx) => clusterPlacement({
...providers,
// per-type strategies resolve lazily, since a `virtual:sigx-actors`
// registry only loads a type's module on demand
definition: ctx.definition,
secret,
}));Precedence for a new activation: defineActor({ placement }) →
clusterPlacement({ typePolicies }) → clusterPlacement({ policy }) →
uniform random.
A declared strategy the cluster cannot use — no choose() — is an error,
not a fallback: silently placing an actor somewhere other than where its author
declared is the kind of failure that leaves no signal pointing at its cause. A
strategy intended for a different placement backend should say so, and is
then ignored in silence:
placement: { name: 'my-do-strategy', backend: 'durable-objects' }Other runtimes
createFetchHandler(app) is the portable entry — the public actor endpoint
plus every plugin route as one (Request) => Response:
import { createFetchHandler } from '@sigx/actors/server';
await app.start();
Deno.serve(createFetchHandler(app)); // Deno
export default { fetch: createFetchHandler(app) }; // Bun, Workers@sigx/actors/node's createAppHandler stays the Node mount — it keeps
core's connect adapter for the public endpoint (backpressure-aware body
pumping) rather than routing everything through the generic bridge.
Pluggable reminders
Durable reminders are a seam too. The default shardedReminders() keeps the
table in ActorStorage under a reserved type, split into 16 hash shards that
hosts divide between them — which assumes many actors per host:
defineActorApp({ actors, reminders: shardedReminders() }) // the defaultWhere that assumption is false, replace it. Under Cloudflare's
one-Durable-Object-per-actor model each actor's reminders live in its own DO
and fire from its own alarm, so there is nothing to shard and nothing to
poll. An implementation gets bind({ storage, scheduler, tickMs, ownsShard,
deliver }) once before start(), mirroring ActorPlacement.bind().
The clock seam
Background work — the idle sweeper, the reminder tick, ctx.timer,
write-behind flushes — runs through an ActorScheduler. Those are the jobs
that must keep running between requests, so they are the ones a runtime
has to redirect. Call deadlines and the shutdown drain stay on host timers,
since they are scoped to an in-flight request or stop:
defineActorApp({ actors, scheduler: timerScheduler() }) // the defaultThat matters for two things. Tests can drive time exactly:
const scheduler = manualScheduler();
const host = createHost({ actors, scheduler, defaults: { idleAfterMs: 0 } });
scheduler.advance(60_000); // an hour of sweeps, instantlyAnd it is what makes a runtime with no background execution possible at all: a Cloudflare Worker only runs while handling a request, so an interval registered at startup never fires. That is an architectural difference, not something a polyfill can hide — hence a seam rather than a shim.
Shipping actors in a package
An app registers actors from anywhere — withActors([Greeter, Presence]).
The build is the part that needs care: sigxActors() only transforms
first-party source (node_modules is excluded), so a package must do its
own client swap, through its exports map:
{
"exports": {
".": {
"types": "./dist/server.d.ts",
"browser": "./dist/client.js",
"import": "./dist/server.js"
}
}
}// client.ts — the browser half
import { __actorRef } from '@sigx/actors/client';
import type { Greeter as GreeterDef } from './server';
// Types come from the real definition; the value is a ref. The `import
// type` is erased, so no implementation reaches the browser.
export const Greeter = __actorRef('acme/greeter', '/_sigx/actor', ['watch']) as typeof GreeterDef;That is the same swap the Vite plugin performs for *.actor.ts, done by
static resolution instead — so it works with any bundler.
Three things are on the package author, because the consuming app's build never sees the source:
- Authorization.
requireAuthorizationcannot inspect a package, so declareauthorizeorallowAnonymousyourself. The host warns for a registered actor that declares neither. - Stream names in the ref must match the definition; they drive wire routing.
- Cacheable read names likewise, as the ref's fourth argument
(
__actorRef(type, endpoint, streams, reads)): they are what makes the client issue GET, and a name that is in one place but not the other means either a 405 or a read that quietly never caches. typeis public API. It is the wire, directory and storage key, so renaming it breaks deployed state. Two different actors claiming one type is refused at startup. Namespace it after the package (acme/greeter), but not with the npm scope form — atypestarting with@or$is refused: those heads belong to the runtime's own data keys (@actor) and mounts ($live).
Authorization
Actor methods are as security-sensitive as server functions, and the runtime
is fail-closed: an actor that declares nothing, in a process with no
server app configured, denies every call with 401. Access is decided by an
authorize policy, or waived explicitly with allowAnonymous: true, or
left to the app's default policy.
// src/server-app.ts — the ONE place app-wide policy lives
export const app = createServerApp<User>({
middleware: [requestId, auditLog], // work, every transport
authenticate: sessionFromCookie, // -> User | null
codec: { encode: (u) => u.id, decode: (id) => ({ id }) }
});defineActor({ type: 'Room', methods }); // app default decides
defineActor({ type: 'Auth', allowAnonymous: true, methods }); // deliberately publicA policy is core's ServerPolicy — (principal, rq, op) => boolean,
strict-true: anything else denies (403; 401 when the principal is
null). A thrown ServerFnError passes through verbatim for a custom
status. Policies run on every transport — the wire endpoint, $live,
and in-process actor() calls — and always outside any turn, so a slow
check never occupies the actor's turn.
The policy sees the instance, which is what makes the common actor question expressible at all:
defineActor({
type: 'Cart',
// op.resource is { kind: 'actor', type: 'Cart', key, method }
authorize: (user, _rq, op) => op.resource.key === user.id,
methods
});methodAuthorize: { methodName: [...] } adds per-method policies, ANDed
after authorize. Actor-to-actor calls (ctx.actor) do not
re-authorize: authentication is per REQUEST and authorization is per ENTRY
POINT — the wire endpoint, the live endpoint, an in-process call, a job
enqueue — and a hop is none of those.
The endpoint-level guard option is gone (it was wire-only by
construction). App middleware runs in the same pre-decode slot and
reaches in-process calls too; a wire-only concern is one
if (fn.transport !== 'wire') return; in the middleware body.
Identity: ctx.principal
The authenticated principal reaches every actor as ctx.principal, with
nothing to stamp and nothing to thread through arguments:
defineActor({
type: 'Room',
methods: (ctx) => ({
async post(text: string) {
const from = (ctx.principal as User | null)?.id;
// …
}
})
});It rides a first-class slot on the call envelope, not the context bag —
so it cannot be forged through .with({ bag }), and it cannot be dropped
by forgetting to stamp it. It is inherited unchanged by ctx.actor and
ctx.publish hops and carried host-to-host, so a downstream actor sees
whoever entered the system, not the actor that called it. Decoding is
lazy and memoized, so an actor that never reads it pays nothing.
Inside a defineJob run body it is job.principal instead: a job outlives
the request that started it, so authorization happens once at enqueue and
the run reads the snapshot recorded there — persisted, so it survives
deactivation and every crash-resume.
It needs codec on createServerApp (a principal has to round-trip as a
string to ride the envelope). Without one it propagates nothing and
dev-warns once — fail-closed at the reader. Treat null as
unauthenticated, never as a default principal: between hosts this rides
outside the cluster HMAC, exactly like the bag, so its trust is the
deployment perimeter rather than a proof.
The request-context bag
The bag is the channel for app DATA — a correlation id, a tenant hint —
that inner hops would otherwise never see. (Before the guard split it also
carried identity; that is ctx.principal's job now, precisely so a
forgotten stamp cannot silently drop the caller mid-chain.)
import { stampCallBag } from '@sigx/actors';
const withTenant: ServerMiddleware = (rq) => {
stampCallBag(rq, { tenant: tenantOf(rq) }); // ← stamped once…
};
defineActor({
type: 'Room',
methods: (ctx) => ({
async post(text: string) {
const tenant = ctx.bag.tenant; // ← …read anywhere
}
})
});stampCallBag(rq, entries) merges string entries (last wins) onto the
request; after the pipeline runs, the endpoint lifts them into the call
context, where they ride the whole chain: ctx.bag on the called actor,
inherited by ctx.actor(...) and ctx.publish(...) hops (policies do not
re-run on those — a hop is not an entry point),
across host-to-host hops on the envelope, and into $live watches. The
in-process actor() entry lifts the same store, so a serverFn's stamp
reaches the actor with no HTTP hop. actor(Def, key).with({ bag })
sets entries explicitly (server-side scripts, tests, ops), merging over the
stamped/inherited ones — explicit wins.
The rules, all deliberate:
- String-only and size-capped: at most 8 entries, 64-byte keys,
256-byte values, 1 KiB total (UTF-8 bytes; the
CALL_BAG_MAX_*exports). The bag rides an HTTP header host-to-host, and a cap that exists keeps the wire honest.stampCallBagand.with({ bag })throw on violation — developer input fails at the developer's stack. - Never client-settable. The public endpoint reads no header into the
bag; the only sources are server-side. A browser-settable
userentry would be an authorization bypass. - Dropped whole, never partially, and never a 400. En route (the envelope), a malformed or over-cap bag is silently dropped in one piece — a partial identity is worse than none, and a header-triggered 400 is a DoS lever. Consequence: treat a missing entry as unauthenticated, never as some default principal.
- Not integrity-protected between hosts. The cluster HMAC signs the call identity, not the envelope body, so the bag's integrity rests on the same perimeter posture as the rest of the envelope: run mTLS/VPC between hosts.
- Detached work does not inherit it: task bodies and volatile timer
ticks read an empty
ctx.bag, exactly as they carry notraceparent— they outlive (or have no) caller. ctx.bagis frozen and resolved per read, so it is turn-correct on interleaving activations, and an empty frozen object outside any turn.
Cacheable reads (reads:)
Declare a method a cacheable read and the endpoint accepts GET for it and
emits the Cache-Control you asked for, so browsers, CDNs and reverse
proxies absorb read traffic that would otherwise reach an actor:
defineActor({
type: 'Product',
allowAnonymous: true,
reads: {
summary: { maxAge: 5 },
price: { maxAge: 60, public: true, staleWhileRevalidate: 30 }
},
state: () => ({ cents: 999 }),
methods: (ctx) => ({
async summary(currency: string) { … },
async price() { return ctx.state.cents; }
})
});GET {base}/r/{token}/Product%23price?args=["p1"] → the same envelope a POST
returns, plus Cache-Control: public, max-age=60, s-maxage=60,
stale-while-revalidate=30. The declaration is core's ServerFnReadCache
vocabulary, unchanged, and the build stamps the names onto the client ref so
the proxy issues GET on its own — nothing at the call site changes.
What you are trading, stated plainly: a cached read bypasses the
turn ordering guarantee. For maxAge seconds the response an
intermediary serves may be older than the actor's state, and nothing on the
server can pull it back — not ctx.save(), not useActorAction, not
cells.invalidate(), which refresh this page's cells and never a CDN's copy.
Declare it where staleness is a product decision, not where it would be a
bug. The declaration is also a promise the runtime cannot check: a listed
method must be side-effect-free and idempotent, exactly as with core's
cache on a serverFn — a mutating method declared cacheable re-opens CSRF.
The rest follows from that, and is checked:
publicis gated. It puts the response in SHARED caches, where one caller's copy is served to the next, so core's contract is args-only — never cookies, auth or headers. A guard is the one thing here that provably reads the request and nothing can inspect what it reads, sopublicon a guarded read is a definition-time throw. Withoutpublicthe read is still cached, per client, and the endpoint addsVary: Cookie.- Guards still run, on GET exactly as on POST. A rejection answers its
status with
Cache-Control: no-store— a failed read is never cacheable. - Streams cannot be declared, and saying so is a definition-time throw rather than a silently ignored declaration.
- POST keeps working for every declared read: the declaration lives on the definition, not on the wire, so a hand-built host with no build transform, an older client, or a service calling by hand all still work.
- Routing is unchanged — the token travels in both carriers, and an actor another host owns is proxied as usual, with the answering host making the caching promise.
- No
content-typeon the GET: it would describe a body that does not exist, and it is a non-safelisted header, so leaving it off is one fewer reason to preflight. Not a promise of no preflight — the routing token header ships by default and triggers one on its own, so a cross-origin caller who needs a genuinely simple GET wantsroute: 'none'and no custom headers too. Same-origin (the usual case) never preflights either way. - A GET puts the actor key and every argument in the URL, where a POST
body kept them out of access logs, proxy traces and referrer headers. That
is the same log-hygiene concern the hashed routing token exists for, and it
now applies to the arguments too, in plaintext.
maxAgevalues are the whole non-negative secondsCache-Controlactually defines — a fractional one is a malformed directive, so it is refused rather than emitted.
Per call, actor(Def, key).with({ get: false }).summary() sends a declared
read as a POST instead: no caching, but no arguments in the URL either, and
no query-length cap (a long enough query is a 414). The endpoint accepts both
carriers for a declared read, so this is a client-side choice.
One-way calls
await actor(Notifier, userId).with({ oneWay: true }).notify(event);A one-way call resolves as Promise<void> when it is accepted into the
target's activation — locally when scheduled, remotely when the transport reply
comes back from the receiving host's enqueue — never when the turn completes.
Fire-and-forget with backpressure and error accounting, instead of the
floating-promise workaround that has neither: awaiting the call still tells
you the runtime has taken responsibility for it, without paying for the turn.
It works on every proxy — the browser client, the server entry, and
ctx.actor(...) — and types narrow to Promise<void> at the call site.
What happens to failures depends on when they happen, and the line is acceptance:
| Failure | When | The caller sees |
|---|---|---|
| guard veto, unknown type, activation failure, wrong host, auth, unreachable peer, host shutting down | before acceptance | a normal rejection |
| the method throws, a deadline expires mid-turn | after acceptance | nothing — dropped, counted as oneWayFailures in metrics() on the host that ran the turn (and a dev-mode console warning) |
The details that follow from "the caller is gone":
- Ordering holds. The call was enqueued before the promise resolved, so an awaited call issued afterwards runs after it — same turn FIFO as always.
- A one-way self-call cannot deadlock. Awaited,
A → Aon a non-reentrant actor is a detectedActorDeadlockError; one-way it is "schedule more work for myself" and simply queues behind the current turn. - The caller's deadline race is skipped — there is no caller left to race. The deadline still rides the context and bounds the turn's own nested awaited calls.
- Streams refuse it (a stream is consumed, not fired-and-forgotten), and
a declared read goes out as a POST even under
get: true— an ack must never be served from an HTTP cache. - On the public wire the proxy sends
x-sigx-one-way: 1and the endpoint answers at acceptance; host-to-host the flag rides the envelope as an additiveowfield, no protocol bump. - Mixed-version clusters degrade gracefully: an older receiving host ignores the flag and answers at turn completion — the call is still delivered exactly once; the sender just waits longer, and a post-acceptance failure can reach it as a rejection during the rolling deploy.
- Delivery is at-most-once after the ack, exactly as for normal calls: a connection lost between acceptance and the reply can be retried by the cluster's routing, the same window every unary call already has.
Persistence
Default is explicit: only
ctx.save()writes — a method that returns success has persisted what it acked.persistence: { mode: 'write-behind', debounceMs }saves automatically after mutating turns. Sharp edge, stated plainly: acked ≠ persisted under write-behind — use it for lossy-tolerant state only. Pending writes flush on deactivation and shutdown.Rich types (
Date,Map,Set,BigInt,URL,RegExp, plus yourserverPlugin({ types })handlers) survive storage and the wire — the same@sigx/serializevocabulary everywhere.migrateStateevolves a record whose shape predates this deploy — the answer to "thestate:shape changed and the stored records didn't". It runs between the storage read and activation, and only on a load that found a record: never on thestate(key)fresh path, never onctx.clearState(). It also runs beforeonActivate, which therefore always sees migrated state.defineActor({ type: 'Cart', state: () => ({ v: 2, items: [], coupons: [] }), migrateState: (stored) => { const s = stored as CartV1 | CartV2; if ('v' in s) return s; // fast path return { v: 2, items: s.items ?? [], coupons: [] }; // v1 → v2 }, // … });storedis already codec-revived (per the bullet above —Date/Mapare real objects), sounknownmeans unknown shape, not raw JSON. A second argument carries{ raw, key }when the revived view can't tell two stored versions apart:rawis the encoded record as storage holds it.Returning the input unchanged is the fast path, and identity is how that's detected — so to migrate, return a new object.
The migrated shape is written back lazily: it rides the next save the actor would have made anyway, so a read-only activation still issues zero writes and a rolling deploy costs no extra ones. That rule holds in both persistence modes —
migrateStatenever causes a write by itself, so a write-behind actor that is only ever read after a migration doesn't persist it. For a record that would otherwise never be saved at all (and so would be re-migrated on every activation forever),{ persist: 'eager', migrate }opts into one CAS write-back at activation; if a peer migrated first, the loser adopts the winner's record rather than failing.The trade is stated rather than hidden: a fleet mid-deploy can migrate the same record on several hosts. That's safe because the hook is a pure function of the stored value and every write is etag-CAS'd — first save wins, and the loser either adopts the winner (eager) or gets
ActorStateConflictErrorand re-activates against it (lazy).Synchronous, and a throw fails activation with
ActorActivationError— the same posture as a throwingonActivate. Corrupt state is loud, and the stored record is never silently reset. Version bookkeeping is your convention: the runtime neither reads nor writes a version field, and this is deliberately not a scheme for versioning an actor's interface across a mixed-version fleet.defineJobdoes not takemigrateState: a job's stored record is the job envelope (status/progress/checkpoint/… with your own state underextra), so a hook over it would hand you a runtime shape you don't own. Migrating theextrahalf wants its own option, and is not part of this.Providers:
memoryStorage()(tests/dev),fileStorage({ dir })(dev; one cat-able JSON file per actor). ImplementActorStorage(load/save/clear with etags) for real databases.Under Vite, a
fileStoragedirinside the project root belongs inserver.watch.ignored— actor state is not source, and a save is a temp file plus a rename, which the HMR file reader races and reports asENOENT … .tmp:export default defineConfig({ server: { watch: { ignored: ['**/.actors/**'] } } });
Reentrancy & interleaving
By default an actor is strictly serial (guarantees 2–3) and a call cycle
back into it is a detected deadlock (guarantee 5). reentrant widens that,
in two steps:
reentrant: 'call-chain' // alias: true — the v1 behavior
reentrant: 'always' // full interleaving, per actor
methodReentrancy: { stats: 'always' } // full interleaving, per method'call-chain'(true):A → B → Aruns inline against your own up-stack turn instead of deadlocking. Unrelated calls still serialize — no foreign interleaving, so state stays turn-consistent.'always': every call is its own turn, launched immediately — unrelated calls interleave at everyawait(the Orleans[Reentrant]model). The single-threaded guarantee narrows to what JS itself gives you: no two turns run between awaits, but your state can change across everyawait— re-read, don't cache, anything another turn may move. In-chain calls complete as concurrent turns (never inline), so a self-cycle cannot deadlock by construction.methodReentrancymarks individual methods'always'on an otherwise serial (or'call-chain') actor — the canonical case is a read-onlyget-style method that must not queue behind a slow write (pairs well withreads:). A mapped method never waits and is never waited for; unlisted methods keep the actor-level behavior, including their mutual exclusion. Keys must namemethods:entries; the runtime's own deliveries ($sigx:reminder,$sigx:topic) follow the actor-level setting only. Redundant next toreentrant: 'always', so that combination is refused.
What interleaving changes elsewhere — saves from concurrent turns are
single-flighted (last-writer-wins at whole-state granularity; a save
resolves once a snapshot at-or-after your mutations is durable), a
write-behind flush may capture mid-logical-turn state (it is still a
synchronously-consistent frame), and deactivation drains all in-flight
turns before onDeactivate. Declarations are validated at the type's first
activation, loudly, in every build. Interleaving needs AsyncLocalStorage
(per-turn call context): built into Node/Deno/Bun; on Cloudflare Workers it
rides the nodejs_compat flag the DO package already requires. Serial
actors never touch it.
Timers & reminders
ctx.timer(name, cb, { due, period, keepAlive })— volatile: ticks run as ordinary turns (coalesced under load) and die with the activation. Timers don't keep an actor alive unlesskeepAlive: true.ctx.reminders.set(name, { due, period })— durable: stored throughActorStorage, fired by the host's scheduler, and they re-activate an idle or restarted actor (onReminder(ctx, name)). Minimum period 60s; coarse resolution ("at or after"); at-most-once per tick.
Streams
Declare server→client streams in the streams: factory as async generators;
clients get them as AsyncIterable over NDJSON:
streams: (ctx) => ({
async *watch() {
yield* ctx.changes({ initial: true });
}
})
// client: for await (const s of actor(CartActor, id).watch()) render(s);Stream bodies are observers, not turns: they run detached from the
any turn (a stream that waited on its own actor's next turn while holding
the activation would self-deadlock), so they must read ctx.snapshot() /
ctx.changes() — never mutate live state. Dev builds warn when a stream
body reads live ctx.state. An open stream keeps the activation alive;
consumer disconnect runs the generator's finally. ctx.changes() yields a
detached snapshot after every mutating turn (bounded buffer, drop-oldest).
Use
changes({ initial: true })to seed, not ayield ctx.snapshot()prologue. The prologue subscribes only once the consumer resumes past that first yield, so every mutation in between is lost — and the snapshot it yielded is already stale.{ initial: true }queues the current snapshot in the same synchronous call that registers the subscription, leaving no gap.The
streams:factory must not touchctxwhile constructing the table (its method names are read at definition time); inside generator bodies, anything goes.computed/watchsetup belongs inmethods:.It runs once per subscription, with a context of its own. That is what lets a disconnect close the feeds a body opened: an async generator parked inside
ctx.changes()is suspended at an internal await, where the spec queuesreturn()instead of running it, so the subscription has to be closable from outside the body. Nothing an author writes changes — the factory is a table constructor, not a place for per-activation state.A quiet stream is kept alive at the byte layer. A stream that yields nothing sends nothing, and every intermediary with an idle timeout (ingress at 60 s, cloud load balancers at ~4 min, mobile NATs) closes it; the client then sees a stream that "ended without a done/error terminator". The endpoint therefore emits a
{"ping":1}line after 30 s of silence, which the client's reader skips. Tune or disable it withhandleActorRequest({ streamPingMs })—0is off.
Reading current state? Prefer useActorState(…, { live: true }) over a
hand-written streams: body. It pushes the result of the read you already
declared, multiplexes every live read on the page onto one connection, and
reconnects on its own. streams: is for a feed that is not a read of current
state: a log tail, a progress sequence, an event history.
Topics (actor-to-actor pub/sub)
An actor that changes something often needs to tell N interested actors without knowing who they are. Declare the interest on the subscriber and publish from anywhere on the server:
import { topic } from '@sigx/actors';
export const chatMessages = topic<{ from: string; text: string }>('chat-messages');
// per-room: topic('chat-messages', roomId)
// The subscriber declares its interest — nothing registers, nothing is stored.
export const RoomFeed = defineActor({
type: 'RoomFeed',
authorize: [requireSession],
state: () => ({ recent: [] as { from: string; text: string }[] }),
methods: (ctx) => ({
async recent() {
return ctx.snapshot().recent;
}
}),
subscriptions: {
// subscriber key = topic key, so RoomFeed/room-1 gets room-1's events
'chat-messages': async (ctx, event) => {
ctx.state.recent.push(event.payload as { from: string; text: string });
await ctx.save();
}
}
});
// Publish from another actor's turn…
const report = await ctx.publish(topic('chat-messages', ctx.key), { from, text });
// …or from a serverFn / script via the running host:
await publishTopic(topic('chat-messages', roomId), { from, text });Subscriptions are implicit and declarative: the subscriber set is a pure
function of the deploy — every registered type whose subscriptions: names
the topic. A publish activates idle subscribers exactly the way a
reminder delivery does, and each delivery is an ordinary dispatch of the
reserved $sigx:topic method through placement, so a subscriber owned by
another host is reached over the internal transport (HMAC, deadlines,
branded errors — all of it) with no topic-specific wire machinery. The cost
model is S dispatches per publish, S = subscribing types, not activations.
Delivery is best-effort, at-most-once, and settled. publish() resolves
when every subscriber's handler turn has settled and reports what happened:
const { subscribers, delivered, failures } = await ctx.publish(chatMessages, msg);
// failures: [{ type, key, message, kind? }] — a throwing handler, a dead
// host, a detected deadlock. The publisher NEVER throws for a subscriber.Nothing is persisted or retried; a subscriber that was down missed the event. Backpressure is intrinsic — the publisher awaits the turns, bounded by its call deadline. FIFO holds per publisher→subscriber pair only when the publisher awaits its publishes sequentially; concurrent publishes have no relative order.
The details worth knowing:
- Key mapping. An entry may be
{ key: (topicKey) => subscriberKey, handle }—key: () => 'aggregate'makes one singleton receive every key's events. The default is identity: topic key = subscriber key. - Cycles are deadlocks, not hangs.
ctx.publishcarries the publishing turn's call chain, so a subscription that dispatches back into a non-reentrant publisher fails that delivery withkind: 'deadlock'in the report — the publisher is awaiting the fan-out, so an undetected cycle could never complete.reentrant: truedelivers inline instead;reentrant: 'always'delivers as a concurrent turn of the publisher. - Handlers are turns. They mutate state and
ctx.save()like any method; a throwing handler fails only its own delivery and does not fault the activation. Handlers are not wire-callable and never appear on the client. - Pages observe topics through a projection. A subscriber actor folds
events into state; the page reads it with
useActorState(RoomFeed, roomId, 'recent', { live: true })— the existing live channel pushes after every handler turn. No new wire. - Rolling deploys skew the subscriber set. A host publishes to the subscribers its registry declares, so a newly-added subscribing type misses publishes from not-yet-rolled hosts until the deploy completes — consistent with best-effort delivery.
- Hot topics pin subscribers active: every delivery is activity, so a
busy topic resets its subscribers' idle clocks (
idleAfterMs).
Tasks (long-running operations)
A method call holds the activation until it settles — fine for milliseconds,
wrong for a sync job or an AI workflow run. Declare that kind of work in the
tasks: factory and start it with ctx.tasks.start(name, input?): the body
runs detached, outside any turn, so ordinary reads, streams and watches
keep answering while it works.
const Sync = defineActor({
type: 'Sync',
allowAnonymous: true,
state: () => ({ done: 0, total: 0, phase: 'idle' as string }),
methods: (ctx) => ({
begin: (total: number) => ctx.tasks.start('run', total),
status: () => ctx.snapshot(),
stop: () => ctx.tasks.cancel('run')
}),
tasks: (ctx) => ({
async run(total: number) {
await ctx.turn((c) => { c.state.phase = 'running'; c.state.total = total; });
for (let i = 0; i < total; i++) {
ctx.abortSignal.throwIfAborted();
await syncOne(i, { signal: ctx.abortSignal });
await ctx.turn((c) => { c.state.done = i + 1; });
}
await ctx.turn(async (c) => { c.state.phase = 'done'; await c.save(); });
}
})
});The rules, and why they hold the actor model together:
- State only through
ctx.turn(fn). A task body is detached, so it gets nostate/save()—turn()enqueuesfnas one ordinary serialized turn with the full context. Every mutation stays race-free, and everything downstream of a turn (change feeds, watches, write-behind) works unmodified. Reads in the body usectx.snapshot()/ctx.changes(). ctx.abortSignalin a task is the RUN's signal. It fires onctx.tasks.cancel(name)(reason'cancelled') and on deactivation for any reason (reason: theDeactivationReason) — before the turn drain. Deactivation gives signalled tasks a bounded grace (taskGraceMs, default 10s) with turns still open, so a winding-down task can run one finalturn()checkpoint.cancelis a request, not a join. It aborts and returns; the run leavesctx.tasks.list()when its body settles. (Awaiting settlement from a method turn would deadlock with the task's own wind-downturn().)- A running task keeps the actor alive — the idle sweeper skips it, like an open stream.
startis single-flight per name and resolves when the body is launched, not finished. A task that throws is terminal — no automatic retry; that policy belongs to the layer above.- No wire surface. Start/cancel/status go through your own methods, so your guard chain governs them like any other call.
Crash-resume is built in. start() resolves only after the run is
durably recorded: a ledger entry in the reserved $sigx:tasks storage
record plus a liveness reminder armed under the same name. From then on:
- A run interrupted by deactivation (any reason but cancel) keeps its
entry — the next activation of the actor restarts it, with
TaskInfo.restartsbumped and the originalinputreplayed through the state codec. Completion, a throw, andcancelall remove the entry (a thrown task is terminal; crash ≠ throw), and an empty ledger disarms the reminder. - The reminder is the crash driver: when a host dies, the reminder shards are re-owned by the surviving hosts, the next tick delivers through placement, and the actor — tasks and all — re-activates wherever the cluster puts it, within roughly 60–90s. No client call needed.
- The contract is at-least-once: the runtime resumes the function;
your code resumes the work from its own checkpointed state (the
ctx.turn()+save()pattern above — resume by reading how far the last checkpoint got). A run that completes in the same instant its host stops may restart once more; make the last step idempotent or gate it on state. - On the Cloudflare Durable Object backend this degrades gracefully: the ledger lives in the DO's own storage and the liveness reminder maps onto its alarm; a fiber does not survive eviction, so a task there is checkpoint-and-resume with short gaps rather than one continuous run — the same at-least-once contract, checkpoint aggressively.
Jobs (@sigx/actors/job)
defineJob is the packaged experience on top of tasks: — one durable
long-running operation per actor, with the state machine, progress,
checkpoints and client surface already decided. Start a job from a request
handler and return immediately; check on it from anywhere in the cluster.
import { defineJob } from '@sigx/actors/job';
export const SecuritySync = defineJob({
type: 'SecuritySync',
authorize: [isAdmin],
maxAttempts: 3, // crash-resume attempts before 'failed'
retainMs: 86_400_000, // keep the terminal record a day, then forget
run: async (job, input: { providerId: string }) => {
const users = await loadUsers(input.providerId, { signal: job.signal });
const from = (job.resumedFrom as { cursor: number } | undefined)?.cursor ?? 0;
for (let i = from; i < users.length; i++) {
job.signal.throwIfAborted();
await syncOne(users[i]);
await job.progress({ done: i + 1, total: users.length });
if (i % 100 === 0) await job.checkpoint({ cursor: i + 1 });
}
return { synced: users.length };
}
});
// A request handler — returns immediately, the job runs on the cluster:
const runId = crypto.randomUUID();
await actor(SecuritySync, runId).start({ providerId });
// Later, from anywhere:
await actor(SecuritySync, runId).status(); // JobInfo: status/progress/attempts
await actor(SecuritySync, runId).cancel(); // marked immediately, run aborted
await actor(SecuritySync, runId).result(); // the return value, once completed
for await (const info of actor(SecuritySync, runId).watch()) render(info);What the layer decides for you:
- State machine:
pending → running → (paused ⇄ running) → completed | failed | cancelled.status()/watch()returnJobInfo— never the checkpoint (private) or the result (fetched once viaresult()). - One actor per run — key = your run id. The directory's single-activation guarantee is the "exactly one runner" guarantee.
startis idempotent under retry: a non-pending job returns its current info and never restarts.- Crash-resume counts, pause-resume is free: a crash-resumed run
arrives with
job.attemptbumped andjob.resumedFromset to the last checkpoint; pastmaxAttemptsthe job is markedfailed.resume(data)on a paused job re-runs withjob.resumeDataand no attempt cost. pauseparks durably:return job.pause(checkpoint)writes the checkpoint, markspaused, and releases the task — the actor idles at zero cost untilresume(). For a timeout, armjob.remindersbefore pausing and handle it inonReminder(control, name)—control.resume()/control.cancel()are internal, so no self-dispatch deadlock.- Progress rides the change feed, not storage:
job.progress()(andjob.update()for your ownstate:extra fields) mutate state in a turn sowatch()pushes them live, but nothing is persisted per tick — after a crash, progress honestly regresses to the last checkpoint. retainMskeeps the terminal record around for lateresult()readers, then a one-shot reminder clears the state and deactivates;discard()does it on demand.
A singleton queue-worker (strict ordering, bounded concurrency), a
cron-on-reminders scheduler, and the Cloudflare DO posture are recipes,
not API — see docs/job-recipes.md.
Stateless workers (defineWorker)
Everything above assumes an actor is somebody — one identity, one
activation, turns in order. Pure compute (validation,
transformation, fan-out work) has none of that: two calls to the same
worker have no shared state to protect, so serializing them behind one
serialization is a bottleneck the semantics never asked for. defineWorker
declares a type whose activations are interchangeable:
import { defineWorker } from '@sigx/actors';
export const Resize = defineWorker({
type: 'Resize',
authorize: [requireUser],
maxLocal: 8, // pool cap; default: hardwareConcurrency (≤16)
methods: () => ({
async run(image: Uint8Array, width: number) {
return transform(image, width);
}
})
});
await actor(Resize, 'any').run(img, 800); // callers look exactly the sameThe contract, stated loudly because it is the whole point:
- Two calls to the same key may run concurrently, on different pool
members. The host keeps up to
maxLocalactivations per (type, key), spun up under load, and each dispatch rides the member with the fewest queued turns.ctx.keyis still the key the caller addressed — it just no longer names a single runner. - Always local, zero directory traffic. A worker activates on
whichever host (or Cloudflare isolate) received the call: no directory
claim, no lookup, no routing, no 421 redirect — and nothing for a
cluster to fence, migrate or rebalance. (Both invariants are gated
exactly in CI:
directory_ops == 0, pool ≤ cap.) - No identity, so no identity-bound surface.
state,persistence,reminders,tasks:,subscriptions:,placementandreentrantdo not exist onWorkerOptions— the option is a compile error, andctx.state/ctx.save()etc. are typed away (WorkerContext) and throw if reached through a cast. - What remains: authorization (
authorize/methodAuthorize/allowAnonymous, same build gate),reads:(a pure read is the ideal cacheable GET),streams:(pure generators — an open stream pins its member against the sweep),onActivate/onDeactivatefor per-member warm-up/teardown (load a model once per member, close it on the way out), andctx.timer/ctx.actor/ctx.publish. - Pool members idle-collect individually after
idleAfterMs— a quiet worker shrinks back to zero footprint. - A same-key self-call is a deadlock, deterministically.
reentrantdoes not exist for workers, soctx.actor(Self, ctx.key)throwsActorDeadlockErrorrather than working only when the pool happens to have a free member. A different key is a different pool and fine. - Watches are refused — a watch is a state-change feed and a worker has no state.
Workers live in *.actor.ts files like every other definition (not
*.worker.ts — that suffix belongs to Vite's Web-Worker convention), and
the build swaps them for the same wire client, so a browser can call one
directly. In an app, app.defineWorker is the plugin-typed twin, exactly
like app.defineActor.
Lifecycle
onActivate(ctx)/onDeactivate(ctx, reason)hooks; anonActivatethrow fails all parked callers and forgets the activation (nothing is remembered — the next call retries from scratch).migrateStateruns before both, between the storage load and activation — see Persistence.- Idle actors deactivate after
idleAfterMs(default 20 min; per-actor override).ctx.deactivate(): finish the queue, then go. defaults.maxActivations(default 0 = unlimited) is a soft cap: when the sweep finds more active than this, it deactivates the least-recently-used idle, unheld ones with reason'capacity'— LRU pressure relief before memory pressure does it for us. Busy, queued, or kept-alive activations are never shed, so a genuinely loaded host may sit over the cap until it quiets; a shed actor re-activates on its next call, state intact. Rides the sweeper (sweepIntervalMs > 0), andmetrics().activations.byReason.capacitysays how often it fires.host.stop()drains every activation, flushes persistence, ends open streams and rejects new external calls.attachSignalHandlers(host, { server, onStopBegin })wires it to SIGTERM/SIGINT and drains the HTTP edge, which is the other half of a graceful shutdown. Both options matter:onStopBeginis what retires keep-alive sockets gracefully, whileserveralone only closes the listener at the end — see Clustering for the full recipe and why the order is what it is. A failed drain exits non-zero rather than vanishing as a clean stop.- Deactivation fires
ctx.abortSignalfirst — before draining the any turn — so a parked turn or a running task can observe it and wind down inside the drain window instead of holding it hostage. - Calls that arrive during deactivation wait and land on a fresh activation.
- External calls get a deadline (
callTimeoutMs, default 30s) — on expiry the caller getsActorCallTimeoutError; the turn itself always runs to completion. Enforcement is never early but coarse when far: a deadline ≥ 10 s away shares one registry tick and may fire up to ~2 s late, while a short budget (a wire hop arriving nearly spent) gets an exact timer.
Seeing which actors are live
host.stats() gives you the counts. host.activations() gives you the
actors themselves — bounded, sorted, and safe to poll:
host.activations({ sortBy: 'queued', limit: 20 });
// [{ type: 'Cart', key: 'user-42', queued: 7, ageMs: 812_004,
// idleMs: 0, keptAlive: false, tasks: 0 }, …]tasks is the running detached-task count — the actors hosting
long-running work, and the usual reason a keptAlive row is being skipped
by the idle sweeper. sigx actors top shows it as a TASKS column.
sortBy picks which end you care about: 'queued' (default) is the hot
actors, 'age' the long-lived ones, 'idle' the next sweep's candidates.
type filters. Ties break on the actor id so the order is stable between
polls — a table that reshuffles equal rows at 1 Hz is unreadable.
It walks the directory, so it costs O(activations) and allocates a record
per candidate; limit defaults to 100 because this is a "top N" view and a
host can hold millions. Poll it at human rates, not per request.
ageMs is monotonic and idleMs is wall-clock — deliberately different
clocks. Age is a duration and must survive an NTP step; idle is compared
against idleAfterMs by the sweeper, which genuinely wants wall time.
stats() also reports transitional: { activating, deactivating }. Those
slots have no activation to read yet, so they are not in activations and
never were in the counts — which meant a host in the middle of an
activation storm read as idle, at exactly the moment you were looking
at it.
Metrics
metrics() is a plugin that counts what the host is doing. Pull-based: no
exporter, no push pipeline, no metrics-library dependency — you read
snapshot() whenever you want, from a route, a health check, or a test.
import { defineActorApp, memoryStorage, metrics } from '@sigx/actors/host';
const m = metrics();
const app = defineActorApp({ actors, storage: memoryStorage() }).use(m);
const host = await app.start();
m.snapshot();
// {
// windowMs: 60_000,
// calls: { total: 12_400, failed: 3, streams: 2 },
// latencyMs: { count, minMs, maxMs, meanMs, p50Ms, p90Ms, p99Ms },
// queueMs: { ... }, // waiting for a turn
// turnMs: { ... }, // holding the activation
// byType: { Cart: { calls, failed, latencyMs, queueMs, turnMs } },
// byMethod: { 'Cart#checkout': { calls, failed, latencyMs, queueMs, turnMs } },
// errors: { byKind: { 'call-timeout': 3 },
// recent: [{ at, type, method, kind, message }] },
// activations: { created: 91, destroyed: 88, byReason: { idle: 88 } },
// storage: { loads, saves, clears, conflicts, latencyMs },
// gauges: { activations: 3, queued: 0, perType: { Cart: 3 } }
// }Read queueMs against turnMs first. They are the two halves of every
call's latency and they mean opposite things:
| | meaning | fix |
|---|---|---|
| high turnMs | the method itself is slow | move I/O out of the turn, split the method |
| high queueMs | the actor is a hotspot — callers are waiting behind each other | shard the key, or reduce traffic to it |
A dispatch middleware only ever sees the sum, which is why queueMs is the
number people usually lack. metrics() gets it from observeTurns, the one
seam this needed (see below).
conflicts is worth an alert rather than a graph: each one is an etag
mismatch that discarded an activation.
Per method, and why calls fail
byType tells you a type is slow; it never tells you which of its methods
is. byMethod carries the same five numbers keyed Type#method, and the
queue/turn split is most useful there — within one type, a hot actor and one
slow method look identical until you separate the methods.
m.snapshot().byMethod['Cart#checkout'];
// { calls: 4_100, failed: 2, latencyMs, queueMs, turnMs }errors.byKind counts ActorErrorKind — 'call-timeout', 'wrong-host',
'state-conflict', 'unreachable', 'deadlock', 'activation',
'method-not-found', 'host-shutdown' — plus '(unknown)' for anything an
actor method threw itself. calls.failed says a host is failing; this says
what is wrong with it, and the two are very different questions: a rising
'unreachable' is a network or membership problem, a rising '(unknown)' is
your code.
errors.recent keeps the last few failures (default 32, recentErrors: 0 to
disable) as { at, type, method, kind, message }. Message only — no args
and no state, because this is read over an HTTP endpoint and a failing call's
arguments are exactly where the secrets are.
Both breakdowns are capped like byType, overflowing into '(other)'.
Methods get their own cap (maxMethods, default 256) rather than sharing
maxTypes, because methods multiply types: 64 types under the type cap would
leave under one method each and the breakdown would be almost entirely
'(other)' on a perfectly ordinary app.
Turning it on and off
Collection can be switched at runtime, and switching it off genuinely stops paying for it:
const m = metrics({ enabled: false }); // wired in, collecting nothing
m.enable(); // ...investigate...
m.disable(); // back to ~free; counters keep their values
m.enabled; // booleandisable() drops the turn subscription rather than returning early inside
it. That distinction is the whole point: the runtime only takes the per-turn
timestamps while an observer is attached, so an inert-but-attached observer
would keep paying for the larger half of the cost. Counters freeze at their
current values — use reset() to clear them.
The intended shape is to leave metrics({ enabled: false }) wired into
production and switch it on when you need to look.
What it costs
Measured on a noop dispatch — the cheapest call there is (~0.5µs) — with
each configuration in its own process, median of 9 runs:
| | throughput | vs no plugin |
|---|---:|---|
| no plugin | 2.05 M ops/s | — |
| an inert plugin (the control) | 2.08 M ops/s | ~0 |
| metrics({ enabled: false }) | 2.05 M ops/s | ~0 |
| metrics({ histograms: false }) | 1.88 M ops/s | −8% |
| metrics() | 1.43 M ops/s | −30% |
Three things to read off that. Disabled is indistinguishable from not
having the plugin at all — the residual branch in the dispatch wrapper is
below measurement noise. Not attaching it is free: with no observer the
dispatch path is unchanged, verified by comparing against a baseline of the
previous commit with the benchmark suite (benchmarks/). And plugins
themselves cost nothing — the inert control says so, which is what makes
the other rows attributable to metrics rather than to the plugin machinery.
Read the −30% in absolute terms before it alarms you: full metrics adds
~200ns per call. It looks like a third of throughput only because the
measured call does nothing at all — for an actor whose turn takes 100µs it
is under 0.25%. Durations use performance.now() rather than the wall
clock, which costs one extra read per observed turn and buys immunity to NTP
or a VM host stepping the clock backwards mid-turn.
The per-method breakdown is ~3.5% of that per dispatch — it was −28% before
byMethod existed. maxMethods: 0 gets the old cost back; what it buys is
per-method call counts, failure counts and the queue/turn split, which is
usually the trade you want.
That 3.5% was measured by loading both builds into one process and
interleaving their rounds, because separate processes could not resolve it:
the machine drifts ~10% between runs, larger than the effect, and the inert
controls disagreed by more than the thing being measured (see
benchmarks/README.md, "Trusting the numbers"). The metrics() row's
absolute ops/s above is therefore the original machine's figure rescaled by
that ratio rather than re-measured — the percentages are what was
observed, the ops/s is derived.
observeTurns
The seam behind the split, available to any plugin:
registry.observeTurns((ref, method, queuedMs, elapsedMs, failed) => { ... });Fires for dispatched turns only — the ones a caller waited for, including
reminder delivery. Volatile ctx.timer ticks and write-behind flushes are
excluded: they have no caller, and their cost is already visible as queue
wait on whatever was behind them. Call-chain-reentrant ctx.actor calls run
inline against the caller's turn and are excluded too. Interleaved turns
(reentrant: 'always' / methodReentrancy) fire once per turn like any
other, but launch immediately: queuedMs
