diff --git a/contents/AgreementDispatcher.md b/contents/AgreementDispatcher.md index f56ef5b..2535b08 100644 --- a/contents/AgreementDispatcher.md +++ b/contents/AgreementDispatcher.md @@ -20,15 +20,17 @@ Brighter supports an Agreement Dispatcher, allowing you to register a lambda fun ### Basic Registration ```csharp -registry.Register( - routingFunc: (request, context) => { /* return handler types */ }, - handlerTypes: [typeof(Handler1), typeof(Handler2), ...] +using Paramore.Brighter; + +registry.Register( + router: (request, context) => [typeof(Handler1)], // return the handler types for this request + handlerTypes: [typeof(Handler1), typeof(Handler2)] ); ``` **Parameters:** -- **routingFunc**: Lambda that takes `IRequest` and `IRequestContext`, returns `List` of handlers +- **router**: Lambda that takes `IRequest` and `IRequestContext`, returns `List` of handlers - **handlerTypes**: Array of all possible handler types (for DI registration) ### Accessing Request Content @@ -75,7 +77,9 @@ registry.Register((request, context) => ### Returning Multiple Handlers -Agreement Dispatcher can return multiple handlers, but it must still obey the rule that `Send` expects a single handler and `Publish` can have zero-to-many handlers. If you return multiple handlers in a `Send` request pipeline, Brighter will throw an exception. +Agreement Dispatcher can return multiple handlers, but it must still obey the rule that `Send` expects a single handler and `Publish` can have zero-to-many handlers. If you return multiple handlers in a `Send` request pipeline, Brighter throws an `ArgumentException`, so a route that returns more than one handler belongs to an event. + +`Publish` runs the handlers you return concurrently, in no particular order, and one handler failing does not stop the others. Return handlers that are independent of each other; the list is not a sequence, so it cannot express "validate, then finalize". For steps that must run in order, use one handler and its [pipeline](/contents/BuildingAPipeline.md). ## Synchronous and Asynchronous Registration @@ -171,6 +175,12 @@ This provides powerful, flexible routing: Here's a complete example showing Agreement Dispatcher with multiple routing strategies: ```csharp +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + public class Startup { public void ConfigureServices(IServiceCollection services) @@ -194,6 +204,37 @@ public class Startup }, [typeof(TaxCalculator2024), typeof(TaxCalculator2025)]); + // Content-based routing to several handlers: OrderPlaced is an event, so you Publish it, + // and each handler runs on its own, in no particular order + registry.Register((request, context) => + { + var order = request as OrderPlaced; + var handlers = new List + { + typeof(OrderConfirmationHandler) // Every order is confirmed + }; + + // High-value orders get a fraud check + if (order?.Total > 10000m) + handlers.Add(typeof(FraudCheckHandler)); + + // International orders need a customs declaration + if (order?.IsInternational == true) + handlers.Add(typeof(CustomsDeclarationHandler)); + + return handlers; + }, + [ + typeof(OrderConfirmationHandler), + typeof(FraudCheckHandler), + typeof(CustomsDeclarationHandler) + ]); + + // Standard routing for simple commands + registry.Register(); + }) + .AsyncHandlers(registry => + { // Country-based routing for payments registry.RegisterAsync((request, context) => { @@ -213,37 +254,6 @@ public class Startup typeof(LocalPaymentHandlerAsync), typeof(InternationalPaymentHandlerAsync) ]); - - // Content-based routing with multiple handlers - registry.Register((request, context) => - { - var order = request as ProcessOrder; - var handlers = new List - { - typeof(ValidateOrderHandler) // Always validate - }; - - // High-value orders get fraud check - if (order?.Total > 10000m) - handlers.Add(typeof(FraudCheckHandler)); - - // International orders need approval - if (order?.IsInternational == true) - handlers.Add(typeof(ApprovalHandler)); - - handlers.Add(typeof(FinalizeOrderHandler)); // Always finalize - - return handlers; - }, - [ - typeof(ValidateOrderHandler), - typeof(FraudCheckHandler), - typeof(ApprovalHandler), - typeof(FinalizeOrderHandler) - ]); - - // Standard routing for simple commands - registry.Register(); }); } } @@ -283,6 +293,8 @@ registry.Register((request, context) => Handle unexpected cases gracefully: ```csharp +using System; + registry.Register((request, context) => { var order = request as ProcessOrder; @@ -317,8 +329,14 @@ registry.RegisterAsync((request, context) => { var payment = request as ProcessPayment; // ... + return [typeof(InternationalPaymentHandlerAsync)]; }, -[/* handlers */]); +[ + typeof(StripePaymentHandlerAsync), + typeof(PayPalPaymentHandlerAsync), + typeof(LocalPaymentHandlerAsync), + typeof(InternationalPaymentHandlerAsync) +]); ``` ### 4. Use Standard Routing When Possible @@ -333,8 +351,10 @@ registry.Register(); registry.Register((request, context) => { // Dynamic routing based on content + // ... + return [typeof(ComplexCommandHandler)]; }, -[/* handlers */]); +[typeof(ComplexCommandHandler)]); ``` ### 5. List All Possible Handlers @@ -343,7 +363,11 @@ Always provide the complete list of handler types: ```csharp // Good - Complete list -registry.Register((request, context) => { /* ... */ }, +registry.Register((request, context) => +{ + // ... + return [typeof(Handler1)]; +}, [ typeof(Handler1), typeof(Handler2), diff --git a/contents/AgreementDispatcherRouting.md b/contents/AgreementDispatcherRouting.md index aee203a..a9aab98 100644 --- a/contents/AgreementDispatcherRouting.md +++ b/contents/AgreementDispatcherRouting.md @@ -78,7 +78,7 @@ services.AddBrighter(options => { }) - Flexible routing based on content - Can change behavior over time -- Supports multiple handlers +- Chooses among several handlers: for a `Send` the route returns exactly one, or `Send` throws; for a `Publish` it may return several, and each runs - Access to request context - Registered explicitly, with `.Handlers()` - `AutoFromAssemblies()` must be told to skip the route's handlers diff --git a/contents/AsyncDispatchARequest.md b/contents/AsyncDispatchARequest.md index 58e3ff0..d13162f 100644 --- a/contents/AsyncDispatchARequest.md +++ b/contents/AsyncDispatchARequest.md @@ -16,28 +16,33 @@ Once you have [implemented your async Request Handler](/contents/ImplementingAsy In the following example code we register a handler, create a command processor, and then use that command processor to send a command to the handler asynchronously. -``` csharp - public class Program +```csharp +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +public class Program +{ + private static async Task Main(string[] args) { - private static async Task Main(string[] args) - { - var host = Host.CreateDefaultBuilder() - .ConfigureServices((hostContext, services) => - - { - services.AddBrighter() - .AutoFromAssemblies(); - } - ) - .UseConsoleLifetime() - .Build(); - - var commandProcessor = host.Services.GetService(); - - await commandProcessor.SendAsync(new GreetingCommand("Ian")); - - await host.RunAsync(); - } + var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + services.AddBrighter() + .AutoFromAssemblies(); + }) + .UseConsoleLifetime() + .Build(); + + var commandProcessor = host.Services.GetRequiredService(); + + await commandProcessor.SendAsync(new GreetingCommand("Ian")); + + await host.RunAsync(); + } +} ``` ## Registering an Async Handler @@ -98,8 +103,21 @@ A handler exposes the parameter you supply via the property **ContinueOnCapture You should pass this value via **ConfigureAwait** if you need to be able to support making this choice at the call site. For example, when you call the base handler in your return statement, to ensure that the decision as to whether to use the scheduler or the context flows down the pipeline. -``` csharp -return await base.HandleAsync(command, ct).ConfigureAwait(ContinueOnCapturedContext); +```csharp +using System; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +public class GreetingCommandHandlerAsync : RequestHandlerAsync +{ + public override async Task HandleAsync(GreetingCommand command, CancellationToken ct = default) + { + Console.WriteLine("Hello from the handler"); + + return await base.HandleAsync(command, ct).ConfigureAwait(ContinueOnCapturedContext); + } +} ``` You can ignore this, if you want to default to using the Task Scheduler. diff --git a/contents/BrighterInboxSupport.md b/contents/BrighterInboxSupport.md index 08afce1..676bdf8 100644 --- a/contents/BrighterInboxSupport.md +++ b/contents/BrighterInboxSupport.md @@ -83,9 +83,9 @@ Your inbox is configured as part of the Brighter extensions to `IServiceCollecti ### Global Inbox Configuration in a Consumer-Only Application -In Brighter 10.7.0, the `InboxConfiguration` you set in `AddConsumers` reaches your handlers' pipelines only when the application also calls `AddProducers`. In an application that only consumes, Brighter adds no Inbox to any handler, so a duplicate is handled again, with no error. This is [BrighterCommand/Brighter#4335](https://github.com/BrighterCommand/Brighter/issues/4335); the fix is on Brighter's development branch and in no release yet. +From Brighter 10.8.0, the `InboxConfiguration` you set in `AddConsumers` reaches your handlers' pipelines whether or not the application also calls `AddProducers`. At 10.7.0 it did so only alongside `AddProducers`: in an application that only consumed, Brighter added no Inbox to any handler, so a duplicate was handled again, with no error ([BrighterCommand/Brighter#4335](https://github.com/BrighterCommand/Brighter/issues/4335)). -Until a release carries it, put the attribute on each handler that must not see a duplicate, as in [Adding an Inbox to a Handler](#adding-an-inbox-to-a-handler). The attribute takes effect whether or not the application registers producers. Its own `onceOnlyAction` decides what a duplicate does, whatever `actionOnExists` the configuration sets, so give it the action you want. An application that calls `AddProducers` gets the global Inbox as configured. +On 10.7.0, put the attribute on each handler that must not see a duplicate, as in [Adding an Inbox to a Handler](#adding-an-inbox-to-a-handler). The attribute takes effect whether or not the application registers producers. Its own `onceOnlyAction` decides what a duplicate does, whatever `actionOnExists` the configuration sets, so give it the action you want. ### Provisioning the Inbox Table diff --git a/contents/BrighterOutboxSupport.md b/contents/BrighterOutboxSupport.md index 97563d4..3bb7656 100644 --- a/contents/BrighterOutboxSupport.md +++ b/contents/BrighterOutboxSupport.md @@ -67,7 +67,7 @@ When creating your **CommandProcessor** you can set an outbox bulk chunk size, i Brighter has the functionality to allow the **Outbox** to participate in the database transactions of your application so that you can ensure that distributed requests will be persisted (or fail to persist) inline with application changes. -To have the Brighter **Outbox** participate in Database transactions your command handler must take a dependency on an **IAmATransactionConnectionProvider**. The provider will be used when **CommandProcessor.DepositPost** is called and if there is an active transaction the **Outbox** will participate in that transaction. You use the **IAmATransactionConnectionProvider** to create the transaction that you use to interact with the Db; you also use it to grab any connection to the Db that you need. +To have the Brighter **Outbox** participate in a relational database's transactions your command handler takes a dependency on an **IAmATransactionConnectionProvider**. (The non-relational stores have their own: see [DynamoDB Outbox](/contents/DynamoOutbox.md) and [MongoDB Outbox](/contents/MongoDBOutbox.md#using-the-outbox-in-handlers).) The provider will be used when **CommandProcessor.DepositPost** is called and if there is an active transaction the **Outbox** will participate in that transaction. You use the **IAmATransactionConnectionProvider** to create the transaction that you use to interact with the Db; you also use it to grab any connection to the Db that you need. Below is an fragment using Dapper @@ -83,7 +83,7 @@ public AddGreetingHandlerAsync(IAmATransactionConnectionProvider transactionProv public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) { - var posts = new List(); + var posts = new List(); // The transaction provider (unit of work) gives us a connection and transaction // that the Outbox can share 'behind the scenes'. diff --git a/contents/CloudEventsSupport.md b/contents/CloudEventsSupport.md index 5209c27..8658b98 100644 --- a/contents/CloudEventsSupport.md +++ b/contents/CloudEventsSupport.md @@ -219,10 +219,13 @@ CloudEvents includes extensions for distributed tracing that integrate with Open Brighter automatically propagates OpenTelemetry trace context using CloudEvents distributed tracing extensions. See [OpenTelemetry Integration](Telemetry.md) for details. ```csharp +using Paramore.Brighter; + public override MyCommand Handle(MyCommand command) { - // Access the current span from context - Context.Span.SetAttribute("custom.attribute", "value"); + // Access the current span, a System.Diagnostics.Activity, from context; + // OpenTelemetry exports its tags as span attributes + Context?.Span?.SetTag("custom.attribute", "value"); // Trace context is automatically propagated via CloudEvents headers // when you publish/post messages diff --git a/contents/Compression.md b/contents/Compression.md index 39a823a..e8df939 100644 --- a/contents/Compression.md +++ b/contents/Compression.md @@ -43,7 +43,7 @@ We provide a matching **UnwrapWithAttribute** of **Decompress** that will use th In this example, we look for a GZip compressed body and if we find it, decompress the body. -**At 10.7.0, Decompress does not recognise a body compressed on the async path.** `WrapAsync` — which `PostAsync` and `DepositPostAsync` use — sets the *Content Type* to `application/gzip; charset=utf-8`, and **Decompress** compares the whole *Content Type* with `application/gzip`, so the message reaches your mapper still compressed. The same holds for Zlib and Brotli. A body compressed by the synchronous `Post` path round-trips. This is [BrighterCommand/Brighter#4432](https://github.com/BrighterCommand/Brighter/issues/4432). +**Before Brighter 10.8.0, Decompress did not recognise a body compressed on the async path.** `WrapAsync` — which `PostAsync` and `DepositPostAsync` use — sets the *Content Type* to `application/gzip; charset=utf-8`, and at 10.7.0 **Decompress** compared the whole *Content Type* with `application/gzip`, so the message reached your mapper still compressed. The same held for Zlib and Brotli. From 10.8.0 it compares the media type alone, and both paths round-trip ([BrighterCommand/Brighter#4432](https://github.com/BrighterCommand/Brighter/issues/4432)). On 10.7.0, compress on the synchronous `Post` path. ```csharp using System.Text.Json; diff --git a/contents/ConfiguringOpenTelemetry.md b/contents/ConfiguringOpenTelemetry.md index 731170a..2733a82 100644 --- a/contents/ConfiguringOpenTelemetry.md +++ b/contents/ConfiguringOpenTelemetry.md @@ -18,7 +18,7 @@ The OpenTelemetry SDK can be configured to listen to Activities emitted by Brigh Brighter emits traces using the following Activity Source: - **Source Name**: `Paramore.Brighter`. OpenTelemetry matches source names without regard to case, so `paramore.brighter` also works -- **Version**: The `Paramore.Brighter` assembly's version, which is `10.0.0.0` at package version 10.7.0 +- **Version**: The `Paramore.Brighter` assembly's version, which is `10.0.0.0` at package version 10.8.0 ### Registering Brighter's Tracer @@ -26,9 +26,9 @@ Brighter writes to that source only through a tracer, an `IAmABrighterTracer`, r container. `AddBrighter()` does not register one, so listening to the source is not enough: `AddSource("paramore.brighter")` on its own records no Brighter span. `AddBrighterInstrumentation()`, from the `Paramore.Brighter.Extensions.Diagnostics` package, does -both: it registers the tracer and adds the source. The Command Processor takes that tracer only when -it has an external bus, configured with `AddProducers`; with no producers, its requests record no -span at 10.7.0. This is reported as [#4510](https://github.com/BrighterCommand/Brighter/issues/4510). +both: it registers the tracer and adds the source. From Brighter 10.8.0 the Command Processor takes +that tracer whether or not it has an external bus; at 10.7.0 it took it only with `AddProducers`, so +with no producers its requests recorded no span ([#4510](https://github.com/BrighterCommand/Brighter/issues/4510)). Use it on the tracer provider that `AddOpenTelemetry()` builds, which shares your application's container. A provider built with `Sdk.CreateTracerProviderBuilder()` keeps its own services, so the diff --git a/contents/DispatcherConfigurationReference.md b/contents/DispatcherConfigurationReference.md index 9ce4c00..2c9c10d 100644 --- a/contents/DispatcherConfigurationReference.md +++ b/contents/DispatcherConfigurationReference.md @@ -260,7 +260,7 @@ To configure our *Inbox* we set the **InboxConfiguration** property on the optio For *Inbox Configuration* you pass the following arguments, each of which is exposed as a property: -* **ActionOnExists**: What do we do if the request has been handled? The default,**OnceOnlyAction.Throw** is to throw a **OnceOnlyException**. If you take no other action this will cause the message to be rejected and sent to a DLQ if one is configured (See [Handler Failure](/contents/HandlerFailure.md)). The alternative is **OnceOnlyAction.Warn** simply logs that the request is a duplicate, but takes no other action. A third option, **OnceOnlyAction.Replay**, is **not in a released package yet**: it ships after Brighter 10.7.0. It also skips the handler but resends the messages that handler produced the first time it ran — it has prerequisites, so see [Replay On Seen](/contents/ReplayOnSeen.md) before you choose it. +* **ActionOnExists**: What do we do if the request has been handled? The default,**OnceOnlyAction.Throw** is to throw a **OnceOnlyException**. If you take no other action this will cause the message to be rejected and sent to a DLQ if one is configured (See [Handler Failure](/contents/HandlerFailure.md)). The alternative is **OnceOnlyAction.Warn** simply logs that the request is a duplicate, but takes no other action. A third option, **OnceOnlyAction.Replay**, requires Brighter 10.8.0 or later. It also skips the handler but resends the messages that handler produced the first time it ran — it has prerequisites, so see [Replay On Seen](/contents/ReplayOnSeen.md) before you choose it. * **OnceOnly**: This defaults to *true* and will check for a duplicate and take the action indicated by **ActionOnExists**. If *false* the *Inbox* will record the request, but will take no further action. (This tends to be set to *false* if you are using the *Inbox* to record what requests caused current state only and not de-duplicate). * **Scope**: This indicates the type of request (*Command* or *Event*) to store in the *Inbox*. By default this is set to **InboxScope.All** and captures everything but you can be explicit and just capture **InboxScope.Commands** or **InboxScope.Events**. (This tends to be set to **InboxScope.Commands** when only commands cause changes to state that are not idempotent). * **Context**: Used to uniquely identify receipt of this request via this handler. If you are recording *Events* and have multiple handlers, then the first event handler to receive the message will block the others from doing so, unless you disambiguate the handler identity by supplying a context method. diff --git a/contents/DispatchingARequest.md b/contents/DispatchingARequest.md index 5b8003d..5b8646e 100644 --- a/contents/DispatchingARequest.md +++ b/contents/DispatchingARequest.md @@ -17,6 +17,11 @@ In the following example code we register a handler, create a *Command Processor ``` csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + public class Program { private static void Main() @@ -76,10 +81,14 @@ This is useful when you want to: ### Example: Setting Partition Key and Headers ```csharp -public class OrderController : ControllerBase -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Paramore.Brighter; +public class OrderController(IAmACommandProcessor commandProcessor) : ControllerBase +{ public async Task CreateOrder(CreateOrderRequest request) { // Create explicit context @@ -98,7 +107,7 @@ public class OrderController : ControllerBase }; // Pass context explicitly to Send - await _commandProcessor.SendAsync( + await commandProcessor.SendAsync( new CreateOrderCommand { OrderId = request.OrderId, @@ -121,10 +130,8 @@ using System.Collections.Generic; using System.Threading.Tasks; using Paramore.Brighter; -public class EventPublisher +public class EventPublisher(IAmACommandProcessor commandProcessor) { - private readonly IAmACommandProcessor _commandProcessor; - public async Task PublishOrderCreatedEvent(Order order) { var context = new RequestContext(); @@ -138,7 +145,7 @@ public class EventPublisher }; // Post the event to the external bus, with the context - await _commandProcessor.PostAsync( + await commandProcessor.PostAsync( new OrderCreatedEvent { OrderId = order.Id, @@ -159,12 +166,11 @@ using System.Threading; using System.Threading.Tasks; using Paramore.Brighter; -public class OrderHandler : RequestHandlerAsync +public class OrderHandler( + IAmACommandProcessor commandProcessor, + IAmATransactionConnectionProvider transactionProvider, + IOrderRepository orderRepository) : RequestHandlerAsync { - private readonly IAmACommandProcessor _commandProcessor; - private readonly IAmATransactionConnectionProvider _transactionProvider; - private readonly IOrderRepository _orderRepository; - public override async Task HandleAsync( CreateOrderCommand command, CancellationToken cancellationToken = default) @@ -181,32 +187,32 @@ public class OrderHandler : RequestHandlerAsync var posts = new List(); - var conn = await _transactionProvider.GetConnectionAsync(cancellationToken); - var tx = await _transactionProvider.GetTransactionAsync(cancellationToken); + var conn = await transactionProvider.GetConnectionAsync(cancellationToken); + var tx = await transactionProvider.GetTransactionAsync(cancellationToken); try { // Save order to database - await _orderRepository.AddAsync(command, cancellationToken); + await orderRepository.AddAsync(command, cancellationToken); // Deposit message with explicit context - posts.Add(await _commandProcessor.DepositPostAsync( + posts.Add(await commandProcessor.DepositPostAsync( new OrderCreatedEvent { OrderId = command.OrderId }, - _transactionProvider, + transactionProvider, requestContext: context, cancellationToken: cancellationToken )); - await _transactionProvider.CommitAsync(cancellationToken); + await transactionProvider.CommitAsync(cancellationToken); } catch (Exception) { - await _transactionProvider.RollbackAsync(cancellationToken); + await transactionProvider.RollbackAsync(cancellationToken); throw; } // Clear outbox - await _commandProcessor.ClearOutboxAsync(posts, cancellationToken: cancellationToken); + await commandProcessor.ClearOutboxAsync(posts, cancellationToken: cancellationToken); return await base.HandleAsync(command, cancellationToken); } @@ -273,47 +279,62 @@ commandProcessor.Post(new GreetingCommand("Ian")); In this exaple, we use **CommandProcessor.DepositPost()** and **CommandProcessor.ClearOutbox** to raise a transactional message. We then immediately clear it to lower latency. (We could have relied on an Outbox Sweeper and you should have an Outbox Sweeper in case this was to fail). -In this example we are using Dapper as the library for writing our entities to the Db, and have used Brighter's Unit of Work support for that (passed into the handler constructor). +In this example we are using Dapper as the library for writing our entities to the Db. The handler's constructor takes an `IAmATransactionConnectionProvider`, `_transactionProvider`, which gives the entity write and the Outbox the same connection and transaction, an `IAmACommandProcessor`, `_postBox`, and an `ILogger`, `_logger`. ```csharp -public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default(CancellationToken)) +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; + +public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) { - var posts = new List(); + var posts = new List(); - //We use the unit of work to grab connection and transaction, because Outbox needs + //We use the transaction provider to grab connection and transaction, because Outbox needs //to share them 'behind the scenes' - var conn = await _uow.GetConnectionAsync(cancellationToken); - await conn.OpenAsync(cancellationToken); - var tx = _uow.GetTransaction(); + var conn = await _transactionProvider.GetConnectionAsync(cancellationToken); + var tx = await _transactionProvider.GetTransactionAsync(cancellationToken); try { - var searchbyName = Predicates.Field(p => p.Name, Operator.Eq, addGreeting.Name); - var people = await conn.GetListAsync(searchbyName, transaction: tx); + var people = await conn.QueryAsync( + "select * from Person where name = @name", new { name = addGreeting.Name }, tx); var person = people.Single(); var greeting = new Greeting(addGreeting.Greeting, person); //write the added child entity to the Db - await conn.InsertAsync(greeting, tx); + await conn.ExecuteAsync( + "insert into Greeting (Message, Recipient_Id) values (@Message, @RecipientId)", + new { greeting.Message, greeting.RecipientId }, tx); //Now write the message we want to send to the Db in the same transaction. - posts.Add(await _postBox.DepositPostAsync(new GreetingMade(greeting.Greet()), cancellationToken: cancellationToken)); + posts.Add(await _postBox.DepositPostAsync( + new GreetingMade(greeting.Greet()), _transactionProvider, cancellationToken: cancellationToken)); //commit both new greeting and outgoing message - await tx.CommitAsync(cancellationToken); + await _transactionProvider.CommitAsync(cancellationToken); } catch (Exception e) { _logger.LogError(e, "Exception thrown handling Add Greeting request"); //it went wrong, rollback the entity change and the downstream message - await tx.RollbackAsync(cancellationToken); + await _transactionProvider.RollbackAsync(cancellationToken); return await base.HandleAsync(addGreeting, cancellationToken); } + finally + { + _transactionProvider.Close(); + } //Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. //Alternatively, you can let the Sweeper do this, but at the cost of increased latency - await _postBox.ClearOutboxAsync(posts, cancellationToken:cancellationToken); + await _postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); return await base.HandleAsync(addGreeting, cancellationToken); } diff --git a/contents/DynamoInbox.md b/contents/DynamoInbox.md index f06827b..0f4d08d 100644 --- a/contents/DynamoInbox.md +++ b/contents/DynamoInbox.md @@ -72,4 +72,4 @@ private static void ConfigureBrighter(HostBuilderContext hostContext, IServiceCo // ... ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). diff --git a/contents/EFCoreOutbox.md b/contents/EFCoreOutbox.md index 84fc4de..e54690f 100644 --- a/contents/EFCoreOutbox.md +++ b/contents/EFCoreOutbox.md @@ -63,48 +63,69 @@ public void ConfigureServices(IServiceCollection services) } ``` -In our handler we take a dependency on our EF Core Context (derived from Db context). We explicitly start a transaction within the handler, because the Outbox is not within the Db Context we cannot rely on the DBContext's implicit transaction. +In our handler we take a dependency on our EF Core context (derived from `DbContext`) and on the `IAmATransactionConnectionProvider` that Brighter registers for the `TransactionProvider` set above. We start the transaction through the provider, which begins it on the context's database, so EF Core's `SaveChangesAsync` and the Outbox write share it. -We call **DepositPostAsync** within that transaction to write the message to the Outbox. Once the transaction has closed we can call **ClearOutboxAsync** to immediately clear, or we can rely on the Outbox Sweeper, if we have configured one to clear for us. (There are equivalent synchronous versions of these APIs). +We call **DepositPostAsync** with the provider to write the message to the Outbox within that transaction. Once the transaction has committed we can call **ClearOutboxAsync** to dispatch the message immediately, or we can rely on the Outbox Sweeper, if we have configured one, to dispatch it for us. (There are equivalent synchronous versions of these APIs). -``` csharp - public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default(CancellationToken)) +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Paramore.Brighter; + +public class AddGreetingHandlerAsync( + GreetingsEntityGateway uow, + IAmATransactionConnectionProvider provider, + IAmACommandProcessor postBox) + : RequestHandlerAsync { - var posts = new List(); - - //We span a Db outside of EF's control, so start an explicit transactional scope - var tx = await _uow.Database.BeginTransactionAsync(cancellationToken); - try - { - var person = await _uow.People - .Where(p => p.Name == addGreeting.Name) - .SingleAsync(cancellationToken); - - var greeting = new Greeting(addGreeting.Greeting); - - person.AddGreeting(greeting); - - //Now write the message we want to send to the Db in the same transaction. - posts.Add(await _postBox.DepositPostAsync(new GreetingMade(greeting.Greet()), cancellationToken: cancellationToken)); - - //write the changed entity to the Db - await _uow.SaveChangesAsync(cancellationToken); - - //write new person and the associated message to the Db - await tx.CommitAsync(cancellationToken); - } - catch (Exception) - { - //it went wrong, rollback the entity change and the downstream message - await tx.RollbackAsync(cancellationToken); - return await base.HandleAsync(addGreeting, cancellationToken); - } - - //Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. - //Alternatively, you can let the Sweeper do this, but at the cost of increased latency - await _postBox.ClearOutboxAsync(posts, cancellationToken:cancellationToken); - - return await base.HandleAsync(addGreeting, cancellationToken); + public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) + { + var posts = new List(); + + await provider.GetTransactionAsync(cancellationToken); + try + { + var person = await uow.People + .Where(p => p.Name == addGreeting.Name) + .SingleAsync(cancellationToken); + + var greeting = new Greeting(addGreeting.Greeting); + + person.AddGreeting(greeting); + + //Now write the message we want to send to the Db in the same transaction. + posts.Add(await postBox.DepositPostAsync( + new GreetingMade(greeting.Greet()), + provider, + cancellationToken: cancellationToken)); + + //write the changed entity to the Db + await uow.SaveChangesAsync(cancellationToken); + + //write new person and the associated message to the Db + await provider.CommitAsync(cancellationToken); + } + catch (Exception) + { + //it went wrong, rollback the entity change and the downstream message + await provider.RollbackAsync(cancellationToken); + return await base.HandleAsync(addGreeting, cancellationToken); + } + finally + { + provider.Close(); + } + + //Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. + //Alternatively, you can let the Sweeper do this, but at the cost of increased latency + await postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); + + return await base.HandleAsync(addGreeting, cancellationToken); + } } ``` diff --git a/contents/FAQ.md b/contents/FAQ.md index a1dd8b8..eb83f39 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -571,7 +571,7 @@ await scheduler.ReSchedulerAsync(schedulerId, newDelay); **Note**: Azure Service Bus Scheduler does NOT support reschedule - you must cancel and create a new schedule. -**Note**: At Brighter 10.7.0 the InMemory scheduler cannot cancel or reschedule a request scheduled through the command processor, as in the examples above: `CancelAsync` returns and the request still runs ([#4437](https://github.com/BrighterCommand/Brighter/issues/4437)). With InMemory, schedule through `scheduler.ScheduleAsync` when you need to cancel. +**Note**: From Brighter 10.8.0 the InMemory scheduler cancels and reschedules a request scheduled through the command processor, as in the examples above. At 10.7.0 it could not: `CancelAsync` returned and the request still ran ([#4437](https://github.com/BrighterCommand/Brighter/issues/4437)), so on 10.7.0 schedule through `scheduler.ScheduleAsync` when you need to cancel. See: [Scheduler Support](/contents/BrighterSchedulerSupport.md) diff --git a/contents/FeatureSwitches.md b/contents/FeatureSwitches.md index 18feb34..f81b735 100644 --- a/contents/FeatureSwitches.md +++ b/contents/FeatureSwitches.md @@ -109,13 +109,16 @@ For more on how `DontAckAction` works and how transports handle unacknowledged m ## Building a config for Feature Switches with FluentConfigRegistryBuilder We provide a **FluentConfigRegistryBuilder** to build a mapping of request handlers to **FeatureSwitchStatus**. For each Handler that you wish to feature switch you supply a type and a status using a fluent -API. The valid statuses used in the builder are **FeatureSwitchStatus.On** and **FeatureSwitchStatus.Off**. +API. The valid statuses used in the builder are **FeatureSwitchStatus.On** and **FeatureSwitchStatus.Off**. This example switches the two handlers declared in the examples above. ``` csharp +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Providers; + var featureSwitchRegistry = FluentConfigRegistryBuilder .With() .StatusOf().Is(FeatureSwitchStatus.On) - .StatusOf().Is(FeatureSwitchStatus.Off) + .StatusOf().Is(FeatureSwitchStatus.Off) .Build(); ``` @@ -130,37 +133,41 @@ in the registry. Your implementation of the **StatusOf** method is used to determine the **FeatureSwitchStatus** of the Handler type that is passed in as a parameter. How you store and retrieve these configurations is then up to you. -In the following example there are two FeatureSwitches configured in the **AppSettings.config**. We then build an **AppSettingsConfigRegistry**. The **StatusOf** method is implemetned to read the config from the App Settings and return the status for the given type. +In the following example there are two Feature Switches configured in **appsettings.json**, under a `FeatureSwitches` section keyed by each handler's full type name. We then build a **ConfigurationFeatureSwitchRegistry** that takes the application's `IConfiguration`. Its **StatusOf** method reads the handler's entry and returns its status, falling back to the **MissingConfigStrategy** when there is none. -``` xml - - - - +```json +{ + "FeatureSwitches": { + "MyNamespace.MyFeatureSwitchedHandler": "on", + "MyNamespace.MyIncompleteHandlerAsync": "off" + } +} ``` -``` csharp -class AppSettingsConfigRegistry : IAmAFeatureSwitchRegistry +```csharp +using System; +using Microsoft.Extensions.Configuration; +using Paramore.Brighter.FeatureSwitch; + +public class ConfigurationFeatureSwitchRegistry(IConfiguration configuration) : IAmAFeatureSwitchRegistry { - public MissingConfigStrategy MissingConfigStrategy { get; set; } + public MissingConfigStrategy MissingConfigStrategy { get; set; } = MissingConfigStrategy.Exception; public FeatureSwitchStatus StatusOf(Type handler) - { - var configStatus = ConfigurationManager.AppSettings[$"FeatureSwitch::{handler}"].ToLower(); + { + var configStatus = configuration[$"FeatureSwitches:{handler.FullName}"]?.ToLowerInvariant(); - switch (configStatus) + return configStatus switch { - case "on": - return FeatureSwitchStatus.On; - case "off": - return FeatureSwitchStatus.Off; - default: - return MissingConfigStrategy is MissingConfigStrategy.SilentOn - ? FeatureSwitchStatus.On - : MissingConfigStrategy is MissingConfigStrategy.SilentOff - ? FeatureSwitchStatus.Off - : throw new InvalidOperationException($"No Feature Switch configuration for {handler} specified"); - } + "on" => FeatureSwitchStatus.On, + "off" => FeatureSwitchStatus.Off, + _ => MissingConfigStrategy switch + { + MissingConfigStrategy.SilentOn => FeatureSwitchStatus.On, + MissingConfigStrategy.SilentOff => FeatureSwitchStatus.Off, + _ => throw new InvalidOperationException($"No Feature Switch configuration for {handler} specified") + } + }; } } ``` diff --git a/contents/FirestoreInbox.md b/contents/FirestoreInbox.md index 8932071..fa24cac 100644 --- a/contents/FirestoreInbox.md +++ b/contents/FirestoreInbox.md @@ -57,7 +57,7 @@ public static class FirestoreInboxRegistration } ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). `FirestoreInbox` also has a constructor taking an `IAmAFirestoreConnectionProvider` alongside the configuration, so one client can serve the Inbox, the Outbox and the lock. diff --git a/contents/HowConfiguringTheCommandProcessorWorks.md b/contents/HowConfiguringTheCommandProcessorWorks.md index cfc673f..0d91806 100644 --- a/contents/HowConfiguringTheCommandProcessorWorks.md +++ b/contents/HowConfiguringTheCommandProcessorWorks.md @@ -176,13 +176,17 @@ This is just the Polly **PolicyRegistry**. Registration requires a string as a key, that you will use in your [UsePolicy] attribute to choose the policy. -The two keys: CommandProcessor.RETRYPOLICY and CommandProcessor.CIRCUITBREAKER are used within Brighter to control our response to broker issues. You can override them if you wish to change our behavior from the default. +The two keys: CommandProcessor.RETRYPOLICY and CommandProcessor.CIRCUITBREAKER are used within Brighter to control our response to broker issues. Brighter requires both in the registry you give it, and throws `ConfigurationException` at startup without them, so start from `DefaultPolicy`, a **PolicyRegistry** that holds them. You can override them if you wish to change our behavior from the default. You can also use them for a generic retry policy, though we recommend building retry policies that handle the kind of exceptions that will be thrown from your handlers. In this example, we set up a policy. To make it easy to reference the string, instead of adding it everywhere, we use a global readonly reference, not shown here. ``` csharp +using System; +using Paramore.Brighter; +using Polly; + var retryPolicy = Policy.Handle().WaitAndRetry( new[] { @@ -193,7 +197,7 @@ var retryPolicy = var circuitBreakerPolicy = Policy.Handle().CircuitBreaker( 1, TimeSpan.FromMilliseconds(500)); -var policyRegistry = new PolicyRegistry() { +var policyRegistry = new DefaultPolicy() { { Globals.MYRETRYPOLICY, retryPolicy }, { Globals.MYCIRCUITBREAKER, circuitBreakerPolicy } }; diff --git a/contents/InMemoryInbox.md b/contents/InMemoryInbox.md index 4d8ba00..0793d96 100644 --- a/contents/InMemoryInbox.md +++ b/contents/InMemoryInbox.md @@ -52,7 +52,7 @@ services.AddConsumers(options => .AutoFromAssemblies(); ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). ## InMemory Inbox Example Usage diff --git a/contents/InMemoryScheduler.md b/contents/InMemoryScheduler.md index 64fc0ab..dc22678 100644 --- a/contents/InMemoryScheduler.md +++ b/contents/InMemoryScheduler.md @@ -258,7 +258,7 @@ public class SendReminderHandler : RequestHandlerAsync `Advance` runs any timer that falls due on the calling thread, so the handler has run by the time the call returns, and the test needs no `Task.Delay`. Until you advance the clock, nothing fires: advancing it by four minutes and fifty-nine seconds leaves the command unhandled, and real time passing does not move a `FakeTimeProvider` at all. -The handler is registered with `AsyncHandlers` rather than `AutoFromAssemblies()` because of a 10.7.0 defect in registering the scheduler's own handlers — see [Registering Handlers When You Schedule Requests](/contents/SchedulingAMessage.md#registering-handlers-when-you-schedule-requests). +The handler is registered with `AsyncHandlers`, which works at every 10.x release. `AutoFromAssemblies()` works too from Brighter 10.8.0; at 10.7.0 it registers the scheduler's own handlers twice — see [Registering Handlers When You Schedule Requests](/contents/SchedulingAMessage.md#registering-handlers-when-you-schedule-requests). ## InMemory Scheduler NuGet Package @@ -327,7 +327,7 @@ public class ReportService(IAmACommandProcessor commandProcessor, ILogger ```csharp // ... -var policyRegistry = new PolicyRegistry(); +var policyRegistry = new DefaultPolicy(); var retryPolicy = Policy .Handle() - .WaitAndRetry(new[] { 1.Seconds(), 2.Seconds(), 3.Seconds() }); + .WaitAndRetry(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3) }); policyRegistry.Add("MyRetryPolicy", retryPolicy); ``` @@ -47,6 +47,11 @@ policyRegistry.Add("MyRetryPolicy", retryPolicy); **V10**: ```csharp +using System; +using Polly; +using Polly.Registry; +using Polly.Retry; + // ... var resiliencePipelineRegistry = new ResiliencePipelineRegistry(); @@ -161,6 +166,9 @@ tell you, because each returns the interface the next call is declared on. By adding the **UsePolicy** attribute, you instruct the Command Processor to insert a handler (filter) into the pipeline that runs all later steps using that Polly policy. ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + // ... public class MyQoSProtectedHandler : RequestHandler { @@ -175,20 +183,26 @@ public class MyQoSProtectedHandler : RequestHandler To configure the Polly policy you use the PolicyRegistry to register the Polly Policy with a name. At runtime we look up that Policy by name. +Start from Brighter's `DefaultPolicy`, a `PolicyRegistry` that already holds the `CommandProcessor.RETRYPOLICY` and `CommandProcessor.CIRCUITBREAKER` policies Brighter requires. Hand Brighter an empty `PolicyRegistry` and it throws `ConfigurationException` at startup. + ```csharp +using System; +using Paramore.Brighter; +using Polly; + // ... -var policyRegistry = new PolicyRegistry(); +var policyRegistry = new DefaultPolicy(); var policy = Policy .Handle() .WaitAndRetry(new[] { - 1.Seconds(), - 2.Seconds(), - 3.Seconds() + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(3) }, (exception, timeSpan) => { - s_retryCount++; + Console.WriteLine($"Retrying in {timeSpan} after {exception.Message}"); }); policyRegistry.Add("MyExceptionPolicy", policy); @@ -199,16 +213,22 @@ You can use multiple policies with a handler, instead of passing in a single pol So if in addition to the above policy we have: ```csharp +using System; +using Polly; + // ... var circuitBreakerPolicy = Policy.Handle().CircuitBreaker( 1, TimeSpan.FromMilliseconds(500)); -policyRegistry.Add("MyCircuitBreakerPolicy", policy); +policyRegistry.Add("MyCircuitBreakerPolicy", circuitBreakerPolicy); ``` then you can add them both to your handler as follows: ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + // ... public class MyQoSProtectedHandler : RequestHandler { @@ -225,7 +245,7 @@ Where we have multiple policies they are evaluated left to right, so in this cas When creating policies, refer to the [Polly](https://github.com/App-vNext/Polly) documentation. -Whilst [Polly](https://github.com/App-vNext/Polly) does not support a Policy that is both Circuit Breaker and Retry i.e. retry n times with an interval between each retry, and then break circuit, to implement that simply put a Circuit Breaker UsePolicy attribute as an earlier step than the Retry UsePolicy attribute. If retries expire, the exception will bubble out to the Circuit Breaker. +Whilst [Polly](https://github.com/App-vNext/Polly) does not support a Policy that is both Circuit Breaker and Retry i.e. retry n times with an interval between each retry, and then break circuit, to implement that put the Circuit Breaker first in the array, as above, so that it wraps the Retry. If retries expire, the exception bubbles out to the Circuit Breaker. A handler method takes one `[UsePolicy]` attribute, so the array is the only way to combine them. ### Timeout (Legacy - Deprecated) @@ -240,6 +260,10 @@ In some scenarios the client library does not provide a timeout, so you have no We provide the Timeout attribute for that circumstance. You can apply it to a Handler to force that Handler into a thread which we will timeout, if it does not complete within the required time period. ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; + // ... public class EditTaskCommandHandler : RequestHandler { @@ -251,8 +275,7 @@ public class EditTaskCommandHandler : RequestHandler } [RequestLogging(step: 1, timing: HandlerTiming.Before)] - [Validation(step: 2, timing: HandlerTiming.Before)] - [TimeoutPolicy(step: 3, milliseconds: 300)] // ⚠️ DEPRECATED + [TimeoutPolicy(step: 2, milliseconds: 300)] // ⚠️ DEPRECATED public override EditTaskCommand Handle(EditTaskCommand editTaskCommand) { using (var scope = _tasksDAO.BeginTransaction()) @@ -275,6 +298,12 @@ public class EditTaskCommandHandler : RequestHandler **V10 Replacement**: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; +using Polly; + // ... // Configure timeout pipeline resiliencePipelineRegistry.TryAddBuilder("EditTaskTimeout", @@ -291,8 +320,7 @@ public class EditTaskCommandHandler : RequestHandler } [RequestLogging(step: 1, timing: HandlerTiming.Before)] - [Validation(step: 2, timing: HandlerTiming.Before)] - [UseResiliencePipeline("EditTaskTimeout", step: 3)] // ✅ V10 recommended + [UseResiliencePipeline("EditTaskTimeout", step: 2)] // ✅ V10 recommended public override EditTaskCommand Handle(EditTaskCommand editTaskCommand) { using (var scope = _tasksDAO.BeginTransaction()) diff --git a/contents/MongoDBInbox.md b/contents/MongoDBInbox.md index b461cd2..d659900 100644 --- a/contents/MongoDBInbox.md +++ b/contents/MongoDBInbox.md @@ -58,7 +58,7 @@ private static void ConfigureBrighter(HostBuilderContext hostContext, IServiceCo } ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). ### Advanced Configuration diff --git a/contents/MongoDBOutbox.md b/contents/MongoDBOutbox.md index 53dc4e4..576e22b 100644 --- a/contents/MongoDBOutbox.md +++ b/contents/MongoDBOutbox.md @@ -242,79 +242,74 @@ When TTL is configured, MongoDB will automatically create a TTL index on the `Ti ## Using the Outbox in Handlers -In your handler, you take a dependency on Brighter's **IAmATransactionConnectionProvider** interface and convert it to a **MongoDbUnitOfWork**. You explicitly start a transaction within the handler and use MongoDB sessions for transactional consistency. +In your handler, take a dependency on **IAmAMongoDbTransactionProvider**, which Brighter registers for the `MongoDbUnitOfWork` you set as the `TransactionProvider`, and on the `IAmAMongoDbConfiguration` you registered beside it. Brighter does not register `IAmATransactionConnectionProvider` for MongoDB; that interface is for relational databases, and a handler that asks for it cannot be created. -You call **DepositPostAsync** within that transaction to write the message to the Outbox. Once the transaction has closed, you can call **ClearOutboxAsync** to immediately clear, or you can rely on the Outbox Sweeper to clear for you. +Use the provider as an `IAmABoxTransactionProvider`. The `MongoDbOutbox` writes in an `IClientSessionHandle`, so that is the transaction type Brighter configures, and `DepositPostAsync` checks the provider you pass against it: passing the `IAmAMongoDbTransactionProvider` itself throws `InvalidOperationException: Supplied transaction provider doesn't match configured transaction type.` It is also the session type the MongoDB driver's collection methods take. + +Start the transaction through the provider, write your documents in its session, and call **DepositPostAsync** with the provider so the message is written to the Outbox in the same transaction. Commit through the provider; if anything fails, roll back and neither your change nor the message is written. Once the transaction has committed, you can call **ClearOutboxAsync** to dispatch the message immediately, or you can rely on the Outbox Sweeper to dispatch it for you. > **Running more than one instance?** Configure a [distributed lock](/contents/DistributedLock.md) so only one Sweeper (and Archiver) runs at a time — see [MongoDB Distributed Lock](/contents/MongoDbDistributedLock.md). ```csharp -public class AddGreetingHandler : RequestHandlerAsync -{ - private readonly IAmATransactionConnectionProvider _connectionProvider; - private readonly IAmACommandProcessor _postBox; - private readonly ILogger _logger; - - public AddGreetingHandler( - IAmATransactionConnectionProvider connectionProvider, - IAmACommandProcessor postBox, - ILogger logger) - { - _connectionProvider = connectionProvider; - _postBox = postBox; - _logger = logger; - } +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using Paramore.Brighter; +using Paramore.Brighter.MongoDb; +public class AddGreetingHandler( + IAmAMongoDbTransactionProvider transactionProvider, + IAmAMongoDbConfiguration configuration, + IAmACommandProcessor postBox, + ILogger logger) : RequestHandlerAsync +{ public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) { - var posts = new List(); - - // We use the unit of work to grab connection and session, because Outbox needs - // to share them 'behind the scenes' - var unitOfWork = _connectionProvider as MongoDbUnitOfWork; - var database = unitOfWork.Database; - var session = unitOfWork.Session; - + var posts = new List(); + + // The MongoDB Outbox writes in an IClientSessionHandle, so use the provider as one + var unitOfWork = (IAmABoxTransactionProvider)transactionProvider; + var session = await unitOfWork.GetTransactionAsync(cancellationToken); + try { - session.StartTransaction(); - - var collection = database.GetCollection("People"); - - // Find and update the person within the transaction - var person = await collection.Find(session, p => p.Name == addGreeting.Name) - .FirstOrDefaultAsync(cancellationToken); - - if (person != null) - { - person.Greetings.Add(addGreeting.Greeting); - - // Update the person document within the transaction - await collection.ReplaceOneAsync(session, - p => p.Id == person.Id, - person, - cancellationToken: cancellationToken); - } + var people = transactionProvider.Client + .GetDatabase(configuration.DatabaseName) + .GetCollection("People"); + + // Update the person document within the transaction + await people.UpdateOneAsync(session, + Builders.Filter.Eq(p => p.Name, addGreeting.Name), + Builders.Update.Push(p => p.Greetings, addGreeting.Greeting), + cancellationToken: cancellationToken); // Now write the message we want to send to the Outbox in the same transaction - posts.Add(await _postBox.DepositPostAsync( - new GreetingMade(addGreeting.Greeting), + posts.Add(await postBox.DepositPostAsync( + new GreetingMade(addGreeting.Greeting), + unitOfWork, cancellationToken: cancellationToken)); // Commit both the entity change and the outgoing message - await session.CommitTransactionAsync(cancellationToken); + await unitOfWork.CommitAsync(cancellationToken); } catch (Exception e) - { - _logger.LogError(e, "Exception thrown handling Add Greeting request"); + { + logger.LogError(e, "Exception thrown handling Add Greeting request"); // It went wrong, rollback the entity change and the downstream message - await session.AbortTransactionAsync(cancellationToken); + await unitOfWork.RollbackAsync(cancellationToken); return await base.HandleAsync(addGreeting, cancellationToken); } + finally + { + unitOfWork.Close(); + } // Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. // Alternatively, you can let the Sweeper do this, but at the cost of increased latency - await _postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); + await postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); return await base.HandleAsync(addGreeting, cancellationToken); } diff --git a/contents/Monitoring.md b/contents/Monitoring.md index d1919b4..3338b7e 100644 --- a/contents/Monitoring.md +++ b/contents/Monitoring.md @@ -17,7 +17,7 @@ Each event names the handler, carries the request it handled, and records how lo Monitoring needs two things in your container besides Brighter itself: -- An `IAmAControlBusSender`, which sends each `MonitorEvent` to your broker +- An `IAmAControlBusSender`, which sends each `MonitorEvent` to your broker, registered also as `IAmAControlBusSenderAsync` if you use `[MonitorAsync]` - A `MonitorConfiguration`, which turns monitoring on and names this instance in every event `ControlBusSenderFactory` builds a sender from an Outbox and a producer registry. The registry needs a publication whose `RequestType` is `MonitorEvent`; its `Topic` is where the events go. This example uses the in-memory bus; in an application, use your transport's producer registry factory, such as `RmqProducerRegistryFactory`, with the same publication: @@ -45,6 +45,8 @@ var controlBusSender = new ControlBusSenderFactory().Create The `step` and `timing` place the monitor in the pipeline like any other attribute. With `step: 1`, the time an event records includes every later step in the pipeline, not only your handler. `handlerType` is what the event reports as the handler's name. -`[MonitorAsync]` is the attribute for a `RequestHandlerAsync`; see [Monitoring Limitations](#monitoring-limitations) before you use it. +`[MonitorAsync]` is the attribute for a `RequestHandlerAsync`. It needs the sender registered as `IAmAControlBusSenderAsync`, as the configuration above does; without it, Brighter cannot build the pipeline and `Send` throws `ConfigurationException`. ## Turning Monitoring On and Off @@ -119,9 +121,9 @@ A consumer on that topic can forward the events to your monitoring tool, for exa ## Monitoring Limitations -Two defects in Brighter 10.7.0 limit what monitoring can do: +Brighter 10.8.0 fixes two defects that limited monitoring at 10.7.0. If you are on 10.7.0: -- **A monitored handler that throws loses its exception.** The monitor tries to send an `ExceptionThrown` event carrying the exception, and serializing an `Exception` fails, so the caller receives a `NotSupportedException` (*"Serialization and deserialization of 'System.Reflection.MethodBase' instances is not supported"*) instead of the exception your handler threw. Monitor only handlers whose exceptions you do not need to see, until this is fixed ([Brighter#4453](https://github.com/BrighterCommand/Brighter/issues/4453)) +- **A monitored handler that throws loses its exception.** The monitor tries to send an `ExceptionThrown` event carrying the exception, and serializing an `Exception` fails, so the caller receives a `NotSupportedException` (*"Serialization and deserialization of 'System.Reflection.MethodBase' instances is not supported"*) instead of the exception your handler threw ([Brighter#4453](https://github.com/BrighterCommand/Brighter/issues/4453)) - **`[MonitorAsync]` cannot send through the sender `ControlBusSenderFactory` builds.** That sender has no async message mapper for `MonitorEvent`, so an async monitored handler fails with *"No message mapper defined for request"* ([Brighter#4454](https://github.com/BrighterCommand/Brighter/issues/4454)) ## Further Reading diff --git a/contents/MySQLInbox.md b/contents/MySQLInbox.md index b7ae862..dd8088c 100644 --- a/contents/MySQLInbox.md +++ b/contents/MySQLInbox.md @@ -43,7 +43,7 @@ private static void ConfigureBrighter(HostBuilderContext hostContext, IServiceCo // ... ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). ## Provisioning the MySQL Inbox Table diff --git a/contents/MySQLOutbox.md b/contents/MySQLOutbox.md index 604229a..bb07f0b 100644 --- a/contents/MySQLOutbox.md +++ b/contents/MySQLOutbox.md @@ -68,20 +68,20 @@ string binaryDdl = MySqlOutboxBuilder.GetDDL(tableName, hasBinaryMessagePayload: Running `MySqlOutboxBuilder.GetDDL("Outbox")` will generate the following SQL script: ```sql -CREATE TABLE Outbox ( - `MessageId`VARCHAR(255) NOT NULL, - `Topic` VARCHAR(255) NOT NULL, - `MessageType` VARCHAR(32) NOT NULL, - `Timestamp` TIMESTAMP(3) NOT NULL, +CREATE TABLE IF NOT EXISTS Outbox ( + `MessageId`VARCHAR(255) NOT NULL, + `Topic` VARCHAR(255) NOT NULL, + `MessageType` VARCHAR(32) NOT NULL, + `Timestamp` TIMESTAMP(3) NOT NULL, `CorrelationId`VARCHAR(255) NULL, `ReplyTo` VARCHAR(255) NULL, - `ContentType` VARCHAR(128) NULL, - `PartitionKey` VARCHAR(255) NULL, + `ContentType` VARCHAR(128) NULL, + `PartitionKey` VARCHAR(128) NULL, `WorkflowId` VARCHAR(255) NULL, `JobId` VARCHAR(255) NULL, - `Dispatched` TIMESTAMP(3) NULL, - `HeaderBag` TEXT NOT NULL, - `Body` TEXT NOT NULL , + `Dispatched` TIMESTAMP(3) NULL, + `HeaderBag` TEXT NOT NULL, + `Body` TEXT NOT NULL, `Source` VARCHAR(255) NULL, `Type` VARCHAR(255) NULL, `DataSchema` VARCHAR(255) NULL, @@ -89,13 +89,19 @@ CREATE TABLE Outbox ( `TraceParent` VARCHAR(255) NULL, `TraceState` VARCHAR(255) NULL, `Baggage` TEXT NULL, + `DataRef` VARCHAR(255) NULL, + `SpecVersion` VARCHAR(255) NULL, + `CausationId` VARCHAR(255) NULL, `Created` TIMESTAMP(3) NOT NULL DEFAULT NOW(3), - `CreatedID` INT(11) NOT NULL AUTO_INCREMENT, + `CreatedID` INT(11) NOT NULL AUTO_INCREMENT, UNIQUE(`CreatedID`), + KEY `idx_CausationId` (`CausationId`), PRIMARY KEY (`MessageId`) ) ENGINE = InnoDB; ``` +`CausationId` and its index arrive at Brighter 10.8.0. A table an earlier release created still takes deposits without them, but [Replay On Seen](/contents/ReplayOnSeenReference.md#store-support) needs both; [Database Provisioning](/contents/BoxProvisioning.md) adds them as the Outbox **V8** migration. + ## MySQL Outbox Configuration To configure the MySQL Outbox, you need to provide an outbox implementation in the `AddProducers` configuration when setting up Brighter. diff --git a/contents/PipelineValidation.md b/contents/PipelineValidation.md index c48f80d..47bc94e 100644 --- a/contents/PipelineValidation.md +++ b/contents/PipelineValidation.md @@ -16,6 +16,7 @@ Brighter can validate your pipeline configuration at startup, catching common mi Add `.ValidatePipelines()` and `.DescribePipelines()` to your Brighter configuration chain: ```csharp +using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; builder.Services.AddBrighter(options => @@ -165,6 +166,9 @@ Key things to look for in the report: Both methods are called on the **IBrighterBuilder** returned by `AddBrighter()`: ```csharp +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; + builder.Services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; @@ -174,14 +178,24 @@ builder.Services.AddBrighter(options => .DescribePipelines(); // enable diagnostic report ``` -You can enable either one independently: +You can enable either one independently. Validation only, with no diagnostic report: + +```csharp +using Paramore.Brighter.Extensions.DependencyInjection; + +builder.Services.AddBrighter() + .AutoFromAssemblies() + .ValidatePipelines(); +``` + +Or the diagnostic report only, with no validation: ```csharp -// Validation only, no diagnostic report -.ValidatePipelines() +using Paramore.Brighter.Extensions.DependencyInjection; -// Diagnostic report only, no validation -.DescribePipelines() +builder.Services.AddBrighter() + .AutoFromAssemblies() + .DescribePipelines(); ``` ### Controlling Error Behavior @@ -189,7 +203,11 @@ You can enable either one independently: By default, validation errors throw a `PipelineValidationException` and prevent the application from starting. You can change this to log errors without stopping startup: ```csharp -.ValidatePipelines(throwOnError: false) +using Paramore.Brighter.Extensions.DependencyInjection; + +builder.Services.AddBrighter() + .AutoFromAssemblies() + .ValidatePipelines(throwOnError: false); ``` When `throwOnError` is `false`: @@ -211,14 +229,24 @@ Warnings never prevent startup regardless of the `throwOnError` setting. Both methods accept an `enabled` parameter that controls whether the feature is registered at all. This lets you gate validation on environment or configuration without removing the method call: ```csharp +using Microsoft.Extensions.Hosting; +using Paramore.Brighter.Extensions.DependencyInjection; + // Only validate in Development -.ValidatePipelines(enabled: builder.Environment.IsDevelopment()) -.DescribePipelines(enabled: builder.Environment.IsDevelopment()) +builder.Services.AddBrighter() + .AutoFromAssemblies() + .ValidatePipelines(enabled: builder.Environment.IsDevelopment()) + .DescribePipelines(enabled: builder.Environment.IsDevelopment()); ``` ```csharp +using Microsoft.Extensions.Configuration; +using Paramore.Brighter.Extensions.DependencyInjection; + // Gate on a configuration setting -.ValidatePipelines(enabled: builder.Configuration.GetValue("Brighter:ValidatePipelines")) +builder.Services.AddBrighter() + .AutoFromAssemblies() + .ValidatePipelines(enabled: builder.Configuration.GetValue("Brighter:ValidatePipelines")); ``` When `enabled` is `false`, no services are registered and there is zero overhead at startup. @@ -246,13 +274,19 @@ An async handler must use async versions of pipeline attributes. The example bel ```csharp +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Reject.Attributes; + public class OrderHandler : RequestHandlerAsync { [RejectMessageOnError(step: 0)] // wrong: sync attribute on async handler - public override async Task HandleAsync(OrderCreated command, + public override async Task HandleAsync(OrderCreated command, CancellationToken cancellationToken = default) { // ... + return await base.HandleAsync(command, cancellationToken); } } ``` @@ -260,13 +294,19 @@ public class OrderHandler : RequestHandlerAsync **After** (fixed): ```csharp +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Reject.Attributes; + public class OrderHandler : RequestHandlerAsync { [RejectMessageOnErrorAsync(step: 0)] // correct: async attribute - public override async Task HandleAsync(OrderCreated command, + public override async Task HandleAsync(OrderCreated command, CancellationToken cancellationToken = default) { // ... + return await base.HandleAsync(command, cancellationToken); } } ``` @@ -328,44 +368,81 @@ A `Reactor` subscription uses synchronous message pumping and requires a sync ha **Before** (error): ```csharp -// Handler is async -public class OrderHandler : RequestHandlerAsync { ... } - -// But subscription uses Reactor (sync) -new RmqSubscription( - // ... - messagePumpType: MessagePumpType.Reactor // wrong: sync pump for async handler -) +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + +// The subscription uses Reactor (sync) +var subscription = new RmqSubscription( + subscriptionName: new SubscriptionName("order-created"), + channelName: new ChannelName("order-created-queue"), + routingKey: new RoutingKey("order-created"), + messagePumpType: MessagePumpType.Reactor); // wrong: sync pump for async handler + +// But its handler is async +public class OrderHandler : RequestHandlerAsync +{ + public override async Task HandleAsync(OrderCreated command, + CancellationToken cancellationToken = default) + { + // ... + return await base.HandleAsync(command, cancellationToken); + } +} ``` **After** (fixed): ```csharp -new RmqSubscription( - // ... - messagePumpType: MessagePumpType.Proactor // correct: async pump for async handler -) +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + +var subscription = new RmqSubscription( + subscriptionName: new SubscriptionName("order-created"), + channelName: new ChannelName("order-created-queue"), + routingKey: new RoutingKey("order-created"), + messagePumpType: MessagePumpType.Proactor); // correct: async pump for async handler ``` ### Missing Handler for Subscription -Every subscription needs at least one handler registered for its `RequestType`. If you use `AutoFromAssemblies()`, ensure the handler's assembly is included. +Every subscription needs at least one handler registered for its `RequestType`. `AutoFromAssemblies()` scans the assemblies already loaded when it runs, plus any you pass it, so if your handler lives in an assembly nothing has loaded yet, pass that assembly explicitly. **Before** (error): ```csharp -// Handler exists but its assembly isn't scanned -.AutoFromAssemblies() // only scans loaded assemblies +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessagingGateway.RMQ.Async; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; -// Subscription for OrderCreated — but OrderHandler is in a separate assembly -new RmqSubscription(...) +services.AddConsumers(options => + { + // A subscription for OrderCreated, but OrderCreatedHandler is in a separate assembly + options.Subscriptions = [ + new RmqSubscription( + subscriptionName: new SubscriptionName("order-created"), + channelName: new ChannelName("order-created-queue"), + routingKey: new RoutingKey("order-created"), + messagePumpType: MessagePumpType.Proactor) + ]; + options.DefaultChannelFactory = new ChannelFactory(new RmqMessageConsumerFactory(rmqConnection)); + }) + .AutoFromAssemblies(); // scans only the assemblies already loaded, so the handler is never found ``` **After** (fixed): ```csharp -// Explicitly include the handler's assembly -.AutoFromAssemblies([typeof(OrderHandler).Assembly]) +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; + +services.AddConsumers(options => + { + // ... the subscription and channel factory, as above + }) + .AutoFromAssemblies([typeof(OrderCreatedHandler).Assembly]); // explicitly include the handler's assembly ``` ### Replay Without Causation Tracking @@ -380,22 +457,41 @@ The usual cause is provisioning one box and forgetting the other. **Before** (warning): ```csharp -[UseInbox(step: 1, contextKey: typeof(ProcessPaymentHandler), onceOnly: true, - onceOnlyAction: OnceOnlyAction.Replay)] -public override ProcessPayment Handle(ProcessPayment command) { ... } +using Paramore.Brighter; +using Paramore.Brighter.BoxProvisioning; +using Paramore.Brighter.BoxProvisioning.MsSql; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Inbox; +using Paramore.Brighter.Inbox.Attributes; -// ... but only the Outbox is provisioned, so the Inbox has no CausationId column +// Only the Outbox is provisioned, so the Inbox has no CausationId column services.AddBrighter() .AddProducers(producers => { /* ... */ }) .UseBoxProvisioning(opts => { opts.AddMsSqlOutbox(outboxConfig); }); + +// ... but the handler asks for Replay +public class ProcessPaymentHandler : RequestHandler +{ + [UseInbox(step: 1, contextKey: typeof(ProcessPaymentHandler), onceOnly: true, + onceOnlyAction: OnceOnlyAction.Replay)] + public override ProcessPayment Handle(ProcessPayment command) + { + // ... + return base.Handle(command); + } +} ``` **After** (fixed): ```csharp +using Paramore.Brighter.BoxProvisioning; +using Paramore.Brighter.BoxProvisioning.MsSql; +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter() .AddProducers(producers => { /* ... */ }) .UseBoxProvisioning(opts => diff --git a/contents/PolicyFallback.md b/contents/PolicyFallback.md index 31964c4..c6fecd4 100644 --- a/contents/PolicyFallback.md +++ b/contents/PolicyFallback.md @@ -31,19 +31,24 @@ The following example shows a Handler with **Request Handler Attributes** for [R ```csharp using System; +using Microsoft.Extensions.Logging; using Paramore.Brighter; using Paramore.Brighter.Policies.Attributes; using Paramore.Brighter.Policies.Handlers; using Polly.CircuitBreaker; -public class MyFallbackProtectedHandler: RequestHandler +public class MyFallbackProtectedHandler( + IExternalService externalService, + IRetryQueue retryQueue, + IAmACommandProcessor commandProcessor, + ILogger logger) : RequestHandler { [FallbackPolicy(backstop: false, circuitBreaker: true, step: 1)] [UseResiliencePipeline("MyCircuitBreakerAndRetryPipeline", step: 2)] public override MyCommand Handle(MyCommand command) { // Do some work that can fail - var result = CallExternalService(); + externalService.Call(command); return base.Handle(command); } @@ -54,18 +59,18 @@ public class MyFallbackProtectedHandler: RequestHandler var exception = Context.Bag[FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION] as Exception; // Log the failure - _logger.LogError(exception, "Handler failed, executing fallback"); + logger.LogError(exception, "Handler failed, executing fallback"); // Take compensating action if (exception is BrokenCircuitException) { // Circuit breaker is open, queue for later retry - _messageQueue.QueueForRetry(command); + retryQueue.QueueForRetry(command); } else { // Other failure, send compensating transaction - _commandProcessor.Send(new UndoCommand { OriginalCommandId = command.Id }); + commandProcessor.Send(new UndoCommand { OriginalCommandId = command.Id }); } } return base.Fallback(command); @@ -152,11 +157,14 @@ public override MyCommand Handle(MyCommand command) When `circuitBreaker: true` (and `backstop: false`), the Fallback Policy only catches **BrokenCircuitException** exceptions. ```csharp +using Paramore.Brighter.Policies.Attributes; + [FallbackPolicy(backstop: false, circuitBreaker: true, step: 1)] [UseResiliencePipeline("MyCircuitBreakerPipeline", step: 2)] public override MyCommand Handle(MyCommand command) { // Only BrokenCircuitException will trigger fallback + return base.Handle(command); } ``` @@ -167,6 +175,11 @@ public override MyCommand Handle(MyCommand command) When a handler fails, issue a compensating transaction to undo any partially committed work. ```csharp +using System; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Handlers; + public override MyCommand Fallback(MyCommand command) { if (Context.Bag.ContainsKey(FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION)) @@ -190,6 +203,12 @@ public override MyCommand Fallback(MyCommand command) When a circuit breaker opens (service is down), queue the message for later retry using the [External Bus](ImplementingExternalBus.md). ```csharp +using System; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Handlers; +using Polly.CircuitBreaker; + public override MyCommand Fallback(MyCommand command) { if (Context.Bag.ContainsKey(FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION)) @@ -218,16 +237,17 @@ using Paramore.Brighter; using Paramore.Brighter.Policies.Attributes; using Paramore.Brighter.Policies.Handlers; -public class GetProductHandler : RequestHandler +public class GetProductHandler( + IProductService productService, + IProductCache cache, + ILogger logger) : RequestHandler { - private readonly IProductCache _cache; - [FallbackPolicy(backstop: true, circuitBreaker: false, step: 1)] [UseResiliencePipeline("ProductServicePipeline", step: 2)] public override GetProductQuery Handle(GetProductQuery query) { // Call external product service - query.Product = _productService.GetProduct(query.ProductId); + query.Product = productService.GetProduct(query.ProductId); return base.Handle(query); } @@ -235,10 +255,10 @@ public class GetProductHandler : RequestHandler { if (Context.Bag.ContainsKey(FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION)) { - _logger.LogWarning("Product service failed, returning cached value"); + logger.LogWarning("Product service failed, returning cached value"); // Return cached product or default - query.Product = _cache.GetProduct(query.ProductId) ?? Product.Default; + query.Product = cache.GetProduct(query.ProductId) ?? Product.Default; } return base.Fallback(query); } @@ -250,32 +270,45 @@ public class GetProductHandler : RequestHandler Log critical errors and alert operations team, then fail gracefully. ```csharp -public override MyCommand Fallback(MyCommand command) +using System; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Handlers; + +public class MyAlertingHandler( + IAlertService alertService, + IFailedCommandRepository failedCommandRepository, + ILogger logger) : RequestHandler { - if (Context.Bag.ContainsKey(FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION)) + // ... Handle, with its FallbackPolicy attribute + + public override MyCommand Fallback(MyCommand command) { - var exception = Context.Bag[FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION] as Exception; + if (Context.Bag.ContainsKey(FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION)) + { + var exception = Context.Bag[FallbackPolicyHandler.CAUSE_OF_FALLBACK_EXCEPTION] as Exception; - // Log critical error - _logger.LogCritical(exception, "Critical handler failure for command {CommandId}", command.Id); + // Log critical error + logger.LogCritical(exception, "Critical handler failure for command {CommandId}", command.Id); - // Alert operations team (e.g., PagerDuty, Slack, etc.) - _alertService.SendAlert(new Alert - { - Severity = AlertSeverity.Critical, - Message = $"Handler failed: {exception.Message}", - Context = command - }); + // Alert operations team (e.g., PagerDuty, Slack, etc.) + alertService.SendAlert(new Alert + { + Severity = AlertSeverity.Critical, + Message = $"Handler failed: {exception.Message}", + Context = command + }); - // Mark command as failed for later investigation - _failedCommandRepository.Add(new FailedCommand - { - CommandId = command.Id, - Exception = exception, - Timestamp = DateTime.UtcNow - }); + // Mark command as failed for later investigation + failedCommandRepository.Add(new FailedCommand + { + CommandId = command.Id, + Exception = exception, + Timestamp = DateTime.UtcNow + }); + } + return base.Fallback(command); } - return base.Fallback(command); } ``` @@ -293,6 +326,7 @@ public override MyCommand Fallback(MyCommand command) You can use both together. A Polly fallback in a pipeline that `[UseResiliencePipeline]` attaches can only substitute the request, so it cannot hand your handler a value. To get a default `Product`, run the service call through a pipeline typed for `Product`, inside the handler, and keep Brighter's `FallbackPolicy` for the order-level recovery: ```csharp +using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter; using Paramore.Brighter.Policies.Attributes; using Paramore.Brighter.Policies.Handlers; diff --git a/contents/PolicyRetryAndCircuitBreaker.md b/contents/PolicyRetryAndCircuitBreaker.md index 2803b9e..3d07981 100644 --- a/contents/PolicyRetryAndCircuitBreaker.md +++ b/contents/PolicyRetryAndCircuitBreaker.md @@ -94,6 +94,11 @@ To configure a Polly resilience pipeline, you use the `ResiliencePipelineRegistr #### Retry Strategy Example ```csharp +using System; +using Polly; +using Polly.Registry; +using Polly.Retry; + var resiliencePipelineRegistry = new ResiliencePipelineRegistry(); resiliencePipelineRegistry.TryAddBuilder("MyRetryPipeline", @@ -114,6 +119,10 @@ resiliencePipelineRegistry.TryAddBuilder("MyRetryPipeline", #### Circuit Breaker Strategy Example ```csharp +using System; +using Polly; +using Polly.CircuitBreaker; + resiliencePipelineRegistry.TryAddBuilder("MyCircuitBreakerPipeline", (builder, context) => builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions { @@ -141,6 +150,9 @@ resiliencePipelineRegistry.TryAddBuilder("MyCircuitBreakerPipeline", **V10**: Use Polly's Timeout strategy instead of the deprecated `TimeoutPolicyAttribute`: ```csharp +using System; +using Polly; + resiliencePipelineRegistry.TryAddBuilder("MyTimeoutPipeline", (builder, context) => builder.AddTimeout(TimeSpan.FromSeconds(5))); ``` @@ -148,6 +160,9 @@ resiliencePipelineRegistry.TryAddBuilder("MyTimeoutPipeline", **Handler Usage**: ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + public class MyTimedHandler : RequestHandler { // V9 (deprecated): @@ -258,7 +273,7 @@ Other attributes, such as `[FallbackPolicy]` or `[RejectMessageOnError]`, can st ## Type-Scoped Pipelines -For strategies like Circuit Breaker, you often want a separate instance per handler type (so failures in one handler don't affect others). Use `UseTypePipeline = true` to scope pipelines by handler type. +For strategies like Circuit Breaker, you often want a separate instance for each kind of request, so that failures handling one don't trip the breaker for the others. Set `UseTypePipeline = true` and Brighter looks up a pipeline typed for the handler's request, a `ResiliencePipeline`, under the policy name. ```csharp using Paramore.Brighter; @@ -269,7 +284,7 @@ public class OrderServiceHandler : RequestHandler [UseResiliencePipeline("SharedCircuitBreaker", step: 1, UseTypePipeline = true)] public override ProcessOrderCommand Handle(ProcessOrderCommand command) { - // Uses circuit breaker scoped to OrderServiceHandler + // Uses the circuit breaker typed for ProcessOrderCommand return base.Handle(command); } } @@ -279,25 +294,26 @@ public class PaymentServiceHandler : RequestHandler [UseResiliencePipeline("SharedCircuitBreaker", step: 1, UseTypePipeline = true)] public override ProcessPaymentCommand Handle(ProcessPaymentCommand command) { - // Uses circuit breaker scoped to PaymentServiceHandler (separate from OrderServiceHandler) + // Uses the circuit breaker typed for ProcessPaymentCommand, separate from ProcessOrderCommand's return base.Handle(command); } } ``` -**How it works**: When `UseTypePipeline = true`, Brighter looks up the pipeline using a key composed of the handler type's full name + the policy name. This allows different instances of the same resilience strategy to be associated uniquely with each handler type. +**How it works**: When `UseTypePipeline = true`, Brighter calls `GetPipeline(policy)` on the registry. The key is the policy name alone, and the pipeline must be registered typed for the request. Polly keeps typed pipelines apart by their type, so `ProcessOrderCommand` and `ProcessPaymentCommand` each get their own instance under the same name; two handlers of the same request share one. The attribute's XML documentation describes a key built from the handler's type name, but Brighter V10 does not look one up, and a pipeline registered under such a key is never found. **Configuration**: ```csharp -// You need to register pipelines with the type-scoped key format -var handlerTypeName = typeof(OrderServiceHandler).FullName; -resiliencePipelineRegistry.TryAddBuilder($"{handlerTypeName}.SharedCircuitBreaker", - (builder, context) => builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions { /* ... */ })); - -var paymentHandlerTypeName = typeof(PaymentServiceHandler).FullName; -resiliencePipelineRegistry.TryAddBuilder($"{paymentHandlerTypeName}.SharedCircuitBreaker", - (builder, context) => builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions { /* ... */ })); +using Polly; +using Polly.CircuitBreaker; + +// Register a pipeline typed for each request, under the policy name +resiliencePipelineRegistry.TryAddBuilder("SharedCircuitBreaker", + (builder, context) => builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions())); + +resiliencePipelineRegistry.TryAddBuilder("SharedCircuitBreaker", + (builder, context) => builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions())); ``` --- @@ -317,7 +333,16 @@ Polly v8 provides the following resilience strategies, all of which can be used ### Rate Limiter Example +The rate limiter strategies ship in their own package, `Polly.RateLimiting`, which Brighter does not reference. Add it to your project before you use them: + +```bash +dotnet add package Polly.RateLimiting +``` + ```csharp +using System.Threading.RateLimiting; +using Polly; + resiliencePipelineRegistry.TryAddBuilder("MyRateLimiterPipeline", (builder, context) => builder.AddConcurrencyLimiter(new ConcurrencyLimiterOptions { @@ -328,8 +353,15 @@ resiliencePipelineRegistry.TryAddBuilder("MyRateLimiterPipeline", ### Hedging Example +Hedging produces a result, so it lives in a pipeline typed for that result, as a Polly fallback does. `[UseResiliencePipeline]` cannot attach it to a handler; resolve it with `GetPipeline("MyHedgingPipeline")` and run your call through it inside the handler. + ```csharp -resiliencePipelineRegistry.TryAddBuilder("MyHedgingPipeline", +using System; +using System.Net.Http; +using Polly; +using Polly.Hedging; + +resiliencePipelineRegistry.TryAddBuilder("MyHedgingPipeline", (builder, context) => builder.AddHedging(new HedgingStrategyOptions { MaxHedgedAttempts = 3, @@ -372,24 +404,37 @@ public class MyCancellableHandler : RequestHandlerAsync ## Request Context Integration -The request context integrates with Polly's resilience context, allowing you to access request metadata within resilience callbacks. +The request context carries a Polly `ResilienceContext`. When you set `RequestContext.ResilienceContext` before you send, Brighter runs the handler's resilience pipeline with that context, so a callback such as `OnRetry` can read the properties you put in it. Brighter adds no properties of its own. ```csharp +using Paramore.Brighter; +using Polly; +using Polly.Retry; + +var tenantKey = new ResiliencePropertyKey("tenant-id"); + resiliencePipelineRegistry.TryAddBuilder("MyContextAwarePipeline", (builder, context) => builder.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, OnRetry = args => { - // Access Brighter's request context through Polly's resilience context - if (args.Context.Properties.TryGetValue("BrighterContext", out var contextObj)) + // Read what the caller put into Polly's resilience context + if (args.Context.Properties.TryGetValue(tenantKey, out var tenantId)) { - var requestContext = contextObj as RequestContext; - // Use request context for logging, tracing, etc. + // Use the value for logging, tracing, etc. } return default; } })); + +// When you send, attach the resilience context to the request context +var resilienceContext = ResilienceContextPool.Shared.Get(); +resilienceContext.Properties.Set(tenantKey, "tenant-42"); + +commandProcessor.Send(new MyCommand(), new RequestContext { ResilienceContext = resilienceContext }); + +ResilienceContextPool.Shared.Return(resilienceContext); ``` See [Request Context documentation](UsingTheContextBag.md) for more details on accessing the resilience context from handlers. @@ -439,7 +484,7 @@ second — **one call, not two.** If you have no legacy policies to carry, call > **startup**, before any message is sent. Brighter only fills the gap for you when you supply > *no* registry at all, because the container does it with `??=`. -Or using dependency injection with ASP.NET Core: +Or using dependency injection with ASP.NET Core, registering the `MyQoSProtectedHandler` from the first example: ```csharp using Microsoft.Extensions.DependencyInjection; @@ -505,23 +550,30 @@ never calls `AddBrighterDefault` for you, so call it yourself. ### Pipeline Not Found Exception -**Symptom**: `InvalidOperationException: Resilience pipeline with key 'MyPipeline' not found` +**Symptom**: `ConfigurationException: Error when building pipeline`, thrown by `Send` before the handler runs. Its inner `KeyNotFoundException` reads *Unable to find a resilience pipeline associated with the key 'MyPipeline'*. **Solution**: Ensure you've registered the pipeline with the exact name referenced in the attribute: ```csharp -resiliencePipelineRegistry.TryAddBuilder("MyPipeline", /* ... */); +using Polly; +using Polly.Retry; + +resiliencePipelineRegistry.TryAddBuilder("MyPipeline", + (builder, context) => builder.AddRetry(new RetryStrategyOptions())); ``` ### Type Pipeline Not Found Exception -**Symptom**: `InvalidOperationException: Resilience pipeline with key 'MyNamespace.MyHandler.MyPipeline' not found` +**Symptom**: the same `ConfigurationException`, whose inner `KeyNotFoundException` reads *Unable to find a generic resilience pipeline of 'MyCommand' associated with the key 'MyPipeline'*. -**Solution**: When using `UseTypePipeline = true`, register the pipeline with the full key: +**Solution**: When using `UseTypePipeline = true`, register the pipeline typed for the handler's request, under the policy name: ```csharp -var handlerTypeName = typeof(MyHandler).FullName; -resiliencePipelineRegistry.TryAddBuilder($"{handlerTypeName}.MyPipeline", /* ... */); +using Polly; +using Polly.Retry; + +resiliencePipelineRegistry.TryAddBuilder("MyPipeline", + (builder, context) => builder.AddRetry(new RetryStrategyOptions())); ``` ### TimeoutPolicy Obsolete Warning diff --git a/contents/PostgreSQLMessageBroker.md b/contents/PostgreSQLMessageBroker.md index 335659f..8e08af6 100644 --- a/contents/PostgreSQLMessageBroker.md +++ b/contents/PostgreSQLMessageBroker.md @@ -385,8 +385,8 @@ await commandProcessor.PostAsync(TimeSpan.FromHours(24), reminder); Without `UseScheduler()`, the scheduler is the in-memory one, which holds the delay in the process — use a durable scheduler for a delay that must outlive it. `PublishAsync` with a delay also goes through the scheduler, and when it falls due it dispatches to handlers in this process, so the -event never reaches the table. At 10.7.0 a scheduled request fails when it falls due if you -registered handlers with `AutoFromAssemblies()`; see +event never reaches the table. Before Brighter 10.8.0, a scheduled request failed when it fell due +if you registered handlers with `AutoFromAssemblies()`; see [Registering Handlers When You Schedule Requests](/contents/SchedulingAMessage.md#registering-handlers-when-you-schedule-requests). The table's `visible_timeout` does delay one thing: a requeue. When a handler defers a message and diff --git a/contents/PostgresInbox.md b/contents/PostgresInbox.md index 77b1e24..61716d4 100644 --- a/contents/PostgresInbox.md +++ b/contents/PostgresInbox.md @@ -43,7 +43,7 @@ private static void ConfigureBrighter(HostBuilderContext hostContext, IServiceCo // ... ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). ## Provisioning the Postgres Inbox Table diff --git a/contents/PostgresOutbox.md b/contents/PostgresOutbox.md index 5bcf0f7..5b05d3e 100644 --- a/contents/PostgresOutbox.md +++ b/contents/PostgresOutbox.md @@ -67,7 +67,7 @@ string binaryDdl = PostgreSqlOutboxBuilder.GetDDL(tableName, binaryMessagePayloa ### **Example SQL Script** -Running `PostgreSqlOutboxBuilder.GetDDL("Outbox")` against **Brighter V10 (10.7.0)** generates the following SQL script: +Running `PostgreSqlOutboxBuilder.GetDDL("Outbox")` against **Brighter V10 (10.8.0)** generates the following SQL script: ```sql CREATE TABLE IF NOT EXISTS "outbox" @@ -80,7 +80,7 @@ CREATE TABLE IF NOT EXISTS "outbox" CorrelationId character varying(255) NULL, ReplyTo character varying(255) NULL, ContentType character varying(128) NULL, - PartitionKey character varying(128) NULL, + PartitionKey character varying(128) NULL, WorkflowId character varying(255) NULL, JobId character varying(255) NULL, Dispatched timestamptz NULL, @@ -94,10 +94,14 @@ CREATE TABLE IF NOT EXISTS "outbox" TraceState character varying (255) NULL, Baggage text NULL, DataRef character varying (255) NULL, + CausationId character varying (255) NULL, SpecVersion character varying (255) NULL ); +CREATE INDEX IF NOT EXISTS idx_outbox_causationid ON "outbox" (causationid); ``` +`CausationId` and its index arrive at Brighter 10.8.0. A table an earlier release created still takes deposits without them, but [Replay On Seen](/contents/ReplayOnSeenReference.md#store-support) needs both; [Database Provisioning](/contents/BoxProvisioning.md) adds them as the Outbox **V8** migration. + **Note the table name.** You configured `"Outbox"` and the DDL emits `"outbox"`: Brighter lowercases the identifier and then quotes it, so that a configured mixed-case name still resolves to the table that older, unquoted DDL created. A `select` against `"Outbox"` therefore fails with `relation "Outbox" does not exist` while `select * from outbox` succeeds. ## PostgreSQL Outbox Configuration diff --git a/contents/RelationalDatabaseConfigurationReference.md b/contents/RelationalDatabaseConfigurationReference.md index d4fece7..904583f 100644 --- a/contents/RelationalDatabaseConfigurationReference.md +++ b/contents/RelationalDatabaseConfigurationReference.md @@ -44,7 +44,7 @@ so **changing either against an existing table is a migration, not a setting**. ## Which Components Take the Relational Configuration -Seventeen, across four families, measured at `10.7.0` by looking for +Seventeen, across four families, measured at `10.8.0` by looking for `IAmARelationalDatabaseConfiguration` in each package. | Family | Provider | Page | diff --git a/contents/SchedulingAMessage.md b/contents/SchedulingAMessage.md index 713351a..02ce59d 100644 --- a/contents/SchedulingAMessage.md +++ b/contents/SchedulingAMessage.md @@ -137,7 +137,7 @@ public class OrderService } ``` -**Note:** Every scheduler but one supports cancellation. At Brighter 10.7.0 the InMemory scheduler cannot cancel or reschedule a request you scheduled through the command processor: `CancelAsync` returns, and the request still runs when it falls due ([#4437](https://github.com/BrighterCommand/Brighter/issues/4437)). Among the others, rescheduling is the operation that varies: the Azure Service Bus scheduler does not reschedule, so cancel the message and schedule it again instead. See [Choosing a Scheduler](/contents/BrighterSchedulerSupport.md#choosing-a-scheduler). +**Note:** Every scheduler supports cancellation. Before Brighter 10.8.0 the InMemory scheduler could not cancel or reschedule a request scheduled through the command processor: `CancelAsync` returned, and the request still ran when it fell due ([#4437](https://github.com/BrighterCommand/Brighter/issues/4437)). Among the schedulers, rescheduling is the operation that varies: the Azure Service Bus scheduler does not reschedule, so cancel the message and schedule it again instead. See [Choosing a Scheduler](/contents/BrighterSchedulerSupport.md#choosing-a-scheduler). ### Retry with Exponential Backoff @@ -210,9 +210,9 @@ public class ProcessPaymentHandlerAsync : RequestHandlerAsync }); ``` -The examples below use `AutoFromAssemblies()`, and they work as shown once you are on a release that carries the fix. - ### Configuring with Hangfire ```csharp diff --git a/contents/ShowMeTheCode.md b/contents/ShowMeTheCode.md index 00edfbf..5c63d2f 100644 --- a/contents/ShowMeTheCode.md +++ b/contents/ShowMeTheCode.md @@ -33,28 +33,49 @@ Both Brighter and Darker allow you to provide middleware that runs between a req ### Sending and Querying Example -In this example, we show sending a command, and querying for the results of issuing it, from within an ASP.NET WebAPI controller method. +In this example, we show sending a command, and querying for the results of issuing it, from within an ASP.NET WebAPI controller. The controller takes the *Command Processor* and the *Query Processor* from dependency injection. -``` csharp -[Route("{name}/new")] -[HttpPost] -public async Task> Post(string name, NewGreeting newGreeting) +```csharp +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Paramore.Brighter; +using Paramore.Darker; + +[ApiController] +[Route("[controller]")] +public class GreetingsController(IAmACommandProcessor commandProcessor, IQueryProcessor queryProcessor) : Controller { - await _commandProcessor.SendAsync(new AddGreeting(name, newGreeting.Greeting)); + [Route("{name}/new")] + [HttpPost] + public async Task> Post(string name, NewGreeting newGreeting) + { + FindPersonResult foundPerson = await queryProcessor.ExecuteAsync(new FindPersonByName(name)); + + if (foundPerson?.Person == null) return new NotFoundResult(); + + await commandProcessor.SendAsync(new AddGreeting(name, newGreeting.Greeting)); - var personsGreetings = await _queryProcessor.ExecuteAsync(new FindGreetingsForPerson(name)); + FindPersonsGreetings personsGreetings = await queryProcessor.ExecuteAsync(new FindGreetingsForPerson(name)); - if (personsGreetings == null) return new NotFoundResult(); + if (personsGreetings == null) return new NotFoundResult(); - return Ok(personsGreetings); + return Ok(personsGreetings); + } } ``` ### Handling Examples -Handler code listens for and responds to requests or queries. The handler for the above request and query are: +Handler code listens for and responds to requests or queries. The first handler below adds a person, and the second answers the query the controller above sends: ``` csharp +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; + [RequestLoggingAsync(0, HandlerTiming.Before)] [UsePolicyAsync(step:1, policy: Policies.Retry.EXPONENTIAL_RETRYPOLICYASYNC)] public override async Task HandleAsync(AddPerson addPerson, CancellationToken cancellationToken = default) @@ -65,46 +86,61 @@ public override async Task HandleAsync(AddPerson addPerson, Cancellat } ``` -``` csharp -[QueryLogging(0)] -[RetryableQuery(1, Retry.EXPONENTIAL_RETRYPOLICYASYNC)] -public override async Task ExecuteAsync(FindGreetingsForPerson query, CancellationToken cancellationToken = new CancellationToken()) -{ - //Retrieving parent and child is a bit tricky with Dapper. From raw SQL We wget back a set that has a row-per-child. We need to turn that - //into one entity per parent, with a collection of children. To do that we bring everything back into memory, group by parent id and collate all - //the children for that group. - - var sql = @"select p.Id, p.Name, g.Id, g.Message - from Person p - inner join Greeting g on g.Recipient_Id = p.Id"; - await using var connection = await _relationalDbConnectionProvider.GetConnectionAsync(cancellationToken); - var people = await connection.QueryAsync(sql, (person, greeting) => - { - person.Greetings.Add(greeting); - - return person; - }, splitOn: "Id"); - - if (!people.Any()) - { - return new FindPersonsGreetings(){Name = query.Name, Greetings = Array.Empty()}; - } - - var peopleGreetings = people.GroupBy(p => p.Id).Select(grp => - { - var groupedPerson = grp.First(); - groupedPerson.Greetings = grp.Select(p => p.Greetings.Single()).ToList(); - return groupedPerson; - }); - - var person = peopleGreetings.Single(); - - return new FindPersonsGreetings - { - Name = person.Name, Greetings = person.Greetings.Select(g => new Salutation(g.Greet())) - }; -} +```csharp +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Paramore.Brighter; +using Paramore.Darker; +using Paramore.Darker.Policies; +using Paramore.Darker.QueryLogging; +public class FindGreetingsForPersonHandlerAsync(IAmARelationalDbConnectionProvider relationalDbConnectionProvider) + : QueryHandlerAsync +{ + [QueryLogging(0)] + [RetryableQuery(1, Retry.EXPONENTIAL_RETRYPOLICYASYNC)] + public override async Task ExecuteAsync(FindGreetingsForPerson query, + CancellationToken cancellationToken = new()) + { + //Retrieving parent and child is a bit tricky with Dapper. From raw SQL we get back a set that has a row-per-child. We need to turn that + //into one entity per parent, with a collection of children. To do that we bring everything back into memory, group by parent id and collate all + //the children for that group. + + string sql = @"select p.Id, p.Name, g.Id, g.Message + from Person p + inner join Greeting g on g.Recipient_Id = p.Id + where p.Name = @name"; + await using DbConnection connection = + await relationalDbConnectionProvider.GetConnectionAsync(cancellationToken); + IEnumerable people = await connection.QueryAsync(sql, (person, greeting) => + { + person.Greetings.Add(greeting); + return person; + }, + new { name = query.Name }, + splitOn: "Id"); + + if (!people.Any()) return new FindPersonsGreetings { Name = String.Empty, Greetings = Array.Empty() }; + + IEnumerable peopleGreetings = people.GroupBy(p => p.Id).Select(grp => + { + Person groupedPerson = grp.First(); + groupedPerson.Greetings = grp.Select(p => p.Greetings.Single()).ToList(); + return groupedPerson; + }); + + Person person = peopleGreetings.First(); + + return new FindPersonsGreetings + { + Name = person.Name, Greetings = person.Greetings.Select(g => new Salutation(g.Greet())) + }; + } } ``` @@ -132,6 +168,13 @@ As well as using an Internal Bus, in Brighter you can use an External Bus - midd The following code shows the simplest way to send a message to another process using Brighter's external bus: ``` csharp +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; + [RequestLoggingAsync(0, HandlerTiming.Before)] [UsePolicyAsync(step: 1, policy: Retry.EXPONENTIAL_RETRYPOLICYASYNC)] public override async Task HandleAsync( @@ -140,10 +183,10 @@ public override async Task HandleAsync( { await using var connection = await _relationalDbConnectionProvider.GetConnectionAsync(cancellationToken); - // Write to database + // Write to database, for the person the greeting names await connection.ExecuteAsync( - "insert into Greeting (Message, Recipient_Id) values (@Message, @RecipientId)", - new { Message = addGreeting.Greeting, RecipientId = addGreeting.PersonId }); + "insert into Greeting (Message, Recipient_Id) select @Message, Id from Person where Name = @Name", + new { Message = addGreeting.Greeting, addGreeting.Name }); // Send message to external bus await _commandProcessor.PostAsync( @@ -167,6 +210,13 @@ public override async Task HandleAsync( The following code receives a message sent from another process via a Dispatcher: ``` csharp +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; + [RequestLoggingAsync(0, HandlerTiming.Before)] [UsePolicyAsync(step: 1, policy: Retry.EXPONENTIAL_RETRYPOLICYASYNC)] public override async Task HandleAsync( diff --git a/contents/SpannerInbox.md b/contents/SpannerInbox.md index 802c999..2c242b7 100644 --- a/contents/SpannerInbox.md +++ b/contents/SpannerInbox.md @@ -52,7 +52,7 @@ public static class SpannerInboxRegistration } ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). Constructed with the configuration alone, `SpannerInboxAsync` builds its own `SpannerConnectionProvider`; a second constructor takes an `IAmARelationalDbConnectionProvider` @@ -108,10 +108,13 @@ CREATE TABLE IF NOT EXISTS `Inbox`( `CommandType` STRING(256), `CommandBody` JSON, `Timestamp` TIMESTAMP, - `ContextKey` STRING(256) + `ContextKey` STRING(256), + `CausationId` STRING(256) ) PRIMARY KEY (`CommandId`, `ContextKey`) ``` +`CausationId` arrives at Brighter 10.8.0. A table an earlier release created still records commands without it, but [Replay On Seen](/contents/ReplayOnSeenReference.md#store-support) needs it, and the Spanner provisioner adds it only on a fresh install, so add it by hand to an existing table. + Duplicate inserts surface as a `SpannerException` carrying an `AlreadyExists` status, which the Inbox translates into the `OnceOnlyAction` you configured. diff --git a/contents/SpannerOutbox.md b/contents/SpannerOutbox.md index c6dbffd..b683af6 100644 --- a/contents/SpannerOutbox.md +++ b/contents/SpannerOutbox.md @@ -127,10 +127,19 @@ CREATE TABLE IF NOT EXISTS `Outbox` `WorkflowId` STRING(255), `JobId` STRING(255), `DataRef` STRING(255), + `CausationId` STRING(255), `SpecVersion` STRING(10) ) PRIMARY KEY (`MessageId`) ``` +`CausationId` arrives at Brighter 10.8.0, and its index is a separate statement, since Spanner has no inline index syntax: `SpannerOutboxBuilder.GetCausationIndexDDL("Outbox")` returns + +```sql +CREATE INDEX IF NOT EXISTS `idx_Outbox_CausationId` ON `Outbox` (`CausationId`) +``` + +A table an earlier release created still takes deposits without them, but [Replay On Seen](/contents/ReplayOnSeenReference.md#store-support) needs both. The Spanner provisioner creates them only on a fresh install, so add them by hand to an existing table. + ## Spanner Connection Provider `SpannerConnectionProvider` opens the connections the Outbox reads and writes on, and diff --git a/contents/SqliteInbox.md b/contents/SqliteInbox.md index 2ecbc80..4bb5468 100644 --- a/contents/SqliteInbox.md +++ b/contents/SqliteInbox.md @@ -44,7 +44,7 @@ private static void ConfigureBrighter(HostBuilderContext hostContext, IServiceCo // ... ``` -In Brighter 10.7.0 this configuration takes effect only in an application that also calls `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). +Before Brighter 10.8.0 this configuration took effect only in an application that also called `AddProducers`; see [Global Inbox Configuration in a Consumer-Only Application](/contents/BrighterInboxSupport.md#global-inbox-configuration-in-a-consumer-only-application). ## Provisioning the Sqlite Inbox Table diff --git a/contents/SqliteOutbox.md b/contents/SqliteOutbox.md index afe22c4..a399648 100644 --- a/contents/SqliteOutbox.md +++ b/contents/SqliteOutbox.md @@ -68,33 +68,37 @@ string binaryDdl = SqliteOutboxBuilder.GetDDL(tableName, hasBinaryMessagePayload Running `SqliteOutboxBuilder.GetDDL("Outbox")` will generate the following SQL script: ```sql -CREATE TABLE Outbox ( - "MessageId" TEXT NOT NULL, - "Topic" TEXT NOT NULL, - "MessageType" TEXT NOT NULL, - "Timestamp" TEXT NOT NULL, - "CorrelationId" TEXT NULL, - "ReplyTo" TEXT NULL, - "ContentType" TEXT NULL, - "PartitionKey" TEXT NULL, - "WorkflowId" TEXT NULL, - "JobId" TEXT NULL, - "Dispatched" TEXT NULL, - "HeaderBag" TEXT NOT NULL, - "Body" TEXT NOT NULL, - "Source" TEXT NULL, - "Type" TEXT NULL, - "DataSchema" TEXT NULL, - "Subject" TEXT NULL, - "TraceParent" TEXT NULL, - "TraceState" TEXT NULL, - "Baggage" TEXT NULL, - "Created" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), - "CreatedID" INTEGER PRIMARY KEY AUTOINCREMENT, - CONSTRAINT "PK_Outbox" PRIMARY KEY ("MessageId") +CREATE TABLE IF NOT EXISTS Outbox +( + [MessageId] TEXT NOT NULL COLLATE NOCASE, + [MessageType] TEXT NULL, + [Topic] TEXT NULL, + [Timestamp] TEXT NULL, + [CorrelationId] TEXT NULL, + [ReplyTo] TEXT NULL, + [ContentType] TEXT NULL, + [PartitionKey] TEXT NULL, + [WorkflowId] TEXT NULL, + [JobId] TEXT NULL, + [Dispatched] TEXT NULL, + [HeaderBag] TEXT NULL, + [Body] TEXT NULL, + [Source] TEXT NULL, + [Type] TEXT NULL, + [DataSchema] TEXT NULL, + [Subject] TEXT NULL, + [TraceParent] TEXT NULL, + [TraceState] TEXT NULL, + [Baggage] TEXT NULL, + [DataRef] TEXT NULL, + [CausationId] TEXT NULL, + [SpecVersion] TEXT NULL ); +CREATE INDEX IF NOT EXISTS [idx_Outbox_CausationId] ON [Outbox] ([CausationId]); ``` +`CausationId` and its index arrive at Brighter 10.8.0. A table an earlier release created still takes deposits without them, but [Replay On Seen](/contents/ReplayOnSeenReference.md#store-support) needs both; [Database Provisioning](/contents/BoxProvisioning.md) adds them as the Outbox **V8** migration. + ## SQLite Outbox Configuration To configure the SQLite Outbox, you need to provide an outbox implementation in the `AddProducers` configuration when setting up Brighter. diff --git a/contents/SweeperCircuitBreaking.md b/contents/SweeperCircuitBreaking.md index 13d26c9..a39f255 100644 --- a/contents/SweeperCircuitBreaking.md +++ b/contents/SweeperCircuitBreaking.md @@ -211,7 +211,7 @@ services.AddHealthChecks() ## Sweeper Circuit Breaking Outbox Support -The sweeper does not filter tripped topics itself. It passes `TrippedTopics` to the Outbox when it asks for outstanding messages, and the Outbox leaves those topics out of its query — so circuit breaking works only where the Outbox honours that list. At Brighter 10.7.0: +The sweeper does not filter tripped topics itself. It passes `TrippedTopics` to the Outbox when it asks for outstanding messages, and the Outbox leaves those topics out of its query — so circuit breaking works only where the Outbox honours that list. At Brighter 10.8.0 every Outbox does: | Outbox | Leaves tripped topics out | |---|---| @@ -219,10 +219,10 @@ The sweeper does not filter tripped topics itself. It passes `TrippedTopics` to | MongoDB | Yes | | Firestore | Yes | | InMemory | Yes | -| DynamoDB, both the V3 and V4 packages | **No** — it accepts the list and ignores it ([#4443](https://github.com/BrighterCommand/Brighter/issues/4443)) | -| Spanner | **No** — its query has no place for the filter, so the filter is dropped ([#4444](https://github.com/BrighterCommand/Brighter/issues/4444)) | +| DynamoDB, both the V3 and V4 packages | Yes, from 10.8.0 | +| Spanner | Yes, from 10.8.0 | -With DynamoDB or Spanner, a registered breaker still records trips, and `TrippedTopics` still reports them, so the monitoring above works. But every sweep reads and sends a tripped topic's messages as though nothing had tripped. +At 10.7.0 the DynamoDB Outbox accepted the list and ignored it ([#4443](https://github.com/BrighterCommand/Brighter/issues/4443)), and the Spanner Outbox's query had no place for the filter ([#4444](https://github.com/BrighterCommand/Brighter/issues/4444)). With either, a breaker still recorded trips, but every sweep sent a tripped topic's messages as though nothing had tripped. An Outbox that honours the list needs nothing extra: register the breaker and the sweeper beside it as usual. With MongoDB, for example: diff --git a/contents/Telemetry.md b/contents/Telemetry.md index 8db2932..93bbc93 100644 --- a/contents/Telemetry.md +++ b/contents/Telemetry.md @@ -49,9 +49,10 @@ services.AddOpenTelemetry() appears. [Configuring OpenTelemetry](/contents/ConfiguringOpenTelemetry.md) shows the setup for a producer and a consumer. -**The Command Processor also needs an external bus.** At 10.7.0 it is given the tracer only when -you configure producers with `AddProducers`; with none, `Send`, `Publish` and their async forms -record no span, even with the tracer registered. This is reported as [#4510](https://github.com/BrighterCommand/Brighter/issues/4510). +**From Brighter 10.8.0 the Command Processor records spans with or without an external bus.** At +10.7.0 it was given the tracer only when you configured producers with `AddProducers`; with none, +`Send`, `Publish` and their async forms recorded no span, even with the tracer registered +([#4510](https://github.com/BrighterCommand/Brighter/issues/4510)). --- @@ -67,11 +68,12 @@ V10 provides fine-grained control over which attributes are recorded to optimize |---|---| | `RequestInformation` | Request ID, type and operation; on messages, the CloudEvents ID, type, source and subject | | `RequestBody` | The request body as JSON; on messages, the message body (expensive) | -| `RequestContext` | Nothing at 10.7.0: no span reads this flag | +| `RequestContext` | Nothing at 10.8.0: no span reads this flag | | `Messaging` | Messaging attributes: destination, partition, message ID and type, body size, headers | | `DatabaseInformation` | Database attributes for Outbox and Inbox operations | | `ClamCheck` | Claim check operations (the member is spelled `ClamCheck`) | | `Brighter` | Brighter's handler instrumentation | +| `CircuitBreaker` | On the Outbox circuit breaker's spans, `circuit_breaker.topic` and `circuit_breaker.cooldown_count`; added at 10.8.0 | | `All` | Every flag above | | `None` | Nothing | @@ -137,7 +139,7 @@ await commandProcessor.SendAsync(new ProcessOrderCommand { OrderId = 123 }); ### Adding Custom Span Attributes -At 10.7.0 Brighter copies nothing from the request context onto a span. To record an attribute of +At 10.8.0 Brighter copies nothing from the request context onto a span. To record an attribute of your own, set it on `Activity.Current` in your handler: while the handler runs, that is the Command Processor's span for the request. @@ -203,7 +205,7 @@ order.placed publish (Producer, the sending service) | Attribute | Type | Description | Example | |-----------|------|-------------|---------| -| `messaging.system` | string | Always `internal_bus` on pump spans at 10.7.0, whichever transport the pump reads | `"internal_bus"` | +| `messaging.system` | string | The transport the pump reads, such as `postgres`, or `internal_bus` for the in-memory bus. At 10.7.0 it was always `internal_bus` on pump spans | `"postgres"` | | `messaging.destination.name` | string | Routing key | `"order.placed"` | | `messaging.operation.type` | string | Operation type | `"receive"`, `"process"` | | `messaging.message.id` | string | Message ID | `"01a0e7cf-3c70-7089-9e82-425719101515"` | diff --git a/contents/UsingTheContextBag.md b/contents/UsingTheContextBag.md index 32ee5e7..794c0bc 100644 --- a/contents/UsingTheContextBag.md +++ b/contents/UsingTheContextBag.md @@ -22,6 +22,8 @@ Instead we provide a **Request Context** and **Context Bag** as part of the Comm The **Context Bag** is a `ConcurrentDictionary` that allows you to pass arbitrary data between handlers in the pipeline. ```csharp +using Paramore.Brighter; + public class MyContextAwareCommandHandler : RequestHandler { public static string TestString { get; set; } @@ -201,7 +203,13 @@ These properties are serialized as CloudEvents extensions when the message is ma For consumers (subscribers to queues or streams), the Request Context now provides access to the **OriginatingMessage**. This allows you to examine properties of the message that was received, which is useful for debugging and accessing message metadata. ```csharp -public class MessageConsumerHandler : RequestHandlerAsync +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; + +public class MessageConsumerHandler(IMyService service, ILogger logger) + : RequestHandlerAsync { public override async Task HandleAsync( MyCommand command, @@ -210,7 +218,7 @@ public class MessageConsumerHandler : RequestHandlerAsync // Access the original message that started this pipeline if (Context.OriginatingMessage != null) { - _logger.LogInformation( + logger.LogInformation( "Processing message {MessageId} from topic {Topic} with correlation {CorrelationId}", Context.OriginatingMessage.Id, Context.OriginatingMessage.Header.Topic, @@ -226,7 +234,7 @@ public class MessageConsumerHandler : RequestHandlerAsync } // Process command... - await _service.ProcessAsync(command, cancellationToken); + await service.ProcessAsync(command, cancellationToken); return await base.HandleAsync(command, cancellationToken); } @@ -244,34 +252,34 @@ public class MessageConsumerHandler : RequestHandlerAsync ### OpenTelemetry Span -The Request Context provides access to the current **Span (Activity)** for adding custom OpenTelemetry attributes, events, and tags. +The Request Context provides access to the current **Span**, a `System.Diagnostics.Activity`, for adding custom tags and events. OpenTelemetry exports an `Activity`'s tags as span attributes, so you use `SetTag` here; `SetAttribute` belongs to OpenTelemetry's own `TelemetrySpan` API, which the context does not expose. ```csharp -public class TracingHandler : RequestHandler +using System.Diagnostics; +using Paramore.Brighter; + +public class TracingHandler(IMyService service) : RequestHandler { public override MyCommand Handle(MyCommand command) { - // Add custom attributes to current span - if (Context.Span != null) + var span = Context?.Span; + + // Add custom tags to the current span; OpenTelemetry exports them as attributes + if (span != null) { - Context.Span.SetAttribute("custom.business.id", command.BusinessId); - Context.Span.SetAttribute("custom.tenant.id", command.TenantId); - Context.Span.SetAttribute("custom.operation.type", "order-processing"); + span.SetTag("custom.business.id", command.BusinessId); + span.SetTag("custom.tenant.id", command.TenantId); + span.SetTag("custom.operation.type", "order-processing"); + span.SetTag("order.value", command.OrderValue); // Add events - Context.Span.AddEvent("Order validation started"); - - // Add tags - Context.Span.SetTag("order.value", command.OrderValue); + span.AddEvent(new ActivityEvent("Order validation started")); } // Process command... - _service.Process(command); + service.Process(command); - if (Context.Span != null) - { - Context.Span.AddEvent("Order validation completed"); - } + span?.AddEvent(new ActivityEvent("Order validation completed")); return base.Handle(command); } @@ -285,6 +293,8 @@ See [Telemetry](Telemetry.md) for more information on OpenTelemetry integration. You can override the destination topic and CloudEvents type for a message using the **Destination** property. ```csharp +using Paramore.Brighter; + public class DynamicRoutingHandler : RequestHandler { public override MyCommand Handle(MyCommand command) @@ -316,7 +326,12 @@ This allows runtime routing decisions based on command content or business rules The Request Context integrates with Polly's **ResiliencePipeline** (V8+) for resilience operations. The **ResilienceContext** can be used to share data across different resilience policies during execution. +`Context.ResilienceContext` is `null` unless the caller attaches one to the `RequestContext` it sends with, as [Request Context Integration](/contents/PolicyRetryAndCircuitBreaker.md#request-context-integration) shows. When it is attached, Brighter runs the handler's pipeline with it, so a property the handler sets is visible to a callback such as `OnRetry`. When it is not, the handler below sets nothing. + ```csharp +using Paramore.Brighter; +using Polly; + public class ResilientHandler : RequestHandler { public override MyCommand Handle(MyCommand command) @@ -347,6 +362,8 @@ See [Polly Resilience Pipeline](PolicyRetryAndCircuitBreaker.md) for more inform Access pre-configured resilience pipelines by name: ```csharp +using Paramore.Brighter; + public class PipelineAwareHandler : RequestHandler { public override MyCommand Handle(MyCommand command) @@ -389,12 +406,19 @@ public static class RequestContextBagNames // Job instance identifier (reserved for future use) public const string JobId = "Brighter-JobId"; + + // Causation id: links an inbox entry to the outbox messages its handling produced (Replay) + public const string CausationId = "Brighter-CausationId"; + + // ... XML documentation omitted } ``` Using these constants ensures consistency and avoids magic strings: ```csharp +using Paramore.Brighter; + // Good - using constants Context.Bag[RequestContextBagNames.PartitionKey] = tenantId; @@ -407,6 +431,8 @@ Context.Bag["Brighter-PartitionKey"] = tenantId; ### 1. Use Well-Known Keys ```csharp +using Paramore.Brighter; + // Good - Type-safe and discoverable Context.Bag[RequestContextBagNames.Headers] = headers; @@ -419,6 +445,9 @@ Context.Bag["my-headers"] = headers; If you place unmanaged resources in the Context Bag, ensure they are properly disposed: ```csharp +using System; +using Paramore.Brighter; + public class ResourceAwareHandler : RequestHandler { public override MyCommand Handle(MyCommand command) @@ -448,6 +477,11 @@ public class ResourceAwareHandler : RequestHandler If you use custom context bag keys, document them clearly: ```csharp +using Paramore.Brighter; + +// Usage +Context.Bag[MyAppContextKeys.UserId] = userId; + // Document your custom keys public static class MyAppContextKeys { @@ -457,22 +491,21 @@ public static class MyAppContextKeys // Tenant identifier for multi-tenancy public const string TenantId = "MyApp-TenantId"; } - -// Usage -Context.Bag[MyAppContextKeys.UserId] = userId; ``` ### 4. Check for Null Before Accessing Properties ```csharp +using Paramore.Brighter; + // Good - defensive check if (Context.Span != null) { - Context.Span.SetAttribute("custom.id", id); + Context.Span.SetTag("custom.id", id); } // Bad - may throw NullReferenceException -Context.Span.SetAttribute("custom.id", id); +Context.Span.SetTag("custom.id", id); ``` ### 5. Use Explicit RequestContext for Important Metadata diff --git a/spec/018-compile_residual/design.md b/spec/018-compile_residual/design.md index 53c5af4..81f0eb5 100644 --- a/spec/018-compile_residual/design.md +++ b/spec/018-compile_residual/design.md @@ -535,7 +535,7 @@ cited from its rows, not restated. ## Design Decisions -D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and D7 needed no ruling. D8 and D9 were ruled at phase 2, on 2026-10-05 and 2026-10-09. D10–D12 were ruled at phase 3, on 2026-10-13. +D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and D7 needed no ruling. D8 and D9 were ruled at phase 2, on 2026-10-05 and 2026-10-09. D10–D12 were ruled at phase 3, on 2026-10-13. D13–D18 were ruled at phase 4, on 2026-10-15, and D19 and D20 on 2026-10-18. | # | Question | Decision | Ruled | |---|---|---|---| @@ -551,6 +551,14 @@ D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and | **D10** | **Watch `UseExternalBus` as well?** Task 3.1 found it chained after `AddServiceActivator` at both of D5's sites, and dead the same way at 10.8.0 (`git -C ../Brighter grep -lw UseExternalBus 10.8.0 -- 'src/*.cs'` → one file, a doc comment). Repairing the sites to `AddProducers` is obligation 21 and needs no ruling; the watchlist row is scope | **Yes.** Same evidence, same two sites, same two V9 discussion pages. A trial run with both rows: **24 entries**, `UseExternalBus` at **8 sites on 4 pages**, of which 6 are discussion lines on `FAQ.md` and `V10MigrationGuide.md`. With both opt-outs, **14 silenced** | **Yes**, the maintainer, 2026-10-13 | | **D11** | **`symbolcheck --verify-list` counts a doc comment as a use.** Task 3.4 found both new rows LIVE: `AddServiceActivator` in 2 files and `UseExternalBus` in 1 at both refs, every hit a `///`. CI runs `--verify-list`, so the rows would fail the build. E6 ran only the plain gate | **Strip comments and strings in `resolve()`**: `git grep -l` still finds the candidates, and each is re-read at its ref through `strip_noncode`, the census's rule. Stripping can only lower a count, so no DEAD row can flip, and a live API keeps its declaration outside any comment. Red-proof in `tasks.md` § *Phase 3 as executed* | **Yes**, the maintainer, 2026-10-13 | | **D12** | **Pin `Quartz.Serialization.Json`?** `QuartzScheduler.md` tells its reader to `dotnet add package Quartz.Serialization.Json` and calls the job store's JSON serializer in 7 blocks; the pin lacked the package, so all 7 failed on it. At 3.22.4, the version Brighter 10.8.0's Quartz dependency implies, `UseJsonSerializer` is `[Obsolete]` for `UseNewtonsoftJsonSerializer`, so the page is repaired either way (ledger 12) | **Yes**, as D4 pinned Shouldly: one package the page's reader installs. `Quartz.Serialization.Json` **3.22.4** in `refs.csproj` and `refs-v4.csproj`; each pin 551 → **553** reference assemblies | **Yes**, the maintainer, 2026-10-13 | +| **D13** | **Pin `Polly.RateLimiting`?** `PolicyRetryAndCircuitBreaker.md` #12 calls `AddConcurrencyLimiter`, which lives in `Polly.RateLimiting`; no pin carries it and the page never names it | **Yes**, as D12: **8.8.0**, Polly's version on the pin, in `refs.csproj` and `refs-v4.csproj`, and the page names the package | **Yes**, the maintainer, 2026-10-15 | +| **D14** | **`FeatureSwitches.md` #6 reads .NET Framework's `ConfigurationManager.AppSettings`.** Keep it and pin `System.Configuration.ConfigurationManager`, or rewrite? | **Rewrite** the registry to read `IConfiguration`, and its `app.config` XML as `appsettings.json` | **Yes**, the maintainer, 2026-10-15 | +| **D15** | **`ShowMeTheCode.md` #1 and #4 diverge from the WebAPI sample they cite** (obligation 17). #1 is `GreetingsController.Post` without its class; #4 sends an `AddGreeting { PersonId }` the sample does not have | **Follow the sample**: #1 shown in its controller, #4 using `AddGreeting(name, greeting)` | **Yes**, the maintainer, 2026-10-15 | +| **D16** | **File `UseTypePipeline`'s XML documentation upstream?** It describes a `"."` key; the runtime calls `GetPipeline(policy)` (ledger 24) | **Yes**, as Brighter#4600 was filed: **BrighterCommand/Brighter#4609** | **Yes**, the maintainer, 2026-10-15 | +| **D17** | **`MongoDBOutbox.md`'s handler cannot run** (R5, ledger 36): it asks for an `IAmATransactionConnectionProvider` nothing registers, casts it to `MongoDbUnitOfWork`, and reads members that type does not have | **Rewrite** on `IAmAMongoDbTransactionProvider`, with a behaviour probe against MongoDB. Carried out at 4.3, when ruled, rather than phase 7. The probe amended the recommendation: that interface cannot be passed to `DepositPostAsync`, so the page uses the provider as `IAmABoxTransactionProvider` (`probe/s5/mongo-outbox`) | **Yes**, the maintainer, 2026-10-15 | +| **D18** | **File the `IAmAMongoDbTransactionProvider` mismatch upstream?** It is an `IAmABoxTransactionProvider`; `MongoDbOutbox` and the transaction type Brighter configures are `IClientSessionHandle`, so `DepositPostAsync` rejects it (D17, `probe/s5/mongo-outbox`) | **Yes**, as D16: **BrighterCommand/Brighter#4616** | **Yes**, the maintainer, 2026-10-15 | +| **D19** | **Sweep the prose that dates a defect to 10.7.0?** The pins moved to 10.8.0 at phase 3; the prose did not. Task 4.5 found **29** lines on **22** pages saying *"at 10.7.0"*, and five defects they describe have fixes in the 10.8.0 tag (#4414, #4335, #4437, #4432, #4510), with `OnceOnlyAction.Replay` still called unreleased. #4414's was probed and repaired at 4.5 (`probe/s1/scheduler-autoscan`, ledger 40); the rest are behavioural claims not yet run | **A new task in phase 4, 4.5a**: each defect probed at 10.7.0 (the control) and 10.8.0, every site repaired, one ledger row per defect, before 4.6 closes | **Yes**, the maintainer, 2026-10-18 | +| **D20** | **Refresh the relational DDL the pages show?** (R7) Seven blocks show their builder's 10.7.0 output; at 10.8.0 every relational builder adds `CausationId`, and the Outbox builders a causation index. A 10.7.0 table still deposits, but Replay needs the column | **A new task in phase 4, 4.5b**: each block regenerated from 10.8.0's `GetDDL`, run against its database, the page saying Replay needs the column; the tutorial's step replayed | **Yes**, the maintainer, 2026-10-18 | ## What the Design Review Found — 2026-10-05 diff --git a/spec/018-compile_residual/probe/s1/inmemory-cancel/Program.cs b/spec/018-compile_residual/probe/s1/inmemory-cancel/Program.cs new file mode 100644 index 0000000..36fa26c --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-cancel/Program.cs @@ -0,0 +1,59 @@ +// Task 4.5a (D19), #4437. Claim (SchedulingAMessage.md, FAQ.md, InMemoryScheduler.md): at 10.7.0 the +// InMemory scheduler cannot cancel or reschedule a request scheduled through the command processor: +// CancelAsync returns and the request still runs when it falls due. The pages' form is FAQ.md's: +// var schedulerId = await commandProcessor.SendAsync(delay, command); +// await scheduler.CancelAsync(schedulerId); // or ReSchedulerAsync(schedulerId, newDelay) +// Cases, one per process (args[0]): +// plain control: SendAsync with a delay, not cancelled; expect handled = 1 at the delay +// cancel the pages' cancel; the claim says handled = 1 at 10.7.0 +// reschedule the pages' reschedule to a later delay; the claim says it still runs at the first +// direct-cancel InMemoryScheduler.md's workaround: scheduler.ScheduleAsync, then CancelAsync +// The scheduler is the IAmARequestSchedulerAsync that UseScheduler registers. +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +var mode = args.Length > 0 ? args[0] : "plain"; +var services = new ServiceCollection(); +services.AddBrighter() + .UseScheduler(new InMemorySchedulerFactory()) + .AsyncHandlers(r => r.RegisterAsync()); +var provider = services.BuildServiceProvider(); +var commandProcessor = provider.GetRequiredService(); +var scheduler = provider.GetRequiredService(); + +var delay = TimeSpan.FromMilliseconds(300); +string id; +if (mode == "direct-cancel") + id = await scheduler.ScheduleAsync(new ScheduledCommand(), RequestSchedulerType.Send, delay); +else + id = await commandProcessor.SendAsync(delay, new ScheduledCommand()); + +var rescheduled = (bool?)null; +if (mode is "cancel" or "direct-cancel") await scheduler.CancelAsync(id); +if (mode == "reschedule") rescheduled = await scheduler.ReSchedulerAsync(id, TimeSpan.FromMilliseconds(1500)); + +await Task.Delay(900); // past the first delay, before the rescheduled one +var atFirst = ScheduledCommandHandler.Handled; +await Task.Delay(1500); // past the rescheduled one +var atEnd = ScheduledCommandHandler.Handled; + +var version = typeof(CommandProcessor).Assembly.GetName().Version; +var fv = System.Diagnostics.FileVersionInfo.GetVersionInfo(typeof(CommandProcessor).Assembly.Location).ProductVersion?.Split('+')[0]; +Console.WriteLine($"{fv} {mode}: id='{id}' rescheduled={rescheduled?.ToString() ?? "-"} handled at 0.9s = {atFirst}, at 2.4s = {atEnd}"); +return 0; + +public class ScheduledCommand() : Command(Id.Random()); + +public class ScheduledCommandHandler : RequestHandlerAsync +{ + public static int Handled; + public override Task HandleAsync(ScheduledCommand command, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref Handled); + return base.HandleAsync(command, cancellationToken); + } +} diff --git a/spec/018-compile_residual/probe/s1/inmemory-cancel/inmemory-cancel.csproj b/spec/018-compile_residual/probe/s1/inmemory-cancel/inmemory-cancel.csproj new file mode 100644 index 0000000..896ecbc --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-cancel/inmemory-cancel.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable10.8.0 + + + + + + diff --git a/spec/018-compile_residual/probe/s1/scheduler-autoscan/Program.cs b/spec/018-compile_residual/probe/s1/scheduler-autoscan/Program.cs new file mode 100644 index 0000000..c77292e --- /dev/null +++ b/spec/018-compile_residual/probe/s1/scheduler-autoscan/Program.cs @@ -0,0 +1,44 @@ +// Task 4.5, SchedulingAMessage.md § Registering Handlers When You Schedule Requests. +// Claim: at 10.7.0 AutoFromAssemblies() registers the scheduler's own handlers twice, so a request +// scheduled with SendAsync throws "More than one handler was found … FireSchedulerRequest" when it +// falls due, and with the InMemory scheduler that ends the process. The page says the fix (#4414) +// is not yet released. Case: 10.8.0. Control: 10.7.0, where the claim says it fails. Each version +// runs twice, once per scan the page names: no argument is AutoFromAssemblies(), "async" is +// AsyncHandlersFromAssemblies over this assembly. +// The process prints "handled" and exits 0 if the command runs; an unhandled throw on the +// scheduler's timer ends it with a non-zero code before that line. +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +var builder = services.AddBrighter() + .UseScheduler(new InMemorySchedulerFactory()); +var scan = args.Length > 0 ? args[0] : "auto"; +if (scan == "async") builder.AsyncHandlersFromAssemblies([typeof(ScheduledCommand).Assembly]); +else builder.AutoFromAssemblies(); + +var provider = services.BuildServiceProvider(); +var commandProcessor = provider.GetRequiredService(); + +await commandProcessor.SendAsync(TimeSpan.FromMilliseconds(100), new ScheduledCommand()); +for (var i = 0; i < 30 && ScheduledCommandHandler.Handled == 0; i++) await Task.Delay(100); + +var version = typeof(CommandProcessor).Assembly.GetName().Version; +Console.WriteLine($"{version} {scan}: handled = {ScheduledCommandHandler.Handled}"); +return ScheduledCommandHandler.Handled == 1 ? 0 : 1; + +public class ScheduledCommand() : Command(Id.Random()); + +public class ScheduledCommandHandler : RequestHandlerAsync +{ + public static int Handled; + public override Task HandleAsync(ScheduledCommand command, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref Handled); + return base.HandleAsync(command, cancellationToken); + } +} diff --git a/spec/018-compile_residual/probe/s1/scheduler-autoscan/scheduler-autoscan.csproj b/spec/018-compile_residual/probe/s1/scheduler-autoscan/scheduler-autoscan.csproj new file mode 100644 index 0000000..68c1af0 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/scheduler-autoscan/scheduler-autoscan.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable10.8.0 + + + + + diff --git a/spec/018-compile_residual/probe/s2/agreement-autoscan/Program.cs b/spec/018-compile_residual/probe/s2/agreement-autoscan/Program.cs new file mode 100644 index 0000000..283326a --- /dev/null +++ b/spec/018-compile_residual/probe/s2/agreement-autoscan/Program.cs @@ -0,0 +1,83 @@ +// AgreementDispatcher.md #14 (§ AutoFromAssemblies Conflicts), against 10.8.0. A project of its own, +// because AutoFromAssemblies() scans every loaded assembly, and this one must hold only #14's handlers. +// case: the page's Solution - excludeDynamicHandlerTypes names the agreement's handlers - Send runs +// the handler the route returns +// control: the page's Problem - the same registration without excludeDynamicHandlerTypes - Send throws +// ArgumentException, "More than one handler was found for the typeof command MyCommand" +// control: the Solution, with the route returning MyOtherHandler - the exclusion did not unregister it +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +var failed = 0; + +failed += Run("case excludeDynamicHandlerTypes, route returns MyHandler", services => + // verbatim from the page's block #14 + services.AddBrighter(options => { }) + .AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(MyHandler), typeof(MyOtherHandler)]) + .Handlers(registry => + { + registry.Register((request, context) => + { + // ... your routing logic + return [typeof(MyHandler)]; + }, + [typeof(MyHandler), typeof(MyOtherHandler)]); + }), + expect: "MyHandler"); + +failed += Run("control no exclusion", services => + services.AddBrighter(options => { }) + .AutoFromAssemblies() // probe + .Handlers(registry => + { + registry.Register((request, context) => [typeof(MyHandler)], + [typeof(MyHandler), typeof(MyOtherHandler)]); + }), + expect: "throws"); + +failed += Run("control excludeDynamicHandlerTypes, route returns MyOtherHandler", services => + services.AddBrighter(options => { }) + .AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(MyHandler), typeof(MyOtherHandler)]) + .Handlers(registry => + { + registry.Register((request, context) => [typeof(MyOtherHandler)], // probe + [typeof(MyHandler), typeof(MyOtherHandler)]); + }), + expect: "MyOtherHandler"); + +return failed == 0 ? 0 : 1; + +static int Run(string name, Action configure, string expect) +{ + var services = new ServiceCollection(); + configure(services); + var cp = services.BuildServiceProvider().GetRequiredService(); + Ran.Handlers.Clear(); + string outcome; + try { cp.Send(new MyCommand()); outcome = string.Join(",", Ran.Handlers); } + catch (Exception e) + { + outcome = "throws"; + Console.WriteLine($" {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + } + var ok = outcome == expect; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: {outcome}"); + return ok ? 0 : 1; +} + +public static class Ran { public static readonly List Handlers = []; } + +public class MyCommand() : Command(Id.Random()); + +public class MyHandler : RequestHandler +{ + public override MyCommand Handle(MyCommand command) { Ran.Handlers.Add(nameof(MyHandler)); return base.Handle(command); } +} + +public class MyOtherHandler : RequestHandler +{ + public override MyCommand Handle(MyCommand command) { Ran.Handlers.Add(nameof(MyOtherHandler)); return base.Handle(command); } +} diff --git a/spec/018-compile_residual/probe/s2/agreement-autoscan/agreement-autoscan.csproj b/spec/018-compile_residual/probe/s2/agreement-autoscan/agreement-autoscan.csproj new file mode 100644 index 0000000..09a4ca1 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/agreement-autoscan/agreement-autoscan.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s2/agreement-dispatcher/Program.cs b/spec/018-compile_residual/probe/s2/agreement-dispatcher/Program.cs new file mode 100644 index 0000000..2a56b9d --- /dev/null +++ b/spec/018-compile_residual/probe/s2/agreement-dispatcher/Program.cs @@ -0,0 +1,244 @@ +// AgreementDispatcher.md #7 and #12, against 10.8.0. +// #7: the page's ProcessOrder route returns ValidateOrderHandler and FinalizeOrderHandler for every +// order, plus FraudCheckHandler and ApprovalHandler on some. ProcessOrder is a Command, and the +// page's own § Returning Multiple Handlers says Send throws on more than one handler. +// case: Send a domestic, low-value order (the fewest handlers the route can return: two) +// control: the same route on an Event, published with a high-value international order - every +// returned handler runs (compared as a set; the order they ran in is printed) +// control: the route returning one handler - Send runs it +// #7, the page now: the route on OrderPlaced, an Event, published +// case: a high-value international order - all three handlers run (compared as a set) +// control: a domestic, low-value order - OrderConfirmationHandler alone +// control: FraudCheckHandler throws - the other two still run, and Publish throws +// AggregateException ("one handler failing does not stop the others") +// #12: "Handler3 missing - will fail at runtime" +// case: the page's Bad registration, returning Handler3, which the list omits +// control: the page's Good registration, whose route returns Handler1, which the list holds +// control: the Good list, with the route returning Handler3 - listed, so it runs +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +var failed = 0; + +failed += Run("#7 case Command, page's route, Send", h => + // verbatim from the page's block #7 + h.Register((request, context) => + { + var order = request as ProcessOrder; + var handlers = new List + { + typeof(ValidateOrderHandler) // Always validate + }; + + // High-value orders get fraud check + if (order?.Total > 10000m) + handlers.Add(typeof(FraudCheckHandler)); + + // International orders need approval + if (order?.IsInternational == true) + handlers.Add(typeof(ApprovalHandler)); + + handlers.Add(typeof(FinalizeOrderHandler)); // Always finalize + + return handlers; + }, + [ + typeof(ValidateOrderHandler), + typeof(FraudCheckHandler), + typeof(ApprovalHandler), + typeof(FinalizeOrderHandler) + ]), + cp => cp.Send(new ProcessOrder { Total = 50m, IsInternational = false }), + expect: "throws"); + +failed += Run("#7 control Event, same route, Publish", h => + h.Register((request, context) => // probe: Event + { + var order = request as OrderPlaced; + var handlers = new List { typeof(ValidateOrderPlacedHandler) }; + if (order?.Total > 10000m) handlers.Add(typeof(FraudCheckOrderPlacedHandler)); + if (order?.IsInternational == true) handlers.Add(typeof(ApprovalOrderPlacedHandler)); + handlers.Add(typeof(FinalizeOrderPlacedHandler)); + return handlers; + }, + [typeof(ValidateOrderPlacedHandler), typeof(FraudCheckOrderPlacedHandler), typeof(ApprovalOrderPlacedHandler), typeof(FinalizeOrderPlacedHandler)]), + cp => cp.Publish(new OrderPlaced { Total = 50000m, IsInternational = true }), + expect: "ApprovalOrderPlacedHandler,FinalizeOrderPlacedHandler,FraudCheckOrderPlacedHandler,ValidateOrderPlacedHandler", + sorted: true); // every returned handler runs; the order is printed, not asserted + +failed += Run("#7 control Command, one handler returned, Send", h => + h.Register((request, context) => [typeof(ValidateOrderHandler)], + [typeof(ValidateOrderHandler), typeof(FinalizeOrderHandler)]), + cp => cp.Send(new ProcessOrder { Total = 50m }), + expect: "ValidateOrderHandler"); + +failed += Run("#7 now high-value international, Publish", PageNow.Routes.PageNow, + cp => cp.Publish(new PageNow.OrderPlaced { Total = 50000m, IsInternational = true }), + expect: "CustomsDeclarationHandler,FraudCheckHandler,OrderConfirmationHandler", sorted: true); +failed += Run("#7 now domestic low-value, Publish", PageNow.Routes.PageNow, + cp => cp.Publish(new PageNow.OrderPlaced { Total = 50m }), + expect: "OrderConfirmationHandler"); +failed += Run("#7 now FraudCheckHandler throws", PageNow.Routes.PageNow, + cp => + { + try { cp.Publish(new PageNow.OrderPlaced { Total = 50000m, IsInternational = true, FailFraudCheck = true }); } + catch (AggregateException) { Ran.Handlers.Add("AggregateException"); } + }, + expect: "AggregateException,CustomsDeclarationHandler,OrderConfirmationHandler", sorted: true); + +failed += Run("#12 case Bad: Handler3 returned, not listed", h => + // verbatim from the page's block #12, Bad + h.Register((request, context) => + { + // Might return Handler3, but it's not in the list! + return [typeof(Handler3)]; + }, + [ + typeof(Handler1), + typeof(Handler2) + // Handler3 missing - will fail at runtime! + ]), + cp => cp.Send(new MyCommand()), + expect: "throws"); + +failed += Run("#12 control Good: Handler1 returned, listed", h => + // verbatim from the page's block #12, Good + h.Register((request, context) => + { + // ... + return [typeof(Handler1)]; + }, + [ + typeof(Handler1), + typeof(Handler2), + typeof(Handler3) + // All handlers that might be returned + ]), + cp => cp.Send(new MyCommand()), + expect: "Handler1"); + +failed += Run("#12 control Good list, Handler3 returned", h => + h.Register((request, context) => [typeof(Handler3)], // probe + [typeof(Handler1), typeof(Handler2), typeof(Handler3)]), + cp => cp.Send(new MyCommand()), + expect: "Handler3"); + +return failed == 0 ? 0 : 1; + +static int Run(string name, Action register, Action act, string expect, bool sorted = false) +{ + var services = new ServiceCollection(); + services.AddBrighter().Handlers(register); + var cp = services.BuildServiceProvider().GetRequiredService(); + Ran.Handlers.Clear(); + string outcome; + try + { + act(cp); + outcome = string.Join(",", Ran.Handlers); + if (sorted) + { + Console.WriteLine($" ran in the order {outcome}"); + Ran.Handlers.Sort(StringComparer.Ordinal); + outcome = string.Join(",", Ran.Handlers); + } + } + catch (Exception e) + { + outcome = "throws"; + var detail = $"{e.GetType().Name}: {e.Message.Split('\n')[0]}"; + for (var inner = e.InnerException; inner != null; inner = inner.InnerException) + detail += $" <- {inner.GetType().Name}: {inner.Message.Split('\n')[0]}"; + Console.WriteLine($" {detail}"); + } + var ok = outcome == expect; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: {outcome}"); + return ok ? 0 : 1; +} + +public static class Ran { public static readonly List Handlers = []; } + +public class Recording : RequestHandler where T : class, IRequest +{ + public override T Handle(T request) { lock (Ran.Handlers) Ran.Handlers.Add(GetType().Name); return base.Handle(request); } +} + +public class MyCommand() : Command(Id.Random()); +public class Handler1 : Recording; +public class Handler2 : Recording; +public class Handler3 : Recording; + +public class ProcessOrder() : Command(Id.Random()) +{ + public decimal Total { get; set; } + public bool IsInternational { get; set; } +} +public class ValidateOrderHandler : Recording; +public class FraudCheckHandler : Recording; +public class ApprovalHandler : Recording; +public class FinalizeOrderHandler : Recording; + +public class OrderPlaced() : Event(Id.Random()) +{ + public decimal Total { get; set; } + public bool IsInternational { get; set; } +} +public class ValidateOrderPlacedHandler : Recording; +public class FraudCheckOrderPlacedHandler : Recording; +public class ApprovalOrderPlacedHandler : Recording; +public class FinalizeOrderPlacedHandler : Recording; + + +namespace PageNow +{ + public static class Routes + { + // verbatim from the page's block #7, as repaired + public static readonly Action PageNow = registry => + registry.Register((request, context) => + { + var order = request as OrderPlaced; + var handlers = new List + { + typeof(OrderConfirmationHandler) // Every order is confirmed + }; + + // High-value orders get a fraud check + if (order?.Total > 10000m) + handlers.Add(typeof(FraudCheckHandler)); + + // International orders need a customs declaration + if (order?.IsInternational == true) + handlers.Add(typeof(CustomsDeclarationHandler)); + + return handlers; + }, + [ + typeof(OrderConfirmationHandler), + typeof(FraudCheckHandler), + typeof(CustomsDeclarationHandler) + ]); + } + + public class OrderPlaced() : Event(Id.Random()) + { + public decimal Total { get; set; } + public bool IsInternational { get; set; } + public bool FailFraudCheck { get; set; } + } + + public class OrderConfirmationHandler : Recording; + public class CustomsDeclarationHandler : Recording; + public class FraudCheckHandler : RequestHandler + { + public override OrderPlaced Handle(OrderPlaced order) + { + if (order.FailFraudCheck) throw new InvalidOperationException("fraud service unavailable"); + lock (Ran.Handlers) Ran.Handlers.Add(nameof(FraudCheckHandler)); + return base.Handle(order); + } + } +} diff --git a/spec/018-compile_residual/probe/s2/agreement-dispatcher/agreement-dispatcher.csproj b/spec/018-compile_residual/probe/s2/agreement-dispatcher/agreement-dispatcher.csproj new file mode 100644 index 0000000..8274295 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/agreement-dispatcher/agreement-dispatcher.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s2/efcore-outbox/Program.cs b/spec/018-compile_residual/probe/s2/efcore-outbox/Program.cs new file mode 100644 index 0000000..5345ef6 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/efcore-outbox/Program.cs @@ -0,0 +1,229 @@ +// EFCoreOutbox.md, against 10.8.0 and MySQL 8: +// docker run -d --name probe-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=greetings -p 3307:3306 mysql:8 +// +// Claim 1: the handler can take the IAmATransactionConnectionProvider "that Brighter registers for the +// TransactionProvider set above", and starting the transaction through it "begins it on the +// context's database". Reported: what resolves, and the context's CurrentTransaction once it has. +// Claim 2: "EF Core's SaveChangesAsync and the Outbox write share it", so committing writes both and +// rolling back writes neither. +// case: the page's handler - the greeting is saved, the Outbox holds the message, ClearOutboxAsync +// puts it on the bus +// control: the page's handler with a failure planted after SaveChangesAsync, before the commit - +// it catches and rolls back: no greeting, no message +// control: as the last, but the message deposited without the provider - the greeting rolls back +// and the message stays. Without it, the control above cannot tell one transaction from +// two writes that both happened to fail +// The registration is the page's, with the probe's connection string, an explicit server version in +// place of ServerVersion.AutoDetect, the bus an InMemory producer, and UseOutboxSweeper() left out. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MySqlConnector; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MySql; +using Paramore.Brighter.MySql.EntityFrameworkCore; +using Paramore.Brighter.Observability; +using Paramore.Brighter.Outbox.MySql; + +const string connectionString = "Server=localhost;Port=3307;Database=greetings;Uid=root;Pwd=root;"; +static string DbConnectionString() => connectionString; + +var services = new ServiceCollection(); +services.AddLogging(); +var bus = new InternalBus(); + +// the page's ConfigureServices +var outboxConfiguration = new RelationalDatabaseConfiguration(DbConnectionString(), outBoxTableName: "Outbox"); +services.AddSingleton(outboxConfiguration); +services.AddDbContext(options => + options.UseMySql(DbConnectionString(), new MySqlServerVersion(new Version(8, 0)))); // probe: no AutoDetect + +services.AddBrighter() + .AddProducers(producers => + { + producers.ProducerRegistry = new InMemoryProducerRegistryFactory(bus, // probe + [new Publication { Topic = new RoutingKey("greeting.made"), RequestType = typeof(GreetingMade) }], + InstrumentationOptions.None).Create(); + producers.Outbox = new MySqlOutbox(outboxConfiguration); + producers.ConnectionProvider = typeof(MySqlConnectionProvider); + // Use the EF Core transaction provider with your DbContext + producers.TransactionProvider = typeof(MySqlEntityFrameworkTransactionProvider); + }) + .AutoFromAssemblies(); +var sp = services.BuildServiceProvider(); + +// schema: the context's tables and Brighter's Outbox +using (var scope = sp.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureDeletedAsync(); + await db.Database.EnsureCreatedAsync(); + await db.Database.ExecuteSqlRawAsync(MySqlOutboxBuilder.GetDDL("Outbox")); +} + +var failed = 0; + +// Claim 1 +using (var scope = sp.CreateScope()) +{ + var provider = scope.ServiceProvider.GetService(); + var uow = scope.ServiceProvider.GetRequiredService(); + var resolved = provider?.GetType().Name ?? "NO"; + var shared = false; + if (provider != null) + { + await provider.GetTransactionAsync(); + shared = uow.Database.CurrentTransaction != null; + await provider.RollbackAsync(); + provider.Close(); + } + var ok = provider is MySqlEntityFrameworkTransactionProvider && shared; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} claim 1 IAmATransactionConnectionProvider resolves: {resolved}; the context has a transaction after GetTransactionAsync: {shared}"); + failed += ok ? 0 : 1; +} + +// Claim 2 +failed += await Run("claim 2 case the page's handler", Plant.None, expect: (1, 1, 1)); +failed += await Run("claim 2 control failure before the commit", Plant.FailBeforeCommit, expect: (0, 0, 0)); +failed += await Run("claim 2 control the same, deposited without the provider", Plant.FailBeforeCommitDepositOutside, expect: (0, 1, 0)); + +using (var scope = sp.CreateScope()) await scope.ServiceProvider.GetRequiredService().Database.EnsureDeletedAsync(); +return failed == 0 ? 0 : 1; + +async Task Run(string name, Plant plant, (int Greetings, int Outbox, int Bus) expect) +{ + using (var scope = sp.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlRawAsync("DELETE FROM Greeting; DELETE FROM Person; DELETE FROM Outbox;"); + db.People.Add(new Person("Ian")); + await db.SaveChangesAsync(); + } + var onBusBefore = bus.Stream(new RoutingKey("greeting.made")).Count(); + using (var scope = sp.CreateScope()) + await scope.ServiceProvider.GetRequiredService().SendAsync(new AddGreeting("Ian", "Hello") { Plant = plant }); + + await using var connection = new MySqlConnection(connectionString); + await connection.OpenAsync(); + async Task Count(string table) + { + await using var cmd = new MySqlCommand($"SELECT COUNT(*) FROM {table}", connection); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + var measured = (await Count("Greeting"), await Count("Outbox"), bus.Stream(new RoutingKey("greeting.made")).Count() - onBusBefore); + var ok = measured == expect; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: greetings {measured.Item1}, Outbox {measured.Item2}, on the bus {measured.Item3}"); + return ok ? 0 : 1; +} + +public enum Plant { None, FailBeforeCommit, FailBeforeCommitDepositOutside } + +public class AddGreeting(string name, string greeting) : Command(Id.Random()) +{ + public string Name { get; } = name; + public string Greeting { get; } = greeting; + public Plant Plant { get; init; } +} + +public class GreetingMade(string greeting) : Event(Id.Random()) +{ + public string Greeting { get; } = greeting; +} + +public class Person(string name) +{ + public int Id { get; set; } + public string Name { get; set; } = name; + public ICollection Greetings { get; set; } = new List(); + + public void AddGreeting(Greeting greeting) + { + greeting.Recipient = this; + Greetings.Add(greeting); + } +} + +public class Greeting(string message) +{ + public long Id { get; set; } + public string Message { get; set; } = message; + public int RecipientId { get; set; } + public Person Recipient { get; set; } = null!; + + public string Greet() => $"{Message} {Recipient.Name}!"; +} + +public class GreetingsEntityGateway(DbContextOptions options) : DbContext(options) +{ + public DbSet People { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToTable("Person"); + modelBuilder.Entity().ToTable("Greeting") + .HasOne(g => g.Recipient).WithMany(p => p.Greetings).HasForeignKey(g => g.RecipientId); + } +} + +// verbatim from the page, but for the probe's plants +public class AddGreetingHandlerAsync( + GreetingsEntityGateway uow, + IAmATransactionConnectionProvider provider, + IAmACommandProcessor postBox) + : RequestHandlerAsync +{ + public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) + { + var posts = new List(); + + await provider.GetTransactionAsync(cancellationToken); + try + { + var person = await uow.People + .Where(p => p.Name == addGreeting.Name) + .SingleAsync(cancellationToken); + + var greeting = new Greeting(addGreeting.Greeting); + + person.AddGreeting(greeting); + + //Now write the message we want to send to the Db in the same transaction. + if (addGreeting.Plant == Plant.FailBeforeCommitDepositOutside) // probe + posts.Add(await postBox.DepositPostAsync(new GreetingMade(greeting.Greet()), cancellationToken: cancellationToken)); + else + posts.Add(await postBox.DepositPostAsync( + new GreetingMade(greeting.Greet()), + provider, + cancellationToken: cancellationToken)); + + //write the changed entity to the Db + await uow.SaveChangesAsync(cancellationToken); + + if (addGreeting.Plant != Plant.None) throw new InvalidOperationException("planted failure"); // probe + + //write new person and the associated message to the Db + await provider.CommitAsync(cancellationToken); + } + catch (Exception) + { + //it went wrong, rollback the entity change and the downstream message + await provider.RollbackAsync(cancellationToken); + return await base.HandleAsync(addGreeting, cancellationToken); + } + finally + { + provider.Close(); + } + + //Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. + //Alternatively, you can let the Sweeper do this, but at the cost of increased latency + await postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); + + return await base.HandleAsync(addGreeting, cancellationToken); + } +} diff --git a/spec/018-compile_residual/probe/s2/efcore-outbox/efcore-outbox.csproj b/spec/018-compile_residual/probe/s2/efcore-outbox/efcore-outbox.csproj new file mode 100644 index 0000000..6e75603 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/efcore-outbox/efcore-outbox.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s2/feature-switch-config/Program.cs b/spec/018-compile_residual/probe/s2/feature-switch-config/Program.cs new file mode 100644 index 0000000..1aff8e5 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/feature-switch-config/Program.cs @@ -0,0 +1,111 @@ +// FeatureSwitches.md, against 10.8.0. Every handler is [FeatureSwitch(..., FeatureSwitchStatus.Config)], +// so the registry decides whether it runs. +// #6: ConfigurationFeatureSwitchRegistry reads appsettings.json's FeatureSwitches section, keyed by the +// handler's full (dotted) type name, and falls back to MissingConfigStrategy when there is no key +// case: "MyNamespace.MyFeatureSwitchedHandler": "on" - runs +// control: "MyNamespace.MyIncompleteHandler": "off" - skipped +// case: no key, MissingConfigStrategy.SilentOff - skipped +// control: no key, MissingConfigStrategy.SilentOn - runs +// case: no key, the default MissingConfigStrategy.Exception - Send throws +// § the opening paragraph: "With no registry at all, FeatureSwitchStatus.Config behaves as On. +// With a registry that has no entry for the handler, its MissingConfigStrategy decides; the +// FluentConfigRegistryBuilder's default throws a ConfigurationException." +// case: no registry - runs +// case: FluentConfigRegistryBuilder with no entry for the handler - Send throws +// ConfigurationException +// control: FluentConfigRegistryBuilder with the handler On - runs +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Providers; +using MyNamespace; + +var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json") + .Build(); + +var failed = 0; +failed += Run("#6 case key \"on\"", new ConfigurationFeatureSwitchRegistry(configuration), "runs"); +failed += Run("#6 control key \"off\"", new ConfigurationFeatureSwitchRegistry(configuration), "skipped"); +failed += Run("#6 case no key, SilentOff", new ConfigurationFeatureSwitchRegistry(configuration) { MissingConfigStrategy = MissingConfigStrategy.SilentOff }, "skipped"); +failed += Run("#6 control no key, SilentOn", new ConfigurationFeatureSwitchRegistry(configuration) { MissingConfigStrategy = MissingConfigStrategy.SilentOn }, "runs"); +failed += Run("#6 case no key, Exception (default)", new ConfigurationFeatureSwitchRegistry(configuration), "throws InvalidOperationException"); +failed += Run("intro case no registry", null, "runs"); +failed += Run("intro case Fluent registry, no entry", FluentConfigRegistryBuilder.With().StatusOf().Is(FeatureSwitchStatus.On).Build(), "throws ConfigurationException"); +failed += Run("intro control Fluent registry, entry On", FluentConfigRegistryBuilder.With().StatusOf().Is(FeatureSwitchStatus.On).Build(), "runs"); +return failed == 0 ? 0 : 1; + +static int Run(string name, IAmAFeatureSwitchRegistry? registry, string expect) where THandler : class, IHandleRequests +{ + var services = new ServiceCollection(); + services.AddBrighter(o => o.FeatureSwitchRegistry = registry) + .Handlers(h => h.Register()); + var cp = services.BuildServiceProvider().GetRequiredService(); + Runs.Count = 0; + string outcome; + try { cp.Send(new MyCommand()); outcome = Runs.Count == 1 ? "runs" : "skipped"; } + catch (Exception e) + { + Console.WriteLine($" Send threw {e.GetType().Name}"); + while (e.InnerException != null && e is not InvalidOperationException && e is not ConfigurationException) e = e.InnerException; + outcome = $"throws {e.GetType().Name}"; + Console.WriteLine($" {e.Message.Split('\n')[0]}"); + } + var ok = outcome == expect; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: {outcome}"); + return ok ? 0 : 1; +} + +// verbatim from the page's block #6 +public class ConfigurationFeatureSwitchRegistry(IConfiguration configuration) : IAmAFeatureSwitchRegistry +{ + public MissingConfigStrategy MissingConfigStrategy { get; set; } = MissingConfigStrategy.Exception; + + public FeatureSwitchStatus StatusOf(Type handler) + { + var configStatus = configuration[$"FeatureSwitches:{handler.FullName}"]?.ToLowerInvariant(); + + return configStatus switch + { + "on" => FeatureSwitchStatus.On, + "off" => FeatureSwitchStatus.Off, + _ => MissingConfigStrategy switch + { + MissingConfigStrategy.SilentOn => FeatureSwitchStatus.On, + MissingConfigStrategy.SilentOff => FeatureSwitchStatus.Off, + _ => throw new InvalidOperationException($"No Feature Switch configuration for {handler} specified") + } + }; + } +} + +namespace MyNamespace +{ + using Paramore.Brighter.FeatureSwitch.Attributes; + + public static class Runs { public static int Count; } + + public class MyCommand() : Command(Id.Random()); + + public class MyFeatureSwitchedHandler : RequestHandler + { + [FeatureSwitch(typeof(MyFeatureSwitchedHandler), FeatureSwitchStatus.Config, step: 1)] + public override MyCommand Handle(MyCommand command) { Runs.Count++; return base.Handle(command); } + } + + public class MyIncompleteHandler : RequestHandler + { + [FeatureSwitch(typeof(MyIncompleteHandler), FeatureSwitchStatus.Config, step: 1)] + public override MyCommand Handle(MyCommand command) { Runs.Count++; return base.Handle(command); } + } + + public class MyUnlistedHandler : RequestHandler + { + [FeatureSwitch(typeof(MyUnlistedHandler), FeatureSwitchStatus.Config, step: 1)] + public override MyCommand Handle(MyCommand command) { Runs.Count++; return base.Handle(command); } + } +} diff --git a/spec/018-compile_residual/probe/s2/feature-switch-config/appsettings.json b/spec/018-compile_residual/probe/s2/feature-switch-config/appsettings.json new file mode 100644 index 0000000..bc9391d --- /dev/null +++ b/spec/018-compile_residual/probe/s2/feature-switch-config/appsettings.json @@ -0,0 +1,6 @@ +{ + "FeatureSwitches": { + "MyNamespace.MyFeatureSwitchedHandler": "on", + "MyNamespace.MyIncompleteHandler": "off" + } +} diff --git a/spec/018-compile_residual/probe/s2/feature-switch-config/feature-switch-config.csproj b/spec/018-compile_residual/probe/s2/feature-switch-config/feature-switch-config.csproj new file mode 100644 index 0000000..ba34eb1 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/feature-switch-config/feature-switch-config.csproj @@ -0,0 +1,10 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s2/legacy-policy-order/Program.cs b/spec/018-compile_residual/probe/s2/legacy-policy-order/Program.cs new file mode 100644 index 0000000..7744a4a --- /dev/null +++ b/spec/018-compile_residual/probe/s2/legacy-policy-order/Program.cs @@ -0,0 +1,112 @@ +// MigratingToPollyV8.md § Legacy: Using Polly v7 Policies, against 10.8.0. +// Claim 1: "Where we have multiple policies they are evaluated left to right, so in this case +// 'MyCircuitBreakerPolicy' wraps 'MyExceptionPolicy'." +// Claim 2: "put a Circuit Breaker UsePolicy attribute as an earlier step than the Retry UsePolicy +// attribute. If retries expire, the exception will bubble out to the Circuit Breaker." +// The handler always throws, and each case sends twice with fresh policies. The page's circuit breaker +// is verbatim (breaks on 1 exception, for 500 ms); its retry is the page's three retries without the +// 1-3 s waits, which this probe does not need. +// case: [UsePolicy(new [] {"MyCircuitBreakerPolicy", "MyExceptionPolicy"}, step: 1)], the page's. +// If the breaker wraps the retry, the first send makes 4 attempts and then opens the breaker, +// so the second send throws BrokenCircuitException without reaching the handler: 4 attempts +// control: the array reversed. The retry wraps the breaker, the first attempt opens it, and every +// retry meets BrokenCircuitException: 1 attempt +// Claim 3, unstated: the page's `var policyRegistry = new PolicyRegistry();` is a registry Brighter +// accepts. Both AddBrighter and CommandProcessorBuilder.Resilience require the +// CommandProcessor.RETRYPOLICY and CIRCUITBREAKER policies in it. +// case: the page's new PolicyRegistry(), with the page's two policies: resolving throws +// control: new DefaultPolicy(), Brighter's registry holding them, with the same two: resolves +// (claims 1's case and control use it) +// Claim 2 is not run: UsePolicyAttribute has no AllowMultiple, so two [UsePolicy] attributes on one +// method do not compile. `dotnet build -p:DefineConstants=TWO_ATTRIBUTES` shows CS0579. +using System; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Policies.Attributes; +using Polly; +using Polly.CircuitBreaker; +using Polly.Registry; + +var failed = 0; +failed += Run("claim 3 case new PolicyRegistry()", () => new PolicyRegistry(), expectAttempts: -1); +failed += Run("claim 3 control new DefaultPolicy()", () => new DefaultPolicy(), expectAttempts: 4); +failed += Run("claim 1 case [CB, Exception]: the breaker wraps the retry", () => new DefaultPolicy(), expectAttempts: 4); +failed += Run("claim 1 control [Exception, CB]: the retry wraps the breaker", () => new DefaultPolicy(), expectAttempts: 1); +return failed == 0 ? 0 : 1; + +// expectAttempts -1: resolving the command processor throws +static int Run(string name, Func newRegistry, int expectAttempts) where THandler : class, IHandleRequests +{ + var policyRegistry = newRegistry(); + var policy = Policy.Handle().Retry(3); // probe: no waits + policyRegistry.Add("MyExceptionPolicy", policy); + var circuitBreakerPolicy = Policy.Handle().CircuitBreaker( + 1, TimeSpan.FromMilliseconds(500)); + policyRegistry.Add("MyCircuitBreakerPolicy", circuitBreakerPolicy); + + var services = new ServiceCollection(); + services.AddBrighter(o => o.PolicyRegistry = policyRegistry) + .Handlers(h => h.Register()); + IAmACommandProcessor cp; + try { cp = services.BuildServiceProvider().GetRequiredService(); } + catch (Exception e) + { + var resolved = expectAttempts == -1; + Console.WriteLine($"{(resolved ? "ok " : "FAIL")} {name}: {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + return resolved ? 0 : 1; + } + if (expectAttempts == -1) { Console.WriteLine($"FAIL {name}: resolved"); return 1; } + + Attempts.Count = 0; + var outcomes = new string[2]; + for (var i = 0; i < 2; i++) + { + try { cp.Send(new MyCommand()); outcomes[i] = "returned"; } + catch (Exception e) { outcomes[i] = Unwrap(e).GetType().Name; } + } + var ok = Attempts.Count == expectAttempts; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: {Attempts.Count} attempt(s); send 1 {outcomes[0]}, send 2 {outcomes[1]}"); + return ok ? 0 : 1; +} + +static Exception Unwrap(Exception e) +{ + while (e.InnerException != null && e is not BrokenCircuitException && e is not InvalidOperationException) e = e.InnerException; + return e; +} + +public static class Attempts { public static int Count; } + +public class MyCommand() : Command(Id.Random()); + +// verbatim from the page, but for the body, which counts the attempt and fails +public class MyQoSProtectedHandler : RequestHandler +{ + [UsePolicy(new [] {"MyCircuitBreakerPolicy", "MyExceptionPolicy"} , step: 1)] + public override MyCommand Handle(MyCommand command) + { + Attempts.Count++; // probe + throw new InvalidOperationException("a distributed computing failure"); // probe + } +} + +public class ReversedHandler : RequestHandler +{ + [UsePolicy(new [] {"MyExceptionPolicy", "MyCircuitBreakerPolicy"} , step: 1)] + public override MyCommand Handle(MyCommand command) + { + Attempts.Count++; + throw new InvalidOperationException("a distributed computing failure"); + } +} + +#if TWO_ATTRIBUTES +// The page's claim 2: the breaker as an earlier step than the retry +public class TwoAttributeHandler : RequestHandler +{ + [UsePolicy("MyCircuitBreakerPolicy", step: 1)] + [UsePolicy("MyExceptionPolicy", step: 2)] + public override MyCommand Handle(MyCommand command) => base.Handle(command); +} +#endif diff --git a/spec/018-compile_residual/probe/s2/legacy-policy-order/legacy-policy-order.csproj b/spec/018-compile_residual/probe/s2/legacy-policy-order/legacy-policy-order.csproj new file mode 100644 index 0000000..cf680df --- /dev/null +++ b/spec/018-compile_residual/probe/s2/legacy-policy-order/legacy-policy-order.csproj @@ -0,0 +1,9 @@ + + + Exenet9.0enableCS0618 + + + + + diff --git a/spec/018-compile_residual/probe/s2/missing-handler/Contracts/Contracts.csproj b/spec/018-compile_residual/probe/s2/missing-handler/Contracts/Contracts.csproj new file mode 100644 index 0000000..5dd301a --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/Contracts/Contracts.csproj @@ -0,0 +1,4 @@ + + net9.0enable + + diff --git a/spec/018-compile_residual/probe/s2/missing-handler/Contracts/OrderCreated.cs b/spec/018-compile_residual/probe/s2/missing-handler/Contracts/OrderCreated.cs new file mode 100644 index 0000000..116a5c6 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/Contracts/OrderCreated.cs @@ -0,0 +1,5 @@ +using Paramore.Brighter; + +namespace Contracts; + +public class OrderCreated() : Event(Id.Random()); diff --git a/spec/018-compile_residual/probe/s2/missing-handler/Handlers/Handlers.csproj b/spec/018-compile_residual/probe/s2/missing-handler/Handlers/Handlers.csproj new file mode 100644 index 0000000..1d76fad --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/Handlers/Handlers.csproj @@ -0,0 +1,4 @@ + + net9.0enable + + diff --git a/spec/018-compile_residual/probe/s2/missing-handler/Handlers/OrderCreatedHandler.cs b/spec/018-compile_residual/probe/s2/missing-handler/Handlers/OrderCreatedHandler.cs new file mode 100644 index 0000000..1438111 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/Handlers/OrderCreatedHandler.cs @@ -0,0 +1,7 @@ +using Contracts; +using Paramore.Brighter; + +namespace Handlers; + +// The page's handler "in a separate assembly" +public class OrderCreatedHandler : RequestHandlerAsync; diff --git a/spec/018-compile_residual/probe/s2/missing-handler/Program.cs b/spec/018-compile_residual/probe/s2/missing-handler/Program.cs new file mode 100644 index 0000000..3a98b62 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/Program.cs @@ -0,0 +1,93 @@ +// PipelineValidation.md § Missing Handler for Subscription, against 10.8.0. "AutoFromAssemblies() scans +// the assemblies already loaded when it runs, plus any you pass it, so if your handler lives in an +// assembly nothing has loaded yet, pass that assembly explicitly", and ValidatePipelines() reports the +// subscription with no handler. OrderCreated is in Contracts.dll, OrderCreatedHandler in Handlers.dll, +// which nothing touches before AutoFromAssemblies runs. Loading is per process, so each case is a run: +// before case: the page's Before block, plus .ValidatePipelines() - Handlers.dll not loaded, and +// the validators report "No handler registered for 'OrderCreated'" +// after control: the page's After block - Handlers.dll passed, no error +// loaded control: the Before block, run after Handlers.dll is loaded - the scan finds it, no error +// The validators are called directly, as BrighterValidationHostedService.StartAsync calls them, so that +// no hosted service starts a consumer against a broker. +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using Contracts; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessagingGateway.RMQ.Async; +using Paramore.Brighter.Validation; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; + +var mode = args.FirstOrDefault() ?? "before"; +var services = new ServiceCollection(); +services.AddLogging(); +var rmqConnection = new RmqMessagingGatewayConnection // probe +{ + AmpqUri = new AmqpUriSpecification(new Uri("amqp://guest:guest@localhost:5672")), + Exchange = new Exchange("paramore.brighter.exchange") +}; + +if (mode == "loaded") Touch.Handlers(); +if (mode == "after") Touch.After(services, rmqConnection); +else Before(services, rmqConnection); + +var handlersLoaded = AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Handlers"); +var provider = services.BuildServiceProvider(); +var errors = provider.GetServices() + .SelectMany(v => v.Validate().Errors) + .Select(e => e.Message.ReplaceLineEndings(" ")) + .ToList(); +foreach (var e in errors) Console.WriteLine($" error: {e}"); + +var missing = errors.Any(e => e.Contains("No handler registered for 'OrderCreated'")); +var ok = mode == "before" ? missing && !handlersLoaded : !missing; +Console.WriteLine($"{(ok ? "ok " : "FAIL")} {mode}: Handlers.dll loaded when scanned: {handlersLoaded}; missing-handler error: {missing}"); +return ok ? 0 : 1; + +[MethodImpl(MethodImplOptions.NoInlining)] +static void Before(IServiceCollection services, RmqMessagingGatewayConnection rmqConnection) +{ + // verbatim from the page's Before block, plus ValidatePipelines() + services.AddConsumers(options => + { + // A subscription for OrderCreated, but OrderCreatedHandler is in a separate assembly + options.Subscriptions = [ + new RmqSubscription( + subscriptionName: new SubscriptionName("order-created"), + channelName: new ChannelName("order-created-queue"), + routingKey: new RoutingKey("order-created"), + messagePumpType: MessagePumpType.Proactor) + ]; + options.DefaultChannelFactory = new ChannelFactory(new RmqMessageConsumerFactory(rmqConnection)); + }) + .AutoFromAssemblies() // scans only the assemblies already loaded, so the handler is never found + .ValidatePipelines(); // probe +} + +// Methods naming Handlers.dll's types live here, so the JIT does not load it while compiling Main +static class Touch +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public static void Handlers() => _ = typeof(global::Handlers.OrderCreatedHandler).Assembly; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void After(IServiceCollection services, RmqMessagingGatewayConnection rmqConnection) + { + // the page's After block, its elided options written out as the Before block has them + services.AddConsumers(options => + { + options.Subscriptions = [ + new RmqSubscription( + subscriptionName: new SubscriptionName("order-created"), + channelName: new ChannelName("order-created-queue"), + routingKey: new RoutingKey("order-created"), + messagePumpType: MessagePumpType.Proactor) + ]; + options.DefaultChannelFactory = new ChannelFactory(new RmqMessageConsumerFactory(rmqConnection)); + }) + .AutoFromAssemblies([typeof(global::Handlers.OrderCreatedHandler).Assembly]) // explicitly include the handler's assembly + .ValidatePipelines(); // probe + } +} diff --git a/spec/018-compile_residual/probe/s2/missing-handler/missing-handler.csproj b/spec/018-compile_residual/probe/s2/missing-handler/missing-handler.csproj new file mode 100644 index 0000000..6063144 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/missing-handler/missing-handler.csproj @@ -0,0 +1,14 @@ + + + Exenet9.0enable + + + + + + + + + + diff --git a/spec/018-compile_residual/probe/s2/resilience-context/Program.cs b/spec/018-compile_residual/probe/s2/resilience-context/Program.cs new file mode 100644 index 0000000..97fea3b --- /dev/null +++ b/spec/018-compile_residual/probe/s2/resilience-context/Program.cs @@ -0,0 +1,122 @@ +// PolicyRetryAndCircuitBreaker.md #15 and UsingTheContextBag.md #10, against 10.8.0. Each case registers +// the page's MyContextAwarePipeline (retry, 3 attempts; the probe drops the default delay) with an +// OnRetry that records what it reads under "tenant-id", and sends a command whose handler fails once. +// #15 "When you set RequestContext.ResilienceContext before you send, Brighter runs the handler's +// resilience pipeline with that context, so a callback such as OnRetry can read the properties" +// case: the page's send, a ResilienceContext holding "tenant-42" - OnRetry reads tenant-42 +// control: the same send without a RequestContext - OnRetry reads nothing +// #10 "The ResilienceContext can be used to share data across different resilience policies": the +// handler sets "tenant-id" on Context.ResilienceContext when it is not null +// case: the caller attaches an empty ResilienceContext - OnRetry reads the handler's value +// control: the caller attaches none - Context.ResilienceContext is null, so the handler sets +// nothing and OnRetry reads nothing +using System; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Policies.Attributes; +using Polly; +using Polly.Registry; +using Polly.Retry; + +var failed = 0; + +failed += Run("#15 case ResilienceContext attached, holding tenant-42", attach: true, preset: "tenant-42", expect: "tenant-42"); +failed += Run("#15 control no RequestContext", attach: false, preset: null, expect: "(none)"); +failed += Run("#10 case empty ResilienceContext attached", attach: true, preset: null, expect: "from-handler"); +failed += Run("#10 control no RequestContext", attach: false, preset: null, expect: "(none)"); +return failed == 0 ? 0 : 1; + +static int Run(string name, bool attach, string? preset, string expect) where THandler : class, IHandleRequests +{ + var seen = "(none)"; + var resiliencePipelineRegistry = new ResiliencePipelineRegistry().AddBrighterDefault(); + + // verbatim from the page's block #15, but for the delay and the recorded value + var tenantKey = new ResiliencePropertyKey("tenant-id"); + + resiliencePipelineRegistry.TryAddBuilder("MyContextAwarePipeline", + (builder, context) => builder.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.Zero, // probe + OnRetry = args => + { + // Read what the caller put into Polly's resilience context + if (args.Context.Properties.TryGetValue(tenantKey, out var tenantId)) + { + // Use the value for logging, tracing, etc. + seen = tenantId; // probe + } + return default; + } + })); + + var services = new ServiceCollection(); + services.AddBrighter(o => o.ResiliencePipelineRegistry = resiliencePipelineRegistry) + .Handlers(h => h.Register()); + var commandProcessor = services.BuildServiceProvider().GetRequiredService(); + Runs.Count = 0; + + if (attach) + { + // When you send, attach the resilience context to the request context + var resilienceContext = ResilienceContextPool.Shared.Get(); + if (preset != null) resilienceContext.Properties.Set(tenantKey, preset); + + commandProcessor.Send(new MyCommand(), new RequestContext { ResilienceContext = resilienceContext }); + + ResilienceContextPool.Shared.Return(resilienceContext); + } + else + { + commandProcessor.Send(new MyCommand()); + } + + var ok = seen == expect && Runs.Count == 2; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: handler ran {Runs.Count} time(s), OnRetry read {seen}"); + return ok ? 0 : 1; +} + +public static class Runs { public static int Count; } + +public class MyCommand() : Command(Id.Random()) +{ + public string TenantId { get; set; } = "from-handler"; +} + +public class ContextAwareHandler : RequestHandler +{ + [UseResiliencePipeline("MyContextAwarePipeline", step: 1)] + public override MyCommand Handle(MyCommand command) + { + if (++Runs.Count == 1) throw new InvalidOperationException("first attempt fails"); + return base.Handle(command); + } +} + +// UsingTheContextBag.md #10, verbatim but for the attribute and the failure, which the probe adds +public class ResilientHandler : RequestHandler +{ + [UseResiliencePipeline("MyContextAwarePipeline", step: 1)] // probe + public override MyCommand Handle(MyCommand command) + { + // Access Polly resilience context + if (Context.ResilienceContext != null) + { + Context.ResilienceContext.Properties.Set( + new ResiliencePropertyKey("tenant-id"), + command.TenantId + ); + + Context.ResilienceContext.Properties.Set( + new ResiliencePropertyKey("retry-count"), + 0 + ); + } + + if (++Runs.Count == 1) throw new InvalidOperationException("first attempt fails"); // probe + return base.Handle(command); + } +} diff --git a/spec/018-compile_residual/probe/s2/resilience-context/resilience-context.csproj b/spec/018-compile_residual/probe/s2/resilience-context/resilience-context.csproj new file mode 100644 index 0000000..9f19ad6 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/resilience-context/resilience-context.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s2/type-pipeline/Program.cs b/spec/018-compile_residual/probe/s2/type-pipeline/Program.cs new file mode 100644 index 0000000..52c7ef0 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/type-pipeline/Program.cs @@ -0,0 +1,88 @@ +// PolicyRetryAndCircuitBreaker.md § Type-Scoped Pipelines. The page (and the attribute's own doc +// comment) says that with UseTypePipeline = true, Brighter looks the pipeline up under +// ".". ResilienceExceptionPolicyHandler.cs:78 at 10.8.0 calls +// GetPipeline(policy) instead. Each case registers one retry pipeline (one retry, no +// delay) and sends a command whose handler fails once, so the handler running twice proves the +// pipeline was found and applied. +// case A, the page's form: an untyped pipeline under ".SharedCircuitBreaker" +// case B, the source's form: a pipeline typed for the request, under "SharedCircuitBreaker" +// control: no UseTypePipeline, an untyped pipeline under "SharedCircuitBreaker" +// Expected at 10.8.0: A throws before the handler runs; B and the control run it twice. +using System; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Policies.Attributes; +using Polly; +using Polly.Registry; +using Polly.Retry; + +var failed = 0; +// The page's form is expected to fail: the handler never runs, Send throws +failed += Run("case A page's key, untyped pipeline", r => r.TryAddBuilder( + $"{typeof(OrderServiceHandler).FullName}.SharedCircuitBreaker", + (b, _) => b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = TimeSpan.Zero })), + typed: true, expectRuns: 0); +failed += Run("case B plain key, pipeline typed for the request", r => r.TryAddBuilder( + "SharedCircuitBreaker", + (b, _) => b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = TimeSpan.Zero })), + typed: true, expectRuns: 2); +failed += Run("control UseTypePipeline off, untyped pipeline", r => r.TryAddBuilder( + "SharedCircuitBreaker", + (b, _) => b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = TimeSpan.Zero })), + typed: false, expectRuns: 2); +// The troubleshooting section's symptoms: what a missing key throws, typed and untyped +failed += Run("symptom UseTypePipeline off, nothing registered", _ => { }, typed: false, expectRuns: 0); +failed += Run("symptom UseTypePipeline on, nothing registered", _ => { }, typed: true, expectRuns: 0); +return failed == 0 ? 0 : 1; + +static int Run(string name, Action> register, bool typed, int expectRuns) +{ + var registry = new ResiliencePipelineRegistry().AddBrighterDefault(); + register(registry); + var services = new ServiceCollection(); + services.AddBrighter(o => o.ResiliencePipelineRegistry = registry) + .Handlers(h => + { + if (typed) h.Register(); + else h.Register(); + }); + var cp = services.BuildServiceProvider().GetRequiredService(); + Runs.Count = 0; + string outcome; + try { cp.Send(new ProcessOrderCommand()); outcome = $"handler ran {Runs.Count} time(s)"; } + catch (Exception e) + { + outcome = $"{e.GetType().Name}: {e.Message.Split('\n')[0]}"; + for (var inner = e.InnerException; inner != null; inner = inner.InnerException) + outcome += $" <- {inner.GetType().Name}: {inner.Message.Split('\n')[0]}"; + } + var ok = Runs.Count == expectRuns; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {name}: {outcome}"); + return ok ? 0 : 1; +} + +public static class Runs { public static int Count; } + +public class ProcessOrderCommand() : Command(Id.Random()); + +public class OrderServiceHandler : RequestHandler +{ + [UseResiliencePipeline("SharedCircuitBreaker", step: 1, UseTypePipeline = true)] + public override ProcessOrderCommand Handle(ProcessOrderCommand command) + { + if (++Runs.Count == 1) throw new InvalidOperationException("first attempt fails"); + return base.Handle(command); + } +} + +public class PlainHandler : RequestHandler +{ + [UseResiliencePipeline("SharedCircuitBreaker", step: 1)] + public override ProcessOrderCommand Handle(ProcessOrderCommand command) + { + if (++Runs.Count == 1) throw new InvalidOperationException("first attempt fails"); + return base.Handle(command); + } +} diff --git a/spec/018-compile_residual/probe/s2/type-pipeline/type-pipeline.csproj b/spec/018-compile_residual/probe/s2/type-pipeline/type-pipeline.csproj new file mode 100644 index 0000000..f1afe07 --- /dev/null +++ b/spec/018-compile_residual/probe/s2/type-pipeline/type-pipeline.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s5/compress-async/Program.cs b/spec/018-compile_residual/probe/s5/compress-async/Program.cs new file mode 100644 index 0000000..e668ba8 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/compress-async/Program.cs @@ -0,0 +1,39 @@ +// Task 4.5a (D19), Compression.md § Decompress. Claim: at 10.7.0 WrapAsync sets the Content Type +// to "application/gzip; charset=utf-8", Decompress compares the whole Content Type with +// "application/gzip", so the body reaches the mapper still compressed; the sync Wrap round-trips. +// Case: 10.8.0. Control: 10.7.0. Within each run, the sync Wrap is the second control. +// Each of GZip, Zlib and Brotli: wrap (async or sync), then unwrap through the transformer. +using System; +using System.IO.Compression; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Transforms.Transformers; + +var json = "{\"Name\":\"" + new string('x', 4096) + "\"}"; +var failed = false; +foreach (var method in new[] { CompressionMethod.GZip, CompressionMethod.Zlib, CompressionMethod.Brotli }) +foreach (var path in new[] { "async", "sync" }) +{ + var message = new Message( + new MessageHeader(Id.Random(), new RoutingKey("greeting"), MessageType.MT_EVENT, + contentType: new ContentType("application/json") { CharSet = "utf-8" }), + new MessageBody(json)); + + var wrap = new CompressPayloadTransformer(); + wrap.InitializeWrapFromAttributeParams(method, CompressionLevel.Optimal, 0); + var wrapped = path == "async" + ? await wrap.WrapAsync(message, new Publication()) + : wrap.Wrap(message, new Publication()); + var wrappedType = wrapped.Header.ContentType?.ToString(); + + var unwrap = new CompressPayloadTransformer(); + unwrap.InitializeUnwrapFromAttributeParams(method); + var unwrapped = path == "async" ? await unwrap.UnwrapAsync(wrapped) : unwrap.Unwrap(wrapped); + + var roundTrips = unwrapped.Body.Value == json; + failed |= path == "sync" && !roundTrips; // the sync path is expected to round-trip at both versions + Console.WriteLine($"{typeof(Message).Assembly.GetName().Version} {method,-6} {path,-5} wrapped as '{wrappedType}': {(roundTrips ? "decompressed" : "STILL COMPRESSED")}"); +} +return failed ? 1 : 0; diff --git a/spec/018-compile_residual/probe/s5/compress-async/compress-async.csproj b/spec/018-compile_residual/probe/s5/compress-async/compress-async.csproj new file mode 100644 index 0000000..93975b3 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/compress-async/compress-async.csproj @@ -0,0 +1,9 @@ + + + Exenet9.0enable10.8.0 + + + + diff --git a/spec/018-compile_residual/probe/s5/ddl-refresh/Program.cs b/spec/018-compile_residual/probe/s5/ddl-refresh/Program.cs new file mode 100644 index 0000000..51fd5cd --- /dev/null +++ b/spec/018-compile_residual/probe/s5/ddl-refresh/Program.cs @@ -0,0 +1,96 @@ +// Task 4.5b (D20). Claim: the DDL on MSSQLOutbox.md, MySQLOutbox.md, PostgresOutbox.md, +// SpannerOutbox.md, SqliteOutbox.md and SpannerInbox.md is what GetDDL generates. Case: 10.8.0's +// output, created on its database, has a CausationId column. Control: 10.7.0's, which the pages +// show, created the same way, has none. `gen` writes the text the pages are compared against. +using System; +using System.Data.Common; +using System.IO; +using System.Linq; +using Google.Cloud.Spanner.Data; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using MySqlConnector; +using Npgsql; +using Paramore.Brighter.Inbox.Spanner; +using Paramore.Brighter.Outbox.MsSql; +using Paramore.Brighter.Outbox.MySql; +using Paramore.Brighter.Outbox.PostgreSql; +using Paramore.Brighter.Outbox.Spanner; +using Paramore.Brighter.Outbox.Sqlite; + +var version = typeof(SqlOutboxBuilder).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .Cast().First().InformationalVersion.Split('+')[0]; + +if (args[0] == "gen") +{ + Directory.CreateDirectory(args[1]); + File.WriteAllText(Path.Combine(args[1], $"mssql-{version}.sql"), SqlOutboxBuilder.GetDDL("Outbox")); + File.WriteAllText(Path.Combine(args[1], $"mysql-{version}.sql"), MySqlOutboxBuilder.GetDDL("Outbox")); + File.WriteAllText(Path.Combine(args[1], $"postgres-{version}.sql"), PostgreSqlOutboxBuilder.GetDDL("Outbox")); + File.WriteAllText(Path.Combine(args[1], $"sqlite-{version}.sql"), SqliteOutboxBuilder.GetDDL("Outbox")); + File.WriteAllText(Path.Combine(args[1], $"spanner-outbox-{version}.sql"), SpannerOutboxBuilder.GetDDL("Outbox")); + File.WriteAllText(Path.Combine(args[1], $"spanner-inbox-{version}.sql"), SpannerInboxBuilder.GetDDL("Inbox")); + if (typeof(SpannerOutboxBuilder).GetMethod("GetCausationIndexDDL")?.Invoke(null, ["Outbox"]) is string ix) + File.WriteAllText(Path.Combine(args[1], $"spanner-outbox-index-{version}.sql"), ix); + Console.WriteLine($"{version}: written to {args[1]}"); + return 0; +} + +var t = "ddl" + Guid.NewGuid().ToString("N")[..8]; +Environment.SetEnvironmentVariable("SPANNER_EMULATOR_HOST", "localhost:9010"); +const string spanner = "Data Source=projects/brighter-tests/instances/brighter-spanner/databases/brightertests;EmulatorDetection=EmulatorOnly"; +var sqlitePath = Path.Combine(Path.GetTempPath(), t + ".db"); +var failed = 0; + +Run("mssql", () => new SqlConnection("Server=127.0.0.1,11433;Database=BrighterTests;User Id=sa;Password=Password123!;Encrypt=false"), + SqlOutboxBuilder.GetDDL(t), $"SELECT CausationId FROM {t} WHERE 1=0", $"DROP TABLE {t}"); +Run("mysql", () => new MySqlConnection("Server=localhost;Uid=root;Pwd=root;Database=BrighterTests"), + MySqlOutboxBuilder.GetDDL(t), $"SELECT CausationId FROM {t} WHERE 1=0", $"DROP TABLE {t}"); +Run("postgres", () => new NpgsqlConnection("Host=localhost;Username=postgres;Password=password;Database=brightertests"), + PostgreSqlOutboxBuilder.GetDDL(t), $"SELECT causationid FROM \"{t}\" WHERE 1=0", $"DROP TABLE \"{t}\""); +Run("sqlite", () => new SqliteConnection($"Data Source={sqlitePath}"), + SqliteOutboxBuilder.GetDDL(t), $"SELECT CausationId FROM {t} WHERE 1=0", $"DROP TABLE {t}"); +// The index is a separate builder method at 10.8.0; read by reflection so 10.7.0 still builds +var indexDdl = typeof(SpannerOutboxBuilder).GetMethod("GetCausationIndexDDL")?.Invoke(null, [t]) as string; +Console.WriteLine($"{version} spanner-outbox: GetCausationIndexDDL {(indexDdl is null ? "absent" : "present")}"); +RunSpanner("spanner-outbox", SpannerOutboxBuilder.GetDDL(t) + (indexDdl is null ? "" : ";" + indexDdl), t); +RunSpanner("spanner-inbox", SpannerInboxBuilder.GetDDL(t + "i"), t + "i"); +File.Delete(sqlitePath); +return failed == 0 ? 0 : 1; + +void Run(string name, Func open, string ddl, string probe, string drop) +{ + using var c = open(); c.Open(); + try + { + Exec(c, ddl); + Console.WriteLine($"{version} {name}: created; CausationId {(Has(c, probe) ? "present" : "absent")}"); + } + catch (Exception e) { failed++; Console.WriteLine($"FAIL {version} {name}: {e.GetType().Name} {e.Message.Split('\n')[0]}"); } + finally { try { Exec(c, drop); } catch { } } +} + +void RunSpanner(string name, string ddl, string table) +{ + using var c = new SpannerConnection(spanner); c.Open(); + try + { + foreach (var s in ddl.Split(';').Select(s => s.Trim()).Where(s => s.Length > 0)) + c.CreateDdlCommand(s).ExecuteNonQuery(); + Console.WriteLine($"{version} {name}: created; CausationId {(Has(c, $"SELECT CausationId FROM `{table}` WHERE 1=0") ? "present" : "absent")}"); + } + catch (Exception e) { failed++; Console.WriteLine($"FAIL {version} {name}: {e.GetType().Name} {e.Message.Split('\n')[0]}"); } + finally + { + foreach (var idx in new[] { $"idx_{table}_CausationId", $"idx_{table.ToLowerInvariant()}_causationid" }) + try { c.CreateDdlCommand($"DROP INDEX `{idx}`").ExecuteNonQuery(); } catch { } + try { c.CreateDdlCommand($"DROP TABLE `{table}`").ExecuteNonQuery(); } catch { } + } +} + +static void Exec(DbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } +static bool Has(DbConnection c, string sql) +{ + try { using var cmd = c.CreateCommand(); cmd.CommandText = sql; using var r = cmd.ExecuteReader(); r.Read(); return true; } // Spanner reads lazily: an absent column throws only at Read() + catch { return false; } +} diff --git a/spec/018-compile_residual/probe/s5/ddl-refresh/ddl-refresh.csproj b/spec/018-compile_residual/probe/s5/ddl-refresh/ddl-refresh.csproj new file mode 100644 index 0000000..5741306 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/ddl-refresh/ddl-refresh.csproj @@ -0,0 +1,16 @@ + + + Exenet9.0enable10.8.0 + + + + + + + + + diff --git a/spec/018-compile_residual/probe/s5/inbox-no-producers/Program.cs b/spec/018-compile_residual/probe/s5/inbox-no-producers/Program.cs new file mode 100644 index 0000000..7df27c0 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/inbox-no-producers/Program.cs @@ -0,0 +1,95 @@ +// Task 4.5a (D19), #4335. Claim (BrighterInboxSupport.md): at 10.7.0 the InboxConfiguration set in +// AddConsumers reaches handlers' pipelines only when the application also calls AddProducers; in a +// consumer-only application a duplicate is handled again, with no error. Workaround: [UseInbox] on +// the handler, which works whether or not producers are registered. +// Cases, by argument: +// consumer - AddConsumers with a global InboxConfiguration, no AddProducers; the same command twice +// producers - the same, plus AddProducers (the page: "gets the global Inbox as configured") +// attribute - consumer-only, no global configuration; the handler carries [UseInbox] (the workaround) +// Each prints the handler count and whether the duplicate threw. The Inbox did its job when the count +// is 1; the defect shows as a count of 2. Exit 0 when the count is 1, else 1. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Inbox; +using Paramore.Brighter.Inbox.Attributes; +using Paramore.Brighter.Observability; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; + +var scenario = args.Length > 0 ? args[0] : "consumer"; +var inbox = new InMemoryInbox(TimeProvider.System); +var services = new ServiceCollection(); +services.AddSingleton(inbox); +services.AddSingleton(inbox); + +var builder = services.AddConsumers(options => +{ + if (scenario != "attribute") + options.InboxConfiguration = new InboxConfiguration(inbox, actionOnExists: OnceOnlyAction.Throw); +}); +if (scenario == "producers") +{ + var bus = new InternalBus(); + builder.AddProducers(options => + { + options.ProducerRegistry = new InMemoryProducerRegistryFactory(bus, + new[] { new Publication { Topic = new RoutingKey("probe.topic"), RequestType = typeof(GlobalCommand) } }, + InstrumentationOptions.None).Create(); + }); +} +builder.Handlers(registry => +{ + registry.Register(); + registry.Register(); +}); + +using var provider = services.BuildServiceProvider(); +var processor = provider.GetRequiredService(); + +string thrown = "none"; +int count; +if (scenario == "attribute") +{ + var command = new AttributedCommand(); + processor.Send(command); + try { processor.Send(command); } catch (Exception e) { thrown = e.GetType().Name; } + count = AttributedCommandHandler.Handled; +} +else +{ + var command = new GlobalCommand(); + processor.Send(command); + try { processor.Send(command); } catch (Exception e) { thrown = e.GetType().Name; } + count = GlobalCommandHandler.Handled; +} + +var version = typeof(CommandProcessor).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) is [System.Reflection.AssemblyInformationalVersionAttribute v, ..] ? v.InformationalVersion.Split('+')[0] : "?"; +Console.WriteLine($"{(count == 1 ? "ok " : "FAIL")} {version} {scenario}: handled = {count}, duplicate threw {thrown}"); +return count == 1 ? 0 : 1; + +public class GlobalCommand() : Command(Id.Random()); +public class AttributedCommand() : Command(Id.Random()); + +public class GlobalCommandHandler : RequestHandler +{ + public static int Handled; + public override GlobalCommand Handle(GlobalCommand command) + { + Handled++; + return base.Handle(command); + } +} + +public class AttributedCommandHandler : RequestHandler +{ + public static int Handled; + [UseInbox(step: 0, contextKey: typeof(AttributedCommandHandler), onceOnly: true, onceOnlyAction: OnceOnlyAction.Throw)] + public override AttributedCommand Handle(AttributedCommand command) + { + Handled++; + return base.Handle(command); + } +} diff --git a/spec/018-compile_residual/probe/s5/inbox-no-producers/inbox-no-producers.csproj b/spec/018-compile_residual/probe/s5/inbox-no-producers/inbox-no-producers.csproj new file mode 100644 index 0000000..71074d9 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/inbox-no-producers/inbox-no-producers.csproj @@ -0,0 +1,12 @@ + + + Exenet9.0enable10.8.0 + + + + + + diff --git a/spec/018-compile_residual/probe/s5/messaging-system/Program.cs b/spec/018-compile_residual/probe/s5/messaging-system/Program.cs new file mode 100644 index 0000000..b08dd17 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/messaging-system/Program.cs @@ -0,0 +1,95 @@ +// Task 4.5a (D19), Telemetry.md § the message pump's span attributes. +// Claim: messaging.system is "Always internal_bus on pump spans at 10.7.0, whichever transport the +// pump reads". At 10.8.0, Reactor and Proactor read it from the channel (IHaveAMessagingSystem). +// Case: "postgres", a Reactor over the Postgres transport, at 10.8.0. Controls: the same at 10.7.0 +// (the claim), and "inmemory" at each version, where internal_bus is the right answer. +// The tracer is registered as AddBrighterInstrumentation() registers it, and an ActivityListener +// stands in for the OpenTelemetry SDK. Prints the distinct messaging.system values on Consumer spans. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessagingGateway.Postgres; +using Paramore.Brighter.Observability; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; +using Paramore.Brighter.ServiceActivator.Extensions.Hosting; + +var mode = args.Length > 0 ? args[0] : "postgres"; +var version = FileVersionInfo.GetVersionInfo(typeof(CommandProcessor).Assembly.Location).ProductVersion?.Split('+')[0]; +var systems = new List(); +using var listener = new ActivityListener +{ + ShouldListenTo = s => s.Name.Equals("paramore.brighter", StringComparison.OrdinalIgnoreCase), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = a => + { + var system = a.GetTagItem("messaging.system")?.ToString(); + if (a.Kind == ActivityKind.Consumer && system is not null) lock (systems) systems.Add($"{a.DisplayName}={system}"); + } +}; +ActivitySource.AddActivityListener(listener); + +var topic = new RoutingKey($"probe.greeting.{Guid.NewGuid():N}"); +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddSingleton(new BrighterTracer(TimeProvider.System)); + +IAmAProducerRegistry producers; +IAmAChannelFactory channelFactory; +Subscription subscription; +if (mode == "postgres") +{ + var connection = new PostgresMessagingGatewayConnection(new RelationalDatabaseConfiguration( + "Host=localhost;Username=postgres;Password=password;Database=brightertests;")); + channelFactory = new PostgresChannelFactory(connection); + subscription = new PostgresSubscription(new SubscriptionName("probe.greeting"), + new ChannelName(topic.Value), topic, timeOut: TimeSpan.FromMilliseconds(500), + messagePumpType: MessagePumpType.Reactor, makeChannels: OnMissingChannel.Create); + producers = new PostgresProducerRegistryFactory(connection, + [new PostgresPublication { Topic = topic, MakeChannels = OnMissingChannel.Create }]).Create(); +} +else +{ + var bus = new InternalBus(); + channelFactory = new InMemoryChannelFactory(bus, TimeProvider.System); + subscription = new Subscription(new SubscriptionName("probe.greeting"), + new ChannelName(topic.Value), topic, timeOut: TimeSpan.FromMilliseconds(500), + messagePumpType: MessagePumpType.Reactor); + producers = new ProducerRegistry(new Dictionary + { + [topic] = new InMemoryMessageProducer(bus, new Publication { Topic = topic, RequestType = typeof(Greeting) }) + }); +} + +builder.Services.AddConsumers(options => + { + options.Subscriptions = [subscription]; + options.DefaultChannelFactory = channelFactory; + options.InstrumentationOptions = InstrumentationOptions.All; + }) + .AddProducers(p => p.ProducerRegistry = producers) + .Handlers(r => r.Register()); +builder.Services.AddHostedService(); + +using var host = builder.Build(); +await host.StartAsync(); +host.Services.GetRequiredService().Post(new Greeting()); +var handled = await Task.WhenAny(GreetingHandler.Handled.Task, Task.Delay(TimeSpan.FromSeconds(20))) == GreetingHandler.Handled.Task; +await host.StopAsync(); + +string[] distinct; +lock (systems) distinct = systems.Select(s => s.Split('=').Last()).Distinct().ToArray(); +Console.WriteLine($"{version} {mode}: handled={handled}, messaging.system on pump spans = [{string.Join(", ", distinct)}] ({systems.Count} spans)"); +return handled && distinct.Length > 0 ? 0 : 1; + +public class Greeting() : Event(Id.Random()); +public class GreetingHandler : RequestHandler +{ + public static readonly TaskCompletionSource Handled = new(TaskCreationOptions.RunContinuationsAsynchronously); + public override Greeting Handle(Greeting e) { Handled.TrySetResult(); return base.Handle(e); } +} diff --git a/spec/018-compile_residual/probe/s5/messaging-system/messaging-system.csproj b/spec/018-compile_residual/probe/s5/messaging-system/messaging-system.csproj new file mode 100644 index 0000000..7d6d3b9 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/messaging-system/messaging-system.csproj @@ -0,0 +1,15 @@ + + + Exenet9.0enable10.8.0 + + + + + + + + diff --git a/spec/018-compile_residual/probe/s5/mongo-outbox/Program.cs b/spec/018-compile_residual/probe/s5/mongo-outbox/Program.cs new file mode 100644 index 0000000..538f632 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/mongo-outbox/Program.cs @@ -0,0 +1,240 @@ +// R5: does MongoDBOutbox.md's handler work, and does the rewrite? +// +// Needs a single-node replica set, since MongoDB transactions need one: +// docker run -d --name probe-mongo -p 27017:27017 mongo:7 --replSet rs0 --bind_ip_all +// docker exec probe-mongo mongosh --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})' +// +// Case A, the page as it was: the handler takes IAmATransactionConnectionProvider and casts it to +// MongoDbUnitOfWork. Expected: no such registration, or a null cast. +// Case B, the form R5 first proposed: IAmAMongoDbTransactionProvider passed to DepositPostAsync. +// C is its control. Both fail the same way, which is the finding: that interface is an +// IAmABoxTransactionProvider, and Brighter configures IClientSessionHandle. +// Case B', the page now: the provider used as IAmABoxTransactionProvider. +// Expected: the person carries the greeting, the Outbox holds one message, and ClearOutboxAsync +// puts it on the bus. +// Case C', the control for B': the same handler with a failure planted after the deposit. The page +// catches, rolls back and returns. Expected: neither the greeting nor the message is written. +// Without it, B' cannot tell a shared transaction from two independent writes. +// Also reported: what DI resolves, and the transaction type Brighter takes from MongoDbUnitOfWork. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MongoDb; +using Paramore.Brighter.Observability; +using Paramore.Brighter.Outbox.MongoDb; + +const string connectionString = "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true"; +var databaseName = "probe_" + Guid.NewGuid().ToString("N")[..8]; +var configuration = new MongoDbConfiguration(connectionString, databaseName) +{ + Outbox = new MongoDbCollectionConfiguration { Name = "Outbox", MakeCollection = OnResolvingACollection.CreateIfNotExists } +}; +var db = configuration.Client.GetDatabase(databaseName); +await db.CreateCollectionAsync("People"); +await db.CreateCollectionAsync("Outbox"); + +var services = new ServiceCollection(); +services.AddLogging(); +services.AddSingleton(configuration); +services.AddSingleton(new Probe()); +var bus = new InternalBus(); +services.AddBrighter() + .AddProducers(producers => + { + producers.Outbox = new MongoDbOutbox(configuration); + producers.ConnectionProvider = typeof(MongoDbConnectionProvider); + producers.TransactionProvider = typeof(MongoDbUnitOfWork); + producers.ProducerRegistry = new InMemoryProducerRegistryFactory(bus, + [new Publication { Topic = new RoutingKey("greeting.made"), RequestType = typeof(GreetingMade) }], + InstrumentationOptions.None).Create(); + }) + .AutoFromAssemblies(); +var sp = services.BuildServiceProvider(); + +var boxProvider = sp.GetRequiredService(); +Console.WriteLine($"registered IAmABoxTransactionProvider: {boxProvider.GetType().Name}"); +Console.WriteLine($"transaction type Brighter takes from it: {CommandProcessor.GetTransactionTypeFromTransactionProvider(boxProvider).Name}"); +Console.WriteLine($"IAmAMongoDbTransactionProvider resolves: {sp.GetService()?.GetType().Name ?? "NO"}"); +Console.WriteLine($"IAmATransactionConnectionProvider resolves: {sp.GetService()?.GetType().Name ?? "NO"}"); + +async Task Run(string name, IRequest command, Func send) +{ + var people = db.GetCollection("People"); + await people.DeleteManyAsync(FilterDefinition.Empty); + await people.InsertOneAsync(new Person { Name = "Ian" }); + await db.GetCollection("Outbox").DeleteManyAsync(FilterDefinition.Empty); + var onBusBefore = bus.Stream(new RoutingKey("greeting.made")).Count(); + string outcome; + try + { + using var scope = sp.CreateScope(); + await send(scope.ServiceProvider.GetRequiredService()); + outcome = "returned"; + } + catch (Exception e) + { + var inner = e; + while (inner.InnerException != null) inner = inner.InnerException; + outcome = $"threw {e.GetType().Name} <- {inner.GetType().Name}: {inner.Message.Split('\n')[0]}"; + } + var greetings = (await people.Find(p => p.Name == "Ian").FirstAsync()).Greetings.Count; + var outbox = await db.GetCollection("Outbox").CountDocumentsAsync(FilterDefinition.Empty); + var dispatched = bus.Stream(new RoutingKey("greeting.made")).Count() - onBusBefore; + Console.WriteLine($"{name}: {outcome}; greetings on Ian = {greetings}; Outbox messages = {outbox}; on the bus = {dispatched}"); +} + +await Run("A page as it was (cast IAmATransactionConnectionProvider)", null!, cp => cp.SendAsync(new AddGreetingCast("Ian", "Hello"))); +await Run("B R5's first form, IAmAMongoDbTransactionProvider", null!, cp => cp.SendAsync(new AddGreeting("Ian", "Hello"))); +await Run("C control, B failing after the deposit", null!, cp => cp.SendAsync(new AddGreeting("Ian", "Hello") { FailAfterDeposit = true })); +await Run("B' the page now, the provider as IAmABoxTransactionProvider", null!, cp => cp.SendAsync(new AddGreetingHandle("Ian", "Hello"))); +await Run("C' control, B' failing after the deposit", null!, cp => cp.SendAsync(new AddGreetingHandle("Ian", "Hello") { FailAfterDeposit = true })); + +await configuration.Client.DropDatabaseAsync(databaseName); + +public class Probe; + +public class Person +{ + [BsonId] public ObjectId Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Greetings { get; set; } = new(); +} + +public class AddGreeting(string name, string greeting) : Command(Id.Random()) +{ + public string Name { get; } = name; + public string Greeting { get; } = greeting; + public bool FailAfterDeposit { get; init; } +} +public class AddGreetingCast(string name, string greeting) : Command(Id.Random()) +{ + public string Name { get; } = name; + public string Greeting { get; } = greeting; +} +public class AddGreetingHandle(string name, string greeting) : Command(Id.Random()) +{ + public string Name { get; } = name; + public string Greeting { get; } = greeting; + public bool FailAfterDeposit { get; init; } +} + +public class GreetingMade(string greeting) : Event(Id.Random()) +{ + public string Greeting { get; } = greeting; +} + +// B and C: the form R5 first proposed +public class AddGreetingHandler(IAmAMongoDbTransactionProvider transactionProvider, IAmACommandProcessor postBox, IAmAMongoDbConfiguration configuration) + : RequestHandlerAsync +{ + public override async Task HandleAsync(AddGreeting addGreeting, CancellationToken cancellationToken = default) + { + var posts = new List(); + // Through IAmAMongoDbTransactionProvider the session is an IClientSession; the driver's + // collection methods take the IClientSessionHandle it is + var session = (IClientSessionHandle)await transactionProvider.GetTransactionAsync(cancellationToken); + try + { + var people = transactionProvider.Client.GetDatabase(configuration.DatabaseName).GetCollection("People"); + await people.UpdateOneAsync(session, + Builders.Filter.Eq(p => p.Name, addGreeting.Name), + Builders.Update.Push(p => p.Greetings, addGreeting.Greeting), + cancellationToken: cancellationToken); + + posts.Add(await postBox.DepositPostAsync(new GreetingMade(addGreeting.Greeting), transactionProvider, cancellationToken: cancellationToken)); + + if (addGreeting.FailAfterDeposit) throw new InvalidOperationException("planted failure after the deposit"); + + await transactionProvider.CommitAsync(cancellationToken); + } + catch + { + await transactionProvider.RollbackAsync(cancellationToken); + throw; + } + finally + { + transactionProvider.Close(); + } + return await base.HandleAsync(addGreeting, cancellationToken); + } +} + +// B' and C': MongoDBOutbox.md's handler as the page now shows it, with the planted failure for C' +public class AddGreetingHandleHandler( + IAmAMongoDbTransactionProvider transactionProvider, + IAmAMongoDbConfiguration configuration, + IAmACommandProcessor postBox, + ILogger logger) : RequestHandlerAsync +{ + public override async Task HandleAsync(AddGreetingHandle addGreeting, CancellationToken cancellationToken = default) + { + var posts = new List(); + + // The MongoDB Outbox writes in an IClientSessionHandle, so use the provider as one + var unitOfWork = (IAmABoxTransactionProvider)transactionProvider; + var session = await unitOfWork.GetTransactionAsync(cancellationToken); + + try + { + var people = transactionProvider.Client + .GetDatabase(configuration.DatabaseName) + .GetCollection("People"); + + // Update the person document within the transaction + await people.UpdateOneAsync(session, + Builders.Filter.Eq(p => p.Name, addGreeting.Name), + Builders.Update.Push(p => p.Greetings, addGreeting.Greeting), + cancellationToken: cancellationToken); + + // Now write the message we want to send to the Outbox in the same transaction + posts.Add(await postBox.DepositPostAsync( + new GreetingMade(addGreeting.Greeting), + unitOfWork, + cancellationToken: cancellationToken)); + + // probe only, C': fail after the deposit + if (addGreeting.FailAfterDeposit) throw new InvalidOperationException("planted failure after the deposit"); + + // Commit both the entity change and the outgoing message + await unitOfWork.CommitAsync(cancellationToken); + } + catch (Exception e) + { + logger.LogError(e, "Exception thrown handling Add Greeting request"); + // It went wrong, rollback the entity change and the downstream message + await unitOfWork.RollbackAsync(cancellationToken); + return await base.HandleAsync(addGreeting, cancellationToken); + } + finally + { + unitOfWork.Close(); + } + + // Send this message via a transport. We need the ids to send just the messages here, not all outstanding ones. + // Alternatively, you can let the Sweeper do this, but at the cost of increased latency + await postBox.ClearOutboxAsync(posts, cancellationToken: cancellationToken); + + return await base.HandleAsync(addGreeting, cancellationToken); + } +} + +// A: the page's form +public class AddGreetingCastHandler(IAmATransactionConnectionProvider connectionProvider) : RequestHandlerAsync +{ + public override async Task HandleAsync(AddGreetingCast command, CancellationToken cancellationToken = default) + { + var unitOfWork = connectionProvider as MongoDbUnitOfWork; + Console.WriteLine($" A: cast gives {(unitOfWork == null ? "null" : "a MongoDbUnitOfWork")}"); + return await base.HandleAsync(command, cancellationToken); + } +} diff --git a/spec/018-compile_residual/probe/s5/mongo-outbox/mongo-outbox.csproj b/spec/018-compile_residual/probe/s5/mongo-outbox/mongo-outbox.csproj new file mode 100644 index 0000000..a2faaa3 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/mongo-outbox/mongo-outbox.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s5/monitor-defects/Program.cs b/spec/018-compile_residual/probe/s5/monitor-defects/Program.cs new file mode 100644 index 0000000..61e1c55 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/monitor-defects/Program.cs @@ -0,0 +1,77 @@ +// Task 4.5a (D19), Monitoring.md § Monitoring Limitations. The configuration is the page's block 1, +// verbatim but for the handlers it registers. Four rows per version: +// sync ok [Monitor] on a handler that returns: the control, works at both versions +// sync throws [Monitor] on a handler that throws: the caller should see its own exception; +// #4453 says it sees NotSupportedException (System.Reflection.MethodBase) at 10.7.0 +// async ok [MonitorAsync] on a handler that returns; #4454 says "No message mapper defined" +// async +reg the same, with the sender also registered as IAmAControlBusSenderAsync, which +// MonitorHandlerAsync takes and the page's block does not register ("reg" argument) +// events how many MonitorEvent messages the sender put on the bus +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Transactions; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Monitoring.Attributes; +using Paramore.Brighter.Monitoring.Configuration; +using Paramore.Brighter.Monitoring.Events; +using Paramore.Brighter.Observability; + +var monitoringTopic = new RoutingKey("brighter.monitoring"); +var bus = new InternalBus(); // probe: kept, to count the events +var producerRegistry = new InMemoryProducerRegistryFactory( + bus, + [new Publication { Topic = monitoringTopic, RequestType = typeof(MonitorEvent) }], + InstrumentationOptions.None) + .Create(); + +var controlBusSender = new ControlBusSenderFactory().Create( + new InMemoryOutbox(TimeProvider.System), producerRegistry, new BrighterTracer()); + +var services = new ServiceCollection(); +services.AddSingleton(controlBusSender); +if (args.Length > 0 && args[0] == "reg") // probe + services.AddSingleton((IAmAControlBusSenderAsync)controlBusSender); +services.AddSingleton(new MonitorConfiguration +{ + IsMonitoringEnabled = true, + InstanceName = "OrdersService" +}); +services.AddBrighter() + .AutoFromAssemblies(); + +var cp = services.BuildServiceProvider().GetRequiredService(); +var version = typeof(Message).Assembly.GetName().Version; + +string Run(Func act) { try { act().GetAwaiter().GetResult(); return "returns"; } catch (Exception e) { var m = ""; for (var x = e; x != null; x = x.InnerException) m += $"{(m == "" ? "" : " <- ")}{x.GetType().Name}: {x.Message.Split('\n')[0]}"; return m; } } + +Console.WriteLine($"{version} sync ok : {Run(() => { cp.Send(new Greeting()); return Task.CompletedTask; })}"); +Console.WriteLine($"{version} sync throws : {Run(() => { cp.Send(new Failing()); return Task.CompletedTask; })}"); +Console.WriteLine($"{version} async {(args.Length > 0 ? "+" + args[0] : " ")} : {Run(() => cp.SendAsync(new GreetingAsync()))}"); +Console.WriteLine($"{version} events : {System.Linq.Enumerable.Count(bus.Stream(monitoringTopic))}"); +return 0; + +public class Greeting() : Command(Id.Random()); +public class Failing() : Command(Id.Random()); +public class GreetingAsync() : Command(Id.Random()); + +public class GreetingHandler : RequestHandler +{ + [Monitor(step: 1, timing: HandlerTiming.Before, handlerType: typeof(GreetingHandler))] + public override Greeting Handle(Greeting command) => base.Handle(command); +} + +public class FailingHandler : RequestHandler +{ + [Monitor(step: 1, timing: HandlerTiming.Before, handlerType: typeof(FailingHandler))] + public override Failing Handle(Failing command) => throw new InvalidOperationException("the handler's own exception"); +} + +public class GreetingHandlerAsync : RequestHandlerAsync +{ + [MonitorAsync(step: 1, timing: HandlerTiming.Before, handlerType: typeof(GreetingHandlerAsync))] + public override Task HandleAsync(GreetingAsync command, CancellationToken cancellationToken = default) + => base.HandleAsync(command, cancellationToken); +} diff --git a/spec/018-compile_residual/probe/s5/monitor-defects/monitor-defects.csproj b/spec/018-compile_residual/probe/s5/monitor-defects/monitor-defects.csproj new file mode 100644 index 0000000..7e350fd --- /dev/null +++ b/spec/018-compile_residual/probe/s5/monitor-defects/monitor-defects.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable10.8.0 + + + + + diff --git a/spec/018-compile_residual/probe/s5/replay-released/Program.cs b/spec/018-compile_residual/probe/s5/replay-released/Program.cs new file mode 100644 index 0000000..9d9849b --- /dev/null +++ b/spec/018-compile_residual/probe/s5/replay-released/Program.cs @@ -0,0 +1,5 @@ +// Task 4.5a (D19): does the released package declare OnceOnlyAction.Replay? +using System; +using Paramore.Brighter.Inbox; + +Console.WriteLine(OnceOnlyAction.Replay); diff --git a/spec/018-compile_residual/probe/s5/replay-released/replay-released.csproj b/spec/018-compile_residual/probe/s5/replay-released/replay-released.csproj new file mode 100644 index 0000000..f818599 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/replay-released/replay-released.csproj @@ -0,0 +1,9 @@ + + + Exenet9.0enable10.8.0 + + + + diff --git a/spec/018-compile_residual/probe/s5/tracer-no-bus/Program.cs b/spec/018-compile_residual/probe/s5/tracer-no-bus/Program.cs new file mode 100644 index 0000000..6f5879c --- /dev/null +++ b/spec/018-compile_residual/probe/s5/tracer-no-bus/Program.cs @@ -0,0 +1,50 @@ +// Task 4.5a (D19), Telemetry.md § The Command Processor also needs an external bus. +// Claim: at 10.7.0, with the tracer registered and no AddProducers, Send records no span. +// The tracer is registered as AddBrighterInstrumentation() registers it (a BrighterTracer +// singleton as IAmABrighterTracer), and an ActivityListener stands in for the OpenTelemetry SDK. +// Case: 10.8.0, "nobus". Controls: 10.7.0 "nobus" (the claim), and "bus" at each version, an +// in-memory producer registered with AddProducers, where both versions should record a span. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Observability; + +var mode = args.Length > 0 ? args[0] : "nobus"; +var spans = new List(); +using var listener = new ActivityListener +{ + ShouldListenTo = s => s.Name.Equals("paramore.brighter", StringComparison.OrdinalIgnoreCase), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => { lock (spans) spans.Add(a.DisplayName); } +}; +ActivitySource.AddActivityListener(listener); + +var services = new ServiceCollection(); +services.AddSingleton(new BrighterTracer(TimeProvider.System)); +var builder = services.AddBrighter(); +if (mode == "bus") +{ + var bus = new InternalBus(); + builder.AddProducers(p => p.ProducerRegistry = new ProducerRegistry(new Dictionary + { + [new RoutingKey("unused")] = new InMemoryMessageProducer(bus, new Publication { Topic = new RoutingKey("unused") }) + })); +} +builder.Handlers(r => r.Register()); + +var provider = services.BuildServiceProvider(); +provider.GetRequiredService().Send(new Ping()); + +Console.WriteLine($"{(PingHandler.Handled == 1 ? "handled" : "NOT HANDLED")}, {mode}: {spans.Count} span(s) [{string.Join(", ", spans)}]"); +return PingHandler.Handled == 1 ? 0 : 1; + +public class Ping() : Command(Id.Random()); +public class PingHandler : RequestHandler +{ + public static int Handled; + public override Ping Handle(Ping command) { Interlocked.Increment(ref Handled); return base.Handle(command); } +} diff --git a/spec/018-compile_residual/probe/s5/tracer-no-bus/tracer-no-bus.csproj b/spec/018-compile_residual/probe/s5/tracer-no-bus/tracer-no-bus.csproj new file mode 100644 index 0000000..668797c --- /dev/null +++ b/spec/018-compile_residual/probe/s5/tracer-no-bus/tracer-no-bus.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable10.8.0 + + + + + diff --git a/spec/018-compile_residual/probe/s5/tripped-topics/Program.cs b/spec/018-compile_residual/probe/s5/tripped-topics/Program.cs new file mode 100644 index 0000000..9506065 --- /dev/null +++ b/spec/018-compile_residual/probe/s5/tripped-topics/Program.cs @@ -0,0 +1,95 @@ +// Task 4.5a (D19), SweeperCircuitBreaking.md § Sweeper Circuit Breaking Outbox Support. +// Claim: at 10.7.0 the DynamoDB Outbox (V3 package here) accepts TrippedTopics and ignores it, and the +// Spanner Outbox drops the filter, so a tripped topic's messages are still returned as outstanding. +// Each run adds one outstanding message on "orders" and one on "payments", then asks for the +// outstanding messages twice: with "orders" tripped (the case) and with nothing tripped (the control, +// which must return both at every version). Prints the count per topic for each query. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime; +using Google.Cloud.Spanner.Data; +using Paramore.Brighter; +using Paramore.Brighter.DynamoDb; +using Paramore.Brighter.Outbox.DynamoDB; +using Paramore.Brighter.Outbox.Spanner; + +var which = args.Length > 0 ? args[0] : "dynamo"; +var version = System.Diagnostics.FileVersionInfo.GetVersionInfo(typeof(DynamoDbOutbox).Assembly.Location).ProductVersion?.Split('+')[0]; +var context = new RequestContext(); +var stamp = DateTimeOffset.UtcNow.AddHours(-2); +Message Make(string topic) => + new(new MessageHeader(Id.Random(), new RoutingKey(topic), MessageType.MT_EVENT, timeStamp: stamp), + new MessageBody("payload")); +var orders = Make("orders"); +var payments = Make("payments"); +IEnumerable tripped = [new RoutingKey("orders")]; + +Func?, Task>> read; +Func cleanup; + +if (which == "dynamo") +{ + Environment.SetEnvironmentVariable("AWS_ENABLE_ENDPOINT_DISCOVERY", "false"); + var client = new AmazonDynamoDBClient(new BasicAWSCredentials("FakeAccessKey", "FakeSecretKey"), + new AmazonDynamoDBConfig { ServiceURL = "http://localhost:8000" }); + var table = $"probe_tripped_{Guid.NewGuid():N}"; + var request = new DynamoDbTableFactory().GenerateCreateTableRequest( + provisionedThroughput: null, billingMode: BillingMode.PAY_PER_REQUEST); + request.TableName = table; + var builder = new DynamoDbTableBuilder(client); + await builder.Build(request); + await builder.EnsureTablesReady([table], TableStatus.ACTIVE); + var outbox = new DynamoDbOutbox(client, new DynamoDbConfiguration(table, timeout: 10000, scanConcurrency: 1)); + await outbox.AddAsync(orders, context); + await outbox.AddAsync(payments, context); + read = t => outbox.OutstandingMessagesAsync(TimeSpan.FromHours(1), context, 100, 1, t); + cleanup = () => client.DeleteTableAsync(table); +} +else +{ + Environment.SetEnvironmentVariable("SPANNER_EMULATOR_HOST", "localhost:9010"); + var cs = new SpannerConnectionStringBuilder( + "Data Source=projects/brighter-tests/instances/brighter-spanner/databases/brightertests") + { EmulatorDetection = Google.Api.Gax.EmulatorDetection.EmulatorOrProduction }.ConnectionString; + var configuration = new RelationalDatabaseConfiguration(cs, outBoxTableName: $"probe_{Guid.NewGuid():N}"); + var connection = new SpannerConnection(cs); + await connection.OpenAsync(); + // The DDL may carry a CREATE INDEX after the CREATE TABLE (10.8.0); the emulator runs one statement per command + foreach (var ddl in SpannerOutboxBuilder.GetDDL(configuration.OutBoxTableName).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + using var create = connection.CreateDdlCommand(ddl); + await create.ExecuteNonQueryAsync(); + } + var outbox = new SpannerOutbox(configuration); + await outbox.AddAsync([orders, payments], context); + read = t => outbox.OutstandingMessagesAsync(TimeSpan.FromHours(1), context, 100, 1, t); + cleanup = async () => + { + // indexes must go first + using var q = connection.CreateSelectCommand($"SELECT INDEX_NAME FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_NAME = '{configuration.OutBoxTableName}' AND INDEX_TYPE = 'INDEX'"); + var names = new List(); + using (var r = await q.ExecuteReaderAsync()) while (await r.ReadAsync()) names.Add(r.GetString(0)); + foreach (var n in names) { using var di = connection.CreateDdlCommand($"DROP INDEX `{n}`"); await di.ExecuteNonQueryAsync(); } + using var drop = connection.CreateDdlCommand($"DROP TABLE `{configuration.OutBoxTableName}`"); + await drop.ExecuteNonQueryAsync(); + }; +} + +string Count(IEnumerable ms) => + $"orders={ms.Count(m => m.Header.Topic.Value == "orders")} payments={ms.Count(m => m.Header.Topic.Value == "payments")}"; + +try +{ + var withTripped = (await read(tripped)).ToList(); + var none = (await read(null)).ToList(); + var caseOk = withTripped.All(m => m.Header.Topic.Value != "orders") && withTripped.Count(m => m.Header.Topic.Value == "payments") == 1; + var controlOk = none.Count == 2; + Console.WriteLine($"{version} {which} case (orders tripped): {Count(withTripped)} -> {(caseOk ? "filtered" : "NOT filtered")}"); + Console.WriteLine($"{version} {which} control (none tripped): {Count(none)} -> {(controlOk ? "both" : "WRONG")}"); + return controlOk ? 0 : 1; +} +finally { await cleanup(); } diff --git a/spec/018-compile_residual/probe/s5/tripped-topics/tripped-topics.csproj b/spec/018-compile_residual/probe/s5/tripped-topics/tripped-topics.csproj new file mode 100644 index 0000000..ab8648f --- /dev/null +++ b/spec/018-compile_residual/probe/s5/tripped-topics/tripped-topics.csproj @@ -0,0 +1,15 @@ + + + Exenet9.0enable10.8.0 + + + + + + diff --git a/spec/018-compile_residual/tasks.md b/spec/018-compile_residual/tasks.md index 5f23f25..d089a11 100644 --- a/spec/018-compile_residual/tasks.md +++ b/spec/018-compile_residual/tasks.md @@ -1326,26 +1326,465 @@ predicts knowing that a fragment is made whole before it is skipped. ### Phase 4 — S2, Commands, Handlers and Pipelines; Get Started *(6 tasks, one PR)* -- [ ] **Task 4.1:** Predict phase 4's gate movement +- [x] **Task 4.1:** Predict phase 4's gate movement - Input: § *The groups*, S2; recurrence greps for S2's defects - Output: § *Phase 4 as executed*, *Predicted*, as 3.1's -- [ ] **Task 4.2:** Repair S2's reachable blocks +- [x] **Task 4.2:** Repair S2's reachable blocks - Input: S2's table, reachable rows - - Output: as 3.2's, for S2. `ShowMeTheCode.md` is a tutorial, so obligation 17 applies -- [ ] **Task 4.3:** Repair S2's hard blocks - - Input: S2's table, hard rows; 10.7.0 source + - Output: as 3.2's, for S2. `ShowMeTheCode.md` cites the WebAPI sample, so obligation 17 applies + to its blocks (017 design rule 9) +- [x] **Task 4.3:** Repair S2's hard blocks + - Input: S2's table, hard rows; 10.8.0 source, the pinned release - Output: as 3.3's, for S2. Each handler block on an S2 page that 2.5 did not wrap or build, as named in § *Phase 2 as executed*, is repaired or listed -- [ ] **Task 4.4:** Run S2's behavioural claims (AC10) +- [x] **Task 4.4:** Run S2's behavioural claims (AC10) - Input: S2's changed blocks - Output: a *Claim / Case / Control* table, each row naming `probe/s2//Program.cs` -- [ ] **Task 4.5:** Second pass for S2 +- [x] **Task 4.5:** Second pass for S2 - Input: 4.1's recurrences; rulings asked - Output: as 3.6's -- [ ] **Task 4.6:** Close phase 4 +- [x] **Task 4.5a:** Sweep the prose that dates a defect to 10.7.0 (D19) + - Input: `grep -rn '10\.7\.0' contents/`; the five defects D19 names, and `OnceOnlyAction.Replay` + - Output: each defect run at 10.7.0 (the control) and 10.8.0 under `probe///`, a + *Claim / Case / Control* table; every site repaired; one § *Defect ledger* row per defect +- [x] **Task 4.5b:** Refresh the relational DDL (D20) + - Input: R7's seven blocks; each builder's `GetDDL` at 10.7.0 and 10.8.0 + - Output: each block regenerated from 10.8.0 and run against its database, under + `probe/s5//`; the page says Replay needs `CausationId`; one ledger row +- [x] **Task 4.6:** Close phase 4 - Input: § 1 obligations 6, 7, 19 - Output: as 3.7's +### Phase 4 as executed + +**Measured at phase start, `5f2294c`.** `--report` → **993 / 430 / 546 / 17, 0 findings**, exit 0; +`--classify` → 112 parse, 28 built-by-using, 84 defect, 28 same-page, 36 values, 258 page-type, +settled in 3 rounds. 552 reference assemblies on each pin; scaffold rule 52 units. `pagelint` +0 errors / **410** warnings; `symbolcheck` 24 entries, 14 silenced; `linkcheck` 168 / 0; +`versioncheck` 0 of 18. + +#### Predicted (4.1) + +**S2 is identical row for row** to § *The groups*: `groups.py` at `5f2294c` gives 10 pages, **107** +FAILED (15 parse, 2 built-by-using, 11 defect, 1 same-page, 11 values, 67 page-type), 3 BUILT, +7 with nothing BUILT. Two methods: the FAILED column, 107; `awk` over `--report` on the ten pages, +107, with 3 BUILT and 4 SKIPPED (`MigratingToPollyV8.md` #1, #3, #5, `PolicyFallback.md` #2, each a +labelled V9 form). The S2 pages hold **114** C# blocks. + +**Said and measured.** + +| Said | Measured | Where the text now stands | +|---|---|---| +| Task 4.2: *"`ShowMeTheCode.md` is a tutorial, so obligation 17 applies"* | its banner is **How-to**. Obligation 17 still binds, by 017 design rule 9's trigger, the sample a block cites: the page cites `samples/WebAPI` | task 4.2 gives the reason that holds | +| Task 4.3: *"10.7.0 source"* | the pins are 10.8.0 | the task says 10.8.0 | +| S2's table: **11 defect** blocks | the second compile's diagnostics (below): **3** are API defects, **1** is an API the pin does not carry, **4** are fragments, and **3** are misreads | the table stands, being the instrument's; the repairs follow the reading | + +**Reading S2's 11 defect blocks** with `probe/second.py` over the ten pages (497 diagnostic lines), +each against the 10.8.0 tag: + +| Page | # | Second compile | Reading against 10.8.0 | Repair | +|---|---:|---|---|---| +| `AgreementDispatcher.md` | 7 | `CS1061` `RegisterAsync` on `IAmASubscriberRegistry` | **defect.** `.Handlers(…)` takes `Action`; `RegisterAsync(router, handlerTypes)` is on `IAmAnAsyncSubcriberRegistry`, which `.AsyncHandlers(…)` takes (`IBrighterBuilder.cs:54`) | 4.3, `.AsyncHandlers` | +| `UsingTheContextBag.md` | 8 | `CS1061` `Activity.SetAttribute` ×3; `CS1503` `AddEvent(string)` ×2 | **defect.** `IRequestContext.Span` is `System.Diagnostics.Activity?` (`IRequestContext.cs:102`), which has `SetTag` and `AddEvent(ActivityEvent)`; `SetAttribute` is OpenTelemetry's `TelemetrySpan` | 4.3, `SetTag` and `new ActivityEvent(…)` | +| `MigratingToPollyV8.md` | 8 | `CS1061` `int.Seconds()` ×3 | **defect.** No pinned package declares `Seconds(this int)`, and nothing in Brighter 10.8.0 does; it is a test library's helper the page never names | 4.3, `TimeSpan.FromSeconds` | +| `FeatureSwitches.md` | 6 | `CS0305` `ConfigurationManager`, `CS0029` ×2 | **an API the pin does not carry.** The classifier supplied a namespace whose `ConfigurationManager` is generic; the page means .NET Framework's `System.Configuration.ConfigurationManager.AppSettings`, which a .NET 9 project has only through a package the page never names | 4.3: the registry read from `IConfiguration`, or the package named; judged at the repair | +| `AsyncDispatchARequest.md` | 4 | `CS0117` `'object'` has no `HandleAsync` | a fragment: one `return await base.HandleAsync(…)` statement, which the wrapper cannot reach (§ *Phase 2 as executed*) | 4.3, made whole inside its handler | +| `PipelineValidation.md` | 7, 8 | `CS0161` | a fragment: an `async Task` whose body is `// ...` alone. #7 carries rule 8's `attr-mismatch-intended` marker, which a body leaves alone | 4.3, a `return` | +| `PolicyFallback.md` | 5 | `CS0104` `FallbackPolicy`, `CS0161` | `CS0104` is **a misread**: the classifier supplied Darker's attributes beside Brighter's. `CS0161` is a fragment, as above. `FallbackPolicyAttribute(backstop, circuitBreaker, step)` matches 10.8.0 (`FallbackPolicyAttribute.cs:46`) | 4.3, a `return`; 4.2, Brighter's `using` only | +| `MigratingToPollyV8.md` | 11, 12 | `CS0653` `ValidationAttribute`; `CS1061` `Task.TaskName` ×3 | **misreads.** `Task` is the page's domain type, not `System.Threading.Tasks.Task`, and `[Validation]` is no Brighter attribute at 10.8.0 (no `Paramore.Brighter.Validation.*` package declares one), so both are the reader's: *page-type*. Whether the page should say so is read at 4.3 | 4.2, stubs | +| `ShowMeTheCode.md` | 1 | `CS1955` `Ok` | **a misread**: a controller action outside its controller. *page-type* | 4.2, obligation 17 | + +**Recurrence greps (obligation 20), run as written at `5f2294c`:** + +| Defect | Grep | Hits | Off-group | +|---|---|---:|---| +| `RegisterAsync` inside `.Handlers(…)` | `grep -rn -A30 '\.Handlers(registry' contents/ \| grep RegisterAsync` | **1** (`AgreementDispatcher.md` #7) | none. Of the other **8** `\.RegisterAsync<` lines that are not mappers, 3 are already under `.AsyncHandlers(…)` and 5 show the registry alone | +| `Activity.SetAttribute`, `AddEvent(string)` | `grep -rnE 'Span\??\.(SetAttribute\(\|AddEvent\(")' contents/` | **8** lines in 3 blocks | **`CloudEventsSupport.md` #5** (S4). On-group: `UsingTheContextBag.md` #8, #17 | +| `int.Seconds()` | `grep -rnE '[0-9]\.Seconds\(\)' contents/` | **4** lines in 2 blocks | none: `MigratingToPollyV8.md` #1 (SKIPPED, V9 form) and #8 | +| `ConfigurationManager.AppSettings` | `grep -rn 'ConfigurationManager.AppSettings' contents/` | **1** (`FeatureSwitches.md` #6) | none | + +**The off-group term is one page and one block**, `CloudEventsSupport.md` #5, FAILED today +(`CS0246`, `CS1061`). Obligation 21 repairs its `SetAttribute` here; whether it then builds depends +on its other diagnostic, so it is **0–1** BUILT. + +**Predicted:** + +| Gate | At `5f2294c` | Predicted at the close | Mechanism | +|---|---|---|---| +| corpus | 993 | **993–1,000** | splits only, where a fence holds alternatives: `PipelineValidation.md` #3 and #13 are candidates. No S2 parse block reads as JSON or YAML | +| BUILT | 430 | **522–537** | S2's 107, less what stays FAILED (at least `PolicyRetryAndCircuitBreaker.md` #11, *same-page*) and any SKIPPED, plus the repaired defects and splits, plus 0–1 off-group. Phase 3 made 122 of 129 build (95%) by making fragments whole rather than skipping them, and this band assumes the same | +| FAILED | 546 | **438–450** | −107 for S2, −0–1 off-group, plus what stays FAILED and listed | +| SKIPPED | 17 | **17–21** | a V9 form found unlabelled on an S2 page, labelled as such. D3's single-option skip is not expected: a fragment is made whole first | +| `pagelint` | 0 errors / 410 | **0 errors / 331–345** | S2's **79** warnings (12 + 4 + 6 + 2 + 10 + 13 + 5 + 10 + 12 + 5, `--per-page`) fall as blocks gain their `using`s; a declared `// ...` keeps its warning. Off-group: `CloudEventsSupport.md` −0–1 | +| `symbolcheck` | 24 entries, 14 silenced | **unmoved** | none of S2's defects is a Brighter name gone dead; `SetAttribute` and `Seconds` never were Brighter's | +| scaffold rule | 52 units | **52–62 units**, 0 violations, 0 compile errors | a unit per S2 page that needs a stub | +| assemblies | 552 | **552–553** | +1 only if `FeatureSwitches.md` #6 keeps `ConfigurationManager.AppSettings` and its package is pinned | +| `linkcheck` · shape · redirects · `--verify` | 168 / 0 · 161, widest 12 of 20 · 77 · 161 = 161 | **unmoved** | no link or `SUMMARY.md` change | +| `versioncheck` · `optioncheck` | 0 of 18 · 0 / 59 / 529 | **unmoved** | no tutorial pin and no option table touched | +| `pagelint --plant` | 5 / 5 | **unmoved** | rule 8's marker on `PipelineValidation.md` #7 stays, bound to the same block | + +#### The repairs (4.2) + +**S2's reachable rows, 80 blocks** (2 built-by-using, 11 values, 67 page-type), plus +`MigratingToPollyV8.md` #11, #12, which 4.1 read as page-type misreads. **77 build**, and three stay +FAILED: `FeatureSwitches.md` #5 and `PolicyRetryAndCircuitBreaker.md` #17 are *same-page* (§ *Blocks +that stay FAILED*), and `PolicyRetryAndCircuitBreaker.md` #12 waits on a ruling (below). +`ShowMeTheCode.md` #1, also read as a misread, waits on a ruling too. `AgreementDispatcher.md` #7's +defect (4.1) was repaired with its page, since the block was otherwise whole. Nine scaffold units +are new (`AgreementDispatcher`, `AsyncDispatchARequest`, `DispatchingARequest`, `FeatureSwitches`, +`PipelineValidation`, `PolicyFallback`, `ShowMeTheCode`, `UsingTheContextBag`) or extended +(`MigratingToPollyV8`, `PolicyRetryAndCircuitBreaker`), with **0** unit-rule violations. + +**Measured after 4.2**, at `074eb99`: `--report` → **993 / 512 / 464 / 17**; 81 findings, every one a BUILT block not +yet baselined, or `UsingTheContextBag.md` #12, whose scaffold changed (4.6 writes the rows). +`pagelint` 0 errors / **372**. + +**What the reachable rows hid.** As in phase 3, a block that did not compile had not been checked +for anything else. Ten falsehoods sat in *page-type* and *values* rows, ledger 21–30, and one of them +was a behaviour claim the page's own troubleshooting repeated: + +- **`UseTypePipeline` (ledger 24).** The page said Brighter looks the pipeline up under + `"."`, as the attribute's XML documentation also says. + `ResilienceExceptionPolicyHandler.cs:78` at 10.8.0 calls `GetPipeline(policy)`. + `probe/s2/type-pipeline` registers each form and sends a command whose handler fails once: + the page's key throws `ConfigurationException` ← `KeyNotFoundException` before the handler runs; + a pipeline typed for the request, under the plain name, runs it twice; the control (no + `UseTypePipeline`, an untyped pipeline) runs it twice. The same probe measured the two + *Troubleshooting* symptoms the page gave as `InvalidOperationException` (ledger 26) +- **Readonly dependencies never assigned.** `DispatchingARequest.md` #2–#4 and `PolicyFallback.md` + #1, #8 declared `_commandProcessor`, `_cache` and others as fields no constructor set, or used + fields they never declared. Each now takes them by primary constructor (phase 3's rule). + `PolicyFallback.md` #9, a `Fallback` using three such fields, is made whole in its class + +**Rulings asked** (obligation 21: a change of scope goes to the maintainer), and **ruled 2026-10-15, each as recommended**: R1–R4 are design **D13–D16**. R4 is filed as **BrighterCommand/Brighter#4609**. + +| # | Block | Question | +|---:|---|---| +| R1 | `PolicyRetryAndCircuitBreaker.md` #12 | `AddConcurrencyLimiter` is in `Polly.RateLimiting`, which no pin carries and the page never names. Pin `Polly.RateLimiting` 8.8.0 (Polly's version on the pin), as D12 pinned `Quartz.Serialization.Json`, and name the package on the page? | +| R2 | `FeatureSwitches.md` #6 | `ConfigurationManager.AppSettings` is .NET Framework's. Rewrite the registry to read `IConfiguration`, or keep it and name `System.Configuration.ConfigurationManager`? | +| R3 | `ShowMeTheCode.md` #1, #4 | #1 is the sample's `GreetingsController.Post` without its class, so `Ok(…)` cannot resolve; building it means showing the controller (obligation 17). #4 sends `AddGreeting { PersonId }`, which the sample's `AddGreeting(name, greeting)` does not have | +| R4 | upstream | `UseResiliencePipelineAttribute.UseTypePipeline`'s XML documentation describes the handler-type key the runtime does not use. File it, as Brighter#4600 was filed? | + +#### The repairs (4.3) + +**Said and measured.** + +| Said | Measured | Where the text now stands | +|---|---|---| +| *The repairs (4.2)*: `--report` **993 / 510 / 466 / 17** after 4.2 | at `074eb99`, **993 / 512 / 464 / 17**, by two runs: one on the pins as they were, one with D13's `Polly.RateLimiting` added, identical block for block. Off S2 the two agree with the close of 4.3 (427 / 439 / 13), so the 2 are S2's: **85** BUILT, 25 FAILED, 4 SKIPPED at `074eb99` | 4.2's line gives `074eb99`'s figures | +| 4.1, assemblies: +1 *"only if `FeatureSwitches.md` #6 keeps `ConfigurationManager.AppSettings` and its package is pinned"* | **553**, +1, from D13's `Polly.RateLimiting`; D14 rewrote #6 without a package | the band held; the mechanism was D13, not D14 | + +**S2's 25 FAILED blocks at `074eb99`**: **23 are BUILT**, and the two *same-page* rows of § *Blocks +that stay FAILED* (`FeatureSwitches.md` #5, `PolicyRetryAndCircuitBreaker.md` #17) stay. None is +SKIPPED, and none is retagged. One fence was split (`PipelineValidation.md` #3, two alternative +chains, § *Splits*), so the S2 pages hold **115** C# blocks: **109 BUILT**, 2 FAILED, 4 SKIPPED, +`awk` over `--report` on the ten pages, against the summary's 994 / 536 / 441 / 17 less the +unchanged 427 / 439 / 13 off S2. + +- **The rulings.** D13: `Polly.RateLimiting` 8.8.0 in both refs csproj, and the page tells its reader + to add it before the *Rate Limiter Example*. D14: `FeatureSwitches.md` #6 is a + `ConfigurationFeatureSwitchRegistry` taking `IConfiguration`, its configuration `appsettings.json` + under a `FeatureSwitches` section; the old registry also called `.ToLower()` on a missing key, + which throws before `MissingConfigStrategy` is read. D15: `ShowMeTheCode.md` #1 is the sample's + `GreetingsController.Post`, in its controller and with the sample's `FindPersonByName` check; + #4 inserts for the person `AddGreeting.Name` names +- **Made whole, as phase 3 did.** `AsyncDispatchARequest.md` #1 (its class closed, its `using`s) + and #4 (the `return` inside a `GreetingCommandHandlerAsync`); `PipelineValidation.md` #3–#8 and + #11–#15, each chain on the builder it extends, each handler with its `return`, the Reactor and + missing-handler examples with real subscriptions; `PolicyFallback.md` #5; + `AgreementDispatcher.md` #1, the signature written as a call; `ShowMeTheCode.md` #3, the + sample's `QueryHandlerAsync` in its class; `UsingTheContextBag.md` #16, its statement before its + type +- **What the hard rows hid.** Ledger 31–35: a parameter name no overload has; `Activity` given + OpenTelemetry's `TelemetrySpan` API (and `CloudEventsSupport.md` #5, off-group, obligation 21); + `int.Seconds()`; and a query that dropped the sample's `where p.Name = @name`, so it returned + every person's greetings and `Single()` threw once there were two people +- **Off-group, ledger 21.** `new List()` is **0**: `BrighterOutboxSupport.md` and + `MongoDBOutbox.md` collect `Id`s, and `EFCoreOutbox.md`'s handler is the 10.8.0 + `WebAPI_EFCore` sample's, which starts the transaction through the `IAmATransactionConnectionProvider` + and passes it to `DepositPostAsync`. The three pages stay FAILED on stubs (phase 7's units) +- **Scaffold.** Five units changed (`AgreementDispatcher`, `AsyncDispatchARequest`, + `PipelineValidation`, `ShowMeTheCode`, `UsingTheContextBag`); 60 units, **0** violations after + `Salutation.Words`, which no block names, was removed + +**Measured at 4.3's close:** `--report` → **994 / 536 / 441 / 17**, 107 findings, all BUILT blocks not +yet baselined or `UsingTheContextBag.md` #12 (4.6). 553 assemblies per pin. `pagelint` 0 errors / +**347**; `--changed origin/master` 0 errors; `--plant` 5 / 5. `linkcheck` 168 / 0. `symbolcheck` 24 +entries / 14 silenced. + +**For 4.4** (claims a changed block makes): `FeatureSwitches.md` #6 resolves a dotted handler name +under `FeatureSwitches:` and falls back to `MissingConfigStrategy` when the key is absent; +`PipelineValidation.md` § *Missing Handler*, `AutoFromAssemblies()` scans only assemblies already +loaded (`ServiceCollectionBrighterBuilder.cs:120` reads `AppDomain.CurrentDomain.GetAssemblies()`) +and `ValidatePipelines()` then reports the subscription; `EFCoreOutbox.md`, the EF Core provider is +resolvable as `IAmATransactionConnectionProvider` from the `AddProducers` registration. + +**D17, carried out.** `MongoDBOutbox.md` § *Using the Outbox in Handlers* is rewritten and run. +`probe/s5/mongo-outbox` registers the page's configuration over a single-node replica set +(`mongo:7`), and its output, at 10.8.0: + +| Case | Handler | Outcome | Greeting | Outbox | Bus | +|---|---|---|---:|---:|---:| +| A | the page as it was: `IAmATransactionConnectionProvider`, cast to `MongoDbUnitOfWork` | `Send` throws `ConfigurationException` ← *Unable to resolve service for type `IAmATransactionConnectionProvider`* | 0 | 0 | 0 | +| B | R5's first form: `IAmAMongoDbTransactionProvider` passed to `DepositPostAsync` | throws *Supplied transaction provider doesn't match configured transaction type* | 0 | 0 | 0 | +| C | B's control, failing after the deposit | the same throw, before the failure is reached | 0 | 0 | 0 | +| B′ | the page now: the provider as `IAmABoxTransactionProvider` | returns | 1 | 1 | 1 | +| C′ | B′'s control, the page's block with a failure planted after the deposit | catches, rolls back, returns | 0 | 0 | 0 | + +**Said and measured.** R5 recommended passing `IAmAMongoDbTransactionProvider`; case B throws. +Brighter takes the transaction type from the *last* generic `IAmABoxTransactionProvider<>` on the +registered type (`CommandProcessor.cs:286`), which for `MongoDbUnitOfWork` is +`IClientSessionHandle`, the type `MongoDbOutbox` writes in. `IAmAMongoDbTransactionProvider` is +the `IClientSession` form, so it never matches, and its session cannot be handed to the driver's +collection methods either. D17 records the amendment, and D18 files it upstream as +**BrighterCommand/Brighter#4616**. B′ is the page's block verbatim but for the +planted line, and C′ is the control that shows the two writes share one transaction. +`BrighterOutboxSupport.md`'s sentence that every handler takes an `IAmATransactionConnectionProvider` +now says relational, and links the DynamoDB and MongoDB pages. The block stays FAILED on page-type +stubs (`AddGreeting`, `Person`, `GreetingMade`), phase 7's unit. + +**Ruling asked** (obligation 21, a change of scope), and **ruled 2026-10-15**: R5 is design **D17**, carried out at 4.3 rather than phase 7 (*D17, carried out*, above). + +| # | Block | Question | +|---:|---|---| +| R5 | `MongoDBOutbox.md` #5 | The handler casts an `IAmATransactionConnectionProvider` to `MongoDbUnitOfWork`, which does not implement it, so the cast is always null; and it reads `unitOfWork.Database` and `.Session`, which `MongoDbUnitOfWork` does not have at 10.8.0 (ledger 36). No sample covers MongoDB. Rewrite the handler on `IAmAMongoDbTransactionProvider` (`GetTransactionAsync`, `Client.GetDatabase`, `DepositPostAsync(…, provider)`) with a behaviour probe against MongoDB, in phase 7 where the page's units are written? | + +#### Behaviour (4.4, AC10) + +**Every run is against the released 10.8.0 packages**, as `probe/s2//` projects, and each +prints `ok` or `FAIL` per row and exits 1 on any `FAIL`. Each block is copied verbatim, the probe's +own lines marked `// probe`. Two are cited, not rerun: `type-pipeline` (4.2, ledger 24, 26) and +`probe/s5/mongo-outbox` (4.3, D17's table). + +| Claim | Case | Control | Probe | Result | +|---|---|---|---|---| +| `AgreementDispatcher.md` #7, as it was: a route returning several handlers for `ProcessOrder`, a *Command* | `Send` a domestic, low-value order (two handlers, the fewest the route returns) | the same route on an *Event*, published; a route returning one handler, sent | `agreement-dispatcher` | `ArgumentException`, *"More than one handler was found"* / all four run; one runs. **Overturned** (ledger 37) | +| `AgreementDispatcher.md` #7, as repaired: the route on `OrderPlaced`, published | a high-value international order | a domestic, low-value order | `agreement-dispatcher` | all three run / `OrderConfirmationHandler` alone | +| `AgreementDispatcher.md` § *Returning Multiple Handlers*, written at this task: `Publish` runs the handlers concurrently, in no order, and one failing does not stop the others | three runs of the four-handler `Publish` | `FraudCheckHandler` throws | `agreement-dispatcher` | three different orders, `ValidateOrderPlacedHandler` first in one, last in another (`Parallel.ForEach`, `CommandProcessor.cs:499`) / the other two run, `Publish` throws `AggregateException` | +| `AgreementDispatcher.md` #12: a returned handler missing from the list *"will fail at runtime"* | the page's *Bad* registration | the *Good* one; the *Good* list with `Handler3` returned | `agreement-dispatcher` | `ConfigurationException`, *"Handler Factory could not construct handler of type Handler3"* / runs; runs | +| `AgreementDispatcher.md` #14: `AutoFromAssemblies()` beside an agreement throws *"More than one handler"*, and `excludeDynamicHandlerTypes` cures it | the page's *Solution* | the *Problem*, no exclusion; the *Solution* routing to the other excluded handler | `agreement-autoscan` | `MyHandler` runs / `ArgumentException` as quoted; `MyOtherHandler` runs | +| `MigratingToPollyV8.md` § *Legacy*: several policies *"are evaluated left to right, so … 'MyCircuitBreakerPolicy' wraps 'MyExceptionPolicy'"* | the page's `[UsePolicy(new [] {CB, Exception})]`, a handler that always throws, sent twice | the array reversed | `legacy-policy-order` | 4 attempts, then `BrokenCircuitException` / 1 attempt | +| `MigratingToPollyV8.md` § *Legacy*: *"put a Circuit Breaker UsePolicy attribute as an earlier step than the Retry UsePolicy attribute"* | the two attributes on one method, `-p:DefineConstants=TWO_ATTRIBUTES` | the one-attribute array, above | `legacy-policy-order` | `CS0579` *Duplicate 'UsePolicy' attribute* / builds and wraps. **Overturned** (ledger 38) | +| `MigratingToPollyV8.md` § *Legacy*, unstated: `new PolicyRegistry()` is a registry Brighter takes | the page's registry and policies | `new DefaultPolicy()` with the same | `legacy-policy-order` | `ConfigurationException`, *"missing the CommandProcessor.RETRYPOLICY policy"* / resolves. **Overturned** (ledger 39) | +| `PolicyRetryAndCircuitBreaker.md` #15: a `ResilienceContext` set on `RequestContext` reaches `OnRetry` | the page's send, `tenant-42` attached; the handler fails once | the same send with no `RequestContext` | `resilience-context` | `OnRetry` reads `tenant-42` / nothing | +| `UsingTheContextBag.md` #10: the `ResilienceContext` shares data across policies | the caller attaches an empty context; the page's handler sets `tenant-id` | no context attached | `resilience-context` | `OnRetry` reads the handler's value / nothing: `Context.ResilienceContext` is null, so the handler's `if` skips. The page now says so | +| `FeatureSwitches.md` #6: a dotted handler name resolves under `FeatureSwitches:`; a missing key falls back to `MissingConfigStrategy` | `"on"` in `appsettings.json`; no key under `SilentOff`; no key under the default | `"off"`; no key under `SilentOn` | `feature-switch-config` | runs, skipped, `InvalidOperationException` / skipped, runs | +| `FeatureSwitches.md` § opening: no registry makes `Config` behave as `On`; the fluent registry's default throws `ConfigurationException` for a handler it lacks | no registry; a fluent registry without the handler | a fluent registry with it `On` | `feature-switch-config` | runs, `ConfigurationException` / runs | +| `PipelineValidation.md` § *Missing Handler*: `AutoFromAssemblies()` scans only loaded assemblies, and `ValidatePipelines()` reports the subscription | the page's *Before* block, `Handlers.dll` never touched | the *After* block; the *Before* block after `Handlers.dll` is loaded | `missing-handler` (one process per case) | *"No handler registered for 'OrderCreated'"*, not loaded / no error, loaded; no error, loaded | +| `EFCoreOutbox.md`: the EF Core provider resolves as `IAmATransactionConnectionProvider` from `AddProducers`, and its transaction is the context's | resolve it in a scope and begin | — (read, not compared) | `efcore-outbox` | `MySqlEntityFrameworkTransactionProvider`; `CurrentTransaction` set | +| `EFCoreOutbox.md`: `SaveChangesAsync` and the Outbox write share the transaction | the page's handler, against `mysql:8` | a failure after `SaveChangesAsync`, before the commit; the same with the message deposited without the provider | `efcore-outbox` | greeting 1, Outbox 1, bus 1 / 0, 0, 0; 0, **1**, 0. The second control is what tells one transaction from two writes that both failed | + +**Three claims overturned, each repaired at every recurrence (obligation 21):** + +- **Ledger 37.** `AgreementDispatcher.md` #7 is now a route on an *Event*, `OrderPlaced`, whose three + handlers are independent of each other. The page's own § *Returning Multiple Handlers* said `Send` + throws on several handlers, and now also says the list is not a sequence, since a probe found + `Publish` running handlers in parallel. The scaffold unit lost `ProcessOrder.Total`, + `IsInternational` and the four `ProcessOrder` handlers, and gained `OrderPlaced` and its three +- **Ledger 38.** The two-attribute advice is the array, circuit breaker first, which the run above + shows wrapping the retry +- **Ledger 39.** Brighter 10.8.0 and 9.33 both refuse a policy registry without + `CommandProcessor.RETRYPOLICY` and `CIRCUITBREAKER` (`CommandProcessorBuilder.cs:168` at 10.8.0, + `:136` at 9.33; `ServiceCollectionExtensions.cs:762`). `DefaultPolicy` holds both and exists at + both versions. Three sites: `MigratingToPollyV8.md` #1 (the V9 form, wrong at V9 too) and #8, and + off-group `HowConfiguringTheCommandProcessorWorks.md` #7, an S3 page, whose sentence that the keys + *"can be overridden"* now also says Brighter requires them. That block gained its `using`s, since + `--changed` made it strict, and stays FAILED on the page's `Globals` (phase 5) + +**Measured after 4.4:** `--report` → **994 / 536 / 441 / 17**, 107 findings, unmoved: every changed +S2 block stays BUILT, and the off-group block stays FAILED. Scaffold 60 units, 0 violations. +`pagelint` 0 errors / **346** (−1, the off-group block's `using`s); `--changed origin/master` 0 +errors. `linkcheck` 168 / 0; `symbolcheck` 24 entries, 0 hits on the four pages. + +#### Second pass (4.5) + +**Every phase-4 ledger row holds.** Rows 21–39 each give their *After* from their grep, run as +written at `0a134b5`: **0** for every row but 30 and 35, whose greps count text that is now there +(`CausationId`, 1; `where p.Name = @name`, 1). 4.1's four recurrence greps are rows 28, 32, 33 and +34, so they are among them. + +**Widened, because a grep that matches one phrasing finds one instance.** Each phase-4 defect was +searched again by its shape rather than its words: + +| Defect | Widened grep | Hits | Reading | +|---|---|---:|---| +| 37, several handlers for a `Send` | `Register(Async)?<\w+>\(` routes outside `AgreementDispatcher.md`, and `multiple handlers` | 14 routes on 4 pages; 1 claim | every route returns one handler. **`AgreementDispatcherRouting.md`**'s *Characteristics* said the dispatcher *"Supports multiple handlers"* of a `MyCommand` route: repaired, ledger 37 | +| 38, two `[UsePolicy]` on one method | adjacent `[UsePolicy` lines | 0 | | +| 39, `new PolicyRegistry()` | `new PolicyRegistry\b` | 3 | `QueryPipelinePolicies.md` (2), `DarkerBasicConfiguration.md`: Darker's, and each registers both `Constants` names, which the page says `AddPolicies()` requires. Not recurrences | +| 21, 23, 25–27, 31–33, 36 | `List\|_uow\|Predicates.Field\|GetListAsync`; `BrighterContext`; `AddHedging`; `pipeline with key`; `\[Validation\b`; `routingFunc`; `.SetAttribute(\|AddEvent("`; `[0-9].(Seconds\|Minutes\|Milliseconds\|Hours)()`; `MongoDbUnitOfWork` | 0 beyond the repaired sites | `MongoDbUnitOfWork` remains only as `TransactionProvider = typeof(…)`, which is correct (D17) | + +**Found beside them: a fix the prose said was unreleased.** Reading the `More than one handler` +hits, `SchedulingAMessage.md` § *Registering Handlers When You Schedule Requests* (S1) said #4414 +was *"merged and not yet released"*, and told the reader to register handlers explicitly *"until it +is"*. Its fix is in the 10.8.0 tag, which the pins have carried since phase 3. Run at both versions: + +| Claim | Case | Control | Probe | Result | +|---|---|---|---|---| +| `SchedulingAMessage.md`: `AutoFromAssemblies()` and `AsyncHandlersFromAssemblies` register the scheduler's handlers twice, and a scheduled `SendAsync` then ends the process | 10.8.0, each scan, one InMemory-scheduled `SendAsync` | 10.7.0, the same | `probe/s1/scheduler-autoscan` (`-p:BV=`, `-- async`) | handled, exit 0, both scans / *"More than one handler was found … FireSchedulerRequest"*, exit 134, both scans. **The 10.7.0 claim holds; "not yet released" is overturned** (ledger 40) | + +Repaired at its three sites: the section says 10.8.0 fixes it and keeps the explicit registration +for 10.7.0; `InMemoryScheduler.md` and `PostgreSQLMessageBroker.md`, which link to it, say the same. +Prose only, so no block moves. The same grep found **29** lines on **22** pages dating a defect to +10.7.0, five of whose defects have fixes in the 10.8.0 tag. That is a change of scope, put as R6 and +ruled **D19**: task 4.5a. + +**Off-group term:** `AgreementDispatcherRouting.md`, `SchedulingAMessage.md`, `InMemoryScheduler.md`, +`PostgreSQLMessageBroker.md`, all prose. `pagelint` 0 errors / **346**; `--changed origin/master` +0 errors; `linkcheck` 0 on the four pages. + +#### Sweep (4.5a, D19) + +**Measured at `5347f53`:** `grep -rn '10\.7\.0' contents/` → **29** lines on **22** pages. Each line is +either a defect dated to 10.7.0, or a measurement labelled with it. **Two methods agree** on the +defect sites: the grep, and `grep -rl 'issues/' contents/` for each issue the lines cite. + +**Every defect was run at both versions**, each probe a `-p:BV=` project defaulting to 10.8.0, so +10.7.0 is the control that reproduces the page. The pin is the case: + +| Claim (as the page said it at 10.7.0) | Case, 10.8.0 | Control, 10.7.0 | Probe | Result | +|---|---|---|---|---| +| #4335: a consumer-only application gets no global Inbox, so a duplicate is handled again | `AddConsumers` + `InboxConfiguration`, no `AddProducers`, the same command twice | the same; with `AddProducers`; `[UseInbox]` alone | `s5/inbox-no-producers` (`-- consumer\|producers\|attribute`) | handled 1, `OnceOnlyException` / **handled 2, no error**; handled 1; handled 1. **Fixed** (ledger 41) | +| #4437: the InMemory scheduler cannot cancel or reschedule a request the command processor scheduled | cancel and reschedule by the id `SendAsync` returns | the same; an uncancelled schedule; `scheduler.ScheduleAsync` then cancel | `s1/inmemory-cancel` | cancelled; rescheduled to the new time / **runs anyway; `ReSchedulerAsync` false**; runs; cancelled at both. **Fixed** (ledger 42) | +| `OnceOnlyAction.Replay` is *"not in a released package yet"* | a one-line use of `OnceOnlyAction.Replay` | the same | `s5/replay-released` | builds, prints `Replay` / `CS0117`. **Released** (ledger 43) | +| #4432: Decompress misses a body compressed by `WrapAsync` | GZip, Zlib, Brotli, async wrap then unwrap | the same; the sync path | `s5/compress-async` | all decompress / **all three stay compressed**; sync decompresses. **Fixed** (ledger 44) | +| #4510: no external bus, no Command Processor span | a `Send` with the tracer and no producers | the same; with `AddProducers` | `s5/tracer-no-bus` | 1 span / **0 spans**; 1 span. **Fixed** (ledger 45) | +| #4453, #4454: a monitored handler that throws loses its exception; `[MonitorAsync]` has no mapper | `Monitoring.md` #1 verbatim, a throwing handler; an async handler | the same; a handler that returns | `s5/monitor-defects` | the handler's exception; returns / **`NotSupportedException`; *"No message mapper"***; returns. **Fixed** (ledger 46) | +| #4443, #4444: the DynamoDB and Spanner Outboxes ignore `TrippedTopics` | two topics outstanding, one tripped | the same; nothing tripped | `s5/tripped-topics` (`-- dynamo\|spanner`; DynamoDB V3 package run, V4 read) | the tripped topic left out / **both returned**; both returned. **Fixed** (ledger 49) | +| `messaging.system` is always `internal_bus` on pump spans | a Reactor over Postgres | the same; the in-memory bus | `s5/messaging-system` (`-- postgres\|inmemory`) | `postgres` / **`internal_bus`**; `internal_bus` at both. **Fixed** (ledger 50) | + +**Every 10.7.0 claim held at 10.7.0, and every one is false at the pin.** The pages now state the +10.8.0 behaviour and keep each 10.7.0 workaround as such: `BrighterInboxSupport.md` and the nine +inbox pages that link to it; `SchedulingAMessage.md`, `FAQ.md`, `InMemoryScheduler.md`; +`DispatcherConfigurationReference.md`; `Compression.md`; `Telemetry.md`, +`ConfiguringOpenTelemetry.md`; `Monitoring.md`; `SweeperCircuitBreaking.md`. Task 4.5's +`TutorialFirstMessage.md` was not a site: its *4437* is inside a log event id. + +**What the runs hid** (as phase 3's hard rows hid falsehoods behind a compile): +- **Ledger 47.** `Monitoring.md` #1 registered the control-bus sender as `IAmAControlBusSender` + only, and `MonitorHandlerAsync` takes `IAmAControlBusSenderAsync`, so `[MonitorAsync]` fails at + **both** versions with `ConfigurationException` ← *"Unable to resolve service for type + 'Paramore.Brighter.IAmAControlBusSenderAsync'"*. The block registers both, which + `s5/monitor-defects`'s registered row runs; it stays BUILT +- **Ledger 48.** 10.8.0 added `InstrumentationOptions.CircuitBreaker` and put it in `All` + (`InstrumentationOptions.cs:43–44`), so `Telemetry.md`'s *"`All` | Every flag above"* was short a + flag. The table gains its row, read from `BrighterTracer.CreateCircuitBreakerSpan`; not run + +**Measurements, re-derived rather than relabelled:** +- `Telemetry.md`'s `RequestContext` row and § *Adding Custom Span Attributes*: still true at 10.8.0. + `InstrumentationOptions.RequestContext` appears only in `///` comments at both tags, and + `BrighterTracer.cs` reads no request context at either. Both relabelled 10.8.0 +- `ConfiguringOpenTelemetry.md`: the assembly version is `10.0.0.0` at 10.8.0, printed by every + probe above. Relabelled +- `RelationalDatabaseConfigurationReference.md`'s seventeen components: the 10.8.0 tag adds two + packages that name the type, `Extensions.DependencyInjection` (the hosted services that resolve + it) and `MessagingGateway.MsSql` (already a row). Seventeen, relabelled +- `BrighterControlAPI.md`'s sample response carries `"executingAssemblyVersion": "10.7.0+…"`: a + captured response, true as captured. Unchanged +- `PostgresOutbox.md`'s DDL, *"against Brighter V10 (10.7.0)"*: **not true at 10.8.0**, whose + relational builders all add `CausationId` (and the Outboxes an index). Put as R7, below + +**Ruling asked** (obligation 21, a change of scope): + +| # | Block | Question | +|---:|---|---| +| R7 | the Outbox DDL on `MSSQLOutbox.md`, `MySQLOutbox.md`, `PostgresOutbox.md`, `SpannerOutbox.md`, `SqliteOutbox.md`, the Inbox DDL on `SpannerInbox.md`, and `TutorialDurableOutbox.md`'s table (7 blocks) | Each shows its builder's 10.7.0 output. At 10.8.0 every relational builder adds `CausationId`, and the Outbox builders a causation index. A 10.7.0 table still deposits (the stores probe for the column), but Replay needs it. Regenerate each from 10.8.0's `GetDDL`, run against its database, and say on each page that Replay needs the column? | + +**Ruled 2026-10-18, as recommended:** R7 is design **D20**, task 4.5b. + +**Measured after 4.5a:** `--report` → **994 / 536 / 441 / 17**, 107 findings, unmoved: the one block +changed, `Monitoring.md` #1, stays BUILT. `pagelint` 0 errors / **346**; `--changed origin/master` +0 errors. `linkcheck` 168 / 0. `grep -rn '10\.7\.0' contents/` → **15**, every one a 10.7.0 workaround +or history beside the 10.8.0 statement, the captured response, or R7's DDL. + +#### The relational DDL (4.5b, D20) + +**Each block against its builder**, `probe/s5/ddl-refresh` (`gen DIR` writes each `GetDDL` the page +names; `run` creates each table under a unique name on its database, probes the column, drops it): + +| Page | The block against 10.7.0's `GetDDL` | Created at 10.8.0 / 10.7.0 (the control) | +|---|---|---| +| `PostgresOutbox.md` | identical, but for whitespace | `CausationId` present / absent | +| `SpannerOutbox.md` | identical, but for whitespace | present / absent; `GetCausationIndexDDL` exists / does not | +| `SpannerInbox.md` | identical, but for whitespace | present / absent | +| `MSSQLOutbox.md` | **not the builder's**: `UNIQUEIDENTIFIER` ids, `DATETIME2`, a `CreatedID` identity and `PK_Outbox`, none of which any 10.x `SqlOutboxBuilder` writes | present / absent | +| `MySQLOutbox.md` | **not the builder's**: no `IF NOT EXISTS`, `PartitionKey` 255 not 128, no `DataRef` or `SpecVersion` | present / absent | +| `SqliteOutbox.md` | **not the builder's**, and not valid SQLite: a `PRIMARY KEY AUTOINCREMENT` column beside a `PRIMARY KEY` constraint | present / absent | + +**Said and measured.** Three pages said *"Running `…GetDDL("Outbox")` will generate the following"* +over DDL no release generates; the other three were 10.7.0's output. The Spanner probe first +reported the column present at **both** versions: Spanner's reader is lazy, so a missing column +throws at `Read()`, not `ExecuteReader()`. Fixed, it reads; the control then failed as it should. + +**Repaired:** each block is 10.8.0's output, verbatim, and each page says `CausationId` arrived at +10.8.0, that an older table still deposits, and that Replay needs it, linking +`ReplayOnSeenReference.md` § *Store Support*. `SpannerOutbox.md` gains +`GetCausationIndexDDL`'s statement, which `run` creates; the Spanner pages say the provisioner adds +the column only on a fresh install, as `ReplayOnSeenReference.md` already did. `TutorialDurableOutbox.md` +was not a site: its *CREATE TABLE* is a comment, and the tutorial provisions the table. +SQL blocks, so `blockcheck` does not move. `pagelint` 0 errors on the six; `linkcheck` 0. + +#### Phase 4 closed (4.6) + +**Baselined.** `baseline.tsv` lists every BUILT block on the ten S2 pages, **109** rows, replacing +their **3** earlier ones (§ the file's own header says how). None keeps its ref: the three kept their +text, but each one's scaffold unit changed in the phase (`MigratingToPollyV8Context.cs`, +`PolicyRetryAndCircuitBreakerContext.cs`; `UsingTheContextBag.md` #12 gained its page's unit). Every +row is at **`6ad8b0a`**. Off-group, `Monitoring.md` #1 moves there too, re-measured after ledger 47; +#2, unchanged, keeps `f2ce226`. **Two methods agree:** the rows, `grep -v '^#' baseline.tsv | grep +-vc '^$'` → **536**, and `--report`'s BUILT → **536**. `--report` exit **0**, **0 findings**. + +**Rule 8.** `pagelint` → **0 errors**; the one `attr-mismatch-intended` marker stays on +`PipelineValidation.md` #7, and `--plant` → 5 / 5. + +**Readings (obligation 19).** `git diff -U0 5f2294c HEAD -- contents`, hunks against fence ranges: +42 pages, **79** changed C# blocks on 16 of them. +- **AC9.** The phase added `// ...` at **8** lines in **7** blocks: `AgreementDispatcher.md` #11, + #12; `PipelineValidation.md` #12, #15, #16 (two); `PolicyFallback.md` #9; `UsingTheContextBag.md` + #12. All 7 are BUILT, so `--explain` has nothing to report for any of them: no added omission + hides an error +- **AC10.** § *Behaviour (4.4)*: fifteen claims run with a control, two cited. § *Second pass + (4.5)*, *Sweep (4.5a)* and *The relational DDL (4.5b)*: ten more, each at 10.7.0 and 10.8.0. The + six changed C# blocks off S2 are each run or assert no behaviour: `EFCoreOutbox.md` #2 + (`efcore-outbox`), `MongoDBOutbox.md` #5 (`s5/mongo-outbox`), + `HowConfiguringTheCommandProcessorWorks.md` #7 (`legacy-policy-order`), `Monitoring.md` #1 + (`s5/monitor-defects`); `BrighterOutboxSupport.md` #1 and `CloudEventsSupport.md` #5 are API + repairs (ledger 21, 32). Read and named, not run: `Telemetry.md`'s `CircuitBreaker` row (ledger 48) + +**Gates against 4.1.** + +| Gate | Predicted (4.1) | Measured at the close | | +|---|---|---|---| +| corpus | 993–1,000 | **994** | in band: one split, `PipelineValidation.md` #3; #13 needed none | +| BUILT | 522–537 | **536** | in band | +| FAILED | 438–450 | **441** | in band | +| SKIPPED | 17–21 | **17** | the floor: no unlabelled V9 form was found | +| `pagelint` | 0 errors / 331–345 | **0 / 346** | **above the band by 1** (below) | +| `symbolcheck` | unmoved | **24, 14**, exit 0 | as predicted | +| scaffold rule | 52–62 units | **60**, 0 violations, 0 compile errors | in band | +| assemblies | 552–553 | **553** on each pin | in band, by D13 rather than the mechanism named (§ *The repairs (4.3)*) | +| `linkcheck` · shape · redirects · `--verify` | unmoved | 168 / 0 · 161, widest 12 of 20 · 77, 7858 bytes · 161 = 161 | as predicted | +| `versioncheck` · `optioncheck` | unmoved | 0 of 18 · 0 / 59 / 529 | as predicted | +| `pagelint --plant` | unmoved | 5 / 5 | as predicted | + +**Why `pagelint` came in above the band.** 4.1 expected S2's **79** warnings to fall as blocks gained +their `using`s, less the declared omissions. They fell to **19**, read block by block against the diff: +- **7 declared `// ...`**, as predicted: `AgreementDispatcher.md` #10–#12, `UsingTheContextBag.md` + #12, and `MigratingToPollyV8.md` #1, #3, #5, SKIPPED V9 forms +- **11 BUILT blocks no repair touched**, which build on their page's scaffold alone and so never + gained a `using`: `AgreementDispatcher.md` #2–#5, #8, #13; `AsyncDispatchARequest.md` #2, #3; + `DispatchingARequest.md` #5–#7 +- **1 SKIPPED V9 form, untouched**: `PolicyFallback.md` #2 + +The band assumed every S2 block would be edited. Off-group, −4 rather than −0–1, from 4.3's and 4.4's +off-group repairs (`CloudEventsSupport.md`, `EFCoreOutbox.md`, +`HowConfiguringTheCommandProcessorWorks.md`, `MongoDBOutbox.md`). 79 − 19 + 4 = **64**, and 410 − 64 = +**346**. The number is not adopted: the next phase predicts a block's `using`s only for the blocks its +repairs touch. + +**Off the prediction.** 4.5a and 4.5b were added by D19 and D20 after 4.1 and moved no gate: prose and +SQL, and one C# block that was already BUILT. `tools/README.md` rows 2 and 9 now give the close's +figures, and its 552 is 553 (D13). + ### Phase 5 — S3, Darker; Understanding Brighter *(6 tasks, one PR)* - [ ] **Task 5.1:** Predict phase 5's gate movement @@ -1625,6 +2064,8 @@ S5: 22 pages, 134 FAILED, **10 with nothing BUILT**. | `HangfireScheduler.md` | 20 | `CS0246` `HangfireAuthorizationFilter` | *same-page*, as block 12 | 3 | | `InMemoryScheduler.md` | 14 | `CS0103` `_handlerRegistry` | *same-page*: a test method of block 9's `SchedulingTests`, which declares the field; the sentence before it says so | 3 | | `QuartzScheduler.md` | 22 | `CS0246` `BrighterJobListener`, `BrighterTriggerListener` | *same-page*: block 15 declares `BrighterJobListener`; the trigger listener is the reader's, "written the same way", as the sentence before it says | 3 | +| `FeatureSwitches.md` | 5 | `CS0246` `MyFeatureSwitchedHandler`, `MyIncompleteHandlerAsync` | *same-page*: blocks 1 and 2 declare them; the sentence before block 5 says so | 4 | +| `PolicyRetryAndCircuitBreaker.md` | 17 | `CS0246` `MyQoSProtectedHandler`; `CS0103` `services` | *same-page*: block 1 declares the handler, as the sentence before block 17 now says | 4 | ## Splits @@ -1636,6 +2077,7 @@ S5: 22 pages, 134 FAILED, **10 with nothing BUILT**. | `BrighterSchedulerSupport.md` | 3 | 4, 5 | 3 | | `HangfireScheduler.md` | 25 | 25, 26 | 3 | | `QuartzScheduler.md` | 23 | 23, 24 | 3 | +| `PipelineValidation.md` | 3 | 3, 4 | 4 | ## Defect ledger @@ -1664,6 +2106,37 @@ design, and their greps were run there.* | 18 | `HandleAsync(FireAzureScheduler …)` shown as the reader's handler; Brighter's `AzureSchedulerFiredHandler` handles it | `AzureScheduler.md` | `grep -rnF 'Task HandleAsync' contents/` | **1** | **0** | task 3.3 | 3 | | 19 | `SchedulerType` and `FireRequestMessage`, neither in Brighter 10.8.0; they are `RequestSchedulerType` and `FireSchedulerRequest` | `CustomScheduler.md` | `grep -rnE '[^t]SchedulerType type\|FireRequestMessage' contents/` | **4** | **0** | task 3.3, with ledger 6 | 3 | | 20 | `new HangfireMessageSchedulerFactory()` while services are configured; it throws, since its `Client` default reads Hangfire's `JobStorage.Current` before `AddHangfire` has set it (BrighterCommand/Brighter#4600) | `BrighterSchedulerSupport.md`, `HangfireScheduler.md`, `InMemoryOptions.md`, `InMemoryScheduler.md`, `SchedulingAMessage.md`, `SwitchingSchedulers.md` | `grep -rnE 'new HangfireMessageSchedulerFactory(\(\)\|$)' contents/` | **12** | **0** | task 3.5, `probe/s1/hangfire-factory` | 3 | +| 21 | A V9 handler shown as current: `new List()` for the ids `DepositPostAsync` returns (`Id` at V10), a `_uow`, DapperExtensions' `Predicates.Field` and `GetListAsync`, which no pinned package has | `DispatchingARequest.md`; recurs on `BrighterOutboxSupport.md`, `EFCoreOutbox.md`, `MongoDBOutbox.md` | `grep -rnF 'new List()' contents/` | **4** | **0** | task 4.2, hidden behind *page-type* | 4 | +| 22 | `policyRegistry.Add("MyCircuitBreakerPolicy", policy)` registers the retry policy of the block before under the circuit breaker's name | `MigratingToPollyV8.md` | `grep -rnF 'CircuitBreakerPolicy", policy)' contents/` | **1** | **0** | task 4.2, hidden behind *values* | 4 | +| 23 | A Polly property `"BrighterContext"` said to hold the request context; Brighter adds none, and runs the pipeline with `RequestContext.ResilienceContext` | `PolicyRetryAndCircuitBreaker.md` | `grep -rnF '"BrighterContext"' contents/` | **1** | **0** | task 4.2, hidden behind *values* | 4 | +| 24 | `UseTypePipeline = true` said to look up `"."`; Brighter calls `GetPipeline(policy)` | `PolicyRetryAndCircuitBreaker.md` | `grep -rnF 'handlerTypeName}.' contents/` | **2** | **0** | task 4.2, `probe/s2/type-pipeline` | 4 | +| 25 | `AddHedging` on an untyped pipeline builder; hedging needs one typed for its result | `PolicyRetryAndCircuitBreaker.md` | `grep -rnF 'TryAddBuilder("MyHedgingPipeline"' contents/` | **1** | **0** | task 4.2, hidden behind *values* | 4 | +| 26 | A missing pipeline said to throw `InvalidOperationException: Resilience pipeline with key … not found`; `Send` throws `ConfigurationException`, whose inner `KeyNotFoundException` names the key | `PolicyRetryAndCircuitBreaker.md` | `grep -rnF 'InvalidOperationException: Resilience pipeline' contents/` | **2** | **0** | task 4.2, `probe/s2/type-pipeline` | 4 | +| 27 | `[Validation(step:, timing:)]` shown as an attribute beside Brighter's; no Brighter 10.8.0 package declares one | `MigratingToPollyV8.md` | `grep -rnF '[Validation(' contents/` | **2** | **0** | task 4.1, read; removed at 4.2 | 4 | +| 28 | `RegisterAsync` called inside `.Handlers(…)`, whose registry has no `RegisterAsync`; it belongs in `.AsyncHandlers(…)` | `AgreementDispatcher.md` | `grep -rn -A30 '\.Handlers(registry' contents/ \| grep -c RegisterAsync` | **1** | **0** | task 4.1, the second compile | 4 | +| 29 | `StatusOf()`, a handler the page never declares; it declares `MyIncompleteHandlerAsync` | `FeatureSwitches.md` | `grep -rnF 'MyIncompleteHandler>' contents/` | **1** | **0** | task 4.2 | 4 | +| 30 | `RequestContextBagNames` listed without `CausationId`, which 10.8.0 added | `UsingTheContextBag.md` | `grep -c CausationId contents/UsingTheContextBag.md` | **0** | **1** | task 4.2 | 4 | +| 31 | `Register(routingFunc: …)`; the parameter is `router` on every `Register` and `RegisterAsync` overload at 10.8.0 | `AgreementDispatcher.md` | `grep -rn routingFunc contents/` | **2** | **0** | task 4.3, hidden behind *parse* | 4 | +| 32 | `IRequestContext.Span` given OpenTelemetry's `TelemetrySpan` API: `SetAttribute` and `AddEvent(string)`; it is a `System.Diagnostics.Activity`, with `SetTag` and `AddEvent(ActivityEvent)` | `UsingTheContextBag.md`; recurs on `CloudEventsSupport.md` | `grep -rnE 'Span\??\.(SetAttribute\(\|AddEvent\(")' contents/` | **8** | **0** | task 4.1, the second compile | 4 | +| 33 | `int.Seconds()`, a test library's extension no pinned package declares | `MigratingToPollyV8.md` | `grep -rnE '[0-9]\.Seconds\(\)' contents/` | **4** | **0** | task 4.1, the second compile | 4 | +| 34 | .NET Framework's `ConfigurationManager.AppSettings` in a V10 registry, which also called `.ToLower()` on a missing key (D14) | `FeatureSwitches.md` | `grep -rn 'ConfigurationManager.AppSettings' contents/` | **1** | **0** | task 4.1, the second compile | 4 | +| 35 | A query handler without the sample's `where p.Name = @name`, returning every person's greetings, then `Single()`; and an `AddGreeting { PersonId }` the sample does not have (D15) | `ShowMeTheCode.md` | `grep -c 'where p.Name = @name' contents/ShowMeTheCode.md`; `grep -rn 'addGreeting.PersonId' contents/` | **0**; **1** | **1**; **0** | task 4.3, read against `samples/WebAPI/WebAPI_Dapper` at 10.8.0 | 4 | +| 36 | A MongoDB handler taking `IAmATransactionConnectionProvider`, which nothing registers for MongoDB, casting it to `MongoDbUnitOfWork`, then reading `unitOfWork.Database` and `.Session`, which that type does not have at 10.8.0 | `MongoDBOutbox.md`; the claim recurs on `BrighterOutboxSupport.md` | `grep -rnE 'unitOfWork\.(Database\|Session)\|as MongoDbUnitOfWork' contents/` | **3** | **0** | task 4.3, read beside ledger 21; `probe/s5/mongo-outbox` (D17) | 4 | +| 37 | A route returning several handlers for a *Command*, which `Send` refuses; and a handler list written as a sequence ("Always validate", "Always finalize"), which `Publish` runs in parallel | `AgreementDispatcher.md`; the claim recurs on `AgreementDispatcherRouting.md` | `grep -rnF 'Always validate' contents/`; `grep -rnF 'Supports multiple handlers' contents/` | **1**; **1** | **0**; **0** | task 4.4, `probe/s2/agreement-dispatcher`; the recurrence at 4.5 | 4 | +| 38 | Two `[UsePolicy]` attributes on one method advised; `UsePolicyAttribute` has no `AllowMultiple`, so it is `CS0579` | `MigratingToPollyV8.md` | `grep -rn 'earlier step than' contents/` | **1** | **0** | task 4.4, `probe/s2/legacy-policy-order` | 4 | +| 39 | `new PolicyRegistry()` handed to Brighter, which throws `ConfigurationException` without `CommandProcessor.RETRYPOLICY` and `CIRCUITBREAKER`; `DefaultPolicy` holds both | `MigratingToPollyV8.md`; recurs on `HowConfiguringTheCommandProcessorWorks.md` | `grep -rnF 'new PolicyRegistry()' contents/` | **3** | **0** | task 4.4, `probe/s2/legacy-policy-order` | 4 | +| 40 | #4414's fix said to be *"merged and not yet released"*, with a workaround *"until it is"*; it is in 10.8.0, the pinned release | `SchedulingAMessage.md`; the framing recurs on `InMemoryScheduler.md`, `PostgreSQLMessageBroker.md` | `grep -rnF 'merged and not yet released' contents/` | **1** | **0** | task 4.5, `probe/s1/scheduler-autoscan` | 4 | +| 41 | #4335 said unfixed: a consumer-only application gets no global Inbox, *"in no release yet"*; 10.8.0 fixes it | `BrighterInboxSupport.md`; recurs on `DynamoInbox.md`, `FirestoreInbox.md`, `InMemoryInbox.md`, `MongoDBInbox.md`, `MSSQLInbox.md`, `MySQLInbox.md`, `PostgresInbox.md`, `SpannerInbox.md`, `SqliteInbox.md` | `grep -rnE 'In Brighter 10\.7\.0 this configuration\|in no release yet' contents/` | **10** | **0** | task 4.5a, `probe/s5/inbox-no-producers` | 4 | +| 42 | #4437 stated as current: the InMemory scheduler cannot cancel or reschedule a command processor's request; 10.8.0 fixes it | `SchedulingAMessage.md`, `FAQ.md`, `InMemoryScheduler.md` | `grep -rnE 'At Brighter 10\.7\.0 the InMemory scheduler' contents/` | **3** | **0** | task 4.5a, `probe/s1/inmemory-cancel` | 4 | +| 43 | `OnceOnlyAction.Replay` said to be *"not in a released package yet"*; it is in 10.8.0 | `DispatcherConfigurationReference.md` | `grep -rnF 'not in a released package yet' contents/` | **1** | **0** | task 4.5a, `probe/s5/replay-released` | 4 | +| 44 | #4432 stated as current: Decompress misses a body `WrapAsync` compressed; 10.8.0 fixes it | `Compression.md` | `grep -rnF 'At 10.7.0, Decompress does not' contents/` | **1** | **0** | task 4.5a, `probe/s5/compress-async` | 4 | +| 45 | #4510 stated as current: no Command Processor span without an external bus; 10.8.0 fixes it | `Telemetry.md`, `ConfiguringOpenTelemetry.md` | `grep -rnE 'is given the tracer only when\|Command Processor takes that tracer only when' contents/` | **2** | **0** | task 4.5a, `probe/s5/tracer-no-bus` | 4 | +| 46 | #4453 and #4454 stated as current monitoring limits; 10.8.0 fixes both | `Monitoring.md` | `grep -rnF 'Two defects in Brighter 10.7.0' contents/` | **1** | **0** | task 4.5a, `probe/s5/monitor-defects` | 4 | +| 47 | The control-bus sender registered only as `IAmAControlBusSender`; `[MonitorAsync]` resolves `IAmAControlBusSenderAsync`, so it fails at 10.7.0 and 10.8.0 | `Monitoring.md` | `grep -c 'IAmAControlBusSenderAsync)controlBusSender' contents/Monitoring.md` | **0** | **1** | task 4.5a, `probe/s5/monitor-defects` | 4 | +| 48 | `InstrumentationOptions` listed without `CircuitBreaker`, which 10.8.0 added to `All` | `Telemetry.md` | `grep -cE '^\| `CircuitBreaker` \|' contents/Telemetry.md` | **0** | **1** | task 4.5a, read | 4 | +| 49 | #4443 and #4444 stated as current: the DynamoDB and Spanner Outboxes ignore `TrippedTopics`; 10.8.0 fixes both | `SweeperCircuitBreaking.md` | `grep -rnF 'it accepts the list and ignores it' contents/` | **1** | **0** | task 4.5a, `probe/s5/tripped-topics` | 4 | +| 50 | `messaging.system` said to be always `internal_bus` on pump spans; at 10.8.0 it is the transport's | `Telemetry.md` | `grep -rnF 'Always `internal_bus` on pump spans' contents/` | **1** | **0** | task 4.5a, `probe/s5/messaging-system` | 4 | +| 51 | Relational DDL that is not 10.8.0's `GetDDL`: three blocks 10.7.0's output, without `CausationId`, and three no release generates, one of them invalid SQLite | `MSSQLOutbox.md`, `MySQLOutbox.md`, `PostgresOutbox.md`, `SqliteOutbox.md`, `SpannerOutbox.md`, `SpannerInbox.md` | `grep -LiE causationid` on the six pages | **6** | **0** | task 4.5b, `probe/s5/ddl-refresh` | 4 | ## Friction ledger diff --git a/tools/README.md b/tools/README.md index 3cad54b..8875f28 100644 --- a/tools/README.md +++ b/tools/README.md @@ -101,17 +101,22 @@ gained `Quartz.Serialization.Json` (design D12), **552** assemblies each. `pagel on the blocks the repairs gave their `using`s. Row 8's two new entries are design D5 and D10, and `--verify-list` now reads a file with its comments and strings stripped (D11). +**Rows 2 and 9 moved at spec 018 phase 4**, Commands, Handlers and Pipelines and Get Started. Their +repairs brought **106** blocks into the baseline, 430 → **536**, with **60** scaffold units. One fence +was split, so the corpus is **994**. Both pins gained `Polly.RateLimiting` (design D13), **553** +assemblies each. `pagelint` fell 410 → **346**, on the blocks the repairs gave their `using`s. + | # | Gate | Command | Expected at `412fd34` | |---:|---|---|---| | 1 | `linkcheck` | `python3 tools/linkcheck.py` | **168 files, 0 broken** — at bugfix 0002, whose record and probe note are files it walks; it read **166** at `94ad0ed` and **165** at `bd95ee0` | -| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 410 warnings, 162 pages**, with **1 block marked `attr-mismatch-intended`** — at spec 018 phase 3; it read **512** at `94ad0ed` plus spec 018 phase 1, which moved no warning, **524** at `bd95ee0`, **537** at `5981b12`, **616** at `ca6a0b2`, **658** at `2223fcb`, **706** at `5407298`, **743** at `b941837`, **744** at `3be2a78` and **757** at `412fd34` | +| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 346 warnings, 162 pages**, with **1 block marked `attr-mismatch-intended`** — at spec 018 phase 4; it read **410** at spec 018 phase 3, **512** at `94ad0ed` plus spec 018 phase 1, which moved no warning, **524** at `bd95ee0`, **537** at `5981b12`, **616** at `ca6a0b2`, **658** at `2223fcb`, **706** at `5407298`, **743** at `b941837`, **744** at `3be2a78` and **757** at `412fd34` | | 3 | shape | `python3 tools/urlmap.py --check-shape` | **161 pages, 12 sections, widest 12 of 20, deepest 4 of 4** | | 4 | redirects | `python3 tools/urlmap.py --check-redirects` | **77 entries, 7858 bytes** | | 5 | `versioncheck` | `python3 tools/versioncheck.py` | **0 stale pins of 18, across 5 pages** | | 6 | `optioncheck` | `dotnet run --project tools/optioncheck` | **0 mismatches across 59 tables, 529 rows** — at bugfix 0002, pinned to Brighter 10.8.0; it read **519 rows** against 10.7.0 | | 7 | `--verify` | `python3 tools/urlmap.py --verify` | **161 predicted = 161 published** | | 8 | `symbolcheck` | `python3 tools/symbolcheck.py` | **0 findings — 24 entries, 161 pages, 14 silenced** — at spec 018 phase 3, which listed `AddServiceActivator` and `UseExternalBus` with opt-outs on `FAQ.md` and `V10MigrationGuide.md`; it read **22 entries, 3 silenced** at `3be2a78` and **5 entries, 1 silenced** at `412fd34` | -| 9 | `blockcheck` | `python3 tools/blockcheck.py --report` | **993 blocks: 430 BUILT, 546 FAILED, 17 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 17 skipped**, against **552 reference assemblies** on each pin, **28 blocks on the v4 pin**, with **52 scaffold units checked, 0 violations, 0 compile errors** and **4 pages on the v4 pin, 0 violations** — at spec 018 phase 3, against Brighter 10.8.0; it read **992 blocks, 304 BUILT, 671 FAILED** against 551 with 45 units at bugfix 0002, **993 blocks, 303 BUILT, 673 FAILED** against 548 at spec 018 phase 2, merged with #199, whose three `Telemetry.md` blocks are FAILED, and **990 blocks, 299 BUILT, 674 FAILED** against 545 with 45 units at `bd95ee0`, **295 BUILT** at `5981b12`, **983 blocks, 234 BUILT** against 542 with 35 units at `ca6a0b2`, **189 BUILT** against 538 with 30 units at `2223fcb`, **982 blocks, 129 BUILT** with 22 units at `5407298`, **989 blocks, 101 BUILT, 872 FAILED** at `b941837`, unmoved at `23aa74f` with 14 units, and **985 blocks, 92 BUILT, 12 SKIPPED** at `1e1944d` | +| 9 | `blockcheck` | `python3 tools/blockcheck.py --report` | **994 blocks: 536 BUILT, 441 FAILED, 17 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 17 skipped**, against **553 reference assemblies** on each pin, **28 blocks on the v4 pin**, with **60 scaffold units checked, 0 violations, 0 compile errors** and **4 pages on the v4 pin, 0 violations** — at spec 018 phase 4, against Brighter 10.8.0; it read **993 blocks, 430 BUILT, 546 FAILED** against 552 with 52 units at spec 018 phase 3, **992 blocks, 304 BUILT, 671 FAILED** against 551 with 45 units at bugfix 0002, **993 blocks, 303 BUILT, 673 FAILED** against 548 at spec 018 phase 2, merged with #199, whose three `Telemetry.md` blocks are FAILED, and **990 blocks, 299 BUILT, 674 FAILED** against 545 with 45 units at `bd95ee0`, **295 BUILT** at `5981b12`, **983 blocks, 234 BUILT** against 542 with 35 units at `ca6a0b2`, **189 BUILT** against 538 with 30 units at `2223fcb`, **982 blocks, 129 BUILT** with 22 units at `5407298`, **989 blocks, 101 BUILT, 872 FAILED** at `b941837`, unmoved at `23aa74f` with 14 units, and **985 blocks, 92 BUILT, 12 SKIPPED** at `1e1944d` | **Four of the nine are not in the `check` job of `.github/workflows/docs.yml`, and each absence is a decision rather than an oversight:** diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 86f0990..d4c0ab1 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -132,13 +132,11 @@ contents/MigratingToNullableReferenceTypes.md 17 MigratingToNullableReferenceTyp contents/MigratingToNullableReferenceTypes.md 18 MigratingToNullableReferenceTypesContext.cs 280d1b7 contents/MigratingToNullableReferenceTypes.md 19 MigratingToNullableReferenceTypesContext.cs 280d1b7 contents/MigratingToNullableReferenceTypes.md 20 MigratingToNullableReferenceTypesContext.cs 280d1b7 -contents/MigratingToPollyV8.md 6 MigratingToPollyV8Context.cs 280d1b7 contents/NullableReferenceTypes.md 8 - 280d1b7 contents/NullableReferenceTypes.md 16 - 280d1b7 contents/PaginationQueryPatterns.md 1 - 280d1b7 contents/ParameterizedQueryPatterns.md 3 DarkerQueryPatternsContext.cs 1cafef9 contents/ParameterizedQueryPatterns.md 5 DarkerQueryPatternsContext.cs 1cafef9 -contents/PolicyRetryAndCircuitBreaker.md 16 PolicyRetryAndCircuitBreakerContext.cs 280d1b7 contents/PostgreSQLTransportAndOutbox.md 1 RelationalTransportContext.cs 280d1b7 contents/PostgreSQLTransportAndOutbox.md 2 RelationalTransportContext.cs 280d1b7 contents/PostgreSQLTransportAndOutbox.md 3 RelationalTransportContext.cs 280d1b7 @@ -217,7 +215,6 @@ contents/TutorialFirstMessage.md 1 - 280d1b7 contents/TutorialStreamingWithKafka.md 1 TutorialStreamingWithKafkaContext.cs 8090477 contents/TutorialStreamingWithKafka.md 2 TutorialStreamingWithKafkaContext.cs 8090477 contents/UsingSweeperCircuitBreaking.md 1 UsingSweeperCircuitBreakingContext.cs 280d1b7 -contents/UsingTheContextBag.md 12 - 280d1b7 contents/BrighterBasicConfiguration.md 3 - a8ede7c contents/BrighterBasicConfiguration.md 4 - a8ede7c contents/DynamoInbox.md 1 DynamoInboxContext.cs a8ede7c @@ -286,7 +283,7 @@ contents/QueryHandlerDependencies.md 2 DarkerQueryPatternsContext.cs 1cafef9 contents/QueryHandlerDependencies.md 4 DarkerQueryPatternsContext.cs 1cafef9 contents/QueryPipelinePolicies.md 7 - 1cafef9 contents/HowConfiguringTheDispatcherWorks.md 3 HowConfiguringTheDispatcherWorksContext.cs f2ce226 -contents/Monitoring.md 1 - f2ce226 +contents/Monitoring.md 1 - 6ad8b0a contents/Monitoring.md 2 - f2ce226 contents/BrighterControlAPI.md 1 BrighterControlAPIContext.cs 2d938c8 contents/CloudEventsReference.md 1 CloudEventsReferenceContext.cs 2d938c8 @@ -470,3 +467,120 @@ contents/SwitchingSchedulers.md 4 SwitchingSchedulersContext.cs 9aafd19 contents/SwitchingSchedulers.md 5 SwitchingSchedulersContext.cs 9aafd19 contents/SwitchingSchedulers.md 6 SwitchingSchedulersContext.cs 9aafd19 contents/SwitchingSchedulers.md 7 SwitchingSchedulersContext.cs 9aafd19 +# +# Spec 018 phase 4 repaired S2, Commands, Handlers and Pipelines and Get Started. Every BUILT +# block on its ten pages is listed below, replacing their three earlier rows +# (MigratingToPollyV8.md #6, PolicyRetryAndCircuitBreaker.md #16, UsingTheContextBag.md #12): each +# of those three kept its text, but its scaffold unit changed in this phase, so none keeps its ref. +# The phase split one fence (PipelineValidation.md #3), so that page's later ordinals moved. Every +# row is at `6ad8b0a`, the phase's last page commit. Monitoring.md #1 is re-measured there too: it +# gained the IAmAControlBusSenderAsync registration (ledger 47). +contents/AgreementDispatcher.md 1 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 2 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 3 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 4 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 5 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 6 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 7 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 8 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 9 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 10 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 11 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 12 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 13 AgreementDispatcherContext.cs 6ad8b0a +contents/AgreementDispatcher.md 14 AgreementDispatcherContext.cs 6ad8b0a +contents/AsyncDispatchARequest.md 1 AsyncDispatchARequestContext.cs 6ad8b0a +contents/AsyncDispatchARequest.md 2 AsyncDispatchARequestContext.cs 6ad8b0a +contents/AsyncDispatchARequest.md 3 AsyncDispatchARequestContext.cs 6ad8b0a +contents/AsyncDispatchARequest.md 4 AsyncDispatchARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 1 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 2 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 3 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 4 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 5 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 6 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 7 DispatchingARequestContext.cs 6ad8b0a +contents/DispatchingARequest.md 8 DispatchingARequestContext.cs 6ad8b0a +contents/FeatureSwitches.md 1 FeatureSwitchesContext.cs 6ad8b0a +contents/FeatureSwitches.md 2 FeatureSwitchesContext.cs 6ad8b0a +contents/FeatureSwitches.md 3 FeatureSwitchesContext.cs 6ad8b0a +contents/FeatureSwitches.md 4 FeatureSwitchesContext.cs 6ad8b0a +contents/FeatureSwitches.md 6 FeatureSwitchesContext.cs 6ad8b0a +contents/FeatureSwitches.md 7 FeatureSwitchesContext.cs 6ad8b0a +contents/MigratingToPollyV8.md 2 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 4 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 6 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 7 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 8 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 9 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 10 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 11 MigratingToPollyV8Context.cs 6ad8b0a +contents/MigratingToPollyV8.md 12 MigratingToPollyV8Context.cs 6ad8b0a +contents/PipelineValidation.md 1 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 2 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 3 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 4 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 5 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 6 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 7 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 8 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 9 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 10 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 11 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 12 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 13 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 14 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 15 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 16 PipelineValidationContext.cs 6ad8b0a +contents/PipelineValidation.md 17 PipelineValidationContext.cs 6ad8b0a +contents/PolicyFallback.md 1 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 3 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 4 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 5 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 6 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 7 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 8 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 9 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyFallback.md 10 PolicyFallbackContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 1 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 2 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 3 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 4 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 5 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 6 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 7 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 8 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 9 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 10 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 11 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 12 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 13 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 14 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 15 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 16 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 18 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/PolicyRetryAndCircuitBreaker.md 19 PolicyRetryAndCircuitBreakerContext.cs 6ad8b0a +contents/ShowMeTheCode.md 1 ShowMeTheCodeContext.cs 6ad8b0a +contents/ShowMeTheCode.md 2 ShowMeTheCodeContext.cs 6ad8b0a +contents/ShowMeTheCode.md 3 ShowMeTheCodeContext.cs 6ad8b0a +contents/ShowMeTheCode.md 4 ShowMeTheCodeContext.cs 6ad8b0a +contents/ShowMeTheCode.md 5 ShowMeTheCodeContext.cs 6ad8b0a +contents/ShowMeTheCode.md 6 ShowMeTheCodeContext.cs 6ad8b0a +contents/UsingTheContextBag.md 1 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 2 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 3 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 4 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 5 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 6 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 7 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 8 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 9 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 10 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 11 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 12 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 13 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 14 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 15 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 16 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 17 UsingTheContextBagContext.cs 6ad8b0a +contents/UsingTheContextBag.md 18 UsingTheContextBagContext.cs 6ad8b0a diff --git a/tools/blockcheck/refs-v4/refs-v4.csproj b/tools/blockcheck/refs-v4/refs-v4.csproj index a132492..9463327 100644 --- a/tools/blockcheck/refs-v4/refs-v4.csproj +++ b/tools/blockcheck/refs-v4/refs-v4.csproj @@ -225,6 +225,9 @@ + + diff --git a/tools/blockcheck/refs/refs.csproj b/tools/blockcheck/refs/refs.csproj index a73294a..d55114f 100644 --- a/tools/blockcheck/refs/refs.csproj +++ b/tools/blockcheck/refs/refs.csproj @@ -204,6 +204,9 @@ + + diff --git a/tools/blockcheck/scaffold/pages.tsv b/tools/blockcheck/scaffold/pages.tsv index c2f932c..a7f2a26 100644 --- a/tools/blockcheck/scaffold/pages.tsv +++ b/tools/blockcheck/scaffold/pages.tsv @@ -119,3 +119,11 @@ contents/SwitchingSchedulers.md SwitchingSchedulersContext - - contents/InMemoryScheduler.md InMemorySchedulerContext - - contents/HangfireScheduler.md HangfireSchedulerContext - - contents/QuartzScheduler.md QuartzSchedulerContext - - +contents/FeatureSwitches.md FeatureSwitchesContext - - +contents/AsyncDispatchARequest.md AsyncDispatchARequestContext - - +contents/DispatchingARequest.md DispatchingARequestContext - - +contents/PolicyFallback.md PolicyFallbackContext - - +contents/AgreementDispatcher.md AgreementDispatcherContext - - +contents/PipelineValidation.md PipelineValidationContext - - +contents/UsingTheContextBag.md UsingTheContextBagContext - - +contents/ShowMeTheCode.md ShowMeTheCodeContext - - diff --git a/tools/blockcheck/scaffold/units/AgreementDispatcherContext.cs b/tools/blockcheck/scaffold/units/AgreementDispatcherContext.cs new file mode 100644 index 0000000..e09c28f --- /dev/null +++ b/tools/blockcheck/scaffold/units/AgreementDispatcherContext.cs @@ -0,0 +1,102 @@ +// Types and values AgreementDispatcher.md names in its blocks and never declares. +// +// The page registers routes over requests and handlers it names and never shows. A request stub +// carries only the properties a route reads; a handler stub is a handler of its request and +// nothing more, since a block names it only in `typeof(…)`. +// +// blockcheck: using static AgreementDispatcherContext; + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +public static class AgreementDispatcherContext +{ + // blocks 1–5, 8–13: `registry.Register<…>(…)`, `registry.RegisterAsync<…>(…)` + public static ServiceCollectionSubscriberRegistry registry => null!; + // blocks 6, 14: `services.AddConsumers(…)`, `services.AddBrighter(…)` + public static IServiceCollection services => null!; + // block 8, the "Bad" route: the expensive calls it warns against + public static object LoadConfigFromDatabase() => null!; + public static object CallExternalApi(MyCommand? command) => null!; + public static List CalculateComplexRouting(MyCommand? command, object config, object result) => null!; +} + +// blocks 2, 4, 5, 8, 12–14: `request as MyCommand`, `myCommand?.Value`, `cmd?.Priority` +public class MyCommand() : Command(Id.Random()) +{ + public string? Value { get; set; } + public string? Priority { get; set; } +} + +public class SpecialHandler : RequestHandler; +public class StandardHandler : RequestHandler; +public class HighPriorityHandler : RequestHandler; +public class HighPriorityHandlerAsync : RequestHandlerAsync; +public class StandardHandlerAsync : RequestHandlerAsync; +public class Handler1 : RequestHandler; +public class Handler2 : RequestHandler; +public class Handler3 : RequestHandler; +public class MyHandler : RequestHandler; +public class MyOtherHandler : RequestHandler; + +// blocks 3, 9: `order?.Country` +public class ProcessOrder() : Command(Id.Random()) +{ + public string? Country { get; set; } +} + +public class PremiumOrderHandler : RequestHandler; +public class StandardOrderHandler : RequestHandler; +public class USOrderHandler : RequestHandler; +public class UKOrderHandler : RequestHandler; +public class EUOrderHandler : RequestHandler; + +// block 7: `order?.Total`, `order?.IsInternational` +public class OrderPlaced() : Event(Id.Random()) +{ + public decimal Total { get; set; } + public bool IsInternational { get; set; } +} + +public class OrderConfirmationHandler : RequestHandler; +public class FraudCheckHandler : RequestHandler; +public class CustomsDeclarationHandler : RequestHandler; + +// block 6: `order?.Country` +public class OrderCreated() : Event(Id.Random()) +{ + public string? Country { get; set; } +} + +public class USOrderCreatedHandler : RequestHandler; +public class UKOrderCreatedHandler : RequestHandler; +public class InternationalOrderCreatedHandler : RequestHandler; + +// block 7: `taxRequest?.EffectiveDate` +public class CalculateTax() : Command(Id.Random()) +{ + public DateTime? EffectiveDate { get; set; } +} + +public class TaxCalculator2024 : RequestHandler; +public class TaxCalculator2025 : RequestHandler; + +// blocks 7, 10: `payment?.Country` +public class ProcessPayment() : Command(Id.Random()) +{ + public string? Country { get; set; } +} + +public class StripePaymentHandlerAsync : RequestHandlerAsync; +public class PayPalPaymentHandlerAsync : RequestHandlerAsync; +public class LocalPaymentHandlerAsync : RequestHandlerAsync; +public class InternationalPaymentHandlerAsync : RequestHandlerAsync; + +// blocks 7, 11: `Register()`, `Register(…)`, `typeof(ComplexCommandHandler)` +public class SimpleCommand() : Command(Id.Random()); +public class SimpleCommandHandler : RequestHandler; +public class ComplexCommand() : Command(Id.Random()); +public class ComplexCommandHandler : RequestHandler; diff --git a/tools/blockcheck/scaffold/units/AsyncDispatchARequestContext.cs b/tools/blockcheck/scaffold/units/AsyncDispatchARequestContext.cs new file mode 100644 index 0000000..04c39ea --- /dev/null +++ b/tools/blockcheck/scaffold/units/AsyncDispatchARequestContext.cs @@ -0,0 +1,18 @@ +// Types and values AsyncDispatchARequest.md names in its blocks and never declares. +// +// The dispatch snippets send a `GreetingCommand` the page assumes rather than defines, through +// the `commandProcessor` block 1 resolves. Values return a default and do nothing; a block that +// calls a member of one is checked against the real type. +// +// blockcheck: using static AsyncDispatchARequestContext; + +using Paramore.Brighter; + +public static class AsyncDispatchARequestContext +{ + // blocks 2, 3: `commandProcessor.SendAsync(…)`, as block 1 resolves it + public static IAmACommandProcessor commandProcessor => null!; +} + +// blocks 1–4: `new GreetingCommand("Ian")`, `RequestHandlerAsync` +public class GreetingCommand(string name) : Command(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/DispatchingARequestContext.cs b/tools/blockcheck/scaffold/units/DispatchingARequestContext.cs new file mode 100644 index 0000000..bdad67f --- /dev/null +++ b/tools/blockcheck/scaffold/units/DispatchingARequestContext.cs @@ -0,0 +1,94 @@ +// Types and values DispatchingARequest.md names in its blocks and never declares. +// +// The page dispatches requests from a greetings and an orders domain it assumes rather than +// defines. Block 8 is a handler's `HandleAsync` shown alone; the sentence before it names the +// three dependencies its constructor takes. Values return a default and do nothing; a block that +// calls a member of one is checked against the real type. +// +// blockcheck: using static DispatchingARequestContext; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; + +public static class DispatchingARequestContext +{ + // blocks 5–7: `commandProcessor.Send(…)`, as block 1 resolves it + public static IAmACommandProcessor commandProcessor => null!; + // block 8: the handler's constructor arguments, as the sentence before it says + public static IAmATransactionConnectionProvider _transactionProvider => null!; + public static IAmACommandProcessor _postBox => null!; + public static ILogger _logger => null!; +} + +// blocks 1, 5, 7: `new GreetingCommand("Ian")` +public class GreetingCommand(string name) : Command(Id.Random()); + +// block 6: `new GreetingEvent("Ian has been greeted")` +public class GreetingEvent(string greeting) : Event(Id.Random()); + +// block 2: `request.TenantId`, `request.OrderId` +public class CreateOrderRequest +{ + public string OrderId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; +} + +// blocks 2, 4: `new CreateOrderCommand { OrderId = …, TenantId = … }` +public class CreateOrderCommand() : Command(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; +} + +// blocks 3, 4: `new OrderCreatedEvent { OrderId = …, CustomerId = … }` +public class OrderCreatedEvent() : Event(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; +} + +// block 3: `order.TotalValue`, `order.CustomerCategory`, `order.ShippingAddress.Region`, `order.Id`, `order.CustomerId` +public class Order +{ + public string Id { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public decimal TotalValue { get; set; } + public string CustomerCategory { get; set; } = string.Empty; + public ShippingAddress ShippingAddress { get; set; } = new(); +} + +// block 3: `order.ShippingAddress.Region` +public class ShippingAddress +{ + public string Region { get; set; } = string.Empty; +} + +// block 4: `orderRepository.AddAsync(command, cancellationToken)` +public interface IOrderRepository +{ + Task AddAsync(CreateOrderCommand command, CancellationToken cancellationToken); +} + +// block 8: `addGreeting.Name`, `addGreeting.Greeting` +public class AddGreeting() : Command(Id.Random()) +{ + public string Name { get; set; } = string.Empty; + public string Greeting { get; set; } = string.Empty; +} + +// block 8: `conn.QueryAsync(…)` +public class Person; + +// block 8: `new Greeting(addGreeting.Greeting, person)`, `greeting.Message`, `greeting.RecipientId`, `greeting.Greet()` +public class Greeting(string message, Person recipient) +{ + public string Message { get; } = message; + public long RecipientId { get; } + public string Greet() => Message; +} + +// block 8: `new GreetingMade(greeting.Greet())` +public class GreetingMade(string greeting) : Event(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/FeatureSwitchesContext.cs b/tools/blockcheck/scaffold/units/FeatureSwitchesContext.cs new file mode 100644 index 0000000..a04d0f3 --- /dev/null +++ b/tools/blockcheck/scaffold/units/FeatureSwitchesContext.cs @@ -0,0 +1,23 @@ +// Types and values FeatureSwitches.md names in its blocks and never declares. +// +// The handlers switch a `MyCommand` the page assumes rather than defines, and block 7 configures +// a `CommandProcessorBuilder` from a subscriber registry and handler factory it never creates. +// Values return a default and do nothing; a block that calls a member of one is checked against +// the real type. +// +// blockcheck: using static FeatureSwitchesContext; + +using Paramore.Brighter; + +public static class FeatureSwitchesContext +{ + // block 7: `new HandlerConfiguration(_registry, _handlerFactory)` + public static SubscriberRegistry _registry => null!; + public static IAmAHandlerFactorySync _handlerFactory => null!; +} + +// blocks 1–4: `RequestHandler`, `RequestHandlerAsync` +public class MyCommand() : Command(Id.Random()); + +// block 7: `StatusOf()` +public class MyFeatureSwitchedConfigHandler : RequestHandler; diff --git a/tools/blockcheck/scaffold/units/MigratingToPollyV8Context.cs b/tools/blockcheck/scaffold/units/MigratingToPollyV8Context.cs index 4e066d2..2ac65b5 100644 --- a/tools/blockcheck/scaffold/units/MigratingToPollyV8Context.cs +++ b/tools/blockcheck/scaffold/units/MigratingToPollyV8Context.cs @@ -1,4 +1,4 @@ -// Values MigratingToPollyV8.md names in its blocks and never declares. +// Types and values MigratingToPollyV8.md names in its blocks and never declares. // // Identifiers only: every member is typed from a pinned package or the BCL, returns a default, // and does nothing. A block that calls a member of one of these is checked @@ -6,6 +6,8 @@ // // blockcheck: using static MigratingToPollyV8Context; +using System; +using System.Data; using Paramore.Brighter; using Polly.Registry; @@ -15,3 +17,31 @@ public static class MigratingToPollyV8Context public static PolicyRegistry policyRegistry => null!; public static ResiliencePipelineRegistry resiliencePipelineRegistry => null!; } + +// blocks 4, 7, 10: `RequestHandler` +public class MyCommand() : Command(Id.Random()); + +// blocks 11, 12: `editTaskCommand.TaskId`, `.TaskName`, `.TaskDescription`, `.TaskDueDate` +public class EditTaskCommand() : Command(Id.Random()) +{ + public int TaskId { get; set; } + public string TaskName { get; set; } = string.Empty; + public string TaskDescription { get; set; } = string.Empty; + public DateTime? TaskDueDate { get; set; } +} + +// blocks 11, 12: the handler's task store; `BeginTransaction()`, `FindById(…)`, `Update(task)` +public interface ITasksDAO +{ + IDbTransaction BeginTransaction(); + Task FindById(int taskId); + void Update(Task task); +} + +// blocks 11, 12: the page's own task entity, `task.TaskName`, `.TaskDescription`, `.DueDate` +public class Task +{ + public string TaskName { get; set; } = string.Empty; + public string TaskDescription { get; set; } = string.Empty; + public DateTime? DueDate { get; set; } +} diff --git a/tools/blockcheck/scaffold/units/PipelineValidationContext.cs b/tools/blockcheck/scaffold/units/PipelineValidationContext.cs new file mode 100644 index 0000000..03782e3 --- /dev/null +++ b/tools/blockcheck/scaffold/units/PipelineValidationContext.cs @@ -0,0 +1,37 @@ +// Types and values PipelineValidation.md names in its blocks and never declares. +// +// The page configures Brighter on an application `builder` and a `services` collection it never +// creates, for handlers of an `OrderCreated` it never shows. Values return a default and do +// nothing; a block that calls a member of one is checked against the real type. +// +// blockcheck: using static PipelineValidationContext; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + +public static class PipelineValidationContext +{ + // blocks 1, 2: `builder.Services.AddBrighter(…)` + public static WebApplicationBuilder builder => null!; + // blocks 14, 15, 16, 17: `services.AddConsumers(…)`, `services.AddBrighter()` + public static IServiceCollection services => null!; + // block 14: `new RmqMessageConsumerFactory(rmqConnection)` + public static RmqMessagingGatewayConnection rmqConnection => null!; + // blocks 16, 17: `opts.AddMsSqlOutbox(outboxConfig)`, `opts.AddMsSqlInbox(inboxConfig)` + public static IAmARelationalDatabaseConfiguration outboxConfig => null!; + public static IAmARelationalDatabaseConfiguration inboxConfig => null!; +} + +// block 1: `typeof(MyHandler).Assembly` +public class MyHandler; + +// blocks 8–14: `RequestHandlerAsync`, `RmqSubscription` +public class OrderCreated() : Event(Id.Random()); + +// block 15: `typeof(OrderCreatedHandler).Assembly`, the handler in an assembly nothing has loaded +public class OrderCreatedHandler : RequestHandlerAsync; + +// block 16: `RequestHandler` +public class ProcessPayment() : Command(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/PolicyFallbackContext.cs b/tools/blockcheck/scaffold/units/PolicyFallbackContext.cs new file mode 100644 index 0000000..0d31a27 --- /dev/null +++ b/tools/blockcheck/scaffold/units/PolicyFallbackContext.cs @@ -0,0 +1,122 @@ +// Types and values PolicyFallback.md names in its blocks and never declares. +// +// The handlers take compensating action against services, queues and stores the page names in +// their constructors and never shows. Blocks 6 and 7 are a `Fallback` method shown alone, using +// the logger and command processor a handler is given. Each stub carries only the members the +// page's blocks use; values return a default and do nothing. +// +// blockcheck: using static PolicyFallbackContext; + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Polly.Registry; + +public static class PolicyFallbackContext +{ + // blocks 6, 7: a `Fallback` shown alone, logging and sending through its handler's dependencies + public static ILogger _logger => null!; + public static IAmACommandProcessor _commandProcessor => null!; + // block 10: `resiliencePipelineRegistry.TryAddBuilder(…)`, `services.AddSingleton(…)` + public static ResiliencePipelineRegistry resiliencePipelineRegistry => null!; + public static IServiceCollection services => null!; +} + +// blocks 1, 3–7, 9: `RequestHandler`; block 6: `command.OrderId` +public class MyCommand() : Command(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; +} + +// block 1: `externalService.Call(command)` +public interface IExternalService +{ + void Call(MyCommand command); +} + +// block 1: `retryQueue.QueueForRetry(command)` +public interface IRetryQueue +{ + void QueueForRetry(MyCommand command); +} + +// block 1: `new UndoCommand { OriginalCommandId = command.Id }` +public class UndoCommand() : Command(Id.Random()) +{ + public Id? OriginalCommandId { get; set; } +} + +// block 6: `new CancelOrderCommand { OrderId = …, Reason = … }` +public class CancelOrderCommand() : Command(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; +} + +// blocks 8, 10: `Product.Default` +public class Product +{ + public static Product Default => null!; +} + +// block 8: `query.ProductId`, `query.Product` +public class GetProductQuery() : Command(Id.Random()) +{ + public string ProductId { get; set; } = string.Empty; + public Product? Product { get; set; } +} + +// blocks 8, 10: `productService.GetProduct(…)` +public interface IProductService +{ + Product GetProduct(string productId); +} + +// block 8: `cache.GetProduct(query.ProductId)` +public interface IProductCache +{ + Product? GetProduct(string productId); +} + +// block 9: `alertService.SendAlert(new Alert { Severity = …, Message = …, Context = command })` +public interface IAlertService +{ + void SendAlert(Alert alert); +} + +public class Alert +{ + public AlertSeverity Severity { get; set; } + public string Message { get; set; } = string.Empty; + public IRequest? Context { get; set; } +} + +public enum AlertSeverity { Critical } + +// block 9: `failedCommandRepository.Add(new FailedCommand { CommandId = …, Exception = …, Timestamp = … })` +public interface IFailedCommandRepository +{ + void Add(FailedCommand failedCommand); +} + +public class FailedCommand +{ + public Id? CommandId { get; set; } + public Exception? Exception { get; set; } + public DateTime Timestamp { get; set; } +} + +// block 10: `command.ProductId`, `command.OrderId` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public string ProductId { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; +} + +// block 10: `orderService.CreateOrder(command, product)`, `orderService.CancelOrder(command.OrderId)` +public interface IOrderService +{ + void CreateOrder(ProcessOrderCommand command, Product product); + void CancelOrder(string orderId); +} diff --git a/tools/blockcheck/scaffold/units/PolicyRetryAndCircuitBreakerContext.cs b/tools/blockcheck/scaffold/units/PolicyRetryAndCircuitBreakerContext.cs index 6ae0ec5..7917e51 100644 --- a/tools/blockcheck/scaffold/units/PolicyRetryAndCircuitBreakerContext.cs +++ b/tools/blockcheck/scaffold/units/PolicyRetryAndCircuitBreakerContext.cs @@ -1,4 +1,4 @@ -// Values PolicyRetryAndCircuitBreaker.md names in its blocks and never declares. +// Types and values PolicyRetryAndCircuitBreaker.md names in its blocks and never declares. // // Identifiers only: every member is typed from a pinned package or the BCL, returns a default, // and does nothing. A block that calls a member of one of these is checked @@ -6,7 +6,12 @@ // // blockcheck: using static PolicyRetryAndCircuitBreakerContext; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using Paramore.Brighter; +using Polly; using Polly.Registry; public static class PolicyRetryAndCircuitBreakerContext @@ -14,4 +19,21 @@ public static class PolicyRetryAndCircuitBreakerContext public static IAmAHandlerFactorySync handlerFactory => null!; public static PolicyRegistry policyRegistry => null!; public static SubscriberRegistry registry => null!; + // blocks 4, 5, 7, 9, 12, 13, 15: the registry block 3 creates + public static ResiliencePipelineRegistry resiliencePipelineRegistry => null!; + // block 15: `commandProcessor.Send(…)` + public static IAmACommandProcessor commandProcessor => null!; + // block 13: the hedged call, `() => CallAlternativeEndpoint()` + public static ValueTask> CallAlternativeEndpoint() => default; + // block 14: `await SomeAsyncOperation(cancellationToken)` + public static Task SomeAsyncOperation(CancellationToken cancellationToken) => Task.CompletedTask; } + +// blocks 1, 2, 6, 8, 9, 14, 15: `RequestHandler`, `new MyCommand()` +public class MyCommand() : Command(Id.Random()); + +// block 10: `RequestHandler` +public class ProcessOrderCommand() : Command(Id.Random()); + +// block 10: `RequestHandler` +public class ProcessPaymentCommand() : Command(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/ShowMeTheCodeContext.cs b/tools/blockcheck/scaffold/units/ShowMeTheCodeContext.cs new file mode 100644 index 0000000..71ad651 --- /dev/null +++ b/tools/blockcheck/scaffold/units/ShowMeTheCodeContext.cs @@ -0,0 +1,106 @@ +// Types and values ShowMeTheCode.md names in its blocks and never declares. +// +// The page shows handlers from the WebAPI sample's GreetingsApp one method at a time, so the +// fields their constructors set, the sample's requests and its policy names are never shown. +// Each stub carries only the members the page's blocks use; values return a default and do +// nothing. +// +// blockcheck: using static ShowMeTheCodeContext; + +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Darker; + +public static class ShowMeTheCodeContext +{ + // blocks 2, 4, 5: the handler's connection provider, set by its constructor in the sample + public static IAmARelationalDbConnectionProvider _relationalDbConnectionProvider => null!; + // block 4: the handler's command processor + public static IAmACommandProcessor _commandProcessor => null!; + // block 6: `services.AddBrighter(…)` + public static IServiceCollection services => null!; +} + +// blocks 3–5: `Retry.EXPONENTIAL_RETRYPOLICYASYNC`, the sample's policy names +public static class Retry +{ + public const string EXPONENTIAL_RETRYPOLICYASYNC = "GreetingsApp.Policies.ExponentialRetryPolicyAsync"; +} + +// block 2: `Policies.Retry.EXPONENTIAL_RETRYPOLICYASYNC`, the same name through the sample's namespace +public static class Policies +{ + public static class Retry + { + public const string EXPONENTIAL_RETRYPOLICYASYNC = "GreetingsApp.Policies.ExponentialRetryPolicyAsync"; + } +} + +// block 2: `addPerson.Name` +public class AddPerson() : Command(Id.Random()) +{ + public string Name { get; set; } = string.Empty; +} + +// blocks 1, 4: `new AddGreeting(name, newGreeting.Greeting)`, `addGreeting.Greeting`, `addGreeting.Name` +public class AddGreeting(string name, string greeting) : Command(Id.Random()) +{ + public string Name { get; } = name; + public string Greeting { get; } = greeting; +} + +// block 1: `Post(string name, NewGreeting newGreeting)`, the sample's request body +public class NewGreeting +{ + public string Greeting { get; set; } = string.Empty; +} + +// block 1: `new FindPersonByName(name)`, whose result's `Person` the controller checks +public class FindPersonByName(string name) : IQuery +{ + public string Name { get; } = name; +} + +// block 1: `foundPerson?.Person` +public class FindPersonResult +{ + public Person? Person { get; set; } +} + +// blocks 1, 3: `new FindGreetingsForPerson(name)`, `query.Name` +public class FindGreetingsForPerson(string name) : IQuery +{ + public string Name { get; } = name; +} + +// blocks 1, 3: the query's result, with `Name` and `Greetings` +public class FindPersonsGreetings +{ + public string? Name { get; set; } + public IEnumerable? Greetings { get; set; } +} + +// block 3: `new Salutation(g.Greet())` +public class Salutation(string words); + +// block 3: `p.Id`, `person.Name`, `person.Greetings` +public class Person +{ + public int Id { get; set; } + public string? Name { get; set; } + public IList Greetings { get; set; } = new List(); +} + +// block 3: `g.Greet()` +public class Greeting +{ + public string? Message { get; set; } + public string Greet() => $"{Message}!"; +} + +// blocks 4–6: `new GreetingMade(…)`, `@event.Greeting` +public class GreetingMade(string greeting) : Event(Id.Random()) +{ + public string Greeting { get; } = greeting; +} diff --git a/tools/blockcheck/scaffold/units/UsingTheContextBagContext.cs b/tools/blockcheck/scaffold/units/UsingTheContextBagContext.cs new file mode 100644 index 0000000..6eb37b9 --- /dev/null +++ b/tools/blockcheck/scaffold/units/UsingTheContextBagContext.cs @@ -0,0 +1,98 @@ +// Types and values UsingTheContextBag.md names in its blocks and never declares. +// +// The handlers read and write the context bag for requests and services the page assumes rather +// than defines. Blocks 4, 13, 14 and 16–18 are lines shown alone, using a `context`, a handler's +// `Context` and values the surrounding code would have. Each stub carries only the members the +// page's blocks use; values return a default and do nothing. +// +// blockcheck: using static UsingTheContextBagContext; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +public static class UsingTheContextBagContext +{ + // block 4: `context.Bag[…]`, a context the caller has created + public static RequestContext context => null!; + // blocks 13, 14, 16, 17: `Context.Bag[…]`, `Context.Span`, a handler's context + public static IRequestContext Context => null!; + // blocks 13, 14, 16, 18: the values the lines put in the bag + public static string tenantId => null!; + public static string userId => null!; + // block 17: `Context.Span.SetTag("custom.id", id)` + public static string id => null!; + public static Dictionary headers => null!; + public static Dictionary criticalHeaders => null!; + // block 18: `commandProcessor.PostAsync(orderCreated, …)` + public static IAmACommandProcessor commandProcessor => null!; + public static OrderCreated orderCreated => null!; +} + +// blocks 1, 3, 7–11, 15: `RequestHandler`; `command.TenantId`, `command.Priority`; +// block 8: `command.BusinessId`, `command.OrderValue` +public class MyCommand() : Command(Id.Random()) +{ + public string TenantId { get; set; } = string.Empty; + public Priority Priority { get; set; } + public string BusinessId { get; set; } = string.Empty; + public decimal OrderValue { get; set; } +} + +// block 9: `Priority.High` +public enum Priority { High } + +// block 2: `request.TenantId`, `request.OrderId` +public class CreateOrderRequest +{ + public string TenantId { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; +} + +// blocks 2, 18: `new OrderCreated { OrderId = … }` +public class OrderCreated() : Event(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; +} + +// block 3: `new TenantWorkDone(command.TenantId)` +public class TenantWorkDone(string tenantId) : Event(Id.Random()); + +// block 5: `command.Priority`, `command.OrderId` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public string Priority { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; +} + +// block 5: `orderService.Process(command)` +public interface IOrderService +{ + void Process(ProcessOrderCommand command); +} + +// block 5: `new OrderProcessed(command.OrderId)` +public class OrderProcessed(string orderId) : Event(Id.Random()); + +// block 6: `command.BusinessContext` +public class PublishEventCommand() : Command(Id.Random()) +{ + public string BusinessContext { get; set; } = string.Empty; +} + +// block 6: `new BusinessEventRaised(command.BusinessContext)` +public class BusinessEventRaised(string businessContext) : Event(Id.Random()); + +// block 7: `service.ProcessAsync(command, cancellationToken)`; block 8: `service.Process(command)` +public interface IMyService +{ + Task ProcessAsync(MyCommand command, CancellationToken cancellationToken); + void Process(MyCommand command); +} + +// block 15: `new DisposableResource()`, disposed through `IDisposable` +public class DisposableResource : System.IDisposable +{ + public void Dispose() { } +}