Skip to content

Commit b1847bf

Browse files
committed
docs: document the event broker and the notification channel
The README and wiki described one kind of event. There are two, and conflating them is the mistake the docs should prevent: a notification reports on a run and may be lost at the cost of a stale dashboard, while a domain event causes a run and may not be lost at all. README gains an Events section covering both channels, the two new endpoints, Runtime.Notify, INotifyingWorkflow, transient token streams, and the LLM pricing configuration. The wiki's events section now separates the two, and documents emitting custom notifications, the notification policy and its two safety rules, transient events, and LLM telemetry. A new "Event broker and event-driven workflows" section covers publishing, triggers, waits, topic matching, delivery scope and the transport table. Extension points gain contracts for custom brokers and subscription stores, including the three obligations a transport has to meet and why exactly-once resumption belongs to the store rather than the wire. Troubleshooting gains the six questions this feature will actually generate, including the two whose answer is "that is by design": llm.delta is absent from event history because it is never stored, and AwaitingInput is what a parked wait looks like.
1 parent 711f95b commit b1847bf

2 files changed

Lines changed: 358 additions & 10 deletions

File tree

README.md

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Abacus Run
22

3-
Abacus Run is a .NET workflow runtime and HTTP host for durable, observable workflow instances. It provides workflow version resolution, bounded concurrency, retries, checkpoints, approvals, event history, server-sent events, cancellation, reruns, and redacted audit/logging surfaces.
3+
Abacus Run is a .NET workflow runtime and HTTP host for durable, observable workflow instances. It provides workflow version resolution, bounded concurrency, retries, checkpoints, approvals, event history, server-sent events, a topic event broker for event-driven pipelines, cancellation, reruns, and redacted audit/logging surfaces.
44

55
The runtime is built on Microsoft Agent Framework workflows. Stores are exposed through interfaces so the in-memory implementation can be replaced by durable persistence without changing workflow definitions.
66

@@ -163,7 +163,8 @@ public static WorkflowHostBuilder AddAbacus(this IServiceCollection services, IC
163163

164164
if (configuration["Abacus:Redis:ConnectionString"] is { Length: > 0 } redis)
165165
{
166-
services.AddRedisEventBus(redis, maxStreamLength: 10_000);
166+
services.AddRedisEventBus(redis, maxStreamLength: 10_000); // SSE fan-out across replicas
167+
services.AddRedisEventBroker(redis, maxStreamLength: 100_000); // cross-service pub/sub
167168
}
168169

169170
return host;
@@ -197,8 +198,8 @@ app.Run();
197198

198199
| Concern | Lives in |
199200
| --- | --- |
200-
| Runtime, dispatch, executors, middleware, HTTP API, in-memory defaults | `Abacus.Run` |
201-
| Razor Pages, SQL Server stores, Redis event bus, startup wiring | your service (`Abacus.Run.Service`) |
201+
| Runtime, dispatch, executors, middleware, HTTP API, in-memory defaults, in-process event broker | `Abacus.Run` |
202+
| Razor Pages, SQL Server stores, Redis event bus and broker, startup wiring | your service (`Abacus.Run.Service`) |
202203

203204
The library carries no Razor, MVC, Entity Framework, or Redis dependency, and an architecture test in the integration suite fails the build if one drifts back in. Splitting a UI host out later is therefore a matter of moving Razor and infrastructure projects, not of untangling the runtime.
204205

@@ -301,6 +302,86 @@ mismatched name returns `404`. `?section=plan,output` narrows the response to na
301302
- `GET /instances/{id}/approvals`
302303
- `POST /approvals/{approvalId}/decision`
303304

305+
### Domain events
306+
307+
- `POST /events`
308+
- `GET /subscriptions`
309+
310+
Publish a message to a topic, and list what is listening or waiting. See [Events](#events).
311+
312+
## Events
313+
314+
Two kinds of events share the word and almost nothing else.
315+
316+
A **notification** describes what a run is doing — keyed by instance, ordered by a gapless sequence,
317+
delivered to whoever is watching. It never affects execution; lose one and a dashboard is briefly out
318+
of date.
319+
320+
A **domain event** describes what happened in the business — keyed by topic, routed to whoever
321+
declared interest, and it *causes* execution; lose one and work that should have happened never does.
322+
323+
That asymmetry is why they are built differently: notifications are best-effort fan-out over a
324+
durable log, while broker delivery is a durable state transition. A domain event may cause a
325+
notification; a notification may never cause work.
326+
327+
### Notifications from a node
328+
329+
`Runtime.Notify` puts a workflow-defined event on the instance's stream, and is nullable in the same
330+
way `Runtime.Audit` is:
331+
332+
```csharp
333+
if (Runtime.Notify is { } notify)
334+
{
335+
await notify.NotifyAsync("documents.scanned", new { count = 3 }, cancellationToken);
336+
}
337+
// → event: custom.documents.scanned
338+
```
339+
340+
The `custom.` prefix is applied by the runtime and cannot be opted out of, so a workflow can never
341+
shadow a framework event, and a consumer can filter the whole class on the prefix.
342+
343+
A definition controls what its runs emit by implementing `INotifyingWorkflow``Minimal`,
344+
`Lifecycle` or `Standard`, overridable per node in both directions, plus the custom names it declares
345+
for the catalog API. Terminal events are never suppressible, and filtering happens before a sequence
346+
number is taken, so the gapless sequence that `Last-Event-ID` catch-up depends on stays intact.
347+
348+
An `LlmExecutor` emits one `llm.completed` per call carrying model, prompt version, tokens, cost,
349+
latency and finish reason. Streamed tokens (`llm.delta`, opt-in per node via `StreamDeltas`) are
350+
**transient**: fanned out live, never stored, and written without an SSE `id:`, so a reconnecting
351+
client never waits for a chunk that no longer exists.
352+
353+
### Event-driven workflows
354+
355+
A workflow publishes to a topic, and another workflow either starts because of it or wakes up
356+
because of it:
357+
358+
```csharp
359+
// Publish, as a side effect on the way past
360+
context.Node(new PublishEventExecutor<OrderPlaced>(
361+
"publish", broker, topic: "orders.placed", correlationKey: o => o.OrderId));
362+
363+
// Start on a message
364+
public IReadOnlyList<EventTrigger> Triggers => [new EventTrigger { TopicFilter = "orders.placed" }];
365+
366+
// Or park mid-run until one arrives
367+
context.Node(new WaitForEventExecutor<PaymentContext, PaymentSettled>(
368+
"await-settlement", subscriptions, "payment.settled", correlationKey: c => c.OrderId));
369+
```
370+
371+
Delivery is a durable state transition, not a message: a trigger creates an instance row, a wait
372+
writes the payload to a subscription row and marks the instance dispatchable. Nothing waits in
373+
memory, so a pipeline survives a restart. A parked instance holds no execution slot and can wait for
374+
days.
375+
376+
Topic filters use `*` for one segment and `#` for the remainder. Scope travels on the message —
377+
`Local` by default, so the same publishing code is correct in one service and in a fleet.
378+
`InProcessEventBroker` is registered by default; `AddRedisEventBroker` replaces it for cross-service
379+
pub/sub, and an impossible combination is rejected at composition time rather than failing silently
380+
in production.
381+
382+
Full walkthrough: [Events, history, and SSE](docs/wiki.md#events-history-and-sse) and
383+
[Event broker](docs/wiki.md#event-broker-and-event-driven-workflows).
384+
304385
## Audit records
305386

306387
Events record what the runtime did. An audit record answers the separate question of why a run's
@@ -399,7 +480,23 @@ Options are read from the `WorkflowHost` configuration section. For example:
399480

400481
The default host uses in-memory instance, event, log, approval, checkpoint, blob, audit, and audit-record stores. Treat this configuration as development-oriented until durable store implementations are supplied.
401482

402-
Set `Abacus:SqlServer:ConnectionString` to enable the EF Core SQL Server stores and `Abacus:Redis:ConnectionString` to enable Redis Streams and control messages. `AddAbacus` keeps the in-memory stores when these settings are absent.
483+
Set `Abacus:SqlServer:ConnectionString` to enable the EF Core SQL Server stores and `Abacus:Redis:ConnectionString` to enable Redis Streams, control messages, and the cross-service event broker. `AddAbacus` keeps the in-memory stores and the in-process broker when these settings are absent.
484+
485+
`Abacus:Llm:Pricing` turns token counts into cost on `llm.completed` and into a drift signal:
486+
487+
```json
488+
{
489+
"Abacus": {
490+
"Llm": {
491+
"Pricing": {
492+
"claude-sonnet-5": { "InputPerMillion": 3.00, "OutputPerMillion": 15.00 }
493+
}
494+
}
495+
}
496+
}
497+
```
498+
499+
A model with no entry reports `null` rather than zero, and unpriced samples are excluded from the cost baseline — "we do not know" and "it was free" are different facts, and conflating them would mask a later cost rise.
403500

404501
`Abacus:AuditRecords:ConnectionString` points the SQLite audit-record store at its database file and
405502
defaults to `Data Source=./data/abacus-audit.db`. The directory is created and the migrations applied
@@ -410,7 +507,7 @@ at startup.
410507
| Project | Responsibility |
411508
| --- | --- |
412509
| `src/Abacus.Run` | Headless framework: workflow runtime, dispatch, executors, middleware, in-memory store defaults, and HTTP API endpoints |
413-
| `src/Abacus.Run.Service` | Deployable host: control-plane UI, SQL Server stores, Redis event bus, the SQLite audit-record store, startup wiring, and the example workflow |
510+
| `src/Abacus.Run.Service` | Deployable host: control-plane UI, SQL Server stores, Redis event bus and event broker, the SQLite audit-record store, startup wiring, and the example workflow |
414511
| `tests/Abacus.Run.UnitTests` | Unit coverage for runtime behavior; references the library only |
415512
| `tests/Abacus.Run.IntegrationTests` | HTTP, control-plane, and architecture-boundary coverage against the real host |
416513
| `tests/Abacus.Run.ChaosTests` | Failure and lifecycle resilience coverage |

0 commit comments

Comments
 (0)