Pular para o conteúdo
Português (BR)

ActorSystem

Este conteúdo não está disponível em sua língua ainda.

The ActorSystem is the top-level container for actors. One per logical application — sometimes one per process, sometimes a couple running side-by-side (e.g. a worker-thread-isolation setup). Every actor lives inside a system; the system owns the dispatcher (which schedules message processing), the scheduler (which runs timers), the supervisor tree (which catches actor failures), the event stream, and any extensions you’ve registered.

import { ActorSystem } from 'actor-ts';
const system = ActorSystem.create('my-app');

The string is the system name — it appears in actor paths (actor-ts://my-app/user/...), log lines, and cluster identification. Different systems can coexist with different names; same name in a clustered setup means “I’m joining the existing cluster”, different name means “I’m a separate cluster”.

create returns synchronously. The system’s root guardians are spawned eagerly; user actors don’t exist yet — you spawn them via spawn (covered below).

ActorSystem.create takes an optional settings object as the second argument:

const actorSystemOptions = ActorSystemOptions.create()
.withLogLevel(LogLevel.Info)
.withConfigFile('./application.conf');
const system = ActorSystem.create('my-app', actorSystemOptions);

The full settings shape:

FieldPurpose
loggerCustom Logger instance. Defaults to a console logger respecting logLevel.
logLevelOne of debug / info / warn / error / off.
dispatcherCustom Dispatcher. Defaults to the immediate dispatcher; swap in a microtask- or throughput-based one to tune scheduling.
schedulerCustom Scheduler. Defaults to a real-time scheduler; tests inject ManualScheduler to control time.
configEither a prebuilt Config or a plain object of HOCON overrides. Layered on top of reference defaults + any application.conf.
configFileExplicit path to an application.conf file. Overrides the ACTOR_TS_CONFIG env var and the CWD lookup.

Constructor settings always win over anything in config — they’re the explicit code-level overrides.

For larger applications, prefer a application.conf file at the project root:

actor-ts {
logger {
level = "info"
}
dispatcher {
throughput = 100
}
cluster {
gossip-interval = 500ms
failure-detector.unreachable-after = 1500ms
}
}

The framework loads it automatically when present. ENV substitution (${?ENV_NAME}) works as the HOCON spec defines — values pulled from the environment fall back to the default when unset. See Configuration for every key the framework reads.

Top-level actors are spawned via system.spawn:

const root = system.spawn(
MyRootActor,
'root', // optional name; framework picks one if omitted
);

The returned ActorRef is a handle, not the instance. Pass it around, store it, hand it to other actors.

Inside an actor, child actors are spawned via context.spawn, not system.spawn:

class Parent extends Actor<...> {
override onReceive(message) {
const child = this.context.spawn(Child, 'worker');
}
}

Children are tied to the parent’s lifecycle — when the parent stops, all children stop first. Children’s failures escalate to the parent’s supervisor strategy. Top-level actors (from system.spawn) escalate to the system’s root guardian instead.

Every actor has a path under the system root. Three top-level “guardian” actors sit just below the root:

actor-ts://my-app/

/user

your application's actors

/system

framework-internal actors

/deadLetters

messages to dead refs

/system/cluster

sharding, singleton, pubsub, …

/system/devtools

hub + probes

When you call system.spawn(actor, name), the actor is created under /user. /user holds only what your code spawned; everything the framework spawns for itself lives under /system, one group per subsystem — see Actor paths for the full layout. There is no public API for spawning into /system.

On termination the two guardians go in sequence, not together: /user is fully drained first, then /system. That order is what lets a user actor’s postStop still reach the framework actors it depends on — unsubscribing from the pub-sub mediator, handing a shard back — instead of racing them into dead letters.

The /deadLetters “actor” is special — messages to a tell on a stopped ref, or to a ref that never existed, route there. Each one is published on the event stream as a DeadLetter naming the recipient it failed to reach, and that is all that happens by default: nothing logs them and nothing keeps them, so with no subscriber the message is gone. Subscribe to react programmatically, or turn on the bounded — optionally durable — queue that keeps and replays them; see Dead letters.

Extensions are the framework’s plugin system. Cluster, persistence, DistributedData, DistributedPubSub, HTTP — they’re all extensions. You register them once at the system level, then reach them via system.extension(...):

import { Cluster, ClusterOptions } from 'actor-ts/cluster';
import { DistributedDataId } from 'actor-ts/crdt';
const cluster = await Cluster.join(system, ClusterOptions.create() /* ... */);
const dd = system.extension(DistributedDataId).start(cluster);

Extensions are lazy: they don’t initialize until you reach for them. An app that never calls system.extension(DistributedDataId) never starts a DD replicator. This keeps single-process apps small; adopt features by reaching for them, drop them by stopping reaching.

import { extensionId, type Extension, type ExtensionId } from 'actor-ts';
class MetricsCollector implements Extension {
constructor(private readonly system: ActorSystem) {}
incCounter(name: string): void { /* ... */ }
}
const MetricsCollectorId: ExtensionId<MetricsCollector> = extensionId(
'MetricsCollector',
(system) => new MetricsCollector(system),
);
// Lookup is idempotent — first call creates, subsequent calls return
// the cached instance.
const metrics = system.extension(MetricsCollectorId);
metrics.incCounter('login.success');

Extensions are useful when:

  • You need cross-cutting state shared by many actors (a connection pool, a metrics collector).
  • The state is expensive to initialize and shouldn’t exist if nothing reaches for it (a cluster join, a DD replicator).
  • You want a clean way to inject test-doubles in unit tests (override the ExtensionId resolver).
await system.terminate();

terminate performs an ordered shutdown:

  1. Notify the cluster (if joined) — gossip “I’m leaving” so peers stop routing to this node.
  2. Drain /user — wait until no actor there has a turn in flight or a message queued, so ref.tell(x); await system.terminate() handles x. The wait is transitive: a cell is marked busy the moment something tells it, so replies, router fan-outs and restarts keep the drain going instead of flushing each mailbox once.
  3. Stop /user recursively — your actors get postStop, children first. Actors with in-flight async onReceives finish their current message before stopping.
  4. Stop /system — framework internals unwind. This starts only once /user is fully drained, so a postStop in step 3 can still talk to the sharding, pub-sub or delivery actors it depends on.
  5. Close the dispatcher and scheduler — no new messages, no new timers.
  6. Resolve the returned promise.

The drain in step 2 is bounded by actor-ts.system.shutdown-drain-timeout (2 s by default), and it returns the instant the tree goes quiet — an idle system pays a tick, not the budget. Set it to 0 to skip draining entirely; anything still queued when the budget runs out is dead-lettered by step 3, which is what happened to the whole backlog before draining existed.

Three things are deliberately not waited for:

  • a mailbox parked by context.throttle(...), and one suspended while a supervisor decides — neither drains at a rate a shutdown can wait for, so both count as quiet;
  • anything under /system — heartbeats, failure detectors and broker reconnect loops are never quiet by design;
  • work that is not in a mailbox yet — a context.timers tick that has not fired, or a tell from a promise a handler started without awaiting it.

To stop one actor after its mailbox drains and wait for that, use gracefulStop(ref, timeoutMs) — see PoisonPill & Kill.

Running until the process is asked to stop

Section titled “Running until the process is asked to stop”

For a service, terminate() is not what you call — it is what happens at the end. The whole of a main after the actors are wired is:

await system.runUntilTerminated();

That installs SIGTERM/SIGINT handlers, resolves once the system is down, and detaches the handlers on the way out. A hand-rolled process.on('SIGTERM', () => system.terminate()) looks equivalent and is not: it stops the actors first, and only then — if you remembered to write it — releases the HTTP port and leaves the cluster. runUntilTerminated() runs the coordinated shutdown pipeline, which unbinds listeners, closes brokers and leaves the cluster before the actors stop, so a rolling deploy takes the node out of rotation while its actors can still finish what they are holding.

It is also the version that works everywhere: Deno delivers signals through Deno.addSignalListener, not process.on, and a listener there keeps the event loop alive until it is removed.

The common answer is one. A second system in the same process means a separate cluster, a separate dispatcher, a separate supervisor tree — typically more overhead than the use case justifies.

Two situations where a second system makes sense:

  • Worker-thread isolation: the main thread runs one system, a worker thread runs another, both spanning the same cluster via the MessageChannelTransport. This is the Worker mesh pattern — multiple systems per OS process, all participating in the same cluster.
  • Test fixtures: a TestActorSystem per test case so cleanup is guaranteed. See TestKit.
  • Actor — the class you’ll spawn into the system.
  • Coordinated shutdown — graceful-shutdown DSL beyond a plain terminate.
  • Cluster overview — when you go from one system per process to many systems in a cluster.
  • Configuration — every HOCON key the framework reads, grouped by extension.

The ActorSystem class API reference documents every public method discussed here.