Ordered streams
StreamHub, actor stream methods, and the resumable SSE endpoint — the ordered, completable tier next to client events.
Client events are at-most-once hints: perfect for "re-query" and latest-wins state, structurally wrong when element identity matters. When a consumer needs the current value and every change since — in order, with completion, resumable across reconnects — that is a stream, and it needs a different contract (ADR-0052).
The litmus test is one question: if a message is lost, is the consumer merely stale, or is it wrong? Client events are at-most-once and self-healing — a lost hint means showing a value slightly longer (the next event or the reconnect greeting carries the complete current truth). Use a stream only when the sequence itself is the data, so a lost element makes the result silently wrong and no re-query can heal it: a client-side chart built from ticks, a recorder or analytics tailer, anything that asks "everything since sequence N", anything that must observe completion ("the auction closed" — client events just go quiet; silence and done are indistinguishable). And don't put a latest-wins UI on streams "because more guarantees can't hurt": full-fidelity sequences to a dashboard are pure wire waste, and per-key connections don't scale to a 100-symbol screen.
Client events are the default; a stream is the exception you justify. The second axis is whether a single live producer per key exists — a sequencer. Ordering has to come from somewhere:
| You need | Producer shape | Use |
|---|---|---|
| One request returns a lazy export, token output, or tail query | One caller owns one response | IStreamHandler |
| Invalidation hints ("re-query") | Any handler, any node, post-commit | Client events |
| Latest-wins live state | Live producer, versioned payload | Client events (payload tier + greeting) |
| Every element, machine consumer | Any handler | Integration events / outbox |
| Every element in order, completion, resume | One sequencer per key (a homed actor) | Streams (this page) |
No sequencer → no ordering source → not a stream. If latest-wins is enough, stay on the payload tier.
StreamHub is not merely an SSE implementation. It is a shared, producer-owned sequence/replay/resume
contract; use a cold IStreamHandler when one request
simply owns one lazy response and no shared sequence exists.
The primitive: StreamHub<T>
A hot, ordered, completable in-memory broadcast, owned by the producer — typically a field on an actor, whose mailbox already serializes publishes:
using Elarion.Streams;
private readonly StreamHub<Quote> _stream = new(new StreamHubOptions {
ReplayCapacity = 256, // the replay ring; 1 (default) = BehaviorSubject, more = resumable window
});
// Inside a turn: assigns the next contiguous sequence and delivers to every subscriber in order.
await _stream.PublishAsync(quote, ct);
// Subscribing is atomic replay-then-live: the retained ring and every subsequent publish, no seam
// between them — the greeting race client events resolve client-side does not exist here.
IAsyncEnumerable<StreamItem<Quote>> subscription = _stream.SubscribeSequenced(new StreamSubscribeOptions {
ResumeAfterSequence = lastSeen, // replay everything newer; a gap that outran the ring
}); // shows as a sequence jump, never a silent hole
_stream.Complete(); // ends every subscription — the signal fire-and-forget events don't have
_stream.Fail(exception); // ends them with the errorEach subscriber picks its own overflow strategy — one slow consumer must never stall the fan-out unless
it asked to: DropOldest (conflation, the default), Wait (true backpressure — trusted in-process
consumers only), or Cancel (the subscriber gets StreamLaggedException and re-subscribes with
ResumeAfterSequence; the publisher is never delayed). SubscriberCount is the interest pull for lazy
producers.
Two contract points come with the single-sequencer design. Serialize publishes with completion: the
ordering guarantees assume the producer serializes PublishAsync with Complete/Fail — automatic when
the hub lives inside an actor's mailbox. Completing concurrently with an in-flight publish makes delivery
of that final element per-subscriber nondeterministic (some live subscribers see it, others don't, and a
late replay subscriber may see an element a live subscriber missed). Cancellation is only clean before
commit: cancelling PublishAsync while it waits to enter the hub discards the element entirely; once
the element is committed (sequence assigned, ring updated), cancelling — e.g. while a Wait-mode
subscriber applies backpressure — abandons the remaining deliveries: the element exists in replay but
pending Wait-mode subscribers never receive it.
Actor stream methods
An [Actor] method returning IAsyncEnumerable<T> becomes a facade stream — the Orleans 7+
grain-interface shape, so call sites stay migration-portable:
[Actor(Placement = ActorPlacementMode.SingleHome)]
public sealed class StockQuoteActor(IActorContext<string> context) : IActorLifecycle {
private readonly StreamHub<Quote> _stream = new(new() { ReplayCapacity = 256 });
public async Task Apply(QuoteTick tick, CancellationToken ct) {
// ...update state...
await _stream.PublishAsync(ToQuote(tick), ct); // inside the turn → ordered per symbol
}
// The attach runs as a mailbox turn (fast); enumeration never holds the mailbox.
public IAsyncEnumerable<StreamItem<Quote>> Watch(long? resumeAfter) =>
_stream.SubscribeSequenced(new StreamSubscribeOptions { ResumeAfterSequence = resumeAfter });
public ValueTask OnActivateAsync(CancellationToken ct) => ValueTask.CompletedTask;
// The lifetime rule: a hub dies with its activation. Complete it here so consumers observe the
// end and re-subscribe (which re-activates the actor) instead of starving silently.
public ValueTask OnDeactivateAsync(CancellationToken ct) {
_stream.Complete();
return ValueTask.CompletedTask;
}
}
// Consumers use the generated facade like any IAsyncEnumerable — nothing happens until iteration,
// and each enumeration is its own subscription:
await foreach (var item in actors.Get<IStockQuote>("ELN").Watch(resumeAfter: null, ct)) { … }Two generator rules keep this safe (ELACT012): a stream method must not take a CancellationToken —
the turn token is pooled and its lifetime ends with the attach turn; the facade adds the trailing token,
which cancels the queued attach and (linked with the enumerator's token) the stream — and it cannot be a
[ConsumeEvent] consumer.
A live enumeration retains the activation (refCount lifetime): idle passivation never ends a stream
mid-flight, so a streaming-only actor — consumers attached, no turns arriving — stays alive, and the
idle window restarts when the last consumer leaves. Correctness passivations are deliberately exempt
(snapshot conflict, shutdown): those complete the hub via OnDeactivateAsync, consumers observe the end
and re-subscribe, and the fresh hub starts a new sequence epoch — which is why resume is a
within-one-hub-lifetime contract, and a consumer that sees ids restart treats it as a reset.
The SSE endpoint: MapElarionStream
Elarion.AspNetCore maps a stream as Server-Sent Events with resume built in: each element's
canonical JSON is one event whose id: is the sequence, so the browser's automatic Last-Event-ID
reconnect header (or an explicit ?after=) resumes from the hub's ring — gap-free within the retained
window, a visible sequence jump beyond it.
app.MapElarionStream<Quote>("/quotes/{symbol}/stream", (context, after) =>
context.Request.RouteValues["symbol"] is string symbol && symbol.Length > 0
? context.RequestServices.GetRequiredService<IActorSystem>()
.Get<IStockQuote>(symbol.ToUpperInvariant()).Watch(after)
: null); // null → 404The delegate is the host's control point: authorization happens inside it (or via
.RequireAuthorization() on the returned builder), and returning null is a 404. The element type must
be in a registered JSON source-gen context. The rest of the path is one TCP connection — ordered
end-to-end with zero extra infrastructure.
Multi-node reach: home-served by design
A stream needs its sequencer, so consumers must connect to the producer's node — which the recommended
topology already guarantees: put the streamed routes under the prefixes the
role-holder proxy (and later your identical ingress rule) sends to the
actor home. pg_notify is deliberately not involved: it has no ordering across sessions and no replay —
and no role, since one HTTP connection to the home is already reliable and ordered. Needing cross-node
streams without home routing is the existing replace-the-seam/Orleans trigger, not a knob.
samples/LiveQuotes ships both tiers side by side: the dashboard converges on conflated
market.quoteChanged client events, while curl -N /quotes/ELN/stream tails every accepted tick in
order with resumable ids.
Client events (near-realtime)
Push after-commit facts to connected browsers as invalidation hints — typed topics, fail-closed subscriptions, and a generated TypeScript client over Server-Sent Events.
Coordination & role leases
Elect one instance for a coarse application role on PostgreSQL, gate work at the holder, and route HTTP traffic to it when needed.