Event Streaming Architecture
How events flow through the Virtufin API: CloudEvents over Dapr pub/sub, exposed to clients as gRPC streams.
Overview
Every event on the platform is a CloudEvents v1.0
message. The API is the only component that talks to Dapr pub/sub; other services publish
and subscribe through the Pubsub gRPC service.
| Mechanism | Direction | Entry point |
|---|---|---|
Pubsub.PublishEvent |
client → Dapr topic | unary gRPC / POST /v1/pubsub/{topic} |
Pubsub.Subscribe |
Dapr topic → client | server-streaming gRPC |
Trigger.ScheduleTrigger |
cron → Dapr topic | unary gRPC / POST /v1/trigger/{name} |
State.SaveState(publish_change: true) |
state write → state.change topic |
unary gRPC |
The wire type
Events are the CloudEvent protobuf message (proto/cloudevents.proto,
package io.cloudevents.v1), not a bespoke envelope:
message CloudEvent {
string id = 1;
string source = 2; // MUST be an absolute URI — see validation below
string spec_version = 3;
string type = 4; // "ce-type"; discriminates events within a topic
map<string, CloudEventAttributeValue> attributes = 5; // optional + extension attributes
oneof data {
bytes binary_data = 6;
string text_data = 7;
google.protobuf.Any proto_data = 8;
}
}
The Pubsub service surface (proto/pubsub.proto):
service Pubsub {
rpc PublishEvent (PublishRequest) returns (PublishResponse);
rpc Subscribe (SubscribeRequest) returns (stream SubscribeResponse);
rpc Unsubscribe (UnsubscribeRequest) returns (UnsubscribeResponse);
}
message PublishRequest { string topic = 1; io.cloudevents.v1.CloudEvent cloudevent = 2; }
message SubscribeRequest { repeated string topics = 1; }
message SubscribeResponse{ string topic = 1; io.cloudevents.v1.CloudEvent cloudevent = 2; }
SubscribeRequest filters by topic only. There is no service filter and no event-type
filter on the wire; a subscriber that wants to react to a subset of a topic's events
inspects cloudevent.type itself.
Publish validation
PublishEvent rejects, with InvalidArgument:
- an empty
topic; - a missing
cloudevent; - an empty
cloudevent.source; - a
cloudevent.sourcethat is not an absolute URI."demo/notebook"is rejected;"https://virtufin.com/demo/notebook"and"urn:virtufin:demo"are accepted.
Nothing else about a topic is validated — see "Topic naming" below.
Topic naming
Topic names follow the <service>.<event_category> convention:
| Topic | Publisher | Contents |
|---|---|---|
websocketmanager.lifecycle |
WebSocketManager | connection lifecycle |
workmanager.lifecycle |
WorkManager | worker lifecycle |
state.change |
the API itself | state mutations (see below) |
The name is defined as a constant in the publishing service's own Configuration/Topics.cs
and is the public contract between that publisher and its consumers. Consult each service's
AGENTS.md for its event taxonomy.
This convention is producer-side discipline, not an API guarantee. The API does not validate topic names against the convention, does not maintain an allow-list, and does not scope topics to callers. Any authenticated caller may publish to, and subscribe to, any topic string — including another service's lifecycle topic and
state.change. Do not rely on the topic namespace for isolation.
State change events
State.SaveState, State.DeleteState and State.DeleteBulkState accept a
publish_change flag. When set, the API publishes a change notification after the
write succeeds. Emitting one is opt-in per request — it is not a property of every state
change.
Where that notification goes depends on what kind of state you wrote.
Infrastructure state → state.change (Tier 0)
Records whose owner is a platform service and whose meaning is deployment-scoped — worker
registrations, connection records, leader leases. Leave change unset and the API
publishes to the global state.change topic with:
type=com.virtufin.api.state-savedorcom.virtufin.api.state-deleted, so subscribers can filter on the action without parsing the payload;source=urn:com.virtufin.api, the publishing service.
{
"service": "workmanager",
"key": "workmanager.worker.abc123",
"action": "save",
"value": "the-state-value"
}
action is "save" or "delete". value appears only when the request sets
include_value: true. DeleteBulkState publishes a keys array in place of key, and
names only the keys actually deleted.
These events carry no version, and cannot. Dapr's state API returns no etag from a write, and re-reading the key to get one would be worse than omitting it: a concurrent writer may have advanced the value in between, so the event would attribute a newer version to an older payload. A subscriber consuming
valueinstead of reading state back is therefore relying on writes to that key being serialized. Where you need a real ordering guarantee, use the Tier 1 path below.
state.change is one shared, unscoped topic, so include_value: true publishes that
value to every subscriber of it.
Domain state → your own topic (Tier 1)
Records that are scenario- or universe-scoped — positions, portfolios, orders, risk and
P&L artifacts. These do not belong on state.change: which service performed the
write is an implementation detail, and subscribers route on the domain topic.
The API cannot build that event for you. It has the store, the key and the bytes, but not
the scenario, the world markers, the clock type, or your service's identity. So supply
the whole CloudEvent and the topic it belongs on, via the change field:
{
"service": "workmanager",
"key": "workmanager.position.BTCUSDT",
"value": "{\"qty\":3}",
"publish_change": true,
"change": {
"topic": "sc.LIVE.trading.position.updated",
"cloudevent": {
"id": "…",
"type": "com.virtufin.position.flipped",
"source": "urn:com.virtufin.trading-engine",
"spec_version": "1.0",
"text_data": "{\"symbol\":\"BTCUSDT\",\"qty\":3}"
}
}
}
The API publishes that event verbatim to that topic once the write lands, and does not
also publish to state.change — publishing to both would double-deliver the change.
Set both topic and cloudevent. A change with only a topic falls back to the Tier 0
path: half a notification is not a Tier 1 event.
Because you control the envelope, this is where ordering lives: if subscribers consume
your payload instead of reading state back, carry a monotonic token (a domain version,
sequence number, or eventtime where the clock is authoritative) and have them discard
anything not newer. Only you know how the entity is versioned.
Do not reach for a
state.change.<state_name>topic. The Tier 0 pattern requires the category to be a domain noun rather than a state name, and per-key topics contradict the platform's cardinality rule. Domain state belongs on the Tier 1 taxonomy, which already has topics your subscribers subscribe to.
Delivery
Publication is at-most-once and is not atomic with the write. If the write succeeds and the publish fails, the event is lost — the API logs it and still reports the write as successful, because failing the call would make you repeat a write that already landed. Design subscribers to reconcile from state rather than assuming every change produced an event.
Measuring the gap
A lost event is otherwise invisible: no retry, no dead letter, and the caller sees success. Two counters make it countable:
| Metric | Meaning |
|---|---|
virtufin_api_state_change_publishes_total |
notifications attempted, after a successful write |
virtufin_api_state_change_publish_failures_total |
of those, the ones lost |
Both are tagged service, action (save/delete) and tier (tier0/tier1) — the
tiers are separated because a lost Tier 1 domain event matters considerably more than a
lost Tier 0 infra notification.
They are exported over OTLP only when OTEL_EXPORTER_OTLP_ENDPOINT is set. There is
no collector in the deployment today, so until one exists the metrics are collected
in-process and go nowhere, and the structured log is the measurement path:
State change publish failed after a successful write: service=… key=… topic=… tier=…
logged at Error from StateService. Counting those occurrences over a period is what
should decide whether the at-most-once gap is worth closing with a transactional outbox —
which would flip the guarantee to at-least-once and make every subscriber's idempotency a
breaking concern. That trade is worth making for a frequent failure and not for a rare
one, and nothing measured it before.
Subscribing
Pubsub.Subscribe is server-streaming: one call, many SubscribeResponse messages, one
gRPC stream per client call regardless of how many topics it names.
using var call = pubsubClient.Subscribe(
new SubscribeRequest { Topics = { "workmanager.lifecycle" } });
await foreach (var evt in call.ResponseStream.ReadAllAsync(cancellationToken))
{
Console.WriteLine($"{evt.Topic}: {evt.Cloudevent.Type}");
}
Behaviors worth knowing:
- An empty
topicslist is not "all topics". It silently defaults to["state.change"]. Name your topics explicitly. - Duplicate topics in one request produce duplicate delivery of each event to that caller.
- Each subscriber buffers up to 1024 events. A consumer that falls further behind than that is treated as stuck and reaped by the health sweeper, so do slow per-event work off the read loop.
- One shared Dapr subscription per topic per process, reference-counted across callers
(
TopicDaprSubscriptionRegistry). This is deliberate: a Dapr subscription per caller would make daprd load-balance each event across callers instead of broadcasting it. - Initial response headers are flushed before any event arrives. Request-reply clients depend on this to know the subscription is live before they publish; see below.
- Cancelling the call is the teardown. Disposing the streaming call releases the in-process subscriber and the shared Dapr subscription refcount.
Unsubscribe — do not use
Pubsub.Unsubscribe takes a subscription_id, but subscription ids are minted server-side
and never sent to the client: SubscribeResponse carries no id field. There is no supported
way to obtain a valid id, and calling it with a guessed one releases only the in-process
subscriber while leaving the Dapr subscription and the server-side stream running. Cancel
the Subscribe call instead.
Request-reply over pub/sub
Request-reply is implemented entirely in the client libraries. The API has no awareness of correlation ids or reply topics — it is a pure conduit, and the responder is always another service.
The requester stamps two CloudEvent extension attributes before publishing:
| Attribute | Meaning |
|---|---|
correlationid |
opaque id, echoed back by the responder; the sole demux key |
replytopic |
topic the responder should publish its reply to |
Both are lowercase with no separators, per the CloudEvents extension-naming rule. The
requester holds one persistent Subscribe stream per reply topic, shared across all
in-flight calls, and matches incoming events on correlationid. It waits for the stream's
response headers before publishing, so a reply cannot be missed in the gap between
publishing and subscribing.
Per-call subscribe/unsubscribe is not an option here: it churns the shared Dapr subscription,
and daprd's Redis Streams component leaks the underlying blocking XREADGROUP connection on
unsubscribe until the pool is exhausted.
Use PublishWithResultAsync (.NET), publish_with_result_async (Python), or
publishWithResult (TypeScript) rather than reimplementing this.
Cron triggers
Trigger.ScheduleTrigger schedules a recurring job through Dapr's Scheduler. When it fires,
DaprAppCallbackService publishes an empty CloudEvent to the trigger's target_topic
(type = com.dapr.event.sent, source = urn:com.virtufin:api). The trigger carries no payload;
subscribers react to the fact that it fired.
Configuration
The Dapr pub/sub component name defaults to "pubsub" and is configurable, in this
precedence:
--pubsub-name(or thePubsubNamesetting);pubsub.pubsubNameinservices.json, which helm renders from.Values.pubsub.name;- the
"pubsub"default.
It is a gateway-wide setting despite living on each service entry.
PublishEventandSubscribetake a topic and no service, so there is nothing to resolve a per-service component against at call time. Every registered entry must therefore agree on the value — helm renders one value into all of them — and the gateway fails at startup if they disagree rather than silently picking one.
Subscriptions are created programmatically at runtime via Dapr's streaming-subscription API, not declared in YAML. The API's declarative subscription callback therefore returns an empty list by design.
Error handling
The pubsub path uses two different error channels, and this trips people up:
| Failure | How it surfaces |
|---|---|
Invalid input (empty topic, bad source) |
thrown RpcException with InvalidArgument |
| Runtime failure (sidecar down, publish rejected) | gRPC status OK, with status.success == false |
A client that only catches RpcException will treat a failed publish as a success. Always
check response.Status.Success:
var response = await pubsubClient.PublishEventAsync(
new PublishRequest { Topic = topic, Cloudevent = ce });
if (!response.Status.Success)
throw new InvalidOperationException($"Publish failed: {response.Status.Message}");
On the subscribe side, handle stream termination as a normal condition and reconnect with backoff:
try
{
await foreach (var evt in call.ResponseStream.ReadAllAsync(token))
ProcessEvent(evt);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
// Client disconnected — normal shutdown.
}
Client examples
C#
using var client = new ApiClient("localhost");
var ce = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Source = "urn:virtufin:my-service",
SpecVersion = "1.0",
Type = "com.virtufin.worker.started",
TextData = """{"workerId":"abc123"}""",
};
await client.Gateway.PublishEventAsync("workmanager.lifecycle", ce);
using var call = client.Gateway.SubscribeToTopic("workmanager.lifecycle");
await foreach (var evt in call.ResponseStream.ReadAllAsync(token))
Console.WriteLine($"{evt.Topic}: {evt.Cloudevent.Type}");
Python
async with ApiClient(host="localhost") as client:
ce = cloudevents_pb2.CloudEvent(
id=str(uuid.uuid4()),
source="urn:virtufin:my-service",
spec_version="1.0",
type="com.virtufin.worker.started",
text_data=json.dumps({"workerId": "abc123"}),
)
await client.pubsub.publish_async("workmanager.lifecycle", ce)
call = await client.pubsub.subscribe_async(topics=["workmanager.lifecycle"])
async for event in call:
print(event.topic, event.cloudevent.type)
TypeScript
const client = new ApiClient({ host: "localhost" });
await client.publish("workmanager.lifecycle", cloudevent);
for await (const evt of client.subscribeToTopic("workmanager.lifecycle")) {
console.log(evt.topic, evt.cloudevent?.type);
}
Best practices
- Name your topics explicitly on
Subscribe— the empty-list default isstate.change, which is almost never what you want. - Check
status.successon every publish; the gRPC status isOKeven on failure. - Pass a cancellation token to
Subscribeand cancel it to tear down; that is the only working teardown path. - Discriminate on
cloudevent.type, not on the topic alone — one topic carries a service's whole event taxonomy. - Leave
include_valueoff unless the state value is public to the whole platform. - Use the SDK's request-reply helper instead of hand-rolling correlation.