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

Event stream

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

The event stream is a system-wide bus for one-to-many messaging that doesn’t fit the parent-child or sender-receiver shape. Any actor can subscribe to a class of event, or to a kind-discriminated type; any code can publish; the bus matches and tells the event to every subscriber.

import { Actor, ActorSystem } from 'actor-ts';
class UserLoggedIn {
constructor(public readonly userId: string) {}
}
class AuditLogger extends Actor<UserLoggedIn> {
override preStart(): void {
this.context.system.eventStream.subscribe(this.context.self, UserLoggedIn);
}
override onReceive(event: UserLoggedIn): void {
this.log.info(`user ${event.userId} logged in`);
}
}
class MetricsCollector extends Actor<UserLoggedIn> {
override preStart(): void {
this.context.system.eventStream.subscribe(this.context.self, UserLoggedIn);
}
override onReceive(event: UserLoggedIn): void {
this.incrementCounter('logins');
}
}
const system = ActorSystem.create('demo');
system.spawnAnonymous(AuditLogger);
system.spawnAnonymous(MetricsCollector);
// Anywhere — including outside an actor:
system.eventStream.publish(new UserLoggedIn('user-42'));
// → both subscribers receive the event

The publisher doesn’t know how many subscribers exist, and the subscribers don’t know who’s publishing. That’s the whole point — loose coupling for cross-cutting concerns (logging, metrics, audit, fan-out alerts).

// A channel is named by a class, by an EventKey, or by the bare kind string.
type EventChannel<TEvent> =
| (abstract new (...args: any[]) => TEvent)
| EventKey<TEvent>
| KindOf<TEvent>;
class EventStream {
subscribe<TEvent>(
subscriber: ActorRef,
channel: EventChannel<TEvent>,
predicate?: (event: TEvent) => boolean,
): boolean;
unsubscribe<TEvent>(subscriber: ActorRef, channel?: EventChannel<TEvent>): boolean;
publish(event: object): void;
}

Three operations:

  • subscribe(ref, channel) — register interest. The channel is a class, an EventKey, or a kind string. Returns true if a new subscription was added, false if a duplicate was ignored, and throws if the channel is unusable (see the caution below).
  • unsubscribe(ref, channel?) — remove subscriptions. With a channel, just that one; without, every subscription for that ref.
  • publish(event) — fire-and-forget. The bus walks its subscription list, matches each subscription’s channel, and tells every match.

Class channels match with instanceof, so subclass instances reach base-class subscribers:

class SystemEvent {}
class UserLoggedIn extends SystemEvent {}
class UserLoggedOut extends SystemEvent {}
eventStream.subscribe(auditor, SystemEvent); // catches both
eventStream.subscribe(loginCounter, UserLoggedIn); // catches only the in-events

This makes hierarchical event taxonomies easy: subscribers pick their level.

Messages tells you to prefer plain objects ({ kind: 'x', n: 1 }) over classes — and a plain type has no constructor to hand subscribe. Name it by its kind instead. A type and a const of the same name give it the call shape a class gets for free:

import { EventKey } from 'actor-ts';
export type UserLoggedInEvent = {
readonly kind: 'user-logged-in';
readonly userId: string;
};
export const UserLoggedInEvent = EventKey.of<UserLoggedInEvent>('user-logged-in');
class AuditLogger extends Actor<UserLoggedInEvent> {
override preStart(): void {
this.context.system.eventStream.subscribe(this.context.self, UserLoggedInEvent);
}
override onReceive(event: UserLoggedInEvent): void {
this.log.info(`user ${event.userId} logged in`);
}
}
system.eventStream.publish({ kind: 'user-logged-in', userId: 'user-42' });

The key carries the event type, so a predicate written against it sees the real shape without you annotating the parameter:

eventStream.subscribe(auditor, UserLoggedInEvent, (event) => event.userId !== 'system');

The bare kind string is the shorthand, and it costs the type — there is nothing for TEvent to be inferred from, so the predicate sees unknown. Supplying the argument brings the typing back and makes the string itself checkable against the type’s kind:

eventStream.subscribe(auditor, 'user-logged-in'); // predicate sees unknown
eventStream.subscribe<UserLoggedInEvent>(auditor, 'user-logged-in'); // typed, and the string is checked

Two identity rules follow from this:

  • A key and its string are the same channel. Subscribing both ways dedups, and either form unsubscribes the other — including a freshly built EventKey.of('user-logged-in').
  • A class and a kind are two channels, even when the class’s instances carry that kind. They select overlapping events, just like a base class and its subclass, so an actor holding both subscriptions receives two deliveries per publish.
eventStream.subscribe(
metricsActor,
HttpResponse,
(event) => event.status >= 500, // only deliver 5xx responses
);

For high-frequency channels (cluster events, every HTTP response, every metric tick), filtering on the bus side is cheaper than filtering inside each subscriber’s onReceive.

Three details:

  • No dedup for predicate-bearing subscriptions. Without a predicate, the bus dedups per (subscriber, channel) pair — re-calling subscribe is a no-op. With a predicate, every call adds a new subscription, because predicate functions have no identity contract. “Replace this filter” means unsubscribe then subscribe again.
  • A throwing predicate is treated as “no match” for that delivery. Other subscribers are unaffected; the subscription stays active. A warning is logged via the system logger.
  • Predicates run at publish time, on the publisher’s stack. Keep them fast and pure — a heavy predicate slows every publish.

Three good fits:

  1. Cross-cutting observation — logging, metrics, audit. Many subscribers, none of which the publisher should know about.
  2. System-wide notifications — “the cluster gained a member,” “the cache flushed.” The cluster extension publishes its own events here; you can subscribe and react.
  3. De-coupling actor wiring in tests — instead of plumbing a spy ref through five layers, publish on the stream and let the spy subscribe directly.

The framework publishes several event types you can subscribe to:

EventWhere it comes from
DeadLetterEvery message routed to /deadLetters is also published. Useful for “alarms on lost messages.”
MemberUp, MemberRemoved, MemberUnreachable, …Concrete cluster membership events published by the cluster extension. Subscribe to each class directly — ClusterEvent is a union type, not a subscribable base class. See Cluster.
ReachabilityChangedThe publishing node’s own failure detector gained or lost sight of a peer — a local observation, unlike MemberUnreachable, which may have arrived in gossip. See Failure detector.
Broker eventsBrokerActor subclasses publish connect/disconnect/lag events.

Everything the framework itself publishes is a class, so class channels remain the way to reach all of the above. Kind channels are for your own application events.

One cluster event is deliberately not here: CurrentClusterState is delivered only to the listener that asked for it, by cluster.subscribe(listener, { replayMode: 'snapshot' }). It states where that subscriber is starting from, which is not news to anybody else, so it never reaches this bus.

See each extension’s docs for the full event vocabulary it publishes.

  • DistributedPubSub — cluster-wide publish/subscribe with topic routing; complementary to the local event stream.
  • Actor systemsystem.eventStream is where you reach the bus.
  • Messages — the shape rules (immutable, no method refs) apply equally to events, and the kind convention documented there is directly subscribable.

The EventStream API reference covers the full surface.