Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d69a554
spec: 017 phase 5 task 5.1 — phase 5's prediction and a verdict per h…
iancooper Sep 28, 2026
1cafef9
docs: 017 task 5.2 — the Commands, Darker and Understanding Brighter …
iancooper Sep 28, 2026
09f5848
spec: 017 task 5.2 — baseline rows for the blocks 1cafef9 built
iancooper Sep 28, 2026
623c786
spec: 017 task 5.2 — ten pages repaired, recorded; 30 of 42
iancooper Sep 28, 2026
f2ce226
docs: 017 task 5.2 — the maintainer's four rulings, and what they rea…
iancooper Sep 28, 2026
a347ba6
spec: 017 task 5.2 — baseline rows for the blocks f2ce226 built
iancooper Sep 28, 2026
6d54306
spec: 017 task 5.2 — the second pass, the four rulings, recorded
iancooper Sep 28, 2026
5129c20
docs: link the monitoring defects from Monitoring.md, #4453 and #4454
iancooper Sep 28, 2026
2d938c8
docs: 017 task 5.3 (WIP) — transports, external bus and tracing pages…
iancooper Sep 28, 2026
154f78c
docs: 017 task 5.3 — S3 ACLs, Control API output, content types, mapp…
iancooper Sep 28, 2026
af77ae4
spec: 017 task 5.3 — baseline rows for the blocks 2d938c8 and 154f78c…
iancooper Sep 28, 2026
8d2fb8b
docs: 017 task 5.3 — FAQ's claim-check mapper, and the Npgsql EF version
iancooper Sep 28, 2026
aec11c1
spec: 017 task 5.3 — the transports, external bus and tracing pages, …
iancooper Sep 28, 2026
07d878b
blockcheck: grow the pin by Microsoft.Extensions.TimeProvider.Testing
iancooper Sep 28, 2026
aeb65f4
blockcheck: grow the pin by Confluent.SchemaRegistry.Serdes.Avro
iancooper Sep 28, 2026
b4c3ae0
docs: 017 task 5.4 — TimeProvider, the Order write model, Avro, Publi…
iancooper Sep 28, 2026
cfd964e
spec: 017 task 5.4 — baseline rows for the blocks b4c3ae0 built
iancooper Sep 28, 2026
e742502
docs: 017 task 5.4 — the Control API's wrong-case 500, filed as #4465
iancooper Sep 28, 2026
608615a
spec: 017 task 5.4 — TimeProvider, Order, Avro and the PublishAsync s…
iancooper Sep 28, 2026
f4cfd88
docs: 017 task 5.5 — the E4 attribute mismatches, and what stood besi…
iancooper Sep 28, 2026
5c68181
spec: 017 task 5.5 — baseline rows for the blocks f4cfd88 built
iancooper Sep 28, 2026
ac44085
spec: 017 task 5.5 — the E4 attribute mismatches, recorded
iancooper Sep 28, 2026
6ddcf7b
docs: 017 task 5.5, second pass — Replay marked, async mappers, the c…
iancooper Sep 28, 2026
4fc4040
spec: 017 task 5.5 — MessageMappers.md's baselined block moved #3 -> …
iancooper Sep 28, 2026
dc3e5bd
docs: 017 task 5.5, second pass — Replay's other two mentions, and #4…
iancooper Sep 28, 2026
5981b12
spec: 017 task 5.5, second pass — recorded
iancooper Sep 28, 2026
45e9da5
spec: 017 task 5.6 — phase 5 closed: gates, AC2, targets, README rows…
iancooper Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions contents/AgreementDispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,19 +380,25 @@ registry.Register<MyCommand>((request, context) => [typeof(MyHandler)],

### AutoFromAssemblies Conflicts

**Problem**: Agreement dispatcher routes not working with `AutoFromAssemblies()`.
**Problem**: Sending a request routed by an agreement throws `ArgumentException`, *"More than one handler was found for the typeof command MyCommand"*, when you also call `AutoFromAssemblies()`.

**Cause**: `AutoFromAssemblies()` creates fixed mappings.
**Cause**: `AutoFromAssemblies()` registers every handler it finds as the one handler for its request type, so the agreement's handlers also become fixed routes beside it.

**Solution**: Use explicit `.Handlers()` registration:
**Solution**: Pass the agreement's handlers to `AutoFromAssemblies()` in `excludeDynamicHandlerTypes`, so the scan skips them:

```csharp
// Instead of AutoFromAssemblies
using Paramore.Brighter.Extensions.DependencyInjection;

services.AddBrighter(options => { })
.AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(MyHandler), typeof(MyOtherHandler)])
.Handlers(registry =>
{
// Explicit registration for Agreement Dispatcher
registry.Register<MyCommand>((request, context) => { /* ... */ }, [/* handlers */]);
registry.Register<MyCommand>((request, context) =>
{
// ... your routing logic
return [typeof(MyHandler)];
},
[typeof(MyHandler), typeof(MyOtherHandler)]);
});
```

Expand Down
59 changes: 39 additions & 20 deletions contents/AgreementDispatcherRouting.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ shows you how to register one.
In standard Brighter routing, each request type maps to exactly one handler type at compile-time:

```csharp
// ...
using Paramore.Brighter.Extensions.DependencyInjection;

services.AddBrighter(options => { })
.Handlers(registry =>
{
Expand Down Expand Up @@ -52,7 +53,8 @@ This is Brighter's default and recommended approach for most scenarios.
Agreement Dispatcher allows dynamic handler selection based on request content or context:

```csharp
// ...
using Paramore.Brighter.Extensions.DependencyInjection;

services.AddBrighter(options => { })
.Handlers(registry =>
{
Expand All @@ -78,8 +80,8 @@ services.AddBrighter(options => { })
- Can change behavior over time
- Supports multiple handlers
- Access to request context
- Cannot use `AutoFromAssemblies()`
- Must register handlers explicitly
- Registered explicitly, with `.Handlers()`
- `AutoFromAssemblies()` must be told to skip the route's handlers
- Small performance overhead (lambda execution)

**When to use Agreement Dispatcher:**
Expand All @@ -97,7 +99,8 @@ services.AddBrighter(options => { })
Route to different handlers as business rules evolve over time:

```csharp
// ...
using System;

registry.Register<ProcessOrder>((request, context) =>
{
var order = request as ProcessOrder;
Expand Down Expand Up @@ -217,7 +220,8 @@ registry.RegisterAsync<CreateUser>((request, context) =>
Route based on current state or status:

```csharp
// ...
using System;

registry.Register<ProcessRefund>((request, context) =>
{
var refund = request as ProcessRefund;
Expand All @@ -243,33 +247,42 @@ registry.Register<ProcessRefund>((request, context) =>

## Agreement Dispatcher Limitations

### Cannot Use AutoFromAssemblies
### AutoFromAssemblies Must Skip the Route's Handlers

Agreement Dispatcher requires explicit handler registration:
An agreement is always registered explicitly. You can still scan your assemblies for every other handler, as long as the scan skips the agreement's handlers:

```csharp
// ...
// Cannot use AutoFromAssemblies with Agreement Dispatcher
using Paramore.Brighter.Extensions.DependencyInjection;

services.AddBrighter(options => { })
.AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(Handler1), typeof(Handler2)])
.Handlers(registry =>
{
registry.Register<MyCommand>((request, context) => { /* ... */ },
registry.Register<MyCommand>((request, context) =>
{
// ... your routing logic
return [typeof(Handler1)];
},
[typeof(Handler1), typeof(Handler2)]);
})
// .AutoFromAssemblies() won't work with Agreement Dispatcher
});
```

**Why?** `AutoFromAssemblies()` creates fixed 1-to-1 mappings. Agreement Dispatcher needs explicit lambda registration and handler type lists for DI.
**Why?** `AutoFromAssemblies()` registers every handler it finds as the one handler for its request type. Leave an agreement's handlers in the scan and each becomes a fixed route beside the agreement, so every `Send` of that request throws an `ArgumentException`, *"More than one handler was found for the typeof command MyCommand"*. `excludeDynamicHandlerTypes` keeps them out of the scan, and the agreement's handler types list still registers them with the container.

**Solution**: Use `.Handlers()` to register Agreement Dispatcher routes explicitly:
**Alternatively**, skip the scan and register every route with `.Handlers()`, mixing agreement and standard routes:

```csharp
// ...
using Paramore.Brighter.Extensions.DependencyInjection;

services.AddBrighter(options => { })
.Handlers(registry =>
{
// Agreement dispatcher routes
registry.Register<MyCommand>((request, context) => { /* ... */ },
registry.Register<MyCommand>((request, context) =>
{
// ... your routing logic
return [typeof(Handler1)];
},
[typeof(Handler1), typeof(Handler2)]);

// You can still mix with standard routes
Expand All @@ -285,7 +298,8 @@ You must provide all possible handler types for DI registration:
// ...
registry.Register<MyCommand>((request, context) =>
{
// Your routing logic...
// ... your routing logic
return [typeof(Handler1)];
},
[
// All handlers that might be returned must be listed here
Expand Down Expand Up @@ -324,17 +338,22 @@ For most applications, this overhead is negligible:

**Optimization tip**: Keep routing lambdas simple. Avoid expensive operations like database calls or external API calls.

✅ **Good** - simple, fast routing logic:

```csharp
// ...
// Good - Simple, fast routing logic
registry.Register<MyCommand>((request, context) =>
{
var cmd = request as MyCommand;
return cmd?.Type == "Fast" ? [typeof(FastHandler)] : [typeof(SlowHandler)];
},
[typeof(FastHandler), typeof(SlowHandler)]);
```

❌ **Bad** - an expensive operation in the routing lambda:

// Bad - Expensive operation in routing lambda
```csharp
// ...
registry.Register<MyCommand>((request, context) =>
{
var cmd = request as MyCommand;
Expand Down
58 changes: 32 additions & 26 deletions contents/BrighterControlAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,38 +24,41 @@ using Paramore.Brighter.ServiceActivator.Control.Api;
app.MapBrighterControlEndpoints();
```

When mapping the Brighter Control API you can pass a string to change the base route of these calls, by default it is set to `/control`
The endpoints are mapped under `/control` by default. Pass a base route to put them somewhere else — `app.MapBrighterControlEndpoints("/ops/brighter")` serves `GET /ops/brighter/status`, and `/control/status` then returns 404.

## API's Provided

### Get Node Status
You can retrieve the status of a Dispatcher node by calling `GET /control/status`
You can retrieve the status of a Dispatcher node by calling `GET /control/status`.

The response contains:
- **nodeName** : The name of the node running the Dispatcher
- **availableTopics** : The Topics that this node can service
- **subscriptions** : An array of Information about currently configured subscriptions
- **topicName** : Name of Topic
- **performers** : An array of performers
- **activePerformers** : Number of currently active performers
- **expectedPerformers** : Number of expected performers
- **isHealthy** : Is this subscription healthy on this node
- **isHealthy** : Is this node Healthy
- **numberOfActivePerformers** : The Number of Performers currently running on the Node
- **timeStamp** : Timestamp of Status Event
- **executingAssemblyVersion** : The version of the running process

``` JSON

- **nodeName**: The name of the node running the Dispatcher — the Dispatcher's `HostName`, `Brighter` followed by a UUID unless you set one
- **availableTopics**: The **subscription names** this node services. Despite the field's name these are not topics: a subscription named `orders-subscription` on the routing key `Orders.OrderPlaced` appears as `orders-subscription`
- **subscriptions**: An array with one entry per subscription:
- **topicName**: The subscription name, as in `availableTopics`
- **performers**: The names of the subscription's open performers
- **activePerformers**: The number of open performers
- **expectedPerformers**: The number of performers the subscription is configured to run
- **isHealthy**: `true` when `activePerformers` equals `expectedPerformers`
- **isHealthy**: `true` when every subscription is healthy
- **numberOfActivePerformers**: The number of open performers across all subscriptions
- **timeStamp**: When the status was taken
- **executingAssemblyVersion**: The version of Brighter's control package, not of your application

A node running one subscription, `orders-subscription`, with one performer:

```json
{
"nodeName": "Brightere4888035-06f4-4ef8-b928-dbd47d958538",
"nodeName": "Brighter01a0e8e2-8d96-770e-9a8b-afc8e684fc23",
"availableTopics": [
"Orders.NewOrderVersionEvent"
"orders-subscription"
],
"subscriptions": [
{
"topicName": "Orders.NewOrderVersionEvent",
"topicName": "orders-subscription",
"performers": [
"Orders.NewOrderVersionEvent-0943a9d2-6a00-4cd5-a4cb-cd97106e2bbe"
"orders-subscription-01a0e8e2-8d9d-7668-a035-011682a1a192"
],
"activePerformers": 1,
"expectedPerformers": 1,
Expand All @@ -64,14 +67,17 @@ The response contains:
],
"isHealthy": true,
"numberOfActivePerformers": 1,
"timeStamp": "2024-06-29T15:45:46.8910117Z",
"executingAssemblyVersion": "9.7.8+476e3ad5c683683086393b17deceea509f68566a"
"timeStamp": "2026-09-28T16:39:17.211498+00:00",
"executingAssemblyVersion": "10.7.0+c1b8af886235ba3ddc9b3a88e880c121050aec77"
}
```

### Update Performer Count
You can update the number of running performers by calling `PATCH /control/subscriptions/{{subscriptionName}}/performers/{{numberOfPerformers:int}}`
You can change the number of performers a subscription runs by calling `PATCH /control/subscriptions/{subscriptionName}/performers/{numberOfPerformers}`, where `numberOfPerformers` is an integer. The Dispatcher opens or closes performers to match, and the change shows in the next `GET /control/status`.

`subscriptionName` is the subscription's name — the value `/control/status` reports as `topicName` — not its routing key. This returns either:

- **200 OK** with a message such as `Active performers for orders-subscription set to 3`
- **400 BAD REQUEST** with a message such as `No such subscription Orders.OrderPlaced`, when no subscription has that name

This will return either :
- OK with a Message such as `Active performers for Orders.NewOrderVersionEvent set to 2`
- BAD REQUEST with a message such as `No such subscription Orders.NewOrderVersionCommand`
**Match the name's case exactly.** The check for an unknown name ignores case but the update does not, so `ORDERS-SUBSCRIPTION` passes the check and then fails with a 500, an `InvalidOperationException` from the Dispatcher. This is reported as [#4465](https://github.com/BrighterCommand/Brighter/issues/4465).
2 changes: 1 addition & 1 deletion contents/BrighterOutboxSupport.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Without the Sweeper, you have two risks:
- An explicit attempt to clear the Outbox by calling `ClearOutbox` on the `CommandProcessor` can fail. Although it is protected by a Polly resilience policy, if that policy still does not succeed in clearing the Outbox, the messages will linger there. Running a Sweeper means they are eventually sent.
- Some transports, notably RabbitMQ and Kafka, support callbacks to inform the caller that a message has been sent. This happens asynchronously, so at the point of calling `Post` or `ClearOutbox` you do not yet know whether the message was sent; instead you must await the callback. Because the calling code has moved on, the response always returns on a new thread, which has no context for the original call and cannot interactively notify you that the operation failed. However, if you have a Sweeper, the message — still in your Outbox — will be sent.

There is a third case, and it makes the Sweeper unavoidable rather than merely advisable. If you configure an Inbox with `OnceOnlyAction.Replay`, a duplicate request makes Brighter clear the dispatched marker on the messages the original handling produced — the `Dispatched` column on a relational Outbox — putting them back in the Sweeper's path. That is the *only* way a replayed message ever leaves your application: replay resets rows, it never dispatches anything itself, so without a Sweeper the messages are marked outstanding and stay there. See [Replay On Seen](/contents/ReplayOnSeen.md).
There is a third case, and it makes the Sweeper unavoidable rather than merely advisable. If you configure an Inbox with `OnceOnlyAction.Replay` — **not in a released package yet**, it ships after Brighter 10.7.0 — a duplicate request makes Brighter clear the dispatched marker on the messages the original handling produced — the `Dispatched` column on a relational Outbox — putting them back in the Sweeper's path. That is the *only* way a replayed message ever leaves your application: replay resets rows, it never dispatches anything itself, so without a Sweeper the messages are marked outstanding and stay there. See [Replay On Seen](/contents/ReplayOnSeen.md).


## Outbox Configuration
Expand Down
20 changes: 14 additions & 6 deletions contents/BrighterSchedulerSupport.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,22 @@ Common scenarios for message scheduling include:
The `IAmACommandProcessor` interface provides methods for scheduling delayed messages:

```csharp
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Paramore.Brighter;

public interface IAmACommandProcessor
{
Task<string> SendAsync<T>(T command, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest;
Task<string> SendAsync<T>(T command, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest;
Task<string> PublishAsync<T>(T @event, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest;
Task<string> PublishAsync<T>(T @event, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest;
Task<string> PostAsync<T>(T request, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest;
Task<string> PostAsync<T>(T request, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest;
// ... the immediate overloads, and the synchronous Send, Publish and Post

Task<string> SendAsync<TRequest>(DateTimeOffset at, TRequest command, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
Task<string> SendAsync<TRequest>(TimeSpan delay, TRequest command, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
Task<string> PublishAsync<TRequest>(DateTimeOffset at, TRequest @event, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
Task<string> PublishAsync<TRequest>(TimeSpan delay, TRequest @event, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
Task<string> PostAsync<TRequest>(DateTimeOffset at, TRequest request, RequestContext? requestContext = null, Dictionary<string, object>? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
Task<string> PostAsync<TRequest>(TimeSpan delay, TRequest request, RequestContext? requestContext = null, Dictionary<string, object>? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest;
}
```

Expand Down
Loading
Loading