Broadcaster Sinks
The broadcaster has two extension points: transports carry events between processes, and sinks consume events to do something useful with them (forward to a UI, write to a database, etc.). Knowing which one you need keeps wiring sane on multi-process topologies.
Transports
A transport implements both ITrainEventBroadcaster (publish side) and ITrainEventReceiver (subscribe side). The shipped one is RabbitMQ via UseRabbitMq. You can write your own (see UseBroadcaster: Implementing a Custom Transport).
Within a single host, the broadcaster wiring looks like:
- Train completes locally → the lifecycle hook that
UseBroadcaster()registers publishes via the transport'sITrainEventBroadcaster. - A remote process publishes → that host's
TrainEventReceiverServicereceives viaITrainEventReceiverand dispatches to every registeredITrainEventHandler.
The receiver service skips events stamped with its own host's instance id, so a host that both produces and consumes does not see its own events twice, while replicas of one app still see each other's (see UseBroadcaster: De-duplication).
Sinks
A sink is anything that reacts to lifecycle events. The two patterns:
| Sink path | Interface | Fires for |
|---|---|---|
| Local | ITrainLifecycleHook | Trains running in the same process |
| Remote | ITrainEventHandler | Events received over a transport |
A sink that wants to react to every event regardless of where the train ran should register as both. The SignalR sink and Trax's built-in GraphQL subscription handler both use this dual-registration pattern.
A purely headless sink (writes to a database, files, an external API) only needs ITrainEventHandler when it lives on a host that receives events over a transport.
Data-change signals
The broadcaster also carries coalesced data-change signals, not just train lifecycle events. A write path calls ITraxChangeSignal.Notify(domain); in a single-process deployment the signal reaches local GraphQL subscribers directly, and when UseBroadcaster() is configured a BroadcastChangeSink forwards it to other processes over the same transport (the receiving side re-publishes it to its own subscribers). This is what drives the dashboard's onDataChanged push without polling. A signal arrives at an ITrainEventHandler as a message with EventType DataChanged and no train fields; the SignalR sink skips it, and a handler that only cares about trains should too. See Subscriptions: Data Change Signals.
Pairing a transport with a sink
This is the canonical multi-process layout: workers and the UI host share a RabbitMQ exchange; the UI host adds SignalR so browsers see events live.
Worker (Program.cs):
using Trax.Effect.Broadcaster.RabbitMQ.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(connStr)
.UseBroadcaster(b => b.UseRabbitMq(rabbitMqUrl))));The worker publishes lifecycle events; it has no UI and registers no sinks beyond the broadcaster's own publish hook.
Hub (Program.cs):
builder.Services.AddSignalR();
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(connStr)
.UseBroadcaster(b => b
.UseRabbitMq(rabbitMqUrl)
.UseSignalRHub(opts => opts.OnlyForEvents("Completed", "Failed")))));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapTraxTrainEventHub(hub => hub.RequireAuthorization("TraxEvents"));The hub subscribes to the RabbitMQ exchange and rebroadcasts every matching event to the browsers its posture admits (see MapTraxTrainEventHub: Authorization). The same hub also handles trains it runs locally, which fire the SignalR sink directly without a transport hop.
When to use SignalR vs GraphQL subscriptions
Both deliver lifecycle events to clients in real time. Use the one that matches the rest of your stack:
- SignalR is the native push channel for Blazor Server. It lands inside the same connection Blazor already maintains, so a hub method call updates component state and the framework pushes the DOM diff with no extra protocol.
- GraphQL subscriptions are the natural fit when the rest of your API is GraphQL and clients are JS SPAs already speaking that protocol.
The two are not mutually exclusive. Trax registers a separate GraphQLTrainEventHandler when AddTraxGraphQL() is wired up; the SignalR sink is registered independently. They coexist by both reading from the same broadcaster pipeline.
Headless sink example
A persister that shreds Output into a local SQLite database:
internal sealed class GeocodeDriftPersister : ITrainEventHandler
{
private readonly IGeocodeDriftRepository _repo;
public GeocodeDriftPersister(IGeocodeDriftRepository repo) => _repo = repo;
public async Task HandleAsync(TrainLifecycleEventMessage message, CancellationToken ct)
{
if (message.EventType != "Completed") return;
if (message.TrainName != typeof(ICheckGeocodeDriftTrain).FullName) return;
if (string.IsNullOrEmpty(message.Output)) return;
var report = JsonSerializer.Deserialize<GeocodeDriftReport>(message.Output)!;
await _repo.SaveAsync(report, ct);
}
}
builder.Services.AddSingleton<ITrainEventHandler, GeocodeDriftPersister>();This sink only reacts to remote events arriving over the transport. If the persister host also runs trains locally and needs to react there too, register the same instance as an ITrainLifecycleHook as well (the SignalR sink shows the pattern).
SDK Reference
UseBroadcaster | UseSignalRHub | MapTraxTrainEventHub | AddLifecycleHook