콘텐츠로 이동
한국어

Become and stash

이 콘텐츠는 아직 번역되지 않았습니다.

An actor’s onReceive is normally a single function that handles every message kind. Two context APIs let it reshape that handling on the fly:

  • context.become(handler) — temporarily replace the current receive handler with a different one. Restore with unbecome().
  • context.stash() + unstashAll() — park messages an actor isn’t ready for, then re-deliver them when it is.

Together they’re the actor-model answer to “this actor has phases where it doesn’t accept normal traffic” — initialization, recovery from a checkpoint, draining before shutdown, awaiting an async config load.

import { match } from 'ts-pattern';
import { Actor, ActorSystem } from 'actor-ts';
type OnMessage = { readonly kind: 'on' };
type OffMessage = { readonly kind: 'off' };
type PressMessage = { readonly kind: 'press' };
type Message = OnMessage | OffMessage | PressMessage;
class Switch extends Actor<Message> {
override onReceive(message: Message): void {
this.offState(message);
}
private offState = (message: Message): void => {
match(message)
.with({ kind: 'press' }, () => this.onPressWhileOff())
.otherwise(() => {});
};
private onPressWhileOff(): void {
this.log.info('turning on');
this.context.become(this.onState);
}
private onState = (message: Message): void => {
match(message)
.with({ kind: 'press' }, () => this.onPressWhileOn())
.otherwise(() => {});
};
private onPressWhileOn(): void {
this.log.info('turning off');
this.context.become(this.offState);
}
}
const system = ActorSystem.create('demo');
const sw = system.spawnAnonymous(Switch);
sw.tell({ kind: 'press' }); // → "turning on"
sw.tell({ kind: 'press' }); // → "turning off"
sw.tell({ kind: 'press' }); // → "turning on"

become(handler) swaps the function the framework calls on the next message. No flag fields, no inline if (this.state === 'on') ladder — the behavior is the state.

The signature:

become(behavior: Receive<TMessage>, discardOld?: boolean): void;
unbecome(): void;

discardOld defaults to true (replace). Pass false to push the new behavior onto a stack instead — unbecome() then pops it and restores the previous one.

class Loader extends Actor<Command> {
override onReceive(message: Command): void { this.idle(message); }
private idle = (message: Command): void => {
match(message)
.with({ kind: 'load' }, () => this.onLoad())
.otherwise(() => {});
};
private onLoad(): void {
this.context.become(this.loading, /* discardOld */ false);
this.startLoading();
}
private loading = (message: Command): void => {
match(message)
.with({ kind: 'done' }, () => this.onDone())
.otherwise(() => {});
};
private onDone(): void {
this.context.unbecome(); // pops loading, idle returns
}
}

Stacking is the right tool when you have a transient sub-behavior (“currently loading”, “currently authenticating”) that returns to a stable base behavior once it’s done. For toggles like the Switch example above, discardOld = true (the default) is cleaner.

Without become, the same Switch would look like this:

class Switch extends Actor<Message> {
private isOn = false;
override onReceive(message: Message): void {
match(message)
.with({ kind: 'press' }, () => this.onPress())
.otherwise(() => {});
}
private onPress(): void {
this.isOn = !this.isOn;
this.log.info(this.isOn ? 'turning on' : 'turning off');
}
}

Two states — the plain-field version is shorter. Where become wins is at N states: with three or four phases, the inline if/else chain on isOn grows into a state-machine ladder that the type system can’t help you with. become makes each phase its own function, with its own valid-message set, naturally narrowed.

For a more formalized version of the same idea, see the FSM pattern — explicit state + transition declarations on top of the same engine.

import { Actor, ActorSystem } from 'actor-ts';
import { match } from 'ts-pattern';
type ConfigureMessage = { readonly kind: 'configure'; readonly url: string };
type RequestMessage = { readonly kind: 'request'; readonly payload: string };
type Message = ConfigureMessage | RequestMessage;
class Worker extends Actor<Message> {
private url?: string;
override onReceive(message: Message): void {
match(message)
.with({ kind: 'configure' }, (m) => this.onConfigure(m))
.with({ kind: 'request' }, (m) => this.onRequest(m))
.exhaustive();
}
private onConfigure(m: ConfigureMessage): void {
this.url = m.url;
// Now drain whatever piled up while we were unconfigured.
this.context.unstashAll();
}
private onRequest(m: RequestMessage): void {
if (!this.url) {
// Not ready yet — park this message.
this.context.stash();
return;
}
this.log.info(`POST ${this.url}: ${m.payload}`);
}
}

The flow: messages arriving before configure are stashed. Once configure runs, unstashAll() re-prepends them onto the mailbox in the order they came in, and the actor processes them with the URL now set.

The signatures:

stash(): void;
unstashAll(): void;
readonly stashSize: number;

Four details that matter:

  • stash() must be called from inside a user-message handler. It parks the currently-handled message. Calling it from preStart, a timer callback that isn’t an actor message, or outside onReceive throws StashOutsideHandlerError.
  • unstashAll() is FIFO. Messages come back in the order they were stashed. For a PriorityMailbox, they’re re-inserted through the priority function — so an unstashed message rejoins its priority tier, not the absolute front of the queue.
  • The stash has a capacity. The buffer is bounded to prevent memory leaks from runaway stashing; overflow throws StashOverflowError. The bound is a fixed 1024 messages, compiled in — there’s no configuration knob for it.
  • A bounded mailbox bounds the replay too. Mailboxes are unbounded by default and none of this applies to them, but if you set one with withMailboxCapacity(...), the replay is subject to it: unstashAll() onto a full mailbox drops under drop-head / drop-new (shedding the newest queued messages to make room for the older stashed ones) and throws MailboxFullError under reject, leaving the batch stashed. See Mailboxes.

The two APIs compose for the “actor with an init phase” pattern:

class Worker extends Actor<Message> {
private url?: string;
override preStart(): void {
// Switch immediately into the "loading config" behavior.
this.context.become(this.loading);
void this.loadConfig();
}
private loading = (message: Message): void => {
match(message)
.with({ kind: 'configure' }, (m) => this.onConfigure(m))
.otherwise(() => this.onDuringLoad());
};
private onConfigure(message: ConfigureMessage): void {
this.url = message.url;
this.context.become(this.ready);
this.context.unstashAll();
}
// Anything else arriving during load — stash it.
private onDuringLoad(): void {
this.context.stash();
}
private ready = (message: Message): void => {
match(message)
.with({ kind: 'request' }, (m) => this.onRequest(m))
.otherwise(() => {});
};
private onRequest(message: RequestMessage): void {
this.log.info(`POST ${this.url}: ${message.payload}`);
}
override onReceive(message: Message): void {
// Initial behavior — the preStart switches into 'loading' before
// any user message arrives. This is here to satisfy the signature.
this.loading(message);
}
private async loadConfig(): Promise<void> {
const config = await fetchConfig();
this.context.self.tell({ kind: 'configure', url: config.url });
}
}

Two behaviors: loading (stashes everything except configure), ready (handles requests). The become makes the transition explicit; the stash + unstashAll makes sure no traffic is dropped during the initialization window.

SituationTool
Two or more discrete phases with different valid-message setsbecome
One phase has a transient sub-phase that should restore on donebecome(..., discardOld=false) + unbecome()
Boolean flag — “ready”/“not ready” — with the same handler shapeA plain field (don’t reach for become)
Some messages must be deferred until later, in orderstash + unstashAll
Init phase that buffers all non-init trafficbecome(loading) + stash in loading + unstashAll in transition
  • Actor — the base class whose onReceive you swap with become.
  • Mailboxes — unstashAll re-inserts into the mailbox; the mailbox decides the order.
  • FSM overview — a more formal state-machine style on top of the same become engine.
  • Typed behaviors — the typed API expresses the same idea as functional Behavior<T> values rather than mutating context.

The ActorContext API reference covers become, stash, and the full surface.