Topics
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)
export const RoomFeed = defineActor({
type: 'RoomFeed',
authorize: requireAuthenticated,
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);
await ctx.save();
},
},
});
// 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. Nothing registers at runtime, nothing is stored, and there
is no subscribe/unsubscribe call to get out of sync.
A publish activates idle subscribers, exactly the way a reminder delivery does. 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, where S is 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? }]
The publisher never throws for a subscriber. A throwing handler, a dead host or a detected
deadlock all arrive as entries in failures.
Nothing is persisted and nothing is retried — a subscriber that was down missed the event. If that is unacceptable, the event is not a topic; it is state a subscriber should read, or a job.
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.
Settled or accepted
What a publish waits for is delivery, on host.publish(), publishTopic() and
ctx.publish() alike:
await ctx.publish(runCompleted, { runId }, { delivery: 'accepted' });
delivery | Resolves when | A handler that throws |
|---|---|---|
'settled' (default) | every subscriber's handler turn has finished | is an entry in failures |
'accepted' | every delivery is queued on its subscriber | is dropped and counted in oneWayFailures, like any one-way call |
A delivery that cannot be queued at all — the subscriber's host is unreachable, say — is still
a failures entry under either mode. The report always carries delivery, because
delivered means "handled" under 'settled' and "queued" under 'accepted': an accepted
publish cannot report a handler that threw, by construction.
Choose 'accepted' when the publisher's own progress must not depend on its subscribers — the
completion path of a workflow, an audit feed, anything fanning into one aggregator. Two things
to know before you do:
- It decouples the publisher, not the subscriber. A singleton subscriber is still a
ceiling on how fast events are consumed, and an accepted publish queues on it rather than
failing. If that subscriber must shed rather than grow, give it
maxQueued. - It cannot deadlock. A subscription that dispatches back into the publisher is an ordinary queued turn, because nothing is waiting on it. A settled publish still reports the cycle, as below.
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 equals subscriber key.
Cycles are deadlocks, not hangs — under 'settled' delivery. ctx.publish carries the publishing turn's call chain,
so a subscription that dispatches back into a non-reentrant publisher fails that delivery
with kind: 'deadlock' in the report. The publisher is awaiting the fan-out, so an undetected
cycle could never complete. reentrant: true delivers inline
instead; reentrant: 'always' delivers as a concurrent turn.
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, and the page reads that:
useActorState(RoomFeed, roomId, 'recent', { live: true });
The existing live channel pushes after every handler turn. No new wire, and no way for a browser to subscribe to a topic directly.
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. That is consistent with best-effort delivery, but worth planning a two-phase rollout around if the subscriber is important.
Hot topics pin subscribers active. Every delivery is activity, so a busy topic resets its
subscribers' idleAfterMs clocks.
Reserved names
Topic names may not start with $ or @, and :topic is reserved by the runtime.
Next steps
- Live reads — how a page sees the projection.
- Reentrancy — what changes for cyclic delivery.
- Jobs — when delivery must not be best-effort.
