Zum Hauptinhalt springen

One Event, Every Screen: Live Fan-Out with Zaris Pub/Sub

· 14 Minuten Lesezeit
Clustron Team
Engineering für verteilte Systeme

Live features look simple on a laptop. A price moves, a game scores, an order ships — you raise an event, your WebSocket handler pushes it to the connected clients, and the screen updates. It keeps working right up until the moment there is more than one copy of your app. Then it quietly breaks, and it breaks in a way that is easy to miss in testing because a single instance always looks fine.

The reason is that an in-process notification — a C# event, an IObservable, a Channel<T> — only reaches code running inside the same process. Put four instances of your API behind a load balancer and the browser sessions spread across all four. A price tick that arrives on instance A raises an event on instance A, which pushes to the sockets instance A is holding. The three-quarters of your users connected to B, C, and D see nothing. The update is real, it just never leaves the process that heard about it.

The fix is a broker that carries the event across instances: one component publishes, every instance that holds interested clients receives, and each forwards to its own sockets. That is exactly what publish/subscribe is for, and Zaris ships it as a first-class native .NET API — IPubSubClient — with one property that makes it right for this job: a message published on any node in the cluster is delivered to subscribers connected to any node, exactly once. This post builds a live fan-out on it, verified against the shipping API.

Why pub/sub, and why it has to cross instances​

Fan-out is a shape, not a feature: one input, many outputs, and the outputs are not known in advance. A publisher does not know — and should not care — how many subscribers exist or where they are. It emits an event to a named channel and moves on. Subscribers declare interest in a channel and receive whatever lands there for as long as they are listening. Nothing about that contract mentions machines, and that is the point: it lets you scale the subscribers horizontally without the publisher ever changing.

For that contract to hold across a fleet of app instances, the delivery has to be genuinely cluster-wide, and this is where a lot of homegrown solutions cut a corner. It is not enough to deliver a published message to subscribers on the same node; the whole problem is that the subscriber you need to reach is on a different node. Zaris handles this inside the cluster: a PublishAsync on one node delivers to that node's local subscribers and simultaneously broadcasts the message across the cluster bus to every peer, each of which delivers to its local subscribers and does not re-broadcast. The net effect is the one you want — a subscriber connected to any node receives a publish issued on any node, exactly once — and your code never thinks about which node is which.

That gives you the one piece the in-process version was missing. Your app instance publishes without knowing who is listening; every other instance holding interested clients receives and forwards to its own sockets. One event, every screen.

The connection string and wiring​

There is nothing special about the connection layer for pub/sub. Point the app at a store by name, the same as any other Zaris feature:

// appsettings.json
{
"ConnectionStrings": {
"Live": "zariss://zaris-a.example.com:7861,zaris-b.example.com:7861/?store=live&token=env:ZARIS_TOKEN"
}
}

zariss:// is the TLS scheme (the extra s, same convention as https); list more than one host and the client fails over between them. Register from configuration and you get an IZarisClientProvider to resolve a store from:

using Clustron.Zaris.Client.DependencyInjection;

builder.Services.AddClustronZarisFromConnectionStrings(builder.Configuration);

Publishing an event​

Pub/sub lives on the full client surface, IZaris, as .PubSub. Publishing is a single call: a channel name and a payload of bytes. The channel is just a string you choose — a topic, an entity id, a hierarchy like prices.NASDAQ.MSFT — and the payload is whatever you serialize.

using System.Text.Json;
using Clustron.Zaris.Client;

public sealed class MarketPublisher
{
private readonly IZarisClientProvider _provider;
private const string Store = "Live";

public MarketPublisher(IZarisClientProvider provider) => _provider = provider;

// Emit a price tick. Anyone subscribed to "prices.<symbol>" on any instance
// receives it. Returns how many local subscribers it reached (see trade-offs).
public async Task PublishTickAsync(string symbol, decimal price, CancellationToken ct = default)
{
var client = await _provider.GetAsync(Store);
var ps = ((IZaris)client).PubSub;

var payload = JsonSerializer.SerializeToUtf8Bytes(
new { symbol, price, at = DateTimeOffset.UtcNow });

await ps.PublishAsync($"prices.{symbol}", payload, ct);
}
}

PublishAsync takes the channel and a byte[], and returns a long. That return value is the number of subscribers the publish was delivered to — and it is worth being precise about what it counts, because it is a local figure, not a cluster total. We come back to that in the trade-offs; for fan-out it does not matter, because a publisher fires and forgets.

Subscribing and forwarding to connected clients​

The receiving side is where the fan-out actually happens. In a web app you want one subscription per instance — not one per browser — that receives every message for the channels this instance cares about and forwards each one to the sockets this instance is holding. A BackgroundService is the natural home: it opens the subscription on start-up and holds it for the lifetime of the process.

using Clustron.Zaris.Client;
using Microsoft.AspNetCore.SignalR;

public sealed class MarketFanOut : BackgroundService
{
private readonly IZarisClientProvider _provider;
private readonly IHubContext<MarketHub> _hub; // your WebSocket/SignalR hub
private IPubSubSubscription? _subscription;
private const string Store = "Live";

public MarketFanOut(IZarisClientProvider provider, IHubContext<MarketHub> hub)
{
_provider = provider;
_hub = hub;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var client = await _provider.GetAsync(Store);
var ps = ((IZaris)client).PubSub;

// One pattern subscription for every symbol. The callback fires on a
// background thread for each message that lands on any matching channel,
// published from anywhere in the cluster.
_subscription = await ps.PSubscribeAsync(
new[] { "prices.*" },
async msg =>
{
var symbol = msg.Channel["prices.".Length..]; // channel the tick landed on
// Forward to the browser clients THIS instance is holding.
await _hub.Clients.Group(symbol).SendAsync("tick", msg.Payload);
},
stoppingToken);
}

public override async Task StopAsync(CancellationToken ct)
{
// Disposing the subscription stops delivery and unregisters this instance.
if (_subscription is not null)
await _subscription.DisposeAsync();
await base.StopAsync(ct);
}
}

A few things are worth pausing on:

  • PSubscribeAsync takes glob patterns, SubscribeAsync takes exact channels. prices.* matches prices.MSFT, prices.NASDAQ.MSFT, and so on — Redis-style *, ?, and [set]. When a pattern subscription fires, msg.Pattern is the pattern that matched and msg.Channel is the concrete channel the message was published on, so you always know the specific subject. For an exact-channel SubscribeAsync, msg.Pattern is null.
  • The callback is Func<ChannelMessage, Task> and fires on a background thread. Each ChannelMessage carries Channel, Pattern, and a Payload of bytes — exactly what was published, no reinterpretation. It runs per message, so keep it quick and non-blocking; here it just hands the bytes to the hub. If your handler touches shared state, synchronize it.
  • The subscription is a handle you hold and dispose. SubscribeAsync / PSubscribeAsync return an IPubSubSubscription; delivery continues until you DisposeAsync it (or call UnsubscribeAsync). Holding it in the BackgroundService ties the subscription's lifetime to the instance's, which is what you want — when the instance drains, the subscription goes with it.

One subscription per instance, fanning out to that instance's own clients. Four instances means four subscriptions on the cluster, each forwarding to a different quarter of your users — and the publisher still made exactly one call.

What actually happens on a publish​

Publisherone PublishAsyncpublishZaris clusterNode Adelivers + broadcastsNode BNode Ccluster busclients on Bclients on Aclients on Ceach tick, exactly once

The publisher makes one call to the node it happens to be connected to. That node does two things at once: it pushes the message down to its own subscribers' connections, and it broadcasts the message across the cluster bus to every peer. Each peer delivers to its local subscribers and stops there — no peer re-broadcasts, so there is no storm and no duplication. A client connected to node B gets the tick that was published through node A, once, and so does every other subscriber on every node. The publisher never learned any of this happened; it emitted one event and the cluster carried it everywhere it needed to go.

Trade-offs, and when to reach for something else​

Pub/sub is fire-and-forget, with no replay. A message is delivered to whoever is subscribed at the moment it is published, and then it is gone. A subscriber that is offline, still starting up, or reconnecting during the publish simply misses it — there is no buffer, no backlog, no "give me what I missed." For live fan-out that is usually the right trade: a price tick that is two seconds old is worthless, and the next tick is already on its way. But if a consumer must see every message even across a restart — think an order pipeline or an audit trail — pub/sub is the wrong tool. Reach for a Zaris stream (durable, replayable, with consumer groups and acknowledged offsets) or the change feed when the requirement is "process each event once, and never lose one."

The delivered-count is local, by design. PublishAsync returns how many subscribers the publishing node delivered to directly; the cross-node fan-out is best-effort and not folded into that number. A peer being unreachable never fails your PublishAsync — the publish succeeds for everyone reachable and does not block on the slowest node. This matches how Redis cluster PUBLISH behaves, and it is the correct default for fan-out, but it means you cannot treat the return value as a cluster-wide receipt. If you need to know a specific consumer got a specific message, you need acknowledgements, which is a stream's job, not pub/sub's.

A subscription lives on one node; plan for re-subscribe. When a subscriber connects, its subscription is registered on the node it connected to. If that node goes down, the client has to reconnect and re-subscribe to resume delivery — the subscription state is not yet replicated for transparent failover the way Zaris Watch replicates its state. In practice your client already needs reconnect logic for any long-lived connection; make sure the reconnect path re-establishes subscriptions rather than assuming they survived. For a BackgroundService like the one above, that means re-running the subscribe step when the underlying connection is re-established.

Keep the callback cheap. The delivery callback fires on a background thread, once per message, on the path that is trying to fan out to everyone. Do the minimum there — hand the payload to your hub, enqueue it, update a counter — and push any slow work (database writes, downstream HTTP calls) onto your own queue. A callback that blocks is a callback that slows delivery for every subscriber behind it.

When to use native versus RESP. If you are migrating an app that already drives fan-out with Redis PUBLISH / SUBSCRIBE / PSUBSCRIBE, Zaris speaks the Redis protocol and those commands work unchanged. The native IPubSubClient shown here is the first-class .NET surface: it hands you a typed ChannelMessage and an IPubSubSubscription you dispose, instead of a raw message loop you manage by hand. Reach for RESP when you are porting; reach for the native API when you are writing new .NET code.

Wrap-up​

A live feature is only live if the update reaches every screen, and the quiet trap is that an in-process event never leaves the instance that raised it. The moment your app runs more than one copy, most of your users stop seeing updates, and nothing in a single-instance test will show it. Pub/sub turns one event into fan-out across the fleet: a publisher emits to a channel without knowing who is listening, and every instance holding interested clients receives and forwards to its own sockets. Zaris ships that as a native, typed .NET API whose defining property is cluster-wide delivery — publish on any node, received by subscribers on any node, exactly once — so your code publishes once and the cluster handles the rest.

Pub/sub is the fire-and-forget half of Zaris's eventing story; streams and the change feed are the durable, replayable half, and choosing between them is really just choosing whether a missed message matters. If you want to see fan-out run end to end — exact and pattern subscriptions, publish, and cross-instance delivery — the PubSub sample in the Zaris samples repository is this post in executable form.