Ir al contenido
Español

Become and stash

Esta página aún no está disponible en tu idioma.

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;

Three 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.

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.
  • MailboxesunstashAll 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.