From d69a5549272d5131e82cf05c199376de7432c7fc Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 08:17:29 +0100 Subject: [PATCH 01/27] =?UTF-8?q?spec:=20017=20phase=205=20task=205.1=20?= =?UTF-8?q?=E2=80=94=20phase=205's=20prediction=20and=20a=20verdict=20per?= =?UTF-8?q?=20hard=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine gates at 976e0e0, all exit 0. BUILT 234 -> 246..294; the floor is 4 short of >= 250. Pages with nothing BUILT 57 -> at most 51, as low as 45. pagelint 616 -> 582..573 before P0-7. attr_mismatch 7 -> 1 (PipelineValidation.md:250); ITimerProvider 4 -> 0. The tranche reads 15 hard, not 16: task 2.4's UseEndpoints repair left BrighterControlAPI.md #1 needing only an `app` stub. Hard blocks read as 5 parse, 10 other (six defects, verified at 10.7.0 / Darker 4.1.1), recurrences run. The [RetryableQuery] ledger grep said 9 lines; any second argument finds 20. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 155 +++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 13695a5..9787929 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1712,7 +1712,7 @@ units and 4 changed, and this file. **Goal:** every 1-hard-block page outside *Outbox and Inbox* leaves whole, and P0-7 is done everywhere. Page list: § *The tranches*, phase 5 table. -- [ ] **Task 5.1:** Predict phase 5's movement +- [x] **Task 5.1:** Predict phase 5's movement - Input: § *The tranches*, phase 5 table; § *Phase 4 as executed*; `attr_mismatch.py`'s count now - Output: § *Phase 5 as executed* opens with a prediction per gate and a verdict per hard block, as 4.1; and the P0-7 predictions — `ITimerProvider` 4 → 0, `attr_mismatch` to exactly the @@ -1749,6 +1749,159 @@ everywhere. Page list: § *The tranches*, phase 5 table. - Output: as 2.6, for phase 5; plus BUILT against **≥ 250** and pages with nothing BUILT against **≤ 60**, both read before phase 6 walks them +### Phase 5 as executed + +**Prediction, 2026-09-28, from `master` `976e0e0`**, the merge of phase 4 (PR #193). The *Now* +column is this task's own run at `976e0e0`, every gate bare, exit code read before its output; all +nine exit 0 and read at `tools/README.md`'s figures. Each prediction names its mechanism: + +| # | Gate | Now | Predicted after phase 5, and why | +|---:|---|---|---| +| 1 | `linkcheck` | 165 files, 0 broken | **none**. No file is added | +| 2 | `pagelint` | 0 errors, 616 warnings, 162 pages | **errors 0; warnings 616 → between 582 and 573, then lower by P0-7 and recurrences, each explained.** On the 16 pages, rule 6 warns on **45** blocks: **43** FAILED (**34** reachable, **9** hard) and **2** BUILT (`PostgreSQLMessageBroker.md` #11, `ProjectionQueryPatterns.md` #4), which no repair touches. Every reachable block given its `using`s: −34, to 582; each warning hard block repaired: −1 more, to 573 if all 9 are. The P0-7 blocks warn too, all but `CQRSWithBrighterAndDarker.md` #7: `InMemoryScheduler.md` #5, `HowServiceActivatorWorks.md` #16, `PipelineValidation.md` #9, #10, `PolicyRetryAndCircuitBreaker.md` #14, `ReactorAndProactor.md` #6, `V10MigrationGuide.md` #10, #12 — **up to −8** more, and none from a block that marks its omission `// ...` instead | +| 3, 4, 7 | shape, redirects, `--verify` | 161 / 77 / 161 | **none**. No `SUMMARY.md` change | +| 5 | `versioncheck` | 0 stale of **18, across 5 pages** | **none, scope held.** Its five pages (`tools/versioncheck.py:81`–`85`) are the tutorials and `GetStarted.md`, none in phase 5 | +| 6 | `optioncheck` | 0 mismatches, 59 tables, 519 rows | **none.** Of the tranche, the P0-7 pages and the recurrence pages (27 files), three carry a table it reads: `PostgreSQLMessageBroker.md` (3 tables, 28 rows), `InMemoryScheduler.md` (1, 4) and `QuartzScheduler.md` (1, 4) — 5 tables, 36 rows. `InMemoryScheduler.md`'s table already documents `InMemorySchedulerFactory.TimeProvider`, which is what the `ITimerProvider` repair points at. A row changes only if a repair finds a default wrong, and is then a defect in § *Defect ledger* | +| 8 | `symbolcheck` | 0 findings, 22 entries, 161 pages, 3 silenced | **none.** The 27 files read 0 findings with **2** of the 3 silenced sites, both `S3LuggageStore.md` (`AddS3LuggageStore`, `S3LuggageStoreCreation`); its repair keeps both opt-outs where they are | +| 9 | `blockcheck` | 983: 234 BUILT, 733 FAILED, 16 SKIPPED; 542 reference assemblies; 35 units; 53 pages mapped | **BUILT 234 → at least 246, at most 294; SKIPPED 16 plus accepted reasons only; reference assemblies 542, no pin change; exit 0; units 35 plus the new ones, 0 violations.** Mechanism below | +| — | `attr_mismatch.py` | **7**, exit 1 | **7 → 1, exit 1, the one hit `PipelineValidation.md:250`** — the deliberate *Before (error)* example. The other six are 5.5's repairs. `:250` holds its line only if nothing above it on that page changes length; 5.5's repairs at `:280` and `:289` are below it | +| — | `grep -rn ITimerProvider contents/` | **4** lines, all `InMemoryScheduler.md` (`:32`, `:50`, `:202`, `:205`) | **4 → 0.** Two sentences, the pipeline diagram and block #5 (`FakeTimerProvider : ITimerProvider`) | + +**Gate 9, by source.** The probe re-run at `976e0e0` (§ *The tranches*' recipe, `Order` excluded) +reads the phase 5 table with **one page moved**: **62 FAILED, 45 reachable, 2 same-page, 15 hard, 6 +BUILT**. **Said at 1.10:** `BrighterControlAPI.md` #1 hard, 0 reachable, 16 hard. **Measured:** +#1 reachable by an empty stub (`app`), 15 hard — task 2.4 repaired its `UseEndpoints` defect off +the tranche (`51b5fef`, § *Defect ledger*), which was the block's hard part. **Second method:** +`--classify` puts the same **62** FAILED blocks on the 16 pages. The 45 reachable are: + +- **1** with a `using` alone — `BuildingAPipeline.md` #3 +- **11** with an empty stub — `BrighterControlAPI.md` #1 (`app`), `CloudEventsReference.md` #1, #3, + #4, `DarkerConfigurationReference.md` #1, #3, `HowConfiguringTheDispatcherWorks.md` #3, + `ParameterizedQueryPatterns.md` #1, `PostgreSQLMessageBroker.md` #9, `S3LuggageStore.md` #2, + `Telemetry.md` #3 +- **18** needing a stub with members — `AggregationQueryPatterns.md` #2, #3, + `BuildingAPipeline.md` #2, #4, `CQRSUseCasesAndPatterns.md` #1, + `HowConfiguringTheDispatcherWorks.md` #2, `PostgreSQLMessageBroker.md` #2, #4, #5, #6, #7, #10, + #13, `ProjectionQueryPatterns.md` #1, #2, `QueryHandlerDependencies.md` #1, #4, `Telemetry.md` #2 +- **15** needing a typed value — `AgreementDispatcherRouting.md` #1–#7, #9–#11, + `DarkerConfigurationReference.md` #2, `PostgreSQLMessageBroker.md` #1, #3, #8, + `QueryHandlerDependencies.md` #3 + +The 2 same-page stay FAILED by rule: `ParameterizedQueryPatterns.md` #2, #6. + +- **Floor 246** = 234 + 1 + 11: only the `using` and empty-stub blocks +- **Ceiling 294** = 234 + 45 + 14 + 1: every reachable block; every hard block but + `S3LuggageStore.md` #1 (below); and `InMemoryScheduler.md` #5, the `ITimerProvider` block, once + rewritten. **`CQRSWithBrighterAndDarker.md` #7, the `Order` block, is not in it**: the probe reads + it SAME-PAGE on `PlaceOrderCommand`, so 5.4's output there is *"showing `Order`"*, not BUILT. + Each hard block listed rather than repaired, and each defect a stub surfaces, lowers it by one +- **The ≥ 250 target is not met by the floor.** 246 is 4 short, so phase 5 must land **at least 4** + of the 33 member, typed-value and hard blocks. The E4 blocks are no help: each is FAILED for + reasons besides its attribute, and 5.5 changes only the attribute +- **The pin: no change predicted.** Every name the hard blocks need past the page's own types + resolves at 542 (`--explain` on the probe's `stageP`), except `Paramore.Brighter.Transformers.AWS.V4` +- **Pages with nothing BUILT: 57 → at most 51, as low as 45** (requirements' `awk` and a Python join + over the same report: **57** both). 12 phase 5 pages have no BUILT block. **6** reach one by a + `using` or an empty stub (`BrighterControlAPI.md`, `BuildingAPipeline.md`, + `CloudEventsReference.md`, `DarkerConfigurationReference.md`, + `HowConfiguringTheDispatcherWorks.md`, `S3LuggageStore.md` by #2); **4** by a stub with members or + a typed value (`AgreementDispatcherRouting.md`, `AggregationQueryPatterns.md`, + `CQRSUseCasesAndPatterns.md`, `QueryHandlerDependencies.md`); **2** only by repairing their one + hard block (`DarkerAndBrighterPipelines.md`, `PostgreSQLBrokerTradeOffs.md`). Seven pages P0-7 or a + recurrence touches also have nothing BUILT — `HowServiceActivatorWorks.md`, `PipelineValidation.md`, + `ReactorAndProactor.md`, `V10MigrationGuide.md`, `QueryPipeline.md`, `QueryPatterns.md`, + `QuartzScheduler.md` — and are not counted: none of their touched blocks is predicted to build + +**A verdict per hard block, before it is touched.** From `--classify` (on the page's own text) and +`--explain` (on the probe's `stageP`, `using`s supplied). *Parse* is placeholder, fragment or not +code; *other* is defect, wrapper or probe artefact, or the pin. + +| Page | # | `--classify` / probe | Verdict | What the diagnostics and the text show | +|---|---:|---|---|---| +| `AgreementDispatcherRouting.md` | 8 | parse / PARSE | **parse — fragment** | The chain ends in a commented-out `// .AutoFromAssemblies()` and never takes its `;` (`CS1002`); `services` is a value. Its comment, *"Cannot use AutoFromAssemblies with Agreement Dispatcher"*, is a claim about behaviour: **run, with a control** (P0-10) | +| `BuildingAPipeline.md` | 1 | import / DEFECT | **other — defect** | A pre-V10 handler: `using Brighter.commandprocessor.Logging;`, `namespace Brighter.commandprocessor`, `logger.InfoFormat` (`CS0234`, `CS0103`). 10.7.0's is `Paramore.Brighter.Logging.Handlers.RequestLoggingHandler` (`src/Paramore.Brighter/Logging/Handlers/RequestLoggingHandler.cs`) | +| `CloudEventsReference.md` | 2 | import / DEFECT | **other — defect** | `PartitionKey = …` in a `Publication` initialiser, `CS0117`. At 10.7.0 `PartitionKey` is on `MessageHeader` (`MessageHeader.cs:263`), and the default mapper reads it from the request context (`JsonMessageMapper.cs:50`, `Context.GetPartitionKey()`). `OrderCreated` is a type the page never shows | +| `S3LuggageStore.md` | 1 | import / DEFECT | **other — the pin** | `CS0234`: `Paramore.Brighter.Transformers.AWS.V4` is not in the pin (D3, 018). The page lists both packages (`:17`, `:20`); under the V3 namespace the types resolve and only `serviceCollection` and `credentials`, values, remain. **Listed, as `DistributedLock.md` #2 was**, once it is built against the released V4 package in scratch | +| `PostgreSQLBrokerTradeOffs.md` | 1 | import / DEFECT | **other — not one program** | `var configuration` declared twice (`CS0128`): a JSONB form and a JSON form in one fence. A split (§ *Splits*) or two names; `connectionString` is a value | +| `PostgreSQLMessageBroker.md` | 12 | import / DEFECT | **other — two defects** | `[ClaimCheck(threshold: 102400, dataStore: typeof(S3LuggageStore))]`: `CS1739` — 10.7.0's constructor is `ClaimCheckAttribute(int step, int thresholdInKb = 0)` (`Transforms/Attributes/ClaimCheckAttribute.cs:43`), and the store is registered by `UseExternalLuggageStore`, not named on the attribute. `ProcessLargeOrderCommand : Command` with no constructor: `CS1729` — `Command` has `Command(Id)` and `Command(Guid)` only (`Command.cs:68`, `:77`) | +| `AggregationQueryPatterns.md` | 1 | import / DEFECT | **other — probe artefact** | `ApplicationDbContext`, never shown, is a page type; the `CS1061` on `TEntity.Name` is the `ToDictionaryAsync` inference failing behind it. A stub with members (`Categories`) reaches it. `--classify` reads *import* on `Id`, a member access | +| `DarkerAndBrighterPipelines.md` | 1 | parse / PARSE | **parse — placeholder, and a defect** | `...` as the last parameter of both signatures, and neither has a body (`CS8635`, `CS0501`). Beside them, `[RetryableQuery(2, "DefaultCircuitBreaker")]` — the open § *Defect ledger* row | +| `DarkerConfigurationReference.md` | 4 | import / DEFECT | **other — defect** | `.Handlers(registry, Activator.CreateInstance, t => {}, Activator.CreateInstance)`: `CS1503`. Darker 4.1.1's overload takes `Func` and `Func` (`Builder/INeedHandlers.cs:9`), and `Activator.CreateInstance` returns `object`. Darker's own README carries the same line at 4.1.1 (`README.md:110`); that text is upstream's. The four query types are the page's | +| `ParameterizedQueryPatterns.md` | 4 | import / DEFECT | **other — defect** | `using System.Threading.Task;` (`CS0234`), then `Task<>` unresolved behind it. The rest are page and same-page types | +| `ProjectionQueryPatterns.md` | 3 | parse / PARSE | **parse — fragment** | A `.Select(o => new OrderDto { … })` with no receiver (`CS1513`, `CS1955`) | +| `QueryHandlerDependencies.md` | 2 | import / DEFECT | **other — probe artefact** | As `AggregationQueryPatterns.md` #1: `ApplicationDbContext`, `CustomerDto`, `GetCustomerWithOrdersQuery` are page types; the `CS1061`s on `TEntity` are the inference behind them | +| `Telemetry.md` | 5 | parse / PARSE | **parse — fragment** | `.SetSampler(new TraceIdRatioBasedSampler(0.1))`, one line with no receiver. `TraceIdRatioBasedSampler` resolves | +| `CQRSUseCasesAndPatterns.md` | 2 | import / DEFECT | **parse — placeholder and fragment** | `: IRequest { /* ... */ }` three times (`CS0535` is the omission), and two controller actions outside any class (`CS0116`, `Ok` non-invocable, `_commandProcessor` and `_queryProcessor` unshown). The probe calls it DEFECT; read, it is an excerpt that says so | +| `HowConfiguringTheDispatcherWorks.md` | 1 | import / DEFECT | **other — defect** | V9's registry: `new MessageMapperRegistry(messageMapperFactory) { { typeof(…), typeof(…) } }`. At 10.7.0 the constructor takes `(IAmAMessageMapperFactory?, IAmAMessageMapperFactoryAsync?)` and the class is not `IEnumerable` (`CS7036`, `CS1922`); registration is `Register()` (`MessageMapperRegistry.cs:64`, `:296`). The page's own `:62` has the V10 constructor | + +**By this reading, 5 parse and 10 other.** `--classify` reads 4 *parse* and 11 *import*; the probe, +4 PARSE and 11 DEFECT. The one block whose kind differs is `CQRSUseCasesAndPatterns.md` #2, an +excerpt the probe calls a DEFECT. The 10 *other*: **six defects** (`BuildingAPipeline.md` #1, +`CloudEventsReference.md` #2, `DarkerConfigurationReference.md` #4, `HowConfiguringTheDispatcherWorks.md` +#1, `ParameterizedQueryPatterns.md` #4, `PostgreSQLMessageBroker.md` #12), **two probe artefacts** +behind a page type, **one** two-programs-in-one-fence and **one** the pin. **One parse block carries +a defect beside its placeholder**: `DarkerAndBrighterPipelines.md` #1. + +**Their recurrences, run now so that 5.2 and 5.3 open with them:** + +| Defect | Grep | Hits | Pages | +|---|---|---:|---| +| The pre-V10 namespace | `grep -rnE 'Brighter\.commandprocessor' contents/` | **4** lines, 3 pages | `BuildingAPipeline.md` ×2; `FAQ.md:377` quotes an old exception message and `Monitoring.md:25` an old `app.config` section — both read in 5.2, neither is a `using` | +| `logger.InfoFormat` | `grep -rnE '\.InfoFormat\(' contents/` | **1** | `BuildingAPipeline.md` | +| `PartitionKey` on a `Publication` | `grep -rnE '\bPartitionKey *=' contents/` → **6** lines, 5 pages, read | **1** | `CloudEventsReference.md`. Of the other five: `KafkaConfiguration.md:213` is read in 5.3; `:724` and `MessageMappers.md:147` set `header.PartitionKey`, right; `UsingTheContextBag.md:350` is a constant; `V10MigrationGuide.md:350` sets `Context.PartitionKey`, and `IRequestContext` has no such property at 10.7.0 — part of the open `IRequestContext` row, 5.5's | +| `[ClaimCheck]` given a threshold in bytes and a store | `grep -rnE 'ClaimCheck\([^)]*(threshold:\|dataStore)' contents/` | **1** | `PostgreSQLMessageBroker.md` | +| A `Command` or `Event` with no constructor | the compiler: `CS1729 … 'Command'\|'Event' does not contain a constructor that takes 0 arguments` over the whole corpus under `stageP` → **4** blocks; a text scan of every fence → **4** classes | **5** on **4** pages, the union | `PostgreSQLMessageBroker.md` #12, `NullableReferenceTypes.md` #9, `MigratingToNullableReferenceTypes.md` #4, `V10MigrationGuide.md` #20 (both methods but the last), and `V10MigrationGuide.md` #1, a skipped V9 form (text scan only). The text scan missed `MigratingToNullableReferenceTypes.md` #4, whose `new CreateOrderCommand()` it read as a constructor | +| `Activator.CreateInstance` as a Darker factory | `grep -rn 'Activator\.CreateInstance' contents/` | **2** | `DarkerConfigurationReference.md:82`, `ImplementAQueryHandler.md:451` | +| `using System.Threading.Task;` | `grep -rn 'using System\.Threading\.Task;' contents/` | **1** | `ParameterizedQueryPatterns.md` | +| V9's `MessageMapperRegistry` initialiser | `grep -rn 'new MessageMapperRegistry(' contents/` → 2 lines, read | **1** | `HowConfiguringTheDispatcherWorks.md:40`; its `:62` is the V10 form | + +**Carried into the repair tasks, not the prediction.** Each is on `master` now, measured at +`976e0e0`, and given the task whose section holds its page: + +- **`[RetryableQuery]`'s second argument (5.2).** § *Defect ledger* recorded **9** lines on **5** + pages. **Said: 9; measured: 20 lines on 6 pages** with any second argument (`grep -rnE + 'RetryableQuery\([^)]*,' contents/` and a Python scan of every `RetryableQuery(…)`, agreeing). The + ledger's grep matched only `DefaultCircuitBreaker` and `circuitBreakerName`, and the defect is + any breaker-shaped name used as though it added a breaker. `ShowMeTheCode.md:70` names a retry + policy, the argument's right kind. The other **19**, on `QueryPipeline.md` (14), + `CQRSWithBrighterAndDarker.md` (2), `DarkerAndBrighterPipelines.md`, `ImplementAQueryHandler.md` + and `QueryPatterns.md`, are read one by one; `QueryPipeline.md:649` is the troubleshooting case, + *"Policy name doesn't exist"* +- **`MessageBody` given a string content type (5.3).** `KafkaConfiguration.md:723` and + `MessageMappers.md:146`, both still there (`grep -rnE 'new MessageBody\([^)]*, *("|MediaTypeNames)' + contents/` → **2**) +- **Mapper excerpts omitting a required member with no `// ...` (5.3)**, the recurrence of the mapper + world 5.3 opens: `Routing.md` #1, `V10MigrationGuide.md` #3, #18, `NullableReferenceTypes.md` #7, + `FAQ.md` #7. Three of the four pages sit in no tranche, and fixing the issue, not the instance, is + why they go with 5.3 rather than to the residual +- **The unshown `Order` (5.4)** is `CQRSWithBrighterAndDarker.md` #7, `:702`: FAILED `CS0103`, + `CS0246`; `IOrderRepository`, `IProductRepository`, `OrderItem`, `OrderStatus` and + `OrderPlacedEvent` are unshown beside it +- **`InMemoryScheduler.md` (5.4) says to install `Paramore.Brighter.InMemoryScheduler`** (`:225`, + `:228`). No project of that name is in `src/` at 10.7.0, and `InMemorySchedulerFactory` is in + `Paramore.Brighter` (`src/Paramore.Brighter/InMemorySchedulerFactory.cs:37`, `TimeProvider`). + Checked against NuGet in 5.4 before it is called a defect. A sentence claiming how the scheduler + uses time is run, with a control (P0-10) +- **`QuartzScheduler.md:359`'s U+200B (5.4)**, still the corpus's one (`grep -rlP '\x{200B}' + contents/` → 1 page, 1 line). Removed with a tool that writes bytes, not the Edit tool +- **`IRequestContext` in `V10MigrationGuide.md` (5.5).** **Said at 2.3: `:320`. Measured: the class + is at `:326`**, block #12, and the section around it also lists `PartitionKey` and `CustomHeaders` + as new `IRequestContext` properties (`:316`, `:317`) and sets `Context.PartitionKey` and + `Context.CustomHeaders` (`:350`, `:353`, block #13). At 10.7.0 the interface has neither + (`git show 10.7.0:src/Paramore.Brighter/IRequestContext.cs | grep -c 'PartitionKey\|CustomHeaders'` + → 0). A second implementation + at `:381` is read beside it +- **E4's line numbers have drifted since the design.** **Said:** `ReactorAndProactor.md:190`, + `V10MigrationGuide.md:282`. **Measured:** `:200` and `:288` (`attr_mismatch.py`, above). The + seven hits sit in `HowServiceActivatorWorks.md` #16, `PipelineValidation.md` #7, #9, #10, + `PolicyRetryAndCircuitBreaker.md` #14, `ReactorAndProactor.md` #6 and `V10MigrationGuide.md` #10, + all FAILED, as the design found them +- **`Order` (the 1.10 ruling) acts on two reachable blocks.** `grep -cw Order` over the `--show` of + the 62 FAILED blocks → **4**, none hard; `--classify` lists `Order` as a missing name on **2** of + them, `PostgreSQLMessageBroker.md` #7 and `QueryHandlerDependencies.md` #1, and the other two use + the word only (`CQRSUseCasesAndPatterns.md` #1, `PostgreSQLMessageBroker.md` #4). Both are read as + a type the page never shows, and get a stub, never `using StackExchange.Redis` + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* From 1cafef957706546f62566a75c8bc34d92cfafb7d Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:00:15 +0100 Subject: [PATCH 02/27] =?UTF-8?q?docs:=20017=20task=205.2=20=E2=80=94=20th?= =?UTF-8?q?e=20Commands,=20Darker=20and=20Understanding=20Brighter=20tranc?= =?UTF-8?q?he=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten tranche pages repaired, and the recurrences their defects reach on eight more: - BuildingAPipeline.md: the pre-V10 logging handler rewritten against 10.7.0; `.Successor =` is `SetSuccessor()`; how a custom generic pipeline handler is registered, run with controls - AgreementDispatcherRouting.md, AgreementDispatcher.md, FAQ.md: "cannot use AutoFromAssemblies" is false — pass the route's handlers in `excludeDynamicHandlerTypes` - DarkerConfigurationReference.md, DarkerBasicConfiguration.md: the query processor defaults to Singleton, not Transient, and the unscoped failure is a root-provider resolution, not a disposed DbContext; `Activator.CreateInstance` cast to Darker's factory types (and on ImplementAQueryHandler.md) - [RetryableQuery]'s second argument is the one policy the decorator runs: QueryPipeline.md, QueryPatterns.md, CQRSWithBrighterAndDarker.md, ImplementAQueryHandler.md, DarkerAndBrighterPipelines.md; QueryPipelinePolicies.md shows a retry wrapped round a breaker - HowConfiguringTheDispatcherWorks.md: V10's MessageMapperRegistry; RmqSubscription defaults to Reactor - The Darker query-pattern pages, CQRSUseCasesAndPatterns.md: usings, whole blocks, and six scaffold units for the types the pages never show Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/AgreementDispatcher.md | 18 ++- contents/AgreementDispatcherRouting.md | 59 ++++++--- contents/BuildingAPipeline.md | 94 ++++++++------ contents/BuildingAnAsyncPipeline.md | 9 +- contents/CQRSUseCasesAndPatterns.md | 92 +++++++++++--- contents/CQRSWithBrighterAndDarker.md | 6 +- contents/DarkerAndBrighterPipelines.md | 8 +- contents/DarkerBasicConfiguration.md | 4 +- contents/DarkerConfigurationReference.md | 22 +++- contents/FAQ.md | 4 +- contents/HowConfiguringTheDispatcherWorks.md | 33 +++-- contents/ImplementAQueryHandler.md | 12 +- contents/ParameterizedQueryPatterns.md | 4 +- contents/ProjectionQueryPatterns.md | 3 + contents/QueryHandlerDependencies.md | 11 +- contents/QueryPatterns.md | 2 +- contents/QueryPipeline.md | 56 ++++---- contents/QueryPipelinePolicies.md | 35 +++++ tools/blockcheck/scaffold/pages.tsv | 8 ++ .../AgreementDispatcherRoutingContext.cs | 111 ++++++++++++++++ .../units/CQRSUseCasesAndPatternsContext.cs | 26 ++++ .../DarkerConfigurationReferenceContext.cs | 32 +++++ .../units/DarkerQueryPatternsContext.cs | 120 ++++++++++++++++++ ...HowConfiguringTheDispatcherWorksContext.cs | 31 +++++ 24 files changed, 645 insertions(+), 155 deletions(-) create mode 100644 tools/blockcheck/scaffold/units/AgreementDispatcherRoutingContext.cs create mode 100644 tools/blockcheck/scaffold/units/CQRSUseCasesAndPatternsContext.cs create mode 100644 tools/blockcheck/scaffold/units/DarkerConfigurationReferenceContext.cs create mode 100644 tools/blockcheck/scaffold/units/DarkerQueryPatternsContext.cs create mode 100644 tools/blockcheck/scaffold/units/HowConfiguringTheDispatcherWorksContext.cs diff --git a/contents/AgreementDispatcher.md b/contents/AgreementDispatcher.md index 6b30a62..c4a894e 100644 --- a/contents/AgreementDispatcher.md +++ b/contents/AgreementDispatcher.md @@ -380,19 +380,25 @@ registry.Register((request, context) => [typeof(MyHandler)], ### AutoFromAssemblies Conflicts -**Problem**: Agreement dispatcher routes not working with `AutoFromAssemblies()`. +**Problem**: Sending a request routed by an agreement throws `ArgumentException`, *"More than one handler was found for the typeof command MyCommand"*, when you also call `AutoFromAssemblies()`. -**Cause**: `AutoFromAssemblies()` creates fixed mappings. +**Cause**: `AutoFromAssemblies()` registers every handler it finds as the one handler for its request type, so the agreement's handlers also become fixed routes beside it. -**Solution**: Use explicit `.Handlers()` registration: +**Solution**: Pass the agreement's handlers to `AutoFromAssemblies()` in `excludeDynamicHandlerTypes`, so the scan skips them: ```csharp -// Instead of AutoFromAssemblies +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) + .AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(MyHandler), typeof(MyOtherHandler)]) .Handlers(registry => { - // Explicit registration for Agreement Dispatcher - registry.Register((request, context) => { /* ... */ }, [/* handlers */]); + registry.Register((request, context) => + { + // ... your routing logic + return [typeof(MyHandler)]; + }, + [typeof(MyHandler), typeof(MyOtherHandler)]); }); ``` diff --git a/contents/AgreementDispatcherRouting.md b/contents/AgreementDispatcherRouting.md index fdae80f..aee203a 100644 --- a/contents/AgreementDispatcherRouting.md +++ b/contents/AgreementDispatcherRouting.md @@ -21,7 +21,8 @@ shows you how to register one. In standard Brighter routing, each request type maps to exactly one handler type at compile-time: ```csharp -// ... +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) .Handlers(registry => { @@ -52,7 +53,8 @@ This is Brighter's default and recommended approach for most scenarios. Agreement Dispatcher allows dynamic handler selection based on request content or context: ```csharp -// ... +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) .Handlers(registry => { @@ -78,8 +80,8 @@ services.AddBrighter(options => { }) - Can change behavior over time - Supports multiple handlers - Access to request context -- Cannot use `AutoFromAssemblies()` -- Must register handlers explicitly +- Registered explicitly, with `.Handlers()` +- `AutoFromAssemblies()` must be told to skip the route's handlers - Small performance overhead (lambda execution) **When to use Agreement Dispatcher:** @@ -97,7 +99,8 @@ services.AddBrighter(options => { }) Route to different handlers as business rules evolve over time: ```csharp -// ... +using System; + registry.Register((request, context) => { var order = request as ProcessOrder; @@ -217,7 +220,8 @@ registry.RegisterAsync((request, context) => Route based on current state or status: ```csharp -// ... +using System; + registry.Register((request, context) => { var refund = request as ProcessRefund; @@ -243,33 +247,42 @@ registry.Register((request, context) => ## Agreement Dispatcher Limitations -### Cannot Use AutoFromAssemblies +### AutoFromAssemblies Must Skip the Route's Handlers -Agreement Dispatcher requires explicit handler registration: +An agreement is always registered explicitly. You can still scan your assemblies for every other handler, as long as the scan skips the agreement's handlers: ```csharp -// ... -// Cannot use AutoFromAssemblies with Agreement Dispatcher +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) + .AutoFromAssemblies(excludeDynamicHandlerTypes: [typeof(Handler1), typeof(Handler2)]) .Handlers(registry => { - registry.Register((request, context) => { /* ... */ }, + registry.Register((request, context) => + { + // ... your routing logic + return [typeof(Handler1)]; + }, [typeof(Handler1), typeof(Handler2)]); - }) - // .AutoFromAssemblies() won't work with Agreement Dispatcher + }); ``` -**Why?** `AutoFromAssemblies()` creates fixed 1-to-1 mappings. Agreement Dispatcher needs explicit lambda registration and handler type lists for DI. +**Why?** `AutoFromAssemblies()` registers every handler it finds as the one handler for its request type. Leave an agreement's handlers in the scan and each becomes a fixed route beside the agreement, so every `Send` of that request throws an `ArgumentException`, *"More than one handler was found for the typeof command MyCommand"*. `excludeDynamicHandlerTypes` keeps them out of the scan, and the agreement's handler types list still registers them with the container. -**Solution**: Use `.Handlers()` to register Agreement Dispatcher routes explicitly: +**Alternatively**, skip the scan and register every route with `.Handlers()`, mixing agreement and standard routes: ```csharp -// ... +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) .Handlers(registry => { // Agreement dispatcher routes - registry.Register((request, context) => { /* ... */ }, + registry.Register((request, context) => + { + // ... your routing logic + return [typeof(Handler1)]; + }, [typeof(Handler1), typeof(Handler2)]); // You can still mix with standard routes @@ -285,7 +298,8 @@ You must provide all possible handler types for DI registration: // ... registry.Register((request, context) => { - // Your routing logic... + // ... your routing logic + return [typeof(Handler1)]; }, [ // All handlers that might be returned must be listed here @@ -324,17 +338,22 @@ For most applications, this overhead is negligible: **Optimization tip**: Keep routing lambdas simple. Avoid expensive operations like database calls or external API calls. +✅ **Good** - simple, fast routing logic: + ```csharp // ... -// Good - Simple, fast routing logic registry.Register((request, context) => { var cmd = request as MyCommand; return cmd?.Type == "Fast" ? [typeof(FastHandler)] : [typeof(SlowHandler)]; }, [typeof(FastHandler), typeof(SlowHandler)]); +``` + +❌ **Bad** - an expensive operation in the routing lambda: -// Bad - Expensive operation in routing lambda +```csharp +// ... registry.Register((request, context) => { var cmd = request as MyCommand; diff --git a/contents/BuildingAPipeline.md b/contents/BuildingAPipeline.md index 39e5122..bf376ef 100644 --- a/contents/BuildingAPipeline.md +++ b/contents/BuildingAPipeline.md @@ -30,7 +30,7 @@ Common examples of orthogonal operations include: To handle these orthogonal concerns our [command processor](/contents/CommandsCommandDispatcherandProcessor.md#the-command-processor-pattern) uses a pipes and filters architectural style: the filters are where processing occurs, they do not share state with other filters, nor do they know about adjacent filters. The pipe is the connector between the filters in our case this is provided by the -**IHandleRequests\** interface which has a method **IHandleRequests\ Successor** that allows us to chain filters together. +**IHandleRequests\** interface which has a method **SetSuccessor(IHandleRequests\ successor)** that allows us to chain filters together. ![PipesAndFilters](_static/images/PipesAndFilters.png) @@ -59,42 +59,47 @@ The limitation here is that you can only make assumptions about the type you rec Although it is possible to implement the [IHandleRequests](https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter/IHandleRequests.cs) interface directly, we recommend deriving your handler from [RequestHandler](https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter/RequestHandler.cs\). -Let us assume that we want to log all requests travelling through the pipeline. (We provide this for you in the Brighter.CommandProcessor packages so this for illustration only). We could implement a generic +Let us assume that we want to log all requests travelling through the pipeline. (Brighter ships this as `RequestLoggingHandler` and `[RequestLogging]` in the `Paramore.Brighter` package, so this is for illustration only.). We could implement a generic handler as follows: -``` csharp +```csharp using System; -using Newtonsoft.Json; -using Brighter.commandprocessor.Logging; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; -namespace Brighter.commandprocessor +public class RequestLoggingHandler + : RequestHandler where TRequest : class, IRequest { - public class RequestLoggingHandler - : RequestHandler where TRequest : class, IRequest + private readonly ILogger> _logger; + private HandlerTiming _timing; + + public RequestLoggingHandler(ILogger> logger) + { + _logger = logger; + } + + public override void InitializeFromAttributeParams( + params object?[] initializerList + ) + { + _timing = (HandlerTiming)initializerList[0]!; + } + + public override TRequest Handle(TRequest command) + { + LogCommand(command); + return base.Handle(command); + } + + private void LogCommand(TRequest request) { - private HandlerTiming _timing; - - public override void InitializeFromAttributeParams( - params object[] initializerList - ) - { - _timing = (HandlerTiming)initializerList[0]; - } - - public override TRequest Handle(TRequest command) - { - LogCommand(command); - return base.Handle(command); - } - - private void LogCommand(TRequest request) - { - logger.InfoFormat("Logging handler pipeline call. Pipeline timing {0} target, for {1} with values of {2} at: {3}", - _timing.ToString(), - typeof(TRequest), - JsonConvert.SerializeObject(request), - DateTime.UtcNow); - } + _logger.LogInformation( + "Logging handler pipeline call. Pipeline timing {Timing} target, for {RequestType} with values of {Request} at: {Time}", + _timing, + typeof(TRequest), + JsonSerializer.Serialize(request), + DateTime.UtcNow); } } ``` @@ -107,7 +112,10 @@ It is worth remembering that handlers may be called after the target handler (in We now need to tell our pipeline to call this orthogonal handler before our target handler. To do this we use attributes. The code we want to write looks like this: -``` csharp +```csharp +using System; +using Paramore.Brighter; + class GreetingCommandHandler : RequestHandler { [RequestLogging(step: 1, timing: HandlerTiming.Before)] @@ -123,7 +131,10 @@ The **RequestLogging** Attribute tells the Command Processor to insert a Logging We implement the **RequestLoggingAttribute** by creating our own Attribute class, derived from **RequestHandlerAttribute**. -``` csharp +```csharp +using System; +using Paramore.Brighter; + public class RequestLoggingAttribute : RequestHandlerAttribute { public RequestLoggingAttribute(int step, HandlerTiming timing) @@ -144,11 +155,10 @@ public class RequestLoggingAttribute : RequestHandlerAttribute The most important part of this implementation is the GetHandlerType() method, where we return the type of our handler. At runtime the Command Processor uses reflection to determine what attributes are on the target handler and requests an instance of that type from the user-supplied **Handler Factory**. -Your Handler Factory needs to respond to requests for instances of a **RequestHandler\** specialized for a concrete type. For example, if you create a **RequestLoggingHandler\** we will ask you for a **RequestLoggingHandler\** etc. Depending on your implementation of HandlerFactory, you may need to register an implementation for every concrete instance of your handler with your -underlying IoC container etc. +Your Handler Factory needs to respond to requests for instances of a **RequestHandler\** specialized for a concrete type. For example, if you create a **RequestLoggingHandler\** we will ask you for a **RequestLoggingHandler\** etc. With `Paramore.Brighter.Extensions.DependencyInjection`, `AutoFromAssemblies()` registers a public open generic handler it finds in your assemblies, so the pipeline can create it. If you register your handlers by hand instead, register the open generic type yourself, with `services.AddTransient(typeof(RequestLoggingHandler<>))`; otherwise the first request through the pipeline throws a `ConfigurationException`, *"Could not create handler"*. Note that as we rely on an user supplied implementation of **IAmAHandlerFactory** to instantiate Handlers, you can have any dependencies in the constructor of your handler that you can resolve at -runtime. In this case we pass in an ILog reference to actually log to. +runtime. In this case we pass in an `ILogger>` to log to. You may wish to pass parameter from your Attribute to the handler. Attributes can have constructor parameters or public members that you can set when adding the Attribute to a target method. These can only be compile time constants, see the documentation [here](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/attributes/). After the Command Processor calls your Handler Factory to create an @@ -164,17 +174,19 @@ Attribute at run time. it can be tempting to set retrieve global state via the [ Using an attribute based approach is not an approach favoured by everyone. Some people prefer a more explicit approach to configuring the pipeline. -The trick is to remember that any handler that derives from **IHandleRequests\** has a **Successor** and you can build a chain by having the first handler call the second handler\'s -**Handle()** method i.e. **Successor.Handle()**. You can derive from **RequestHandler\** and call **base.Handle()** for this, even if you don\'t want to use the Attribute based pipelines. +The trick is to remember that any handler that derives from **IHandleRequests\** has a successor, set with **SetSuccessor()**, and you can build a chain by having the first handler call the second handler\'s +**Handle()** method. You can derive from **RequestHandler\** and call **base.Handle()** for this, even if you don\'t want to use the Attribute based pipelines. In the SubscriberRegistry you just register the first Handler in your pipeline. When we lookup the Handler for the Command in the SubscriberRegistry we will call it\'s Handle method. It can execute your -code, and then call it\'s Successor (using the Russian Doll approach). +code, and then call it\'s successor (using the Russian Doll approach). The SubscriberRegistry holds the handler's *type*, so your Handler Factory must return the instance you wired up when it is asked for a `MyLoggingHandler`; a new `MyLoggingHandler` has no successor, and the request never reaches `MyCommandHandler`. + +```csharp +using Paramore.Brighter; -``` csharp var myCommandHandler = new MyCommandHandler(); var myLoggingHandler = new MyLoggingHandler(log); -myLoggingHandler.Successor = myCommandHandler; +myLoggingHandler.SetSuccessor(myCommandHandler); var subscriberRegistry = new SubscriberRegistry(); subscriberRegistry.Register(); diff --git a/contents/BuildingAnAsyncPipeline.md b/contents/BuildingAnAsyncPipeline.md index d8a1fa7..07759e1 100644 --- a/contents/BuildingAnAsyncPipeline.md +++ b/contents/BuildingAnAsyncPipeline.md @@ -27,7 +27,7 @@ Although it is possible to implement the interface directly, we recommend deriving your handler from [RequestHandlerAsync\](https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter/RequestHandlerAsync.cs). -Let us assume that we want to log all requests travelling through the pipeline. (We provide this for you in the Brighter.CommandProcessor packages so this for illustration only). We could implement a generic +Let us assume that we want to record every request travelling through the pipeline in an Inbox before it is handled, which is command sourcing. (Brighter ships this as `[UseInboxAsync]` in the `Paramore.Brighter` package, so this is for illustration only.) We could implement a generic handler as follows: ``` csharp @@ -54,7 +54,7 @@ public class CommandSourcingHandlerAsync : RequestHandlerAsync where T : c } ``` -Our HandleAsync method is the method which will be called by the pipeline to service the request. After we log we call **return await base.HandleAsync(command, cancellationToken)** to ensure that the next handler in the +Our HandleAsync method is the method which will be called by the pipeline to service the request. After we add the command to the Inbox we call **return await base.HandleAsync(command, cancellationToken)** to ensure that the next handler in the chain is called. If we failed to do this, the *target handler* would not be called nor any subsequent handlers in the chain. This call to the next item in the chain is how we support the \'Russian Doll\' model - because the next @@ -113,11 +113,10 @@ public class UseCommandSourcingAsyncAttribute : RequestHandlerAttribute The most important part of this implementation is the GetHandlerType() method, where we return the type of our handler. At runtime the Command Processor uses reflection to determine what attributes are on the target handler and requests an instance of that type from the user-supplied **Handler Factory**. Your Handler Factory needs to respond to requests for instances of a **RequestHandlerAsync\** specialized for a concrete type. For example, if you create a **CommandSourcingHandlerAsync\** we -will ask you for a **CommandSourcingHandlerAsync\** etc. Depending on your implementation of HandlerFactory, you may need to register an implementation for every concrete instance of your handler -with your underlying IoC container etc. +will ask you for a **CommandSourcingHandlerAsync\** etc. With `Paramore.Brighter.Extensions.DependencyInjection`, `AutoFromAssemblies()` registers a public open generic handler it finds in your assemblies, so the pipeline can create it. If you register your handlers by hand instead, register the open generic type yourself, with `services.AddTransient(typeof(CommandSourcingHandlerAsync<>))`; otherwise the first request through the pipeline throws a `ConfigurationException`, *"Could not create handler"*. Note that as we rely on an user supplied implementation of **IAmAHandlerFactoryAsync** to instantiate Handlers, you can have any dependencies in the constructor of your handler that you can resolve at -runtime. In this case we pass in an ILog reference to actually log to. +runtime. In this case we pass in an `IAmAnInboxAsync` to write to. You may wish to pass parameter from your Attribute to the handler. Attributes can have constructor parameters or public members that you can set when adding the Attribute to a target method. These can only be compile time constants, see the documentation [here](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/attributes). diff --git a/contents/CQRSUseCasesAndPatterns.md b/contents/CQRSUseCasesAndPatterns.md index 7c401f2..288ccc8 100644 --- a/contents/CQRSUseCasesAndPatterns.md +++ b/contents/CQRSUseCasesAndPatterns.md @@ -21,7 +21,14 @@ is the page that explains the pattern itself. This is the simplest CQRS pattern, suitable for most applications. Both commands and queries use the same database, but with different models and optimizations. ```csharp -// ... +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Paramore.Darker; + // Write Model (Domain Entity) - normalized, enforces business rules public class Order { @@ -29,6 +36,8 @@ public class Order public int Id { get; private set; } public int CustomerId { get; private set; } + public Customer Customer { get; private set; } = null!; + public DateTime CreatedAt { get; private set; } public OrderStatus Status { get; private set; } public IReadOnlyList Items => _items.AsReadOnly(); @@ -53,20 +62,37 @@ public class Order public class OrderSummaryDto { public int OrderId { get; set; } - public string CustomerName { get; set; } // Joined from Customer table + public string CustomerName { get; set; } = ""; // Joined from Customer table public int ItemCount { get; set; } public decimal TotalAmount { get; set; } - public string Status { get; set; } + public string Status { get; set; } = ""; public DateTime OrderDate { get; set; } } +// Query - asks for one order's summary +public class GetOrderSummaryQuery(int orderId) : IQuery +{ + public int OrderId { get; } = orderId; +} + +// The EF Core context both sides share: one database, two models +public class ApplicationDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} + // Query Handler - optimized for read performance public class GetOrderSummaryQueryHandler : - QueryHandlerAsync + QueryHandlerAsync { private readonly ApplicationDbContext _dbContext; - public override async Task ExecuteAsync( + public GetOrderSummaryQueryHandler(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + public override async Task ExecuteAsync( GetOrderSummaryQuery query, CancellationToken cancellationToken = default) { @@ -190,29 +216,53 @@ This advanced pattern stores all state changes as a sequence of events. The quer In a task-based UI, instead of generic CRUD operations, the UI presents specific business tasks as commands: ```csharp -// ... +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Paramore.Brighter; +using Paramore.Darker; + // Task-based commands (specific business operations) -public class ApproveOrderCommand : IRequest { /* ... */ } -public class RejectOrderCommand : IRequest { /* ... */ } -public class ShipOrderCommand : IRequest { /* ... */ } +public class ApproveOrderCommand(int orderId) : Command(Id.Random()) +{ + public int OrderId { get; } = orderId; +} -// Generic queries for display -public class GetOrderForApprovalQuery : IQuery { /* ... */ } +public class RejectOrderCommand(int orderId) : Command(Id.Random()) +{ + public int OrderId { get; } = orderId; +} -// Controller -[HttpPost("orders/{orderId}/approve")] -public async Task ApproveOrder(int orderId) +public class ShipOrderCommand(int orderId) : Command(Id.Random()) { - await _commandProcessor.SendAsync(new ApproveOrderCommand(orderId)); - return Ok(); + public int OrderId { get; } = orderId; } -[HttpGet("orders/{orderId}/approval")] -public async Task GetOrderForApproval(int orderId) +// Generic queries for display +public class GetOrderForApprovalQuery(int orderId) : IQuery { - var result = await _queryProcessor.ExecuteAsync( - new GetOrderForApprovalQuery(orderId)); - return Ok(result); + public int OrderId { get; } = orderId; +} + +// Controller +[ApiController] +public class OrderApprovalController( + IAmACommandProcessor commandProcessor, + IQueryProcessor queryProcessor) : ControllerBase +{ + [HttpPost("orders/{orderId}/approve")] + public async Task ApproveOrder(int orderId) + { + await commandProcessor.SendAsync(new ApproveOrderCommand(orderId)); + return Ok(); + } + + [HttpGet("orders/{orderId}/approval")] + public async Task GetOrderForApproval(int orderId) + { + var result = await queryProcessor.ExecuteAsync( + new GetOrderForApprovalQuery(orderId)); + return Ok(result); + } } ``` diff --git a/contents/CQRSWithBrighterAndDarker.md b/contents/CQRSWithBrighterAndDarker.md index a1e0cfb..c786c9f 100644 --- a/contents/CQRSWithBrighterAndDarker.md +++ b/contents/CQRSWithBrighterAndDarker.md @@ -229,6 +229,8 @@ Here's a brief example of a Darker query handler. For complete details, see [Imp using Paramore.Darker; using Paramore.Darker.Policies; using Paramore.Darker.QueryLogging; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -267,7 +269,7 @@ public sealed class GetOrderDetailsQueryHandler : } [QueryLogging(step: 1)] - [RetryableQuery(step: 2, circuitBreakerName: "DatabaseCircuitBreaker")] + [RetryableQuery(step: 2)] public override async Task ExecuteAsync( GetOrderDetailsQuery query, CancellationToken cancellationToken = default) @@ -787,7 +789,7 @@ public sealed class GetOrderDetailsQueryHandler : } [QueryLogging(step: 1)] - [RetryableQuery(step: 2, circuitBreakerName: "DatabaseCircuitBreaker")] + [RetryableQuery(step: 2)] public override async Task ExecuteAsync( GetOrderDetailsQuery query, CancellationToken cancellationToken = default) diff --git a/contents/DarkerAndBrighterPipelines.md b/contents/DarkerAndBrighterPipelines.md index 371a4d0..2ca2c79 100644 --- a/contents/DarkerAndBrighterPipelines.md +++ b/contents/DarkerAndBrighterPipelines.md @@ -18,17 +18,19 @@ Both frameworks use the same pipeline architecture where each handler/decorator **Attribute-Based Ordering:** Both use attributes with step numbers to control decorator execution order: + + ```csharp // ... // Brighter [RequestLoggingAsync(1, HandlerTiming.Before)] [UseResiliencePipelineAsync("RetryPolicy", 2)] -public override Task HandleAsync(AddGreetingCommand command, ...) +public override Task HandleAsync(AddGreetingCommand command, CancellationToken cancellationToken = default) // Darker [QueryLogging(1)] -[RetryableQuery(2, "DefaultCircuitBreaker")] -public override Task ExecuteAsync(GetPersonNameQuery query, ...) +[RetryableQuery(2)] +public override Task ExecuteAsync(GetPersonNameQuery query, CancellationToken cancellationToken = default) ``` **Policy Integration:** diff --git a/contents/DarkerBasicConfiguration.md b/contents/DarkerBasicConfiguration.md index 1cc8300..b3c5ec1 100644 --- a/contents/DarkerBasicConfiguration.md +++ b/contents/DarkerBasicConfiguration.md @@ -374,7 +374,7 @@ var app = builder.Build(); app.Run(); ``` -Without the scoped configuration, you'll encounter exceptions about disposed DbContext instances. +Without the scoped configuration, the query processor is a singleton that resolves handlers from the root provider: in Development, where scope validation is on, the first query throws an `InvalidOperationException` (*"Cannot resolve … from root provider because it requires scoped service"*), and elsewhere every query shares one `DbContext` for the life of the application. ### Pattern: Multiple Handler Assemblies @@ -413,7 +413,7 @@ If you receive an exception that a handler cannot be found for a query: **Lifetime scope issues with EF Core** -If you see exceptions about a disposed DbContext: +If you see an `InvalidOperationException` saying a handler cannot be resolved from the root provider because it requires a scoped service, or queries see each other's changes through one shared `DbContext`: - Ensure you've configured `QueryProcessorLifetime = ServiceLifetime.Scoped` in the Darker options - Verify your DbContext is registered with scoped lifetime (default for EF Core) - Check that you're not trying to use the query result after the scope has been disposed diff --git a/contents/DarkerConfigurationReference.md b/contents/DarkerConfigurationReference.md index 703e5c9..7cdc7f7 100644 --- a/contents/DarkerConfigurationReference.md +++ b/contents/DarkerConfigurationReference.md @@ -13,12 +13,13 @@ The options `AddDarker` and `AddHandlersFromAssemblies` take: the query processo ## Darker Query Processor Lifetime -By default, the `IQueryProcessor` is registered with a **Transient** lifetime, meaning a new instance is created each time it's requested. However, if you're using Entity Framework Core, you need to register the Query Processor with a **Scoped** lifetime to match the EF Core DbContext lifetime. +By default, the `IQueryProcessor` is registered with a **Singleton** lifetime, meaning one instance serves the whole application, and it resolves your handlers from the root service provider. However, if you're using Entity Framework Core, you need to register the Query Processor with a **Scoped** lifetime to match the EF Core DbContext lifetime. -**Default Configuration (Transient):** +**Default Configuration (Singleton):** ```csharp -// ... +using Paramore.Darker.AspNetCore; + builder.Services.AddDarker() .AddHandlersFromAssemblies(typeof(Program).Assembly); ``` @@ -41,7 +42,7 @@ builder.Services.AddDarker(options => .AddHandlersFromAssemblies(typeof(Program).Assembly); ``` -If you don't configure the scoped lifetime when using EF Core, you may encounter exceptions related to accessing a disposed DbContext. +If you don't configure the scoped lifetime when using EF Core, the singleton query processor resolves each handler, and so its `DbContext`, from the root provider. Where scope validation is on, as it is by default in the Development environment, the first query throws an `InvalidOperationException`, *"Cannot resolve … from root provider because it requires scoped service"*. Elsewhere every query shares one `DbContext` for the life of the application. ## Darker Handler Registration Strategies @@ -52,7 +53,8 @@ Darker provides two ways to register query handlers: automatic assembly scanning The `AddHandlersFromAssemblies` method scans one or more assemblies and automatically registers all query handlers it finds: ```csharp -// ... +using Paramore.Darker.AspNetCore; + // Scan a single assembly builder.Services.AddDarker() .AddHandlersFromAssemblies(typeof(GetPeopleQuery).Assembly); @@ -71,6 +73,8 @@ This approach follows convention over configuration and is the easiest way to re For more control over handler registration, you can use `QueryHandlerRegistry` to register handlers explicitly: ```csharp +using System; +using System.Collections.Generic; using Paramore.Darker; using Paramore.Darker.Builder; @@ -79,12 +83,16 @@ registry.Register, GetPeopleQue registry.Register(); IQueryProcessor queryProcessor = QueryProcessorBuilder.With() - .Handlers(registry, Activator.CreateInstance, t => {}, Activator.CreateInstance) + .Handlers( + registry, + t => (IQueryHandler)Activator.CreateInstance(t)!, + t => { }, + t => (IQueryHandlerDecorator)Activator.CreateInstance(t)!) .InMemoryQueryContextFactory() .Build(); ``` -Manual registration is useful when you need fine-grained control over which handlers are registered or when you're not using ASP.NET Core's dependency injection. +`Handlers` takes a factory for handlers and one for decorators, each a `Func` returning Darker's interface, so `Activator.CreateInstance`, which returns `object`, needs a cast; it also needs each handler to have a parameterless constructor. Manual registration is useful when you need fine-grained control over which handlers are registered or when you're not using ASP.NET Core's dependency injection. ## Further Reading diff --git a/contents/FAQ.md b/contents/FAQ.md index 684f701..0702d0f 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -355,7 +355,7 @@ registry.RegisterAsync((request, context) => ); ``` -**Note**: You cannot use `AutoFromAssemblies()` with Agreement Dispatcher - must use `Handlers()` method. +**Note**: If you also call `AutoFromAssemblies()`, pass the agreement's handlers in `excludeDynamicHandlerTypes`. Otherwise the scan registers each of them as a fixed route beside the agreement, and sending the request throws *"More than one handler was found"*. See: [Agreement Dispatcher](/contents/AgreementDispatcher.md) @@ -374,7 +374,7 @@ ICommand command = new GreetingCommand("Ian"); commandProcessor.Send(command); ``` -Then you will get this error: *\"ArgumentException \"No command handler was found for the typeof command Brighter.commandprocessor.ICommand - a command should have exactly one handler.\"\"* +Then you will get this error: *\"ArgumentException \"No command handler was found for the typeof command Paramore.Brighter.ICommand - a command should have exactly one handler.\"\"* Now, you don\'t see this issue if you pass the concrete type in, so the compiler can correctly resolve the run-time type. diff --git a/contents/HowConfiguringTheDispatcherWorks.md b/contents/HowConfiguringTheDispatcherWorks.md index 0d0c3cc..315f49b 100644 --- a/contents/HowConfiguringTheDispatcherWorks.md +++ b/contents/HowConfiguringTheDispatcherWorks.md @@ -36,11 +36,11 @@ The default message mapper typically handles serializing and deserializing messa If you are using a custom Message Mapper, then you need to register your [Message Mapper](/contents/MessageMappers.md) so that we can find it. The registry must implement **IAmAMessageMapperRegistry**. We recommend using Brighter's **MessageMapperRegistry** unless you have more specific requirements. -``` csharp -var messageMapperRegistry = new MessageMapperRegistry(messageMapperFactory) -{ - { typeof(GreetingCommand), typeof(GreetingCommandMessageMapper) } -}; +```csharp +using Paramore.Brighter; + +var messageMapperRegistry = new MessageMapperRegistry(messageMapperFactory, null); +messageMapperRegistry.Register(); ``` ### Channel Factory @@ -51,7 +51,7 @@ The Channel Factory is where we take a dependency on a specific Broker. We pass This code fragment shows putting the whole thing together -``` csharp +```csharp using System; using Paramore.Brighter; using Paramore.Brighter.MessagingGateway.RMQ.Sync; @@ -102,9 +102,11 @@ _dispatcher = DispatchBuilder.StartNew() **Two details in that block will bite you if you change them.** The subscription is typed `RmqSubscription` rather than `Subscription`, because a transport's channel factory casts to its own subscription type and throws `ConfigurationException` when the cast fails — code that -compiles perfectly and dies at `Receive()`. And `messagePumpType` is set explicitly to -`Reactor`: `Subscription` defaults to `Proactor`, which needs the *async* mapper registry, -and the third and fourth arguments to `MessageMappers` here are `null`. +compiles perfectly and dies at `Receive()`. And `messagePumpType` is `Reactor`, which is also +`RmqSubscription`'s default, though `Subscription` defaults to `Proactor`. A `Proactor` needs +the *async* mapper registry, and the third and fourth arguments to `MessageMappers` here are +`null`, so switching the pump throws `ConfigurationException` at `Receive()`, *"You must provide a +message mapper registry for the Dispatcher to work"*. ## Validating Consumer Configuration @@ -131,25 +133,28 @@ The following code shows an example of using the **Dispatcher** from Topshelf. T We do allow you to start and stop individual channels, but this is an advanced feature for operating the services. -``` csharp +```csharp +using Paramore.Brighter.ServiceActivator; +using Topshelf; + internal class GreetingService : ServiceControl { - private Dispatcher _dispatcher; + private Dispatcher? _dispatcher; public GreetingService() { - /* Configfuration Code Goes here*/ + // ... configure the Dispatcher, as above } public bool Start(HostControl hostControl) { - _dispatcher.Receive(); + _dispatcher!.Receive(); return true; } public bool Stop(HostControl hostControl) { - _dispatcher.End().Wait(); + _dispatcher!.End().Wait(); _dispatcher = null; return false; } diff --git a/contents/ImplementAQueryHandler.md b/contents/ImplementAQueryHandler.md index 31ab033..3010bce 100644 --- a/contents/ImplementAQueryHandler.md +++ b/contents/ImplementAQueryHandler.md @@ -176,7 +176,7 @@ public sealed class GetPeopleQueryHandler : QueryHandlerAsync> ExecuteAsync( GetPeopleQuery query, CancellationToken cancellationToken = default) @@ -436,6 +436,8 @@ The assembly scanner looks for: For fine-grained control, register handlers explicitly using `QueryHandlerRegistry`: ```csharp +using System; +using System.Collections.Generic; using Paramore.Darker; using Paramore.Darker.Builder; @@ -448,12 +450,16 @@ registry.Register(); // Build the query processor IQueryProcessor queryProcessor = QueryProcessorBuilder.With() - .Handlers(registry, Activator.CreateInstance, t => {}, Activator.CreateInstance) + .Handlers( + registry, + t => (IQueryHandler)Activator.CreateInstance(t)!, + t => { }, + t => (IQueryHandlerDecorator)Activator.CreateInstance(t)!) .InMemoryQueryContextFactory() .Build(); ``` -Manual registration is useful when: +`Activator.CreateInstance` returns `object`, so each factory casts to Darker's interface, and each handler needs a parameterless constructor. Manual registration is useful when: - You need precise control over which handlers are registered - You're not using ASP.NET Core's dependency injection diff --git a/contents/ParameterizedQueryPatterns.md b/contents/ParameterizedQueryPatterns.md index 121b436..5ea1f27 100644 --- a/contents/ParameterizedQueryPatterns.md +++ b/contents/ParameterizedQueryPatterns.md @@ -18,6 +18,7 @@ Query recipes that take parameters: looking up a single entity, filtering a list This is the most common query pattern - retrieving one entity when you have its ID or another unique key. ```csharp +using System; using Paramore.Darker; // Query by primary key @@ -63,6 +64,7 @@ public sealed class GetOrderLineQuery : IQuery ```csharp using Microsoft.EntityFrameworkCore; using Paramore.Darker; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -133,7 +135,7 @@ using Paramore.Darker; using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Task; +using System.Threading.Tasks; public sealed class GetOrdersByCustomerQueryHandler : QueryHandlerAsync> diff --git a/contents/ProjectionQueryPatterns.md b/contents/ProjectionQueryPatterns.md index 3bcf67c..518333a 100644 --- a/contents/ProjectionQueryPatterns.md +++ b/contents/ProjectionQueryPatterns.md @@ -19,6 +19,7 @@ How to return only the fields a caller needs: simple projections, projections ac using Microsoft.EntityFrameworkCore; using Paramore.Darker; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -71,6 +72,8 @@ public sealed class GetCustomerSummariesQueryHandler : ```csharp using Microsoft.EntityFrameworkCore; using Paramore.Darker; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; diff --git a/contents/QueryHandlerDependencies.md b/contents/QueryHandlerDependencies.md index 93c7537..45614c0 100644 --- a/contents/QueryHandlerDependencies.md +++ b/contents/QueryHandlerDependencies.md @@ -16,7 +16,7 @@ Query handlers typically need dependencies like repositories, database contexts, Inject dependencies through the handler's constructor: ```csharp -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Paramore.Darker; using System.Threading; using System.Threading.Tasks; @@ -93,7 +93,9 @@ public sealed class GetCustomerWithOrdersQueryHandler : QueryHandlerAsync { options.QueryProcessorLifetime = ServiceLifetime.Scoped; @@ -106,7 +108,10 @@ builder.Services.AddDarker(options => Handlers can have multiple dependencies injected: ```csharp -// ... +using Paramore.Darker; +using System.Threading; +using System.Threading.Tasks; + public sealed class GetOrderSummaryQueryHandler : QueryHandlerAsync { private readonly IOrderRepository _orderRepository; diff --git a/contents/QueryPatterns.md b/contents/QueryPatterns.md index 38ac140..954ab14 100644 --- a/contents/QueryPatterns.md +++ b/contents/QueryPatterns.md @@ -163,7 +163,7 @@ public sealed class GetProductCatalogQueryHandler : } [QueryLogging(step: 1)] - [RetryableQuery(step: 2, circuitBreakerName: "DatabaseCircuitBreaker")] + [RetryableQuery(step: 2)] public override async Task> ExecuteAsync( GetProductCatalogQuery query, CancellationToken cancellationToken = default) diff --git a/contents/QueryPipeline.md b/contents/QueryPipeline.md index e70b2ad..028b733 100644 --- a/contents/QueryPipeline.md +++ b/contents/QueryPipeline.md @@ -63,6 +63,7 @@ The order in which decorators execute is controlled by the **step number** speci ```csharp using Paramore.Darker; +using Paramore.Darker.Attributes; using Paramore.Darker.Policies; using Paramore.Darker.QueryLogging; using System.Threading; @@ -72,13 +73,14 @@ public sealed class GetPersonQueryHandler : QueryHandlerAsync ExecuteAsync( GetPersonNameQuery query, CancellationToken cancellationToken = default) { - // Your query logic here + // ... your query logic here // This executes LAST, after all decorators + return string.Empty; } } ``` @@ -87,7 +89,7 @@ public sealed class GetPersonQueryHandler : QueryHandlerAsync> ExecuteAsync( GetPeopleQuery query, CancellationToken cancellationToken = default) @@ -236,9 +238,9 @@ public sealed class GetPeopleQueryHandler : QueryHandlerAsync ExecuteAsync( GetPersonNameQuery query, CancellationToken cancellationToken = default) @@ -332,7 +335,7 @@ The fallback method should: #### Circuit Breaker Integration -Both `RetryableQuery` and custom policies can integrate with Polly circuit breakers. A circuit breaker prevents your application from repeatedly attempting operations that are likely to fail, giving failing systems time to recover. +A Polly circuit breaker is a policy like any other: register it under a name, and pass that name to `RetryableQuery`. A circuit breaker prevents your application from repeatedly attempting operations that are likely to fail, giving failing systems time to recover. **Circuit Breaker States:** @@ -350,10 +353,11 @@ Both `RetryableQuery` and custom policies can integrate with Polly circuit break **Usage:** -Circuit breakers are specified by name in the `RetryableQuery` attribute: +The `RetryableQuery` attribute runs only the policy it names, so naming a circuit breaker on its own gives you a breaker with no retry. To retry and break, name a policy that wraps a retry around a breaker, as [Configuring Polly Policies](/contents/QueryPipelinePolicies.md#advanced-query-policy-configurations) shows: ```csharp -[RetryableQuery(2, "ExternalApiCircuitBreaker")] +// ... +[RetryableQuery(2, "ExternalApiRetryAndBreak")] public override async Task ExecuteAsync( GetOrderQuery query, CancellationToken cancellationToken = default) @@ -362,7 +366,7 @@ public override async Task ExecuteAsync( } ``` -You can use different circuit breakers for different types of failures or different external dependencies. See [Configuring Polly Policies](/contents/QueryPipelinePolicies.md) for how to define circuit breakers. +You can register a different policy for each external dependency, and name it on that dependency's handlers. See [Configuring Polly Policies](/contents/QueryPipelinePolicies.md) for how to define them. ### Custom Decorators @@ -406,7 +410,7 @@ public sealed class GetPeopleQueryHandler : QueryHandlerAsync> ExecuteAsync( GetPeopleQuery query, CancellationToken cancellationToken = default) @@ -427,7 +431,7 @@ public sealed class GetPeopleQueryHandler : QueryHandlerAsync ExecuteAsync( GetPersonNameQuery query, CancellationToken cancellationToken = default) @@ -492,7 +497,7 @@ public sealed class GetPersonQueryHandler : QueryHandlerAsync FallbackAsync(Query query, ...) Each decorator should have a single responsibility. Compose multiple simple decorators rather than creating complex custom decorators. -**5. Use named circuit breakers for different failure types** +**5. Use named policies for different dependencies** -Create separate circuit breakers for different external dependencies or failure scenarios: +Register a separate circuit breaker for each external dependency, and name it on that dependency's handlers: ```csharp [RetryableQuery(2, "DatabaseCircuitBreaker")] // For database queries [RetryableQuery(2, "ExternalApiCircuitBreaker")] // For API queries @@ -612,14 +618,16 @@ Putting retry before logging means individual retry attempts won't be logged. Pu ❌ Bad: ```csharp -[RetryableQuery(1, "CB")] +// ... +[RetryableQuery(1)] [QueryLogging(2)] // Won't log individual retries ``` ✅ Good: ```csharp +// ... [QueryLogging(1)] // Logs everything including retries -[RetryableQuery(2, "CB")] +[RetryableQuery(2)] ``` **2. Forgetting to configure policies** @@ -640,9 +648,9 @@ builder.Services.AddDarker() .AddDefaultPolicies(); ``` -**3. Circuit breaker naming mismatches** +**3. Policy naming mismatches** -Referencing a circuit breaker name that doesn't exist in the policy registry will cause runtime errors. +Naming a policy that doesn't exist in the policy registry throws a `ConfigurationException`, *"Policy does not exist in policy registry"*, the first time the handler runs. ❌ Bad: ```csharp diff --git a/contents/QueryPipelinePolicies.md b/contents/QueryPipelinePolicies.md index 8ee8af7..8bc2d9b 100644 --- a/contents/QueryPipelinePolicies.md +++ b/contents/QueryPipelinePolicies.md @@ -179,6 +179,41 @@ var circuitBreaker = Policy }); ``` +**Retry and circuit breaker in one policy:** + +`[RetryableQuery]` runs the one policy it names, so a handler that names a circuit breaker gets no retry. To retry *and* break, wrap the two and register the wrap under its own name: + +```csharp +using System; +using Paramore.Darker.Policies; +using Polly; +using Polly.Registry; + +var retry = Policy + .Handle() + .WaitAndRetryAsync(new[] + { + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(150) + }); + +var breaker = Policy + .Handle() + .CircuitBreakerAsync( + exceptionsAllowedBeforeBreaking: 2, + durationOfBreak: TimeSpan.FromSeconds(30)); + +var policyRegistry = new PolicyRegistry +{ + { Constants.RetryPolicyName, retry }, + { Constants.CircuitBreakerPolicyName, breaker }, + { "ExternalApiRetryAndBreak", Policy.WrapAsync(retry, breaker) } +}; +``` + +A handler then names the wrap, `[RetryableQuery(2, "ExternalApiRetryAndBreak")]`. The retry is on the outside, so every attempt passes through the breaker: after two failures the breaker opens, and the retries left fail at once with `BrokenCircuitException`, without reaching your handler. `AddPolicies()` requires both `Constants` names in the registry, whatever else you register, and throws a `ConfigurationException` if either is missing. + For more information on Polly policies, see the [Polly documentation](https://github.com/App-vNext/Polly) and the Brighter documentation on [Supporting Retry and Circuit Breaker](/contents/PolicyRetryAndCircuitBreaker.md). ## Further Reading diff --git a/tools/blockcheck/scaffold/pages.tsv b/tools/blockcheck/scaffold/pages.tsv index 39375f5..ed0399b 100644 --- a/tools/blockcheck/scaffold/pages.tsv +++ b/tools/blockcheck/scaffold/pages.tsv @@ -89,3 +89,11 @@ contents/MongoDbDistributedLock.md DistributedLockProviderContext - contents/MsSqlDistributedLock.md DistributedLockProviderContext - contents/MySqlDistributedLock.md DistributedLockProviderContext - contents/PostgresDistributedLock.md DistributedLockProviderContext - +contents/AgreementDispatcherRouting.md AgreementDispatcherRoutingContext - +contents/AggregationQueryPatterns.md DarkerQueryPatternsContext - +contents/ProjectionQueryPatterns.md DarkerQueryPatternsContext - +contents/ParameterizedQueryPatterns.md DarkerQueryPatternsContext - +contents/QueryHandlerDependencies.md DarkerQueryPatternsContext - +contents/DarkerConfigurationReference.md DarkerConfigurationReferenceContext - +contents/HowConfiguringTheDispatcherWorks.md HowConfiguringTheDispatcherWorksContext - +contents/CQRSUseCasesAndPatterns.md CQRSUseCasesAndPatternsContext - diff --git a/tools/blockcheck/scaffold/units/AgreementDispatcherRoutingContext.cs b/tools/blockcheck/scaffold/units/AgreementDispatcherRoutingContext.cs new file mode 100644 index 0000000..ad4e093 --- /dev/null +++ b/tools/blockcheck/scaffold/units/AgreementDispatcherRoutingContext.cs @@ -0,0 +1,111 @@ +// Types and values AgreementDispatcherRouting.md names in its blocks and never declares. +// +// The page explains routing by content through five scenarios, each registering a route 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 AgreementDispatcherRoutingContext; + +using System; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +public static class AgreementDispatcherRoutingContext +{ + // blocks 1, 2, 8, 9: `services.AddBrighter(…)` + public static IServiceCollection services => null!; + + // blocks 3–7, 10, 11: `registry.Register<…>(…)`, `registry.RegisterAsync<…>(…)` + public static ServiceCollectionSubscriberRegistry registry => null!; +} + +// blocks 1, 2, 8–11: `registry.Register`, `command?.Priority`, `cmd?.Type` +public class MyCommand() : Command(Id.Random()) +{ + public string? Priority { get; set; } + public string? Type { get; set; } +} + +// block 1: `registry.Register()` +public class MyCommandHandler : RequestHandler; + +// block 2 +public class HighPriorityHandler : RequestHandler; +public class StandardHandler : RequestHandler; + +// blocks 8–10 +public class Handler1 : RequestHandler; +public class Handler2 : RequestHandler; +public class Handler3 : RequestHandler; + +// block 11 +public class FastHandler : RequestHandler; +public class SlowHandler : RequestHandler; + +// block 9: `registry.Register()` +public class OtherCommand() : Command(Id.Random()); +public class OtherCommandHandler : RequestHandler; + +// blocks 3, 5: `order?.OrderDate`, `order?.Type`, `order?.IsPreOrder`, `order?.ContainsHazardousMaterials` +public class ProcessOrder() : Command(Id.Random()) +{ + public DateTime? OrderDate { get; set; } + public OrderType Type { get; set; } + public bool IsPreOrder { get; set; } + public bool ContainsHazardousMaterials { get; set; } +} + +// block 5: `OrderType.Digital` +public enum OrderType { Digital } + +// block 3 +public class LegacyTaxOrderHandler : RequestHandler; +public class ModernTaxOrderHandler : RequestHandler; + +// block 5 +public class DigitalOrderHandler : RequestHandler; +public class PreOrderHandler : RequestHandler; +public class HazmatOrderHandler : RequestHandler; +public class StandardOrderHandler : RequestHandler; + +// block 4: `payment?.Country` +public class ProcessPayment() : Command(Id.Random()) +{ + public string? Country { get; set; } +} + +// block 4 +public class USPaymentHandler : RequestHandler; +public class UKPaymentHandler : RequestHandler; +public class EUPaymentHandler : RequestHandler; +public class JapanPaymentHandler : RequestHandler; +public class InternationalPaymentHandler : RequestHandler; + +// block 6: `createUser?.ApiVersion` +public class CreateUser() : Command(Id.Random()) +{ + public string? ApiVersion { get; set; } +} + +// block 6 +public class CreateUserV1HandlerAsync : RequestHandlerAsync; +public class CreateUserV2HandlerAsync : RequestHandlerAsync; +public class CreateUserV3HandlerAsync : RequestHandlerAsync; +public class CreateUserLatestHandlerAsync : RequestHandlerAsync; + +// block 7: `refund?.OrderStatus` +public class ProcessRefund() : Command(Id.Random()) +{ + public OrderStatus? OrderStatus { get; set; } +} + +// block 7: `OrderStatus.Pending`, `.Shipped`, `.Delivered`, `.PartiallyReturned` +public enum OrderStatus { Pending, Shipped, Delivered, PartiallyReturned } + +// block 7 +public class CancelOrderRefundHandler : RequestHandler; +public class ReturnAndRefundHandler : RequestHandler; +public class FullRefundHandler : RequestHandler; +public class PartialRefundHandler : RequestHandler; diff --git a/tools/blockcheck/scaffold/units/CQRSUseCasesAndPatternsContext.cs b/tools/blockcheck/scaffold/units/CQRSUseCasesAndPatternsContext.cs new file mode 100644 index 0000000..5dc018b --- /dev/null +++ b/tools/blockcheck/scaffold/units/CQRSUseCasesAndPatternsContext.cs @@ -0,0 +1,26 @@ +// Types CQRSUseCasesAndPatterns.md names in its blocks and never declares. +// +// Block 1 declares the write model, read model, query and context; the order's items, its +// customer and its status are the domain around them, which the page never shows. Block 2's query +// returns a DTO it never shows. Each stub carries only the members a block names, and none names +// a type a block declares, so the unit compiles beside either block. + +// block 1: `new OrderItem(productId, quantity, price)`, `i.Price * i.Quantity`. No block reads +// `productId` back, so it is a parameter and not a property +public class OrderItem(int productId, int quantity, decimal price) +{ + public int Quantity { get; } = quantity; + public decimal Price { get; } = price; +} + +// block 1: `o.Customer.Name` +public class Customer +{ + public string Name { get; set; } = ""; +} + +// block 1: `OrderStatus.Shipped`, `OrderStatus.Cancelled` +public enum OrderStatus { Shipped, Cancelled } + +// block 2: `IQuery` +public class OrderApprovalDto { } diff --git a/tools/blockcheck/scaffold/units/DarkerConfigurationReferenceContext.cs b/tools/blockcheck/scaffold/units/DarkerConfigurationReferenceContext.cs new file mode 100644 index 0000000..f12a2ba --- /dev/null +++ b/tools/blockcheck/scaffold/units/DarkerConfigurationReferenceContext.cs @@ -0,0 +1,32 @@ +// Types and values DarkerConfigurationReference.md names in its blocks and never declares. +// +// The page registers the query handlers Implementing a Query Handler writes, so it names the queries +// and handlers and never shows them. A handler stub is a handler of its query and nothing more: +// abstract, so it need not declare the `Execute` no block names. +// +// blockcheck: using static DarkerConfigurationReferenceContext; + +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Paramore.Darker; + +public static class DarkerConfigurationReferenceContext +{ + // block 3: `builder.Services.AddDarker()` + public static WebApplicationBuilder builder => null!; +} + +// blocks 3, 4: `typeof(GetPeopleQuery).Assembly`, `registry.Register()` +public class GetPeopleQuery : IQuery> { } + +// block 3: `typeof(GetOrdersQuery).Assembly` +public class GetOrdersQuery { } + +// block 4: `registry.Register()` +public class GetPersonNameQuery : IQuery { } + +// block 4: `registry.Register, GetPeopleQueryHandler>()` +public abstract class GetPeopleQueryHandler : QueryHandler> { } + +// block 4: `registry.Register()` +public abstract class GetPersonQueryHandler : QueryHandler { } diff --git a/tools/blockcheck/scaffold/units/DarkerQueryPatternsContext.cs b/tools/blockcheck/scaffold/units/DarkerQueryPatternsContext.cs new file mode 100644 index 0000000..b2e0a50 --- /dev/null +++ b/tools/blockcheck/scaffold/units/DarkerQueryPatternsContext.cs @@ -0,0 +1,120 @@ +// Types DarkerQueryPatternsContext's four pages name in their blocks and never declare: +// AggregationQueryPatterns.md, ProjectionQueryPatterns.md, ParameterizedQueryPatterns.md and +// QueryHandlerDependencies.md. +// +// Their handlers query one EF Core model the pages never show — customers, orders, their items and +// products, categories — through an `ApplicationDbContext`, or through repositories and services +// over it. Each stub carries only the members a block names. `Order` is read as a type the pages +// never show (spec 017 task 1.10), never as `StackExchange.Redis.Order`. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Paramore.Darker; + +// Aggregation 1–3, Projection 1, 2, QueryHandlerDependencies 2: `_dbContext.Categories`, `.Orders`, `.Customers` +public class ApplicationDbContext : DbContext +{ + public DbSet Categories { get; set; } = null!; + public DbSet Orders { get; set; } = null!; + public DbSet Customers { get; set; } = null!; +} + +// Aggregation 1: `ToDictionaryAsync(c => c.Id, c => c.Name, …)` into `IReadOnlyDictionary` +public class Category +{ + public int Id { get; set; } + public string Name { get; set; } = ""; +} + +// Aggregation 2, 3, Projection 2, QueryHandlerDependencies 4: `o.Status`, `o.CustomerId`, `o.OrderDate`, +// `o.Items`, `o.Id`, `o.Customer`, `o.ShippingAddress`; `order.CustomerId`. No block names the type of +// an item or an address, only their members (`i.Quantity`, `i.UnitPrice`, `i.Product`, +// `o.ShippingAddress.Street`, `.City`), so each is a tuple rather than a stub type +public class Order +{ + public int Id { get; set; } + public int CustomerId { get; set; } + public DateTime OrderDate { get; set; } + public OrderStatus Status { get; set; } + public Customer Customer { get; set; } = null!; + public (string Street, string City) ShippingAddress { get; set; } + public List<(int Quantity, decimal UnitPrice, Product Product)> Items { get; set; } = new(); +} + +// Aggregation 2: `OrderStatus.Pending` +public enum OrderStatus { Pending } + +// Projection 2: `.ThenInclude(i => i.Product)`, `i.Product.Name` +public class Product +{ + public string Name { get; set; } = ""; +} + +// Projection 1, 2, QueryHandlerDependencies 2: `c.Id`, `c.Name`, `c.Email`, `c.Orders.Count` +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + public List Orders { get; set; } = new(); +} + +// Parameterized 1: `IQuery`; QueryHandlerDependencies 2: `new CustomerDto { Id, Name, OrderCount }` +public class CustomerDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public int OrderCount { get; set; } +} + +// Parameterized 1: `IQuery` +public class OrderLineDto { } + +// QueryHandlerDependencies 1: `QueryHandlerAsync`, `query.OrderId` +public class GetOrderQuery : IQuery +{ + public int OrderId { get; set; } +} + +// QueryHandlerDependencies 2: `QueryHandlerAsync`, `query.CustomerId` +public class GetCustomerWithOrdersQuery : IQuery +{ + public int CustomerId { get; set; } +} + +// QueryHandlerDependencies 4: `QueryHandlerAsync`, `query.OrderId` +public class GetOrderSummaryQuery : IQuery +{ + public int OrderId { get; set; } +} + +// QueryHandlerDependencies 4: `_mapper.Map(…)` +public class OrderSummary { } + +// QueryHandlerDependencies 1, 4: `_repository.GetByIdAsync(query.OrderId, cancellationToken)` +public interface IOrderRepository +{ + Task GetByIdAsync(int orderId, CancellationToken cancellationToken); +} + +// QueryHandlerDependencies 4: `_customerRepository.GetByIdAsync(order.CustomerId, cancellationToken)` +public interface ICustomerRepository +{ + Task GetByIdAsync(int customerId, CancellationToken cancellationToken); +} + +// QueryHandlerDependencies 4: `_pricingService.CalculateTotalAsync(order, cancellationToken)` +public interface IPricingService +{ + Task CalculateTotalAsync(Order order, CancellationToken cancellationToken); +} + +// QueryHandlerDependencies 4: `_mapper.Map((order, customer, pricing))` — an object +// mapper the page never names, shaped as AutoMapper's `IMapper.Map(object)` +public interface IMapper +{ + TDestination Map(object source); +} diff --git a/tools/blockcheck/scaffold/units/HowConfiguringTheDispatcherWorksContext.cs b/tools/blockcheck/scaffold/units/HowConfiguringTheDispatcherWorksContext.cs new file mode 100644 index 0000000..5b82b43 --- /dev/null +++ b/tools/blockcheck/scaffold/units/HowConfiguringTheDispatcherWorksContext.cs @@ -0,0 +1,31 @@ +// Types and values HowConfiguringTheDispatcherWorks.md names in its blocks and never declares. +// +// The page builds a Dispatcher by hand for a `GreetingCommand` and its mapper, from a handler +// factory, subscriber registry and mapper factory that How Configuring the Command Processor Works +// sets up. The mapper stub derives from Brighter's own `JsonMessageMapper`, so it declares no +// member of its own: no block names one. +// +// blockcheck: using static HowConfiguringTheDispatcherWorksContext; + +using Paramore.Brighter; +using Paramore.Brighter.MessageMappers; +using Paramore.Brighter.ServiceActivator; + +public static class HowConfiguringTheDispatcherWorksContext +{ + // blocks 1, 2: `new MessageMapperRegistry(messageMapperFactory, null)` + public static IAmAMessageMapperFactory messageMapperFactory => null!; + + // block 2: `new HandlerConfiguration(subscriberRegistry, handlerFactory)` + public static IAmASubscriberRegistry subscriberRegistry => null!; + public static IAmAHandlerFactory handlerFactory => null!; + + // block 2: `_dispatcher = DispatchBuilder.StartNew()…Build();` + public static IDispatcher? _dispatcher; +} + +// blocks 1, 2: `Register()`, `RmqSubscription` +public class GreetingCommand() : Command(Id.Random()); + +// blocks 1, 2: `Register()` +public class GreetingCommandMessageMapper : JsonMessageMapper; From 09f58487b9a25a3ab77bd5b12ce2875de937215c Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:01:34 +0100 Subject: [PATCH 03/27] =?UTF-8?q?spec:=20017=20task=205.2=20=E2=80=94=20ba?= =?UTF-8?q?seline=20rows=20for=20the=20blocks=201cafef9=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 rows at 1cafef9, and three re-admissions: ParameterizedQueryPatterns.md #3, #5 and ProjectionQueryPatterns.md #4, BUILT before and now compiled with DarkerQueryPatternsContext.cs. --report: exit 0, 985 blocks: 262 BUILT, 706 FAILED, 17 SKIPPED; 40 units, 0 violations. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 8016b14..9b979e0 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -139,8 +139,8 @@ 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 - 280d1b7 -contents/ParameterizedQueryPatterns.md 5 - 280d1b7 +contents/ParameterizedQueryPatterns.md 3 DarkerQueryPatternsContext.cs 1cafef9 +contents/ParameterizedQueryPatterns.md 5 DarkerQueryPatternsContext.cs 1cafef9 contents/PolicyRetryAndCircuitBreaker.md 16 PolicyRetryAndCircuitBreakerContext.cs 280d1b7 contents/PostgreSQLMessageBroker.md 11 - 280d1b7 contents/PostgreSQLTransportAndOutbox.md 1 RelationalTransportContext.cs 280d1b7 @@ -153,7 +153,7 @@ contents/PostgreSQLTransportAndOutbox.md 7 RelationalTransportContext.cs 280d1b7 contents/PostgresOutbox.md 1 RelationalOutboxContext.cs a8ede7c contents/PostgresOutbox.md 2 RelationalOutboxContext.cs a8ede7c contents/PostgresOutbox.md 3 RelationalOutboxContext.cs a8ede7c -contents/ProjectionQueryPatterns.md 4 - 280d1b7 +contents/ProjectionQueryPatterns.md 4 DarkerQueryPatternsContext.cs 1cafef9 contents/QueriesAndQueryObjects.md 1 - 280d1b7 contents/QueriesAndQueryObjects.md 2 - 280d1b7 contents/QueriesAndQueryObjects.md 3 - 280d1b7 @@ -259,3 +259,31 @@ contents/UsingSweeperCircuitBreaking.md 5 UsingSweeperCircuitBreakingContext.cs contents/SweeperCircuitBreaking.md 2 SweeperCircuitBreakingContext.cs a61893b contents/SweeperCircuitBreaking.md 7 SweeperCircuitBreakingContext.cs a61893b contents/SweeperCircuitBreaking.md 5 SweeperCircuitBreakingContext.cs a61893b +contents/AggregationQueryPatterns.md 1 DarkerQueryPatternsContext.cs 1cafef9 +contents/AggregationQueryPatterns.md 2 DarkerQueryPatternsContext.cs 1cafef9 +contents/AggregationQueryPatterns.md 3 DarkerQueryPatternsContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 1 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 2 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 3 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 4 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 5 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 6 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 7 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 8 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 9 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 10 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/AgreementDispatcherRouting.md 11 AgreementDispatcherRoutingContext.cs 1cafef9 +contents/BuildingAPipeline.md 1 - 1cafef9 +contents/CQRSUseCasesAndPatterns.md 1 CQRSUseCasesAndPatternsContext.cs 1cafef9 +contents/CQRSUseCasesAndPatterns.md 2 CQRSUseCasesAndPatternsContext.cs 1cafef9 +contents/DarkerConfigurationReference.md 3 DarkerConfigurationReferenceContext.cs 1cafef9 +contents/DarkerConfigurationReference.md 4 DarkerConfigurationReferenceContext.cs 1cafef9 +contents/HowConfiguringTheDispatcherWorks.md 1 HowConfiguringTheDispatcherWorksContext.cs 1cafef9 +contents/HowConfiguringTheDispatcherWorks.md 2 HowConfiguringTheDispatcherWorksContext.cs 1cafef9 +contents/ParameterizedQueryPatterns.md 1 DarkerQueryPatternsContext.cs 1cafef9 +contents/ProjectionQueryPatterns.md 1 DarkerQueryPatternsContext.cs 1cafef9 +contents/ProjectionQueryPatterns.md 2 DarkerQueryPatternsContext.cs 1cafef9 +contents/QueryHandlerDependencies.md 1 DarkerQueryPatternsContext.cs 1cafef9 +contents/QueryHandlerDependencies.md 2 DarkerQueryPatternsContext.cs 1cafef9 +contents/QueryHandlerDependencies.md 4 DarkerQueryPatternsContext.cs 1cafef9 +contents/QueryPipelinePolicies.md 7 - 1cafef9 From 623c7866f4c376cc0d58a128e498c55dddd00988 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:05:45 +0100 Subject: [PATCH 04/27] =?UTF-8?q?spec:=20017=20task=205.2=20=E2=80=94=20te?= =?UTF-8?q?n=20pages=20repaired,=20recorded;=2030=20of=2042?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILT 234 -> 262; pagelint 616 -> 599; pages with nothing BUILT 57 -> 49. Nine defects and the carried [RetryableQuery] row in the ledger, twelve blocks listed as staying FAILED, one split, and four findings off the tranche put to the maintainer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 141 +++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 9787929..e17d096 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1718,7 +1718,7 @@ everywhere. Page list: § *The tranches*, phase 5 table. as 4.1; and the P0-7 predictions — `ITimerProvider` 4 → 0, `attr_mismatch` to exactly the deliberate `PipelineValidation.md:250` -- [ ] **Task 5.2:** Repair the tranche pages in *Commands, Handlers and Pipelines*, *Darker* and *Understanding Brighter* +- [x] **Task 5.2:** Repair the tranche pages in *Commands, Handlers and Pipelines*, *Darker* and *Understanding Brighter* - Input: those sections' phase 5 rows; 5.1's verdicts - Output: each page whole; baseline rows; ledger rows as 4.2 @@ -1902,6 +1902,120 @@ a defect beside its placeholder**: `DarkerAndBrighterPipelines.md` #1. the word only (`CQRSUseCasesAndPatterns.md` #1, `PostgreSQLMessageBroker.md` #4). Both are read as a type the page never shows, and get a stub, never `using StackExchange.Redis` +**Task 5.2 — the *Commands, Handlers and Pipelines*, *Darker* and *Understanding Brighter* pages.** +Ten pages. **BUILT 234 → 262** (+28): **27** `FAILED -> BUILT` on nine of the ten, and one new key, +`QueryPipelinePolicies.md` #7, appended. The rest of the AC2 diff, joined on page and ordinal +against the report at `d69a554`: `DarkerAndBrighterPipelines.md` #1 `FAILED -> SKIPPED` and +`AgreementDispatcherRouting.md` #12 `- -> FAILED`, a split (§ *Splits*). `pagelint` **616 → 599**, +every one of the 17 on a page whose blocks gained `using`s: `AgreementDispatcherRouting.md` −5, +`BuildingAPipeline.md` −3, `CQRSUseCasesAndPatterns.md`, `DarkerConfigurationReference.md`, +`HowConfiguringTheDispatcherWorks.md`, `QueryHandlerDependencies.md` −2 each, `AgreementDispatcher.md` +−1 (per page, against a worktree at `origin/master`). Pages with nothing BUILT **57 → 49**, by +requirements' `awk` and a Python join over the same report: seven gained a BUILT block +(`AggregationQueryPatterns.md`, `AgreementDispatcherRouting.md`, `BuildingAPipeline.md`, +`CQRSUseCasesAndPatterns.md`, `DarkerConfigurationReference.md`, `HowConfiguringTheDispatcherWorks.md`, +`QueryHandlerDependencies.md`), and `DarkerAndBrighterPipelines.md` left because its one block is now +SKIPPED, not because it built. + +- **Against 5.1's reading, block by block.** The floor was 246: 234 + 1 `using` + 11 empty stubs. + **Said:** `BuildingAPipeline.md` #3 BUILT by a `using` alone. **Measured:** same-page — the `using` + that builds it, `Paramore.Brighter.Logging.Handlers`, names Brighter's own `RequestLoggingHandler<>`, + not the one block 1 writes; #2 likewise resolves `[RequestLogging]` to Brighter's attribute, not + block 3's. **Said:** `DarkerConfigurationReference.md` #1 empty stub, #2 and + `QueryHandlerDependencies.md` #3 typed value. **Measured:** each also names `Program`, which Q2 + rules out. **Said:** `HowConfiguringTheDispatcherWorks.md` #3 empty stub. **Measured:** + `ServiceControl` and `HostControl` are Topshelf's, a package the pin does not carry, and a unit may + not fake a third-party package. **Said:** `BuildingAPipeline.md` #4 members. **Measured:** unit rule + 1 — the section tells the reader to write the handlers it chains. So **7** of the **27** reachable + blocks on these pages stay FAILED, and **7** of their **10** hard blocks built; one is SKIPPED and + two stay FAILED (`ParameterizedQueryPatterns.md` #4, same-page once its `using` is right, and + `ProjectionQueryPatterns.md` #3, a fragment). 20 + 7 + the appended block = **28** +- **Six units.** `AgreementDispatcherRoutingContext.cs` (the scenarios' requests and handlers, + `services`, `registry`), `DarkerQueryPatternsContext.cs` (one EF Core model for four pages: + `AggregationQueryPatterns.md`, `ProjectionQueryPatterns.md`, `ParameterizedQueryPatterns.md`, + `QueryHandlerDependencies.md`), `DarkerConfigurationReferenceContext.cs`, + `HowConfiguringTheDispatcherWorksContext.cs`, `CQRSUseCasesAndPatternsContext.cs`. None is a type a + page tells the reader to write (rule 1, by reading). **Three shapes the rule forced, each recorded + in its unit:** a type no block names but whose members a block reads (an order's items, its + address) is a tuple, not a stub; a Darker handler stub is `abstract`, so it need not declare the + `Execute` no block names — its first form did, and put **8** errors against the scaffold tree, + which `--report` prints as a warning and does not fail on; a mapper stub derives from Brighter's + `JsonMessageMapper` and so declares no member. `CQRSUseCasesAndPatterns.md` #1 now declares its + query and `DbContext` itself, because a unit that named the block's `Order` would not compile + beside block 2. `ParameterizedQueryPatterns.md` #3, #5 and `ProjectionQueryPatterns.md` #4, BUILT + before, are re-admitted with the new unit. `--report` → *"40 units checked, 0 violations"* +- **Made whole:** `AgreementDispatcherRouting.md` #8 (it ended in a commented-out call with no `;`, + and now shows the scan working, below) and the three `{ /* ... */ }` routing lambdas that returned + nothing; `CQRSUseCasesAndPatterns.md` #2, whose commands were `: IRequest { /* ... */ }` and whose + actions sat outside any class, now a controller over `Command`s; `BuildingAPipeline.md` #1 +- **Skipped, with an accepted reason:** `DarkerAndBrighterPipelines.md` #1, two attribute stacks on + signatures shown side by side for comparison. Its `...` parameters are now the real + `CancellationToken`, and its `[RetryableQuery]` the corrected form +- **Nine defects, and the carried `[RetryableQuery]` row closed** (§ *Defect ledger*). The pre-V10 + logging handler, and the recurrence of its namespace in `FAQ.md`'s quoted exception, which a run + gives as `Paramore.Brighter.ICommand`; `.Successor =` for `SetSuccessor()`, and the manual chain + said to work from the registry alone; *"Cannot use AutoFromAssemblies"* with an Agreement + Dispatcher, on three pages; Darker's query processor said to default to Transient, and the + unscoped failure said to be a disposed `DbContext`, on two; `Activator.CreateInstance` as a Darker + factory, on two; `using System.Threading.Task;`; V9's `MessageMapperRegistry` initialiser; + `RmqSubscription`'s pump type given the wrong reason; `CQRSUseCasesAndPatterns.md` #1's handler + reading members its own write model lacks. **`[RetryableQuery]`: 20 lines → 8**, every one left a + policy name that is registered, or a deliberate *"doesn't exist"*, and described as the one policy + the decorator runs; `QueryPipelinePolicies.md` gains the retry-and-breaker wrap it links to +- **`--explain` on all 46 blocks the diff touches**, after `pagelint --changed` asked for `using`s on + four `QueryPipeline.md` fragments (now marked `// ...`): `[FallbackPolicy]` used with no `using + Paramore.Darker.Attributes` on three `QueryPipeline.md` blocks, a placeholder handler body that + returned nothing, and `System.Linq`, `System` and `System.Collections.Generic` missing from + `ProjectionQueryPatterns.md` #1, `ParameterizedQueryPatterns.md` #2 and + `CQRSWithBrighterAndDarker.md` #3. All repaired; what remains on those blocks is names their pages + never show +- **The blocks that stay FAILED compile where their world exists** (§ *Blocks that stay FAILED*): + `DarkerConfigurationReference.md` #1, #2 and `QueryHandlerDependencies.md` #3 as a `Program.cs` + against Darker 4.1.1; `HowConfiguringTheDispatcherWorks.md` #3 beside #2 against Topshelf 4.3.0; + `ParameterizedQueryPatterns.md`'s six blocks together, with the never-shown entity stubbed; + `BuildingAPipeline.md` #1–#3 together in the pipeline run below — each **0** errors +- **Behaviour, run with controls** against released packages (Brighter 10.7.0, Darker 4.1.1, + EF Core 9.0.15) in scratch console apps, net10.0, one process per case: + + | Claim | Case → result | Control → result | + |---|---|---| + | `BuildingAPipeline.md` #1–#3: the attribute puts the logging handler before the target | `AutoFromAssemblies()` → the handler logs, then *"Hello Ian"* | no attribute → *"Hello Ian"* only | + | The new sentence: a custom open generic handler must be registered | `.Handlers(r => r.Register<…>())` plus `AddTransient(typeof(RequestLoggingHandler<>))` → logs | without it → `ConfigurationException`, *"Could not create handler RequestLoggingHandler`1[GreetingCommand]"*; the async form the same, both ways | + | `AutoFromAssemblies` skips a non-public handler (why `BuildingAPipeline.md` says *public*) | public handler → found | `internal` → `ArgumentException`, *"No command handler was found"* | + | #4: the manual chain | a handler factory returning the wired `MyLoggingHandler` → both handlers run | a factory returning a new one → the logging handler only | + | `FAQ.md`: sending through `ICommand` | `ArgumentException`, *"… typeof command Paramore.Brighter.ICommand …"* | the concrete type → handled | + | `AgreementDispatcherRouting.md` #8: scan beside an agreement | `AutoFromAssemblies(excludeDynamicHandlerTypes: […])`, either order → High → `HighPriorityHandler`, Low → `StandardHandler`, a scanned `OtherCommand` handled | no exclusion, either order → every `Send` of `MyCommand` throws *"More than one handler was found"* | + | `DarkerConfigurationReference.md`: the default lifetime and the unscoped failure | `AddDarker()` → `IQueryProcessor` **Singleton**; scope validation on → *"Cannot resolve 'GetContextQueryHandler' from root provider because it requires scoped service"*; off → both scopes' queries see `DbContext` **#1**, never disposed | `QueryProcessorLifetime = Scoped` → **#1**, then **#2** | + | #4 and `ImplementAQueryHandler.md`: manual registration with the casts | the query returns `Ada,Bob` | the old form → `CS1503`, compiled | + | `QueryPipelinePolicies.md` #7: a retry wrapped round a breaker | `[RetryableQuery(1, "RetryAndBreak")]`, breaker of 2 → **2** attempts, then `BrokenCircuitException`; call 2 → **0** attempts | default → **4** attempts; the default breaker alone → **1**, then **0**; `"DefaultCircuitBreaker"` → `ConfigurationException`, 0 | + | *"`AddPolicies()` requires both `Constants` names"* | a registry without the breaker → `ConfigurationException`, *"… missing the Darker.CircuitBreakerPolicy policy"* | both → accepted | + | `HowConfiguringTheDispatcherWorks.md` #2 and its two warnings, against RabbitMQ | the block verbatim → a sent `GreetingCommand` handled **1** time | `Subscription` → `ConfigurationException`, *"We expect an RmqSubscription"*; `Proactor` → *"You must provide a message mapper registry"*; `messagePumpType` omitted → handled, which is why the page now says `Reactor` is `RmqSubscription`'s default | + | The Darker query-pattern handlers against SQL Server 2022 | `AggregationQueryPatterns.md` #1–#3, `ProjectionQueryPatterns.md` #1, #2, `QueryHandlerDependencies.md` #2 → correct results; #3's statistics 155 / 51.67 / 25 / 100 | #3 on an empty range → zeros, its `?? new SalesStatisticsDto()`; `ProjectionQueryPatterns.md` #1's comment, *"SELECT Id, Name, Email only"*, against the whole entity → four columns | + + On SQLite, `AggregationQueryPatterns.md` #3 throws *"SQLite cannot apply aggregate operator 'Min' + on expressions of type 'decimal'"* — the provider's limit, and the page names none. Read, not run: + `CQRSUseCasesAndPatterns.md` #1 joins and aggregates as `ProjectionQueryPatterns.md` #2 does, run + above; its #2 is a controller over the command and query processors +- **Put to the maintainer, not repaired** — each found reading this task's pages, none on its tranche: + **`Monitoring.md`**'s *Config file* section registers `MonitoringConfigurationSection, + Brighter.commandprocessor` in `app.config`; at 10.7.0 that type is a plain class, and the page's + links go to the retired `brightercommand.github.io`. **Topshelf** — pin it, so + `HowConfiguringTheDispatcherWorks.md` #3 builds, or retire the recommendation. **Handlers declared + without `public`**: 18 lines on 7 pages, which `AutoFromAssemblies()` never finds (the run above). + **`DarkerBasicConfiguration.md`'s troubleshooting** says handlers must end in *"Handler"* and not + be nested; Darker 4.1.1 scans `ExportedTypes` for `IQueryHandler<,>` and neither holds. Darker's own + README carries the uncast `Activator.CreateInstance` line (`README.md:110`) +- **`attr_mismatch.py` → 7**, before the baseline rows +- **Baseline:** 28 rows and 3 re-admissions at `1cafef9` (`09f5848`). `--report` → exit **0**, + *"985 blocks: 262 BUILT, 706 FAILED, 17 SKIPPED"*, baseline 262, 0 findings +- `linkcheck` 165 files, 0 broken; `versioncheck` 0 stale of 18 across 5; `symbolcheck` 0 findings, + 22 entries, 3 silenced; `optioncheck` 0 mismatches across 59 tables, 519 rows; shape, redirects and + `--verify` unmoved; `pagelint --changed origin/master` 0 errors. **Pages changed: 18** + (`git diff --name-only d69a554..HEAD -- contents`): **9** of the 10 tranche pages — + `AggregationQueryPatterns.md` built by its unit alone — and **9** by recurrence: `AgreementDispatcher.md`, `BuildingAnAsyncPipeline.md`, `CQRSWithBrighterAndDarker.md`, + `DarkerBasicConfiguration.md`, `FAQ.md`, `ImplementAQueryHandler.md`, `QueryPatterns.md`, + `QueryPipeline.md`, `QueryPipelinePolicies.md` + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2216,6 +2330,18 @@ is rewritten against the tables below. | `PaginationQueryPatterns.md` | 4 | `CS0246` `GetOrdersCursorQuery`, `CursorPagedResult<>`, `OrderDto`; `ApplicationDbContext` | same-page: block 3 declares the first two, block 1 `OrderDto`; block 4 is *"Handler with cursor pagination:"*, straight after block 3 | 3 | | `QueryPipelinePolicies.md` | 1 | `CS0246` `Program` | instrument: a `Program.cs` with no declaration after its statements takes the `statements` wrapper, which declares no `Program`; Q2 (1.8) rules out a stub. Builds as a `Program.cs` in scratch against Darker 4.1.1, **0** errors | 3 | | `ReplayOnSeenReference.md` | 1 | `CS0117` `RequestContextBagNames.CausationId`; `CS0246` `ProcessPayment`; `CS0103` `_commandProcessor`, `batchId`, `orderId` | P2-2: `CausationId` is on Brighter `master` (`RequestContextBagNames.cs:143`), in no release. The pin bump brings it in through the ratchet; the other names are the handler's, and want a unit then | 4 | +| `AgreementDispatcherRouting.md` | 12 | `CS0103` `_database` | the ❌ example calls a database the page never shows, through a type no block names, so unit rule 1 admits no stub. Split from #11 (§ *Splits*) | 5 | +| `BuildingAPipeline.md` | 2 | `CS0246` `GreetingCommand`, `RequestLogging` | same-page: block 3 declares `RequestLoggingAttribute`, and the next sentence says so (*"We implement the **RequestLoggingAttribute** by creating our own Attribute class"*). A `using` of Brighter's `Logging.Attributes` builds it against Brighter's attribute instead | 5 | +| `BuildingAPipeline.md` | 3 | `CS0246` `RequestLoggingHandler<>` | same-page: block 1 declares it. A `using` of Brighter's `Logging.Handlers` builds it against Brighter's handler instead. Blocks 1–3 compile together and run (§ *Phase 5 as executed*, 5.2) | 5 | +| `BuildingAPipeline.md` | 4 | `CS0246` `MyCommand`, `MyCommandHandler`, `MyLoggingHandler`; `CS0103` `log` | **unit rule 1**: the section tells the reader to write the handlers it chains (*"You can derive from **RequestHandler\** and call **base.Handle()**"*). Run in scratch with them written | 5 | +| `DarkerConfigurationReference.md` | 1 | `CS0246` `Program` | instrument: `typeof(Program).Assembly` takes the `statements` wrapper; Q2 (1.8) rules out a stub. Builds as a `Program.cs` against Darker 4.1.1 in scratch, **0** errors | 5 | +| `DarkerConfigurationReference.md` | 2 | `CS0246` `Program` | as #1 | 5 | +| `ParameterizedQueryPatterns.md` | 2 | `CS0246` `GetCustomerByEmailQuery` | same-page: block 1 declares it; block 2 is *"**Handler Example:**"* after it. The six blocks compile together, with the never-shown entity stubbed, at **0** errors | 5 | +| `ParameterizedQueryPatterns.md` | 4 | `CS0246` `GetOrdersByCustomerQuery`, `OrderSummaryDto` | same-page, once its `using System.Threading.Tasks` is right: block 3 declares both; *"**Handler with optional filters:**"* | 5 | +| `ParameterizedQueryPatterns.md` | 6 | `CS0246` `SearchProductsQuery`, `ProductDto` | same-page: block 5 declares both; *"**Handler with multiple optional criteria:**"* | 5 | +| `ProjectionQueryPatterns.md` | 3 | `CS1513`, `CS0103` `Select` | parse — a fragment: the `.Select(…)` of block 2's handler with no receiver, under *"Database-computed fields"*. The reader has the whole in block 2 | 5 | +| `QueryHandlerDependencies.md` | 3 | `CS0246` `Program`; `CS0103` `builder` | instrument, as `DarkerConfigurationReference.md` #1; `builder` is not stubbed, since no BUILT block would name it. Builds as a `Program.cs` against Darker 4.1.1, **0** errors | 5 | +| `HowConfiguringTheDispatcherWorks.md` | 3 | `CS0246` `Topshelf`, `ServiceControl`, `HostControl` | the page hosts the Dispatcher in Topshelf, which the pin does not carry, and a unit may not stand in for a third-party package. Builds beside block 2 against Topshelf 4.3.0 in scratch, **0** errors. Put to the maintainer | 5 | ## Splits @@ -2224,6 +2350,8 @@ is rewritten against the tables below. | Page | Old # | New # | Why | Task | |---|---:|---:|---|---:| | `SchedulingAMessage.md` | 7, 8, 9 | 8, 9, 10 | not a split: a block inserted at #7, the #4414 workaround. All three were FAILED at `c7329bb` and are BUILT now, so the AC2 diff reads #7–#9 as `FAILED -> BUILT` and #10 as a new key | 3.4 | +| `AgreementDispatcherRouting.md` | 11 | 11, 12 | the ✅ and ❌ routing lambdas were one fence. #12's database is a type no block names, so no unit may supply it; apart, #11 builds and #12 is listed. #12 is a new key in the AC2 diff | 5.2 | +| `QueryPipelinePolicies.md` | — | 7 | not a split: a block appended after the page's last, the retry-and-breaker wrap. A new key, BUILT | 5.2 | ## Blocks removed @@ -2278,7 +2406,7 @@ BUILT, re-admitted at `ec38400`. | InMemory cancel and reschedule said to work on a request scheduled through the command processor — each scheduled call gets a new `InMemoryScheduler`, so `CancelAsync` finds nothing and the request runs; `ReSchedulerAsync` returns `False` | run, control same instance; `CommandProcessor.cs:427`, `InMemoryScheduler.cs:57` — **upstream, BrighterCommand/Brighter#4437**, filed 3.4 | `SchedulingAMessage.md`, `FAQ.md`, `InMemoryScheduler.md` (cancel example, `Should_Cancel_Scheduled_Command`) | `grep -rl 'issues/4437' contents/` | **3** pages | **stated** on all 3 — maintainer's ruling | 3.4, running block 4's claim | | Darker's default policies described as *"exponential backoff"* and a breaker that *"opens after consecutive failures"*, and as applying once registered. They retry 3 times after 50, 100 and 150 ms, the breaker opens on 1 failure for 500 ms, and neither runs without `[RetryableQuery]` | Darker 4.1.1 `QueryProcessorBuilderExtensions.cs:51`, `RetryableQueryDecorator.cs`; run, control without the attribute | `QueryPipelinePolicies.md` (the list and block 2's comment) | `grep -rnE 'Retries with exponential backoff\|Opens after consecutive failures\|Retry policy with exponential backoff' contents/` | **3** | **0** | 3.5, reading the page against Darker's source, then running | | *"The ASP.NET model binder will validate these attributes before the query reaches your handler"*. Only a controller marked `[ApiController]`, or a minimal API after `AddValidation()` (.NET 10), rejects the query; elsewhere it reaches the code | run on net10.0, controls both ways | `QueryObjectValidation.md` | `grep -rn 'model binder will validate' contents/` | **1** | **0** | 3.5, running block 2's claim | -| `[RetryableQuery]`'s second argument described and used as a circuit-breaker name that adds a breaker to the retry. It is a policy name, and the decorator runs that one policy. `"DefaultCircuitBreaker"` is not registered by `AddDefaultPolicies()`, so it throws `ConfigurationException`; `circuitBreakerName:` is not a parameter (`CS1739`) | Darker 4.1.1 `RetryableQueryAttribute.cs:11`, `Constants.cs`; run, control `Constants.CircuitBreakerPolicyName`; compiled | `QueryPipeline.md` (4 lines, and the parameter list at line 239), `CQRSWithBrighterAndDarker.md` (2), `DarkerAndBrighterPipelines.md`, `ImplementAQueryHandler.md`, `QueryPatterns.md` | `grep -rnE 'RetryableQuery\(.*(DefaultCircuitBreaker\|circuitBreakerName)' contents/` | **9** lines, 5 pages | **open — phase 5**, maintainer's ruling | 3.5, reading Darker's source for the tranche's policy defaults | +| `[RetryableQuery]`'s second argument described and used as a circuit-breaker name that adds a breaker to the retry. It is a policy name, and the decorator runs that one policy. `"DefaultCircuitBreaker"` is not registered by `AddDefaultPolicies()`, so it throws `ConfigurationException`; `circuitBreakerName:` is not a parameter (`CS1739`) | Darker 4.1.1 `RetryableQueryAttribute.cs:11`, `Constants.cs`; run, control `Constants.CircuitBreakerPolicyName`; compiled | `QueryPipeline.md` (4 lines, and the parameter list at line 239), `CQRSWithBrighterAndDarker.md` (2), `DarkerAndBrighterPipelines.md`, `ImplementAQueryHandler.md`, `QueryPatterns.md` | `grep -rnE 'RetryableQuery\(.*(DefaultCircuitBreaker\|circuitBreakerName)' contents/` | **9** lines, 5 pages; **20** lines, 6 pages, with any second argument (`grep -rnE 'RetryableQuery\([^)]*,' contents/`) | **0**; **8** with any second argument, each a registered policy or the deliberate *"doesn't exist"*, described as the one policy the decorator runs — maintainer's ruling, repaired in 5.2 | 3.5, reading Darker's source for the tranche's policy defaults | | `.ConfigureServices(hostContext, services) =>` — the lambda's parameter list never opened, and its body never closed (`CS1519`, `CS1001`) | compiled, old form `CS1519` | `MSSQLInbox.md`, `MySQLInbox.md`, `PostgresInbox.md`, `SqliteInbox.md`, `DynamoInbox.md`, `AzureBlobArchiveProvider.md`, `BrighterBasicConfiguration.md` ×2, `DispatcherConfigurationReference.md` ×5 | `grep -rn 'ConfigureServices(hostContext, services) =>' contents/` | **13** lines, 8 pages | **0** | 4.1, `--classify` | | `opt.InboxConfiguration` inside `AddConsumers(options => …)` — `CS0103` | compiled | `MySQLInbox.md`, `PostgresInbox.md`, `SqliteInbox.md` | `grep -rn '^\s*opt\.InboxConfiguration' contents/` — **4** before, **1** after, `DynamoInbox.md`'s, whose parameter is `opt` | **3** | **0** | 4.1, a scan of every lambda | | `[UseInboxAsync]` on a handler class — `CS0592`; `RequestHandlerAttribute` is valid on methods only | `RequestHandlerAttribute.cs`, `AttributeUsage(AttributeTargets.Method)` | `InMemoryInbox.md` #2 | `grep -rn -A1 '^\s*\[UseInbox' contents/ \| grep -c class` | **1** | **0** | 4.1, `--explain` | @@ -2295,6 +2423,15 @@ BUILT, re-admitted at `ec38400`. | Cooldown time given as `CooldownCount × interval`, recovery *"when the cooldown reaches zero"* — a topic sits out `CooldownCount` sweeps and is retried on the next, `(CooldownCount + 1) × TimerInterval` | `InMemoryOutboxCircuitBreaker.cs` (removes below zero), `OutboxProducerMediator.cs:721`; run end to end, controls no breaker and `0` | `SweeperCircuitBreaking.md` (formula, example, #2, #7 comments, steps), `UsingSweeperCircuitBreaking.md` #2 | `grep -rnE '(^\|[^+] )[0-9]+ (sweeps )?× [0-9]+s\|total cooldown\|[Rr]ecover after [0-9]\|When the cooldown reaches zero' contents/`, at `05fdeaf` and after | **8** | **0** | 4.4, running the sweeper for the row above | | Circuit breaking said to work with every Outbox, and `.UseMongoDbOutbox(…)` — no such method. The DynamoDB (V3, V4) and Spanner Outboxes ignore `trippedTopics`, so a tripped topic is swept as normal | `DynamoDbOutbox.cs:582`, `SpannerQueries.cs:12`; run against DynamoDB Local and the Spanner emulator, controls SQLite and MongoDB — **upstream, BrighterCommand/Brighter#4443, #4444**, filed 4.4 | `SweeperCircuitBreaking.md` (section, #5, troubleshooting) | the next row's grep, its first four alternatives | **4** | **0** — the table states it | 4.4, maintainer's ruling | | Explicit clearing said both to ignore the breaker and to respect it, and failed batches to be *"retried individually per topic"*. An explicit clear sends a tripped topic's messages; a failed `ClearOutboxAsync` trips the topic, a failed `ClearOutbox` does not unless the producer confirms publication | `OutboxProducerMediator.cs:425`, `:1220`, `:984`; run, sync and async, pre-tripped and fresh | `SweeperCircuitBreaking.md` § 6, § *Bulk Dispatch Support* | `grep -rnE 'UseMongoDbOutbox\|fully integrated with MongoDB\|works automatically with MongoDB\|works with all Brighter Outbox\|NOT subject to circuit breaking\|respects circuit breaker state\|retried individually per topic' contents/`, at `05fdeaf` and after; its last three alternatives are this row's | **3** | **0** | 4.4, maintainer's ruling | +| A pre-V10 logging handler: `using Brighter.commandprocessor.Logging`, `namespace Brighter.commandprocessor`, `logger.InfoFormat`; prose placing it in *"the Brighter.CommandProcessor packages"* and passing *"an ILog reference"* — and on the async page, prose about logging beside a block that writes to an Inbox; `FAQ.md` quoting `Brighter.commandprocessor.ICommand` in an exception | `Logging/Handlers/RequestLoggingHandler.cs`; run, the FAQ's message `Paramore.Brighter.ICommand` | `BuildingAPipeline.md`, `BuildingAnAsyncPipeline.md`, `FAQ.md` | `grep -rnE 'Brighter\.commandprocessor\|Brighter\.CommandProcessor packages\|ILog reference' contents/` | **8** | **1** — `Monitoring.md:25`, an `app.config` section, put to the maintainer | 5.1, `--explain` | +| `.Successor = …` and a method *"IHandleRequests\ Successor"* — the method is `SetSuccessor()`; and the manual chain said to run from the registry alone, which holds the handler's type, so a factory must return the wired instance | `RequestHandler.cs:74`; run, control a new instance | `BuildingAPipeline.md` | `grep -rnE '\.Successor *=\|TRequest\\> Successor\*\*\|Successor\.Handle\(\)' contents/` | **3** | **0** | 5.2, `--explain` | +| *"Cannot use AutoFromAssemblies"* with an Agreement Dispatcher, *"creates fixed mappings"*. `AutoFromAssemblies(excludeDynamicHandlerTypes: …)` scans beside an agreement; without the exclusion every `Send` throws *"More than one handler was found"* | `IBrighterBuilder.cs:46`, `ServiceCollectionBrighterBuilder.cs:238`; run, both orders, controls both ways | `AgreementDispatcherRouting.md`, `AgreementDispatcher.md`, `FAQ.md` | `grep -rnEi "cannot use .?AutoFromAssemblies\|AutoFromAssemblies.? (won't\|will not) work\|creates fixed (1-to-1 )?mappings\|Instead of AutoFromAssemblies" contents/` | **7** | **0** | 5.1, P0-10 — running #8's claim | +| Darker's query processor said to default to **Transient**, and an unscoped EF Core handler to fail on a *disposed DbContext*. It defaults to **Singleton** and resolves handlers from the root provider: with scope validation, *"Cannot resolve … from root provider"*; without, one `DbContext` for every query | Darker 4.1.1 `DarkerOptions.cs:9`; run, control `Scoped` | `DarkerConfigurationReference.md`, `DarkerBasicConfiguration.md` | `grep -rnEi 'disposed DbContext\|IQueryProcessor.{0,40}Transient\|Default Configuration \(Transient\)' contents/` | **5** | **0** | 5.2, reading the page against `DarkerOptions` | +| `.Handlers(registry, Activator.CreateInstance, t => {}, Activator.CreateInstance)` — Darker's factories are `Func` and `Func`, and `Activator.CreateInstance` returns `object` (`CS1503`). Darker's README carries the same line | `Builder/INeedHandlers.cs:9`; compiled, and run with the casts | `DarkerConfigurationReference.md`, `ImplementAQueryHandler.md` | `grep -rnE 'Handlers\(registry, Activator\.CreateInstance' contents/` | **2** | **0** | 5.1, `--explain` | +| `using System.Threading.Task;` (`CS0234`) | compiled | `ParameterizedQueryPatterns.md` | `grep -rn 'using System\.Threading\.Task;' contents/` | **1** | **0** | 5.1, `--explain` | +| V9's `new MessageMapperRegistry(messageMapperFactory) { { typeof(…), typeof(…) } }` (`CS7036`, `CS1922`) | `MessageMapperRegistry.cs:64`, `:296` | `HowConfiguringTheDispatcherWorks.md` | `grep -rnE 'new MessageMapperRegistry\([a-zA-Z]+\)$' contents/` | **1** | **0** | 5.1, `--explain` | +| `messagePumpType` said to be set to `Reactor` because *"`Subscription` defaults to `Proactor`"* — the block's `RmqSubscription` (RMQ.Sync) defaults to `Reactor`; switching to `Proactor` is what fails | `RmqSubscription.cs:107`, `Subscription.cs:291`; run against RabbitMQ, controls omitted and `Proactor` | `HowConfiguringTheDispatcherWorks.md` | `grep -rn 'messagePumpType. is set explicitly' contents/` | **1** | **0** | 5.2, running #2's warnings | +| A query handler reading `o.Customer` and `o.CreatedAt` from a write model that declares neither (`CS1061` once its names resolve); commands written `: IRequest { /* ... */ }` and controller actions outside a class | compiled | `CQRSUseCasesAndPatterns.md` #1, #2 | — | **2** blocks | **0** | 5.2, stubbing #1 | ## Friction ledger From f2ce22656534f7669f4a90697ee731ccdbfb6c53 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:38:12 +0100 Subject: [PATCH 05/27] =?UTF-8?q?docs:=20017=20task=205.2=20=E2=80=94=20th?= =?UTF-8?q?e=20maintainer's=20four=20rulings,=20and=20what=20they=20reache?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Monitoring.md rewritten for V10: MonitorConfiguration and a control bus sender in the container, [Monitor] on the handler, the message format captured from a run, and two 10.7.0 defects stated (a throwing handler's exception is replaced; [MonitorAsync] cannot send through ControlBusSenderFactory's sender) - HowConfiguringTheDispatcherWorks.md: Topshelf retired; the Dispatcher run from a console app until Ctrl+C, End() letting the pump finish its message - Handlers declared without `public`: 18 on 6 pages made public, and CommandProcessorConfigurationReference.md says the scan finds only public handlers - DarkerBasicConfiguration.md: Darker's scan takes exported types; names and nesting in a public class do not matter - Found by --explain on the blocks those touched: [UseResiliencePipeline] stacked on one method (CS0579) on five pages, now one composed pipeline; Polly v8's first strategy is the outermost, so MyComprehensivePipeline ran its timeout around all its retries; a FeatureSwitches.md handler awaited without `async` Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/BuildingAPipeline.md | 2 +- contents/BuildingAnAsyncPipeline.md | 2 +- .../CommandProcessorConfigurationReference.md | 4 +- contents/DarkerBasicConfiguration.md | 5 +- contents/FeatureSwitches.md | 36 ++++- contents/HandlerFailure.md | 10 +- .../HowConfiguringTheCommandProcessorWorks.md | 18 ++- contents/HowConfiguringTheDispatcherWorks.md | 47 ++---- contents/MigratingToPollyV8.md | 28 +++- contents/Monitoring.md | 142 ++++++++++++------ contents/PolicyFallback.md | 24 ++- contents/PolicyRetryAndCircuitBreaker.md | 73 ++++++--- 12 files changed, 249 insertions(+), 142 deletions(-) diff --git a/contents/BuildingAPipeline.md b/contents/BuildingAPipeline.md index bf376ef..e303da3 100644 --- a/contents/BuildingAPipeline.md +++ b/contents/BuildingAPipeline.md @@ -116,7 +116,7 @@ We now need to tell our pipeline to call this orthogonal handler before our targ using System; using Paramore.Brighter; -class GreetingCommandHandler : RequestHandler +public class GreetingCommandHandler : RequestHandler { [RequestLogging(step: 1, timing: HandlerTiming.Before)] public override GreetingCommand Handle(GreetingCommand command) diff --git a/contents/BuildingAnAsyncPipeline.md b/contents/BuildingAnAsyncPipeline.md index 07759e1..d09bfbe 100644 --- a/contents/BuildingAnAsyncPipeline.md +++ b/contents/BuildingAnAsyncPipeline.md @@ -70,7 +70,7 @@ using System.Threading; using System.Threading.Tasks; using Paramore.Brighter; -internal class GreetingCommandRequestHandlerAsync : RequestHandlerAsync +public class GreetingCommandRequestHandlerAsync : RequestHandlerAsync { [UseCommandSourcingAsync(step: 1, timing: HandlerTiming.Before)] public override async Task HandleAsync(GreetingCommand command, CancellationToken cancellationToken = default) diff --git a/contents/CommandProcessorConfigurationReference.md b/contents/CommandProcessorConfigurationReference.md index abf421f..b3c63e4 100644 --- a/contents/CommandProcessorConfigurationReference.md +++ b/contents/CommandProcessorConfigurationReference.md @@ -79,7 +79,7 @@ And use them in your handler like this: ``` csharp // ... -internal class MyQoSProtectedHandler : RequestHandler +public class MyQoSProtectedHandler : RequestHandler { [UseResiliencePipeline(policy: "RetryPipeline", step: 1)] public override MyCommand Handle(MyCommand command) @@ -425,7 +425,7 @@ sets `ThrowOnError`; neither is usually set by hand. ### Type Registration -The **IBrighterBuilder** fluent interface can scan your assemblies for your *Request Handlers* (inherit from **IHandleRequests<>** or **IHandleRequestsAsync<>**) and *Message Mappers* (inherit from **IAmAMessageMapper<>**) and register then with the **ServiceCollection**. This is the most common way to register your code. +The **IBrighterBuilder** fluent interface can scan your assemblies for your *Request Handlers* (inherit from **IHandleRequests<>** or **IHandleRequestsAsync<>**) and *Message Mappers* (inherit from **IAmAMessageMapper<>**) and register them with the **ServiceCollection**. This is the most common way to register your code. The scan registers only **public** handlers: a handler declared `internal` is never found, and sending its request throws an `ArgumentException`, *"No command handler was found"*. ``` csharp // ... diff --git a/contents/DarkerBasicConfiguration.md b/contents/DarkerBasicConfiguration.md index b3c5ec1..fcd5f53 100644 --- a/contents/DarkerBasicConfiguration.md +++ b/contents/DarkerBasicConfiguration.md @@ -405,7 +405,7 @@ This pattern is useful in modular monoliths or when organizing queries by domain **Handler not found errors** -If you receive an exception that a handler cannot be found for a query: +If a query throws `MissingHandlerException`, *"No handler registered for query"*: - Verify that the handler class implements `QueryHandler` or `QueryHandlerAsync` - Ensure the handler's assembly is registered with `AddHandlersFromAssemblies` - Check that the query and handler types match exactly (including generic type parameters) @@ -423,8 +423,7 @@ If you see an `InvalidOperationException` saying a handler cannot be resolved fr If handlers aren't being registered automatically: - Verify you're passing the correct assembly to `AddHandlersFromAssemblies` - Ensure handlers are in the same assembly or you've registered all relevant assemblies -- Check that handler classes are public and not nested within other classes -- Verify handlers follow the naming conventions (end with "Handler" or "QueryHandler") +- Check that each handler class is visible outside its assembly: `public`, and, if it is nested, nested in a `public` class. The scan reads only an assembly's exported types. A handler's name does not matter, and neither does nesting in a public class **Policy not found errors** diff --git a/contents/FeatureSwitches.md b/contents/FeatureSwitches.md index dd7ad21..149e082 100644 --- a/contents/FeatureSwitches.md +++ b/contents/FeatureSwitches.md @@ -24,8 +24,12 @@ By adding the **FeatureSwitch** Attribute or **FeatureSwitchAsync** Attribute, y In the following example, **MyFeatureSwitchedHandler** will only be run if it has been configured in the **Feature Switch Registry** and set to **FeatureSwitchStatus.On**. -``` csharp -class MyFeatureSwitchedHandler : RequestHandler +```csharp +using Paramore.Brighter; +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Attributes; + +public class MyFeatureSwitchedHandler : RequestHandler { [FeatureSwitch(typeof(MyFeatureSwitchedHandler), FeatureSwitchStatus.Config, step: 1)] public override MyCommand Handle (MyCommand command) @@ -38,13 +42,19 @@ class MyFeatureSwitchedHandler : RequestHandler In the second example, **MyIncompleteHandlerAsync** will not be run in the pipeline. -``` csharp -class MyIncompleteHandlerAsync : RequestHandlerAsync +```csharp +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Attributes; + +public class MyIncompleteHandlerAsync : RequestHandlerAsync { [FeatureSwitchAsync(typeof(MyIncompleteHandlerAsync), FeatureSwitchStatus.Off, step: 1)] - public override Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default) + public override async Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default) { - /* Nothing implmented so we're skipping this handler */ + /* Nothing implemented so we're skipping this handler */ return await base.HandleAsync(command, cancellationToken); } } @@ -57,7 +67,11 @@ By default, when a feature switch is **Off**, the handler is skipped and the mes The `dontAck` parameter controls this behavior. When set to `true` and the feature is off, the attribute throws a `DontAckAction` instead of silently consuming the message. The [message pump](/contents/HowServiceActivatorWorks.md) leaves the message unacknowledged on the channel, and the transport re-delivers it after its visibility timeout expires. ```csharp -class MyFeatureSwitchedHandler : RequestHandler +using Paramore.Brighter; +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Attributes; + +public class MyFeatureSwitchedHandler : RequestHandler { [FeatureSwitch(typeof(MyFeatureSwitchedHandler), FeatureSwitchStatus.Config, step: 1, dontAck: true)] public override MyCommand Handle(MyCommand command) @@ -72,7 +86,13 @@ class MyFeatureSwitchedHandler : RequestHandler The async variant works the same way: ```csharp -class MyFeatureSwitchedHandlerAsync : RequestHandlerAsync +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.FeatureSwitch; +using Paramore.Brighter.FeatureSwitch.Attributes; + +public class MyFeatureSwitchedHandlerAsync : RequestHandlerAsync { [FeatureSwitchAsync(typeof(MyFeatureSwitchedHandlerAsync), FeatureSwitchStatus.Config, step: 1, dontAck: true)] public override async Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default) diff --git a/contents/HandlerFailure.md b/contents/HandlerFailure.md index e8f5383..9ed4d31 100644 --- a/contents/HandlerFailure.md +++ b/contents/HandlerFailure.md @@ -427,9 +427,8 @@ Backstop attributes should be at the **outermost** position in the pipeline (low ```csharp public class OrderHandler : RequestHandler { - [RejectMessageOnError(step: 0)] // Outermost: backstop - [UseResiliencePipeline("OrderCircuitBreaker", step: 1)] // Middle: circuit breaker - [UseResiliencePipeline("OrderRetryPolicy", step: 2)] // Innermost: retry + [RejectMessageOnError(step: 0)] // Outermost: backstop + [UseResiliencePipeline("OrderCircuitBreakerAndRetry", step: 1)] // Inside: circuit breaker, then retry public override PlaceOrder Handle(PlaceOrder command) { // 1. Retry wraps the handler (retries transient failures) @@ -443,14 +442,15 @@ public class OrderHandler : RequestHandler } ``` +A handler method takes one `[UseResiliencePipeline]`, so the circuit breaker and the retry are one pipeline, `OrderCircuitBreakerAndRetry`, which adds its circuit breaker before its retry so that the breaker wraps the retry; see [Combining Multiple Strategies](/contents/PolicyRetryAndCircuitBreaker.md#combining-multiple-strategies). A second `[UseResiliencePipeline]` on the method would not compile. + The async equivalent uses the async variants of each attribute: ```csharp public class OrderHandler : RequestHandlerAsync { [RejectMessageOnErrorAsync(step: 0)] - [UseResiliencePipelineAsync("OrderCircuitBreaker", step: 1)] - [UseResiliencePipelineAsync("OrderRetryPolicy", step: 2)] + [UseResiliencePipelineAsync("OrderCircuitBreakerAndRetry", step: 1)] public override async Task HandleAsync( PlaceOrder command, CancellationToken cancellationToken = default) { diff --git a/contents/HowConfiguringTheCommandProcessorWorks.md b/contents/HowConfiguringTheCommandProcessorWorks.md index 31ff2db..c545b5c 100644 --- a/contents/HowConfiguringTheCommandProcessorWorks.md +++ b/contents/HowConfiguringTheCommandProcessorWorks.md @@ -99,7 +99,8 @@ Registration requires a string as a key, that you will use in your `[UseResilien In this example, we set up resilience pipelines. To make it easy to reference the string, instead of adding it everywhere, we use a global readonly reference, not shown here. -``` csharp +```csharp +using System; using Polly; using Polly.Registry; using Polly.Retry; @@ -122,6 +123,12 @@ resiliencePipelineRegistry.TryAddBuilder(Globals.MYCIRCUITBREAKER, MinimumThroughput = 10, BreakDuration = TimeSpan.FromSeconds(30) })); + +// Both, in one pipeline: the circuit breaker, added first, wraps the retry +resiliencePipelineRegistry.TryAddBuilder(Globals.MYCIRCUITBREAKERANDRETRY, + (builder, context) => builder + .AddCircuitBreaker(new CircuitBreakerStrategyOptions()) + .AddRetry(new RetryStrategyOptions())); ``` When you attribute your code, you then use the key to attach a specific resilience pipeline: @@ -142,14 +149,15 @@ public override TaskReminderCommand Handle(TaskReminderCommand command) } ``` -If you need multiple resilience pipelines then you can use multiple attributes. We evaluate them based on their step order. +A handler method takes only one `[UseResiliencePipeline]`; a second on the same method does not compile. If you need several strategies, such as a circuit breaker around a retry, compose them in one pipeline and attach that. The first strategy you add is the outermost; see [Combining Multiple Strategies](/contents/PolicyRetryAndCircuitBreaker.md#combining-multiple-strategies). -``` csharp -[UseResiliencePipeline(Globals.MYCIRCUITBREAKER, step: 1)] -[UseResiliencePipeline(Globals.MYRETRYPIPELINE, step: 2)] +```csharp +// ... +[UseResiliencePipeline(Globals.MYCIRCUITBREAKERANDRETRY, step: 1)] public override TaskReminderCommand Handle(TaskReminderCommand command) { // Circuit breaker wraps retry, which wraps this handler + return base.Handle(command); } ``` diff --git a/contents/HowConfiguringTheDispatcherWorks.md b/contents/HowConfiguringTheDispatcherWorks.md index 315f49b..ee5a5ad 100644 --- a/contents/HowConfiguringTheDispatcherWorks.md +++ b/contents/HowConfiguringTheDispatcherWorks.md @@ -127,44 +127,27 @@ The Dispatcher reads messages of input channels. Internally it creates a message To use the Dispatcher you need to host it in a consumer application. Usually a console application or Windows Service is appropriate. -We recommend using HostBuilder, but if not you will need to use something like [Topshelf](http://topshelf-project.com/) to host your consumers. +We recommend using HostBuilder: `AddConsumers()` runs the Dispatcher for you in a hosted service, `ServiceActivatorHostedService`, which starts it with the host and stops it on shutdown. See [BasicConfiguration](/contents/BrighterBasicConfiguration.md). -The following code shows an example of using the **Dispatcher** from Topshelf. The key methods are **Dispatcher.Receive()** to start the message pumps and **Dispatcher.End()** to shut them. +Without HostBuilder, you start and stop the Dispatcher yourself. The key methods are **Dispatcher.Receive()** to start the message pumps and **Dispatcher.End()** to shut them, which waits for each pump to finish the message it is handling. The following code runs the Dispatcher built above in a console application until you press Ctrl+C. We do allow you to start and stop individual channels, but this is an advanced feature for operating the services. ```csharp -using Paramore.Brighter.ServiceActivator; -using Topshelf; +using System; +using System.Threading; + +// ... build _dispatcher, as above -internal class GreetingService : ServiceControl +using var stop = new ManualResetEventSlim(); +Console.CancelKeyPress += (_, e) => { - private Dispatcher? _dispatcher; - - public GreetingService() - { - // ... configure the Dispatcher, as above - } - - public bool Start(HostControl hostControl) - { - _dispatcher!.Receive(); - return true; - } - - public bool Stop(HostControl hostControl) - { - _dispatcher!.End().Wait(); - _dispatcher = null; - return false; - } - - public void Shutdown(HostControl hostcontrol) - { - if (_dispatcher != null) - _dispatcher.End(); - return; - } -} + e.Cancel = true; // let the Dispatcher shut down, rather than killing the process + stop.Set(); +}; + +_dispatcher.Receive(); +stop.Wait(); +await _dispatcher.End(); ``` diff --git a/contents/MigratingToPollyV8.md b/contents/MigratingToPollyV8.md index 4b6505a..f6f1765 100644 --- a/contents/MigratingToPollyV8.md +++ b/contents/MigratingToPollyV8.md @@ -66,7 +66,7 @@ resiliencePipelineRegistry.TryAddBuilder("MyRetryPipeline", ```csharp // ... -internal class MyHandler : RequestHandler +public class MyHandler : RequestHandler { [UsePolicy("MyRetryPolicy", step: 1)] [TimeoutPolicy(milliseconds: 5000, step: 2)] @@ -77,17 +77,27 @@ internal class MyHandler : RequestHandler } ``` -**V10**: +**V10**: a handler method takes one `[UseResiliencePipeline]`, so the retry and the timeout become one pipeline. Add the retry first, so that it wraps the timeout and each attempt is timed on its own, as the two V9 attributes did: ```csharp -// ... -internal class MyHandler : RequestHandler +using System; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; +using Polly; +using Polly.Retry; + +resiliencePipelineRegistry.TryAddBuilder("MyRetryWithTimeoutPipeline", + (builder, context) => builder + .AddRetry(new RetryStrategyOptions()) // Outer: retries a timed-out attempt + .AddTimeout(TimeSpan.FromSeconds(5))); // Inner: times each attempt + +public class MyHandler : RequestHandler { - [UseResiliencePipeline("MyRetryPipeline", step: 1)] - [UseResiliencePipeline("MyTimeoutPipeline", step: 2)] + [UseResiliencePipeline("MyRetryWithTimeoutPipeline", step: 1)] public override MyCommand Handle(MyCommand command) { // Handler logic + return base.Handle(command); } } ``` @@ -152,12 +162,13 @@ By adding the **UsePolicy** attribute, you instruct the Command Processor to ins ```csharp // ... -internal class MyQoSProtectedHandler : RequestHandler +public class MyQoSProtectedHandler : RequestHandler { [UsePolicy(policy: "MyExceptionPolicy", step: 1)] public override MyCommand Handle(MyCommand command) { /*Do work that could throw error because of distributed computing reliability*/ + return base.Handle(command); } } ``` @@ -199,12 +210,13 @@ then you can add them both to your handler as follows: ```csharp // ... -internal class MyQoSProtectedHandler : RequestHandler +public class MyQoSProtectedHandler : RequestHandler { [UsePolicy(new [] {"MyCircuitBreakerPolicy", "MyExceptionPolicy"} , step: 1)] public override MyCommand Handle(MyCommand command) { /*Do work that could throw error because of distributed computing reliability*/ + return base.Handle(command); } } ``` diff --git a/contents/Monitoring.md b/contents/Monitoring.md index 079d5d3..e569d47 100644 --- a/contents/Monitoring.md +++ b/contents/Monitoring.md @@ -1,5 +1,5 @@ --- -description: "Brighter emits monitoring information from an External Bus using a configured Control Bus." +description: "A monitored handler posts an event to a control bus as it is entered and exited, so you can watch what your handlers do from outside the process." layout: description: visible: false @@ -9,78 +9,122 @@ layout: > **Reference** · Applies to **Brighter V10** -Brighter emits monitoring information from an External Bus using a configured [Control Bus](https://brightercommand.github.io/Brighter/ControlBus.html). +A monitored handler posts an event to a control bus as it is entered and exited, so you can watch what your handlers do from outside the process. -## Configuring Monitoring +Each event names the handler, carries the request it handled, and records how long it took. The events are ordinary messages on a topic of your choosing, so anything that can read your broker can consume them. For tracing and metrics through OpenTelemetry instead, see [Telemetry](/contents/Telemetry.md). -Firstly [configure a Control -Bus](https://brightercommand.github.io/Brighter/ControlBus.html#configure) in the brighter application to emit monitoring messages +## Monitoring Configuration -## Config file +Monitoring needs two things in your container besides Brighter itself: -Monitoring requires a new section to be added to the application config file: +- An `IAmAControlBusSender`, which sends each `MonitorEvent` to your broker +- A `MonitorConfiguration`, which turns monitoring on and names this instance in every event -``` xml - -
- -``` +`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: -The monitoring config can then be speicified later in the file: +```csharp +using System; +using System.Transactions; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Monitoring.Configuration; +using Paramore.Brighter.Monitoring.Events; +using Paramore.Brighter.Observability; -``` xml - - - -``` +var monitoringTopic = new RoutingKey("brighter.monitoring"); -This enables runtime changes to enable/disable emitting of monitoring messages. +var producerRegistry = new InMemoryProducerRegistryFactory( + new InternalBus(), + [new Publication { Topic = monitoringTopic, RequestType = typeof(MonitorEvent) }], + InstrumentationOptions.None) + .Create(); -## Handler Configuration +var controlBusSender = new ControlBusSenderFactory().Create( + new InMemoryOutbox(TimeProvider.System), producerRegistry, new BrighterTracer()); -Each handler that requires monitoring must be configured in two stages, a Handler attribute and container registration of a MonitorHandler for the given request: +var services = new ServiceCollection(); +services.AddSingleton(controlBusSender); +services.AddSingleton(new MonitorConfiguration +{ + IsMonitoringEnabled = true, + InstanceName = "OrdersService" +}); + +services.AddBrighter() + .AutoFromAssemblies(); +``` -For example, given: +The sender has its own command processor and Outbox, separate from the ones your application posts through. -- TRequest - a Brighter Request, inheriting from IRequest -- TRequestHandler - handles the TRequest, inheriting IHandleRequest - \ +You do not register the monitoring handler itself. `AddBrighter()` makes Brighter's own `MonitorHandler` available to the pipeline, whether you register your handlers with `AutoFromAssemblies()` or with `Handlers()`. -### Attribute +## Monitor Attribute Usage -The following attribute must be added to the Handle method in the handler, TRequestHandler: +Mark each handler you want monitored with `[Monitor]`, naming the handler's own type: + +```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Monitoring.Attributes; + +public class GreetingCommand() : Command(Id.Random()) +{ + public string Name { get; set; } = ""; +} -``` csharp -[Monitor(step:1, timing:HandlerTiming.Before, handlerType:typeof(TRequestHandler))] +public class GreetingCommandHandler : RequestHandler +{ + [Monitor(step: 1, timing: HandlerTiming.Before, handlerType: typeof(GreetingCommandHandler))] + public override GreetingCommand Handle(GreetingCommand command) + { + Console.WriteLine($"Hello {command.Name}"); + return base.Handle(command); + } +} ``` -Please note the step and timing can vary if monitoring should be after another attribute step, or timing should be emitted after. +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. -### Container registration +`[MonitorAsync]` is the attribute for a `RequestHandlerAsync`; see [Monitoring Limitations](#monitoring-limitations) before you use it. -The following additional handler must be registered in the application container (where `MonitorHandler` is a built-in Brighter handler): +## Turning Monitoring On and Off -``` csharp -container.Register> -``` +`MonitorConfiguration.IsMonitoringEnabled` is read each time a monitor handler is created. With the default transient handler lifetime that is once per request, so setting it to `false` on the instance you registered stops the events from the next request, without removing any attributes. While it is `false`, the monitor simply passes the request on. -## Monitor message format +## Monitor Message Format -A message is emitted from the Control Bus on Handler Entry and Handler Exit. The following is the form of the message: +A monitored request produces two messages, an `EnterHandler` event before the handler runs and an `ExitHandler` event after it. Each is an `MT_EVENT` on the publication's topic, with a JSON body like this one, captured from the example above (its assembly was named `mon`): -``` javascript +```json { - "Exception": null, // or Exception message - "EventType": "EnterHandler or ExitHandler", - "EventTime": "2016-06-21T15:48:26.1390192Z", - "TimeElapsedMs": 0 or Duration, - "HandlerName": "...", - "HandlerFullAssemblyName": "...", - "InstanceName": "ManagementAndMonitoring", - "RequestBody": "{\"Id\":\"dc32b35f-bc75-4197-9178-c8310a63e4fb\", ... }", - "Id": "048cc207-e820-40fa-b931-55b60203fbc2" + "exception": null, + "eventType": "ExitHandler", + "eventTime": "2026-09-28T08:25:33.219522Z", + "timeElapsedMs": 47, + "handlerName": "GreetingCommandHandler", + "handlerFullAssemblyName": "GreetingCommandHandler, mon, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", + "instanceName": "OrdersService", + "requestBody": "{\"name\":\"Ada\",\"correlationId\":null,\"id\":\"01a0e71e-88ed-7acb-a5aa-a5863e075b6c\"}", + "correlationId": null, + "id": "01a0e71e-8923-7ae3-82a0-f3654db2fcbf" } ``` -Messages can be processed from the queue and interated with your monitoring tool of choice, for example Live python consumers emitting to console or logstash consumption to the ELK stack using relevant plugins -to provide performance raditators or dashboards. +- `timeElapsedMs` is `0` on `EnterHandler`, and the time from entry to exit on `ExitHandler` +- `requestBody` is the request serialized to JSON, as a string +- `instanceName` is `MonitorConfiguration.InstanceName`, which tells apart the instances of a service that share a topic + +A consumer on that topic can forward the events to your monitoring tool, for example to Logstash and the ELK stack for dashboards. + +## Monitoring Limitations + +Two defects in Brighter 10.7.0 limit what monitoring can do: + +- **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 +- **`[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"* + +## Further Reading + +- [Telemetry](/contents/Telemetry.md) - Tracing and metrics through OpenTelemetry +- [Building a Pipeline of Request Handlers](/contents/BuildingAPipeline.md) - How attributes such as `[Monitor]` place a handler in the pipeline diff --git a/contents/PolicyFallback.md b/contents/PolicyFallback.md index 629fcd4..3d60995 100644 --- a/contents/PolicyFallback.md +++ b/contents/PolicyFallback.md @@ -30,11 +30,16 @@ The following example shows a Handler with **Request Handler Attributes** for [R ### Example with Resilience Pipelines (V10) ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; +using Paramore.Brighter.Policies.Handlers; +using Polly.CircuitBreaker; + public class MyFallbackProtectedHandler: RequestHandler { [FallbackPolicy(backstop: false, circuitBreaker: true, step: 1)] - [UseResiliencePipeline("MyCircuitBreakerPipeline", step: 2)] - [UseResiliencePipeline("MyRetryPipeline", step: 3)] + [UseResiliencePipeline("MyCircuitBreakerAndRetryPipeline", step: 2)] public override MyCommand Handle(MyCommand command) { // Do some work that can fail @@ -99,22 +104,25 @@ Where you put any **FallbackPolicy** attribute determines what exceptions it wil ### Pipeline Order ```csharp -[FallbackPolicy(backstop: true, step: 1)] // Outermost: Catches ALL exceptions -[UseResiliencePipeline("CircuitBreaker", step: 2)] // Middle: Circuit breaker -[UseResiliencePipeline("Retry", step: 3)] // Innermost: Retry +// ... +[FallbackPolicy(backstop: true, step: 1)] // Outermost: Catches ALL exceptions +[UseResiliencePipeline("CircuitBreakerAndRetry", step: 2)] // Inside: circuit breaker, then retry public override MyCommand Handle(MyCommand command) { // Handler logic + return base.Handle(command); } ``` **Execution flow**: -1. Fallback wraps everything (catches all exceptions from steps 2, 3, and handler) -2. Circuit breaker wraps retry and handler (fails fast if open) -3. Retry wraps handler (retries on failures) +1. Fallback wraps everything (catches all exceptions from step 2 and the handler) +2. The pipeline's circuit breaker wraps its retry and the handler (fails fast if open) +3. The pipeline's retry wraps the handler (retries on failures) 4. Handler executes +`CircuitBreakerAndRetry` is one pipeline that adds its circuit breaker before its retry: a handler method takes one `[UseResiliencePipeline]`, and the first strategy added to a pipeline is its outermost. See [Combining Multiple Strategies](/contents/PolicyRetryAndCircuitBreaker.md#combining-multiple-strategies). + If the handler throws an exception: 1. Retry catches it and retries (up to max attempts) diff --git a/contents/PolicyRetryAndCircuitBreaker.md b/contents/PolicyRetryAndCircuitBreaker.md index 9a0c0d7..f56e488 100644 --- a/contents/PolicyRetryAndCircuitBreaker.md +++ b/contents/PolicyRetryAndCircuitBreaker.md @@ -47,7 +47,10 @@ By adding the **UseResiliencePipeline** attribute, you instruct the Command Proc ### Basic Example ```csharp -internal class MyQoSProtectedHandler : RequestHandler +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + +public class MyQoSProtectedHandler : RequestHandler { [UseResiliencePipeline(policy: "MyRetryPipeline", step: 1)] public override MyCommand Handle(MyCommand command) @@ -69,7 +72,7 @@ using System.Threading.Tasks; using Paramore.Brighter; using Paramore.Brighter.Policies.Attributes; -internal class MyQoSProtectedHandlerAsync : RequestHandlerAsync +public class MyQoSProtectedHandlerAsync : RequestHandlerAsync { [UseResiliencePipelineAsync(policy: "MyRetryPipeline", step: 1)] public override async Task HandleAsync( @@ -164,32 +167,42 @@ public class MyTimedHandler : RequestHandler ## Combining Multiple Strategies -You can combine multiple resilience strategies in a single pipeline. Strategies are applied in the order they're added (inner to outer wrapping). +You can combine multiple resilience strategies in a single pipeline. The first strategy you add is the outermost: it wraps every strategy added after it. So to time out each attempt, retry the attempts that fail, and stop retrying while a service is known to be down, add the circuit breaker first and the timeout last. ### Retry + Circuit Breaker + Timeout ```csharp +using System; +using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; + resiliencePipelineRegistry.TryAddBuilder("MyComprehensivePipeline", (builder, context) => builder - .AddTimeout(TimeSpan.FromSeconds(10)) // Innermost: Timeout individual attempts + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + MinimumThroughput = 10, + BreakDuration = TimeSpan.FromSeconds(60) + }) // Outermost: Circuit breaker .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(1), BackoffType = DelayBackoffType.Exponential }) // Middle: Retry on failures - .AddCircuitBreaker(new CircuitBreakerStrategyOptions - { - FailureRatio = 0.5, - MinimumThroughput = 10, - BreakDuration = TimeSpan.FromSeconds(60) - })); // Outermost: Circuit breaker + .AddTimeout(TimeSpan.FromSeconds(10))); // Innermost: Timeout individual attempts ``` +Added the other way round, the timeout would wrap the retries and limit all of them together to 10 seconds, and the circuit breaker would sit inside the retry. + **Handler Usage**: ```csharp -internal class MyQoSProtectedHandler : RequestHandler +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + +public class MyQoSProtectedHandler : RequestHandler { [UseResiliencePipeline("MyComprehensivePipeline", step: 1)] public override MyCommand Handle(MyCommand command) @@ -210,15 +223,27 @@ internal class MyQoSProtectedHandler : RequestHandler --- -## Using Multiple Pipelines on a Handler +## Combining Resilience Strategies on a Handler -You can apply multiple resilience pipeline attributes to a handler. Each attribute wraps subsequent steps in the pipeline. +A handler method takes one `[UseResiliencePipeline]`. The attribute is not repeatable, so a second one on the same method does not compile (`CS0579`, *"Duplicate 'UseResiliencePipeline' attribute"*). To layer strategies, compose them in one pipeline, as [Combining Multiple Strategies](#combining-multiple-strategies) shows, and name that: ```csharp -internal class MyMultiPipelineHandler : RequestHandler +using System; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; +using Polly; +using Polly.CircuitBreaker; +using Polly.Registry; +using Polly.Retry; + +resiliencePipelineRegistry.TryAddBuilder("MyCircuitBreakerAndRetryPipeline", + (builder, context) => builder + .AddCircuitBreaker(new CircuitBreakerStrategyOptions()) // Outermost + .AddRetry(new RetryStrategyOptions())); // Inside the circuit breaker + +public class MyMultiPipelineHandler : RequestHandler { - [UseResiliencePipeline("MyCircuitBreakerPipeline", step: 1)] - [UseResiliencePipeline("MyRetryPipeline", step: 2)] + [UseResiliencePipeline("MyCircuitBreakerAndRetryPipeline", step: 1)] public override MyCommand Handle(MyCommand command) { // Circuit breaker wraps retry, which wraps this handler @@ -227,7 +252,7 @@ internal class MyMultiPipelineHandler : RequestHandler } ``` -**Execution order**: Circuit Breaker → Retry → Handler +Other attributes, such as `[FallbackPolicy]` or `[RejectMessageOnError]`, can still sit beside it at their own steps. --- @@ -236,7 +261,10 @@ internal class MyMultiPipelineHandler : RequestHandler 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. ```csharp -internal class OrderServiceHandler : RequestHandler +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + +public class OrderServiceHandler : RequestHandler { [UseResiliencePipeline("SharedCircuitBreaker", step: 1, UseTypePipeline = true)] public override ProcessOrderCommand Handle(ProcessOrderCommand command) @@ -246,7 +274,7 @@ internal class OrderServiceHandler : RequestHandler } } -internal class PaymentServiceHandler : RequestHandler +public class PaymentServiceHandler : RequestHandler { [UseResiliencePipeline("SharedCircuitBreaker", step: 1, UseTypePipeline = true)] public override ProcessPaymentCommand Handle(ProcessPaymentCommand command) @@ -321,7 +349,12 @@ resiliencePipelineRegistry.TryAddBuilder("MyHedgingPipeline", Polly v8 resilience pipelines properly integrate with `CancellationToken`, allowing you to cancel operations in progress. ```csharp -internal class MyCancellableHandler : RequestHandlerAsync +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + +public class MyCancellableHandler : RequestHandlerAsync { [UseResiliencePipeline("MyRetryPipeline", step: 1)] public override async Task HandleAsync( From a347ba65e90f4da0ae4db988b07c35c67b1565e1 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:38:32 +0100 Subject: [PATCH 06/27] =?UTF-8?q?spec:=20017=20task=205.2=20=E2=80=94=20ba?= =?UTF-8?q?seline=20rows=20for=20the=20blocks=20f2ce226=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HowConfiguringTheDispatcherWorks.md #3 and Monitoring.md #1, #2. --report: exit 0, 985 blocks: 265 BUILT, 703 FAILED, 17 SKIPPED. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 9b979e0..637b461 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -287,3 +287,6 @@ contents/QueryHandlerDependencies.md 1 DarkerQueryPatternsContext.cs 1cafef9 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 2 - f2ce226 From 6d54306c7501a6c98d7e67c9a21f4bb682992cc8 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 09:40:02 +0100 Subject: [PATCH 07/27] =?UTF-8?q?spec:=20017=20task=205.2=20=E2=80=94=20th?= =?UTF-8?q?e=20second=20pass,=20the=20four=20rulings,=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILT 262 -> 265; pagelint 599 -> 585; pages with nothing BUILT 49 -> 48. Seven defect rows, two of them upstream and stated, filing put to the maintainer. The Topshelf row leaves § Blocks that stay FAILED: the block builds. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 76 +++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index e17d096..4daf8bc 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1924,10 +1924,10 @@ SKIPPED, not because it built. block 3's. **Said:** `DarkerConfigurationReference.md` #1 empty stub, #2 and `QueryHandlerDependencies.md` #3 typed value. **Measured:** each also names `Program`, which Q2 rules out. **Said:** `HowConfiguringTheDispatcherWorks.md` #3 empty stub. **Measured:** - `ServiceControl` and `HostControl` are Topshelf's, a package the pin does not carry, and a unit may - not fake a third-party package. **Said:** `BuildingAPipeline.md` #4 members. **Measured:** unit rule + `ServiceControl` and `HostControl` are Topshelf's, a package the pin does not carry; the block + builds only since Topshelf was retired (second pass, below). **Said:** `BuildingAPipeline.md` #4 members. **Measured:** unit rule 1 — the section tells the reader to write the handlers it chains. So **7** of the **27** reachable - blocks on these pages stay FAILED, and **7** of their **10** hard blocks built; one is SKIPPED and + blocks on these pages stayed FAILED in the first pass (**6** after the second), and **7** of their **10** hard blocks built; one is SKIPPED and two stay FAILED (`ParameterizedQueryPatterns.md` #4, same-page once its `using` is right, and `ProjectionQueryPatterns.md` #3, a fragment). 20 + 7 + the appended block = **28** - **Six units.** `AgreementDispatcherRoutingContext.cs` (the scenarios' requests and handlers, @@ -1971,7 +1971,7 @@ SKIPPED, not because it built. never show - **The blocks that stay FAILED compile where their world exists** (§ *Blocks that stay FAILED*): `DarkerConfigurationReference.md` #1, #2 and `QueryHandlerDependencies.md` #3 as a `Program.cs` - against Darker 4.1.1; `HowConfiguringTheDispatcherWorks.md` #3 beside #2 against Topshelf 4.3.0; + against Darker 4.1.1; `ParameterizedQueryPatterns.md`'s six blocks together, with the never-shown entity stubbed; `BuildingAPipeline.md` #1–#3 together in the pipeline run below — each **0** errors - **Behaviour, run with controls** against released packages (Brighter 10.7.0, Darker 4.1.1, @@ -1996,15 +1996,10 @@ SKIPPED, not because it built. on expressions of type 'decimal'"* — the provider's limit, and the page names none. Read, not run: `CQRSUseCasesAndPatterns.md` #1 joins and aggregates as `ProjectionQueryPatterns.md` #2 does, run above; its #2 is a controller over the command and query processors -- **Put to the maintainer, not repaired** — each found reading this task's pages, none on its tranche: - **`Monitoring.md`**'s *Config file* section registers `MonitoringConfigurationSection, - Brighter.commandprocessor` in `app.config`; at 10.7.0 that type is a plain class, and the page's - links go to the retired `brightercommand.github.io`. **Topshelf** — pin it, so - `HowConfiguringTheDispatcherWorks.md` #3 builds, or retire the recommendation. **Handlers declared - without `public`**: 18 lines on 7 pages, which `AutoFromAssemblies()` never finds (the run above). - **`DarkerBasicConfiguration.md`'s troubleshooting** says handlers must end in *"Handler"* and not - be nested; Darker 4.1.1 scans `ExportedTypes` for `IQueryHandler<,>` and neither holds. Darker's own - README carries the uncast `Activator.CreateInstance` line (`README.md:110`) +- **Four findings off the tranche were put to the maintainer** — `Monitoring.md`'s V9 `app.config` + section, Topshelf, handlers declared without `public`, and `DarkerBasicConfiguration.md`'s naming + and nesting rules — and each was ruled on and repaired in the second pass, below. Darker's own README + carries the uncast `Activator.CreateInstance` line (`README.md:110`) - **`attr_mismatch.py` → 7**, before the baseline rows - **Baseline:** 28 rows and 3 re-admissions at `1cafef9` (`09f5848`). `--report` → exit **0**, *"985 blocks: 262 BUILT, 706 FAILED, 17 SKIPPED"*, baseline 262, 0 findings @@ -2016,6 +2011,51 @@ SKIPPED, not because it built. `DarkerBasicConfiguration.md`, `FAQ.md`, `ImplementAQueryHandler.md`, `QueryPatterns.md`, `QueryPipeline.md`, `QueryPipelinePolicies.md` +**Task 5.2, second pass — the four findings, ruled 2026-09-28.** The maintainer: rewrite +`Monitoring.md`; retire Topshelf; make the non-public handlers public; fix +`DarkerBasicConfiguration.md`'s troubleshooting. **BUILT 262 → 265**: `Monitoring.md` #1, #2 and +`HowConfiguringTheDispatcherWorks.md` #3, all `FAILED -> BUILT`, no other key moved. `pagelint` +**599 → 585**: `PolicyRetryAndCircuitBreaker.md` −6, `FeatureSwitches.md` −4, `Monitoring.md` −2, +`MigratingToPollyV8.md` and `PolicyFallback.md` −1 each (per page, against a worktree at `623c786`). +Pages with nothing BUILT **49 → 48**, `Monitoring.md`. `attr_mismatch.py` **7**, one hit's line moved +by the `using`s above it: `PolicyRetryAndCircuitBreaker.md:326` → `:359`. Repair `f2ce226`, baseline +`a347ba6`, `--report` exit 0, *"985 blocks: 265 BUILT, 703 FAILED, 17 SKIPPED"*. + +- **`Monitoring.md`, rewritten for V10.** The page registered a V9 `app.config` section and a + container call for `MonitorHandler`, and linked the retired site's Control Bus page. At 10.7.0 + the handler takes an `IAmAControlBusSender` and a `MonitorConfiguration` (a plain class) from the + container, and `AddBrighter()` makes `MonitorHandler` available with either registration style. + The page now builds a sender with `ControlBusSenderFactory`, shows `[Monitor]`, and prints the + message format captured from a run. **Two upstream defects, found running it and stated on the + page** (§ *Defect ledger*); filing them is put to the maintainer +- **Topshelf retired.** `HowConfiguringTheDispatcherWorks.md` now recommends `AddConsumers()`'s hosted + service and, without HostBuilder, runs the Dispatcher from a console app until Ctrl+C. #3 builds +- **Handlers declared without `public`: 18 → 0** on 6 pages (a grep and a Python scan of every C# + fence agreeing), and `CommandProcessorConfigurationReference.md` says the scan registers only public + handlers. The mapper scan has no such filter, so the sentence names handlers alone +- **`DarkerBasicConfiguration.md`**: a handler must be exported — public, and nested only in a public + class; its name does not matter +- **`--explain` on the 20 blocks the rulings touched found three more**, each repaired at every + recurrence: `[UseResiliencePipeline]` stacked on one method, `CS0579` — the attribute is not + repeatable, by design since BrighterCommand/Brighter#2580 — on **7** blocks across **5** pages, each + now one pipeline composed in the order the stack meant; `PolicyRetryAndCircuitBreaker.md` saying a + pipeline's strategies wrap *"inner to outer"* in the order added, and building its comprehensive + pipeline timeout-first — Polly v8 makes the first strategy added the outermost, so that timeout + wrapped every retry; and a `FeatureSwitches.md` handler that awaited without `async`. With them, + `using`s on the touched blocks and two placeholder bodies that returned nothing +- **Behaviour, run with controls**, released 10.7.0 packages, net10.0: + + | Claim | Case → result | Control → result | + |---|---|---| + | `Monitoring.md` #1, #2 verbatim | `Send` → *"Hello Ada"*, **2** `MT_EVENT`s on `brighter.monitoring`, `EnterHandler` then `ExitHandler` | `IsMonitoringEnabled = false` → **0**; `.Handlers(…)` in place of `AutoFromAssemblies()` → **2** | + | Turning it off at runtime | request 1 → **2** events; flag set `false`; request 2 → none | — the first request is its control | + | A monitored handler that throws | `InvalidOperationException` in the handler → the caller gets **`NotSupportedException`**, *"… 'System.Reflection.MethodBase' instances is not supported"*; **1** event | the handler not throwing → **2** events | + | `[MonitorAsync]` through the factory's sender | *"No message mapper defined for request"*; **0** events | `[Monitor]`, sync → **2** | + | `HowConfiguringTheDispatcherWorks.md` #2 + #3 against RabbitMQ | Ctrl+C a second into a 4 s handler → *stopping*, the handler finishes, *ended*, exit **0** | without `e.Cancel = true` → exit **−2**, the handler never finishes | + | Darker's scan | a handler named `FetchSomething`, and one nested in a public class → found | `internal` → `MissingHandlerException`; public nested in an `internal` class → the same | + | Polly v8 order | `AddRetry().AddTimeout(100 ms)`, 300 ms work → **4** attempts | `AddTimeout().AddRetry()` → **1** attempt | + | One composed pipeline in place of two attributes | `AddCircuitBreaker().AddRetry(3)`, always failing → sends 1, 2 **4** attempts each, send 3 `BrokenCircuitException`, **0** | two `[UseResiliencePipeline]` on the method → `CS0579` | + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2341,7 +2381,6 @@ is rewritten against the tables below. | `ParameterizedQueryPatterns.md` | 6 | `CS0246` `SearchProductsQuery`, `ProductDto` | same-page: block 5 declares both; *"**Handler with multiple optional criteria:**"* | 5 | | `ProjectionQueryPatterns.md` | 3 | `CS1513`, `CS0103` `Select` | parse — a fragment: the `.Select(…)` of block 2's handler with no receiver, under *"Database-computed fields"*. The reader has the whole in block 2 | 5 | | `QueryHandlerDependencies.md` | 3 | `CS0246` `Program`; `CS0103` `builder` | instrument, as `DarkerConfigurationReference.md` #1; `builder` is not stubbed, since no BUILT block would name it. Builds as a `Program.cs` against Darker 4.1.1, **0** errors | 5 | -| `HowConfiguringTheDispatcherWorks.md` | 3 | `CS0246` `Topshelf`, `ServiceControl`, `HostControl` | the page hosts the Dispatcher in Topshelf, which the pin does not carry, and a unit may not stand in for a third-party package. Builds beside block 2 against Topshelf 4.3.0 in scratch, **0** errors. Put to the maintainer | 5 | ## Splits @@ -2423,7 +2462,7 @@ BUILT, re-admitted at `ec38400`. | Cooldown time given as `CooldownCount × interval`, recovery *"when the cooldown reaches zero"* — a topic sits out `CooldownCount` sweeps and is retried on the next, `(CooldownCount + 1) × TimerInterval` | `InMemoryOutboxCircuitBreaker.cs` (removes below zero), `OutboxProducerMediator.cs:721`; run end to end, controls no breaker and `0` | `SweeperCircuitBreaking.md` (formula, example, #2, #7 comments, steps), `UsingSweeperCircuitBreaking.md` #2 | `grep -rnE '(^\|[^+] )[0-9]+ (sweeps )?× [0-9]+s\|total cooldown\|[Rr]ecover after [0-9]\|When the cooldown reaches zero' contents/`, at `05fdeaf` and after | **8** | **0** | 4.4, running the sweeper for the row above | | Circuit breaking said to work with every Outbox, and `.UseMongoDbOutbox(…)` — no such method. The DynamoDB (V3, V4) and Spanner Outboxes ignore `trippedTopics`, so a tripped topic is swept as normal | `DynamoDbOutbox.cs:582`, `SpannerQueries.cs:12`; run against DynamoDB Local and the Spanner emulator, controls SQLite and MongoDB — **upstream, BrighterCommand/Brighter#4443, #4444**, filed 4.4 | `SweeperCircuitBreaking.md` (section, #5, troubleshooting) | the next row's grep, its first four alternatives | **4** | **0** — the table states it | 4.4, maintainer's ruling | | Explicit clearing said both to ignore the breaker and to respect it, and failed batches to be *"retried individually per topic"*. An explicit clear sends a tripped topic's messages; a failed `ClearOutboxAsync` trips the topic, a failed `ClearOutbox` does not unless the producer confirms publication | `OutboxProducerMediator.cs:425`, `:1220`, `:984`; run, sync and async, pre-tripped and fresh | `SweeperCircuitBreaking.md` § 6, § *Bulk Dispatch Support* | `grep -rnE 'UseMongoDbOutbox\|fully integrated with MongoDB\|works automatically with MongoDB\|works with all Brighter Outbox\|NOT subject to circuit breaking\|respects circuit breaker state\|retried individually per topic' contents/`, at `05fdeaf` and after; its last three alternatives are this row's | **3** | **0** | 4.4, maintainer's ruling | -| A pre-V10 logging handler: `using Brighter.commandprocessor.Logging`, `namespace Brighter.commandprocessor`, `logger.InfoFormat`; prose placing it in *"the Brighter.CommandProcessor packages"* and passing *"an ILog reference"* — and on the async page, prose about logging beside a block that writes to an Inbox; `FAQ.md` quoting `Brighter.commandprocessor.ICommand` in an exception | `Logging/Handlers/RequestLoggingHandler.cs`; run, the FAQ's message `Paramore.Brighter.ICommand` | `BuildingAPipeline.md`, `BuildingAnAsyncPipeline.md`, `FAQ.md` | `grep -rnE 'Brighter\.commandprocessor\|Brighter\.CommandProcessor packages\|ILog reference' contents/` | **8** | **1** — `Monitoring.md:25`, an `app.config` section, put to the maintainer | 5.1, `--explain` | +| A pre-V10 logging handler: `using Brighter.commandprocessor.Logging`, `namespace Brighter.commandprocessor`, `logger.InfoFormat`; prose placing it in *"the Brighter.CommandProcessor packages"* and passing *"an ILog reference"* — and on the async page, prose about logging beside a block that writes to an Inbox; `FAQ.md` quoting `Brighter.commandprocessor.ICommand` in an exception | `Logging/Handlers/RequestLoggingHandler.cs`; run, the FAQ's message `Paramore.Brighter.ICommand` | `BuildingAPipeline.md`, `BuildingAnAsyncPipeline.md`, `FAQ.md` | `grep -rnE 'Brighter\.commandprocessor\|Brighter\.CommandProcessor packages\|ILog reference' contents/` | **8** | **0** — `Monitoring.md:25`'s `app.config` section went with the page's rewrite, by ruling | 5.1, `--explain` | | `.Successor = …` and a method *"IHandleRequests\ Successor"* — the method is `SetSuccessor()`; and the manual chain said to run from the registry alone, which holds the handler's type, so a factory must return the wired instance | `RequestHandler.cs:74`; run, control a new instance | `BuildingAPipeline.md` | `grep -rnE '\.Successor *=\|TRequest\\> Successor\*\*\|Successor\.Handle\(\)' contents/` | **3** | **0** | 5.2, `--explain` | | *"Cannot use AutoFromAssemblies"* with an Agreement Dispatcher, *"creates fixed mappings"*. `AutoFromAssemblies(excludeDynamicHandlerTypes: …)` scans beside an agreement; without the exclusion every `Send` throws *"More than one handler was found"* | `IBrighterBuilder.cs:46`, `ServiceCollectionBrighterBuilder.cs:238`; run, both orders, controls both ways | `AgreementDispatcherRouting.md`, `AgreementDispatcher.md`, `FAQ.md` | `grep -rnEi "cannot use .?AutoFromAssemblies\|AutoFromAssemblies.? (won't\|will not) work\|creates fixed (1-to-1 )?mappings\|Instead of AutoFromAssemblies" contents/` | **7** | **0** | 5.1, P0-10 — running #8's claim | | Darker's query processor said to default to **Transient**, and an unscoped EF Core handler to fail on a *disposed DbContext*. It defaults to **Singleton** and resolves handlers from the root provider: with scope validation, *"Cannot resolve … from root provider"*; without, one `DbContext` for every query | Darker 4.1.1 `DarkerOptions.cs:9`; run, control `Scoped` | `DarkerConfigurationReference.md`, `DarkerBasicConfiguration.md` | `grep -rnEi 'disposed DbContext\|IQueryProcessor.{0,40}Transient\|Default Configuration \(Transient\)' contents/` | **5** | **0** | 5.2, reading the page against `DarkerOptions` | @@ -2432,6 +2471,13 @@ BUILT, re-admitted at `ec38400`. | V9's `new MessageMapperRegistry(messageMapperFactory) { { typeof(…), typeof(…) } }` (`CS7036`, `CS1922`) | `MessageMapperRegistry.cs:64`, `:296` | `HowConfiguringTheDispatcherWorks.md` | `grep -rnE 'new MessageMapperRegistry\([a-zA-Z]+\)$' contents/` | **1** | **0** | 5.1, `--explain` | | `messagePumpType` said to be set to `Reactor` because *"`Subscription` defaults to `Proactor`"* — the block's `RmqSubscription` (RMQ.Sync) defaults to `Reactor`; switching to `Proactor` is what fails | `RmqSubscription.cs:107`, `Subscription.cs:291`; run against RabbitMQ, controls omitted and `Proactor` | `HowConfiguringTheDispatcherWorks.md` | `grep -rn 'messagePumpType. is set explicitly' contents/` | **1** | **0** | 5.2, running #2's warnings | | A query handler reading `o.Customer` and `o.CreatedAt` from a write model that declares neither (`CS1061` once its names resolve); commands written `: IRequest { /* ... */ }` and controller actions outside a class | compiled | `CQRSUseCasesAndPatterns.md` #1, #2 | — | **2** blocks | **0** | 5.2, stubbing #1 | +| `Monitoring.md` written for V9: an `app.config` section typed `MonitoringConfigurationSection, Brighter.commandprocessor`, `container.Register>`, and the Control Bus on the retired site. At 10.7.0 the handler takes an `IAmAControlBusSender` and a `MonitorConfiguration` from the container | `Monitoring/Handlers/MonitorHandler.cs`, `Configuration/MonitoringConfigurationSection.cs`; run end to end | `Monitoring.md` | `grep -rnE 'configSections\|MonitoringConfigurationSection\|brightercommand\.github\.io/Brighter/ControlBus' contents/` | **5** | **0** | 5.1, the pre-V10 namespace recurrence; maintainer's ruling | +| A handler declared without `public` — `AutoFromAssemblies()` scans only public handler types, so its request throws *"No command handler was found"* | `ServiceCollectionBrighterBuilder.cs:238`; run, control public | `BuildingAPipeline.md`, `BuildingAnAsyncPipeline.md`, `CommandProcessorConfigurationReference.md`, `FeatureSwitches.md`, `MigratingToPollyV8.md`, `PolicyRetryAndCircuitBreaker.md` | `grep -rnE '^\s*(internal\s+)?class\s+\w+\s*:\s*(RequestHandler\|RequestHandlerAsync)<' contents/`, and a scan of every C# fence | **18** | **0** | 5.2, running `BuildingAPipeline.md`; maintainer's ruling | +| Darker's scan said to need handlers named *"…Handler"* and not nested. It registers every exported non-abstract `IQueryHandler<,>`: the name and nesting in a public class do not matter; `internal` does | Darker 4.1.1 `QueryHandlerRegistry.cs:48`; run, four cases | `DarkerBasicConfiguration.md` | `grep -rnEi 'end with .?"?Handler\|not nested within' contents/` | **2** | **0** | 5.2, reading the lifetime row's page; maintainer's ruling | +| `[UseResiliencePipeline]` stacked on one method (`CS0579`) — not repeatable, by design since BrighterCommand/Brighter#2580; strategies are layered in one pipeline. `HowConfiguringTheCommandProcessorWorks.md` said *"you can use multiple attributes"* | `UseResiliencePipelineAttribute.cs:52`; compiled, and the composed form run | `PolicyRetryAndCircuitBreaker.md`, `HandlerFailure.md` ×2, `HowConfiguringTheCommandProcessorWorks.md`, `MigratingToPollyV8.md`, `PolicyFallback.md` ×2 | a scan of every C# fence for one attribute twice in one run of attribute lines | **7** blocks, 5 pages | **0**, `QueryPipeline.md`'s two `[RetryableQuery]` alternatives, commented as two handlers', aside | 5.2, `--explain` on the touched blocks | +| A Polly v8 pipeline's strategies said to wrap *"inner to outer"* in the order added, and `MyComprehensivePipeline` added timeout, retry, breaker. The first added is the outermost, so its 10 s timeout covered every retry together | run, both orders | `PolicyRetryAndCircuitBreaker.md` | `grep -rnE "order they.re added\|inner to outer" contents/`, and a scan of every `TryAddBuilder` chain | **1** sentence, 1 chain | **0** — `PolicyFallback.md`'s chain was right | 5.2, rewriting the stacked attributes | +| `await` in a handler not marked `async` (`CS4032`) | compiled | `FeatureSwitches.md` #2 | — | **1** | **0** | 5.2, `--explain` | +| A monitored handler that throws: the monitor's `ExceptionThrown` event carries the `Exception`, which `System.Text.Json` cannot serialize, so the caller gets `NotSupportedException` in place of the handler's exception. And `[MonitorAsync]` cannot send through `ControlBusSenderFactory`'s sender, which registers no async mapper. Both on Brighter `master` too | `MonitorEvent.cs:74`, `ControlBusSenderFactory.cs:56`; run, controls non-throwing and sync — **upstream, not filed** | `Monitoring.md` states both | — | **2** | **stated** — filing put to the maintainer | 5.2, running the rewritten page | ## Friction ledger From 5129c204c396807d8c3810ed7cd268da4b86cde7 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 11:00:47 +0100 Subject: [PATCH 08/27] docs: link the monitoring defects from Monitoring.md, #4453 and #4454 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/Monitoring.md | 4 ++-- spec/017-compile_repairs/tasks.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contents/Monitoring.md b/contents/Monitoring.md index e569d47..d1919b4 100644 --- a/contents/Monitoring.md +++ b/contents/Monitoring.md @@ -121,8 +121,8 @@ A consumer on that topic can forward the events to your monitoring tool, for exa Two defects in Brighter 10.7.0 limit what monitoring can do: -- **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 -- **`[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"* +- **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)) +- **`[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/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 4daf8bc..ee754ee 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -2027,7 +2027,7 @@ by the `using`s above it: `PolicyRetryAndCircuitBreaker.md:326` → `:359`. Repa container, and `AddBrighter()` makes `MonitorHandler` available with either registration style. The page now builds a sender with `ControlBusSenderFactory`, shows `[Monitor]`, and prints the message format captured from a run. **Two upstream defects, found running it and stated on the - page** (§ *Defect ledger*); filing them is put to the maintainer + page** (§ *Defect ledger*), and filed on the maintainer's word: BrighterCommand/Brighter#4453, #4454 - **Topshelf retired.** `HowConfiguringTheDispatcherWorks.md` now recommends `AddConsumers()`'s hosted service and, without HostBuilder, runs the Dispatcher from a console app until Ctrl+C. #3 builds - **Handlers declared without `public`: 18 → 0** on 6 pages (a grep and a Python scan of every C# @@ -2477,7 +2477,7 @@ BUILT, re-admitted at `ec38400`. | `[UseResiliencePipeline]` stacked on one method (`CS0579`) — not repeatable, by design since BrighterCommand/Brighter#2580; strategies are layered in one pipeline. `HowConfiguringTheCommandProcessorWorks.md` said *"you can use multiple attributes"* | `UseResiliencePipelineAttribute.cs:52`; compiled, and the composed form run | `PolicyRetryAndCircuitBreaker.md`, `HandlerFailure.md` ×2, `HowConfiguringTheCommandProcessorWorks.md`, `MigratingToPollyV8.md`, `PolicyFallback.md` ×2 | a scan of every C# fence for one attribute twice in one run of attribute lines | **7** blocks, 5 pages | **0**, `QueryPipeline.md`'s two `[RetryableQuery]` alternatives, commented as two handlers', aside | 5.2, `--explain` on the touched blocks | | A Polly v8 pipeline's strategies said to wrap *"inner to outer"* in the order added, and `MyComprehensivePipeline` added timeout, retry, breaker. The first added is the outermost, so its 10 s timeout covered every retry together | run, both orders | `PolicyRetryAndCircuitBreaker.md` | `grep -rnE "order they.re added\|inner to outer" contents/`, and a scan of every `TryAddBuilder` chain | **1** sentence, 1 chain | **0** — `PolicyFallback.md`'s chain was right | 5.2, rewriting the stacked attributes | | `await` in a handler not marked `async` (`CS4032`) | compiled | `FeatureSwitches.md` #2 | — | **1** | **0** | 5.2, `--explain` | -| A monitored handler that throws: the monitor's `ExceptionThrown` event carries the `Exception`, which `System.Text.Json` cannot serialize, so the caller gets `NotSupportedException` in place of the handler's exception. And `[MonitorAsync]` cannot send through `ControlBusSenderFactory`'s sender, which registers no async mapper. Both on Brighter `master` too | `MonitorEvent.cs:74`, `ControlBusSenderFactory.cs:56`; run, controls non-throwing and sync — **upstream, not filed** | `Monitoring.md` states both | — | **2** | **stated** — filing put to the maintainer | 5.2, running the rewritten page | +| A monitored handler that throws: the monitor's `ExceptionThrown` event carries the `Exception`, which `System.Text.Json` cannot serialize, so the caller gets `NotSupportedException` in place of the handler's exception. And `[MonitorAsync]` cannot send through `ControlBusSenderFactory`'s sender, which registers no async mapper. Both on Brighter `master` too | `MonitorEvent.cs:74`, `ControlBusSenderFactory.cs:56`; run, controls non-throwing and sync — **upstream, BrighterCommand/Brighter#4453, #4454**, filed 5.2 by the maintainer's word | `Monitoring.md` states both and links them | `grep -rn 'issues/445[34]' contents/` | **2** | **stated** | 5.2, running the rewritten page | ## Friction ledger From 2d938c83b4881ad82d2fc6c2963b5188ecd7d733 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 17:14:01 +0100 Subject: [PATCH 09/27] =?UTF-8?q?docs:=20017=20task=205.3=20(WIP)=20?= =?UTF-8?q?=E2=80=94=20transports,=20external=20bus=20and=20tracing=20page?= =?UTF-8?q?s=20repaired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work in progress, not yet gated: baseline rows, tasks.md records, the S3 ACL fix and the carried 5.1 items are still to come. - PostgreSQLMessageBroker.md: consumer channelName must equal the publication Topic; PostAsync, not PublishAsync, reaches the table; scheduled messages go through the scheduler, visible_timeout delays only a requeue; outbox deposit takes the transaction provider; claim check on a mapper; messagePumpType on every subscription; SQL no longer names a created_at column - PostgreSQLBrokerTradeOffs.md: JSONB/JSON fence split; message size measured (50 MB round-trips, 150 MB rejected); SQS 1 MiB - CloudEventsReference.md, CloudEventsSupport.md: header names as captured from RabbitMQ and Kafka; the mapper chooses the content mode; Kafka partition key from the request context; souce, #4458 - Telemetry.md, ConfiguringOpenTelemetry.md: spans need a registered tracer (AddBrighterInstrumentation); tables rewritten from captured spans; Jaeger exporter replaced by OTLP - S3LuggageStore.md: link label, IHttpClientFactory, list nesting - Units for BrighterControlAPI, CloudEventsReference, PostgreSQL (both pages) and Telemetry Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/CloudEventsReference.md | 87 ++++-- contents/CloudEventsSupport.md | 49 ++-- contents/ConfiguringOpenTelemetry.md | 135 +++++---- contents/PostgreSQLBrokerTradeOffs.md | 28 +- contents/PostgreSQLMessageBroker.md | 197 ++++++++++--- contents/S3LuggageStore.md | 12 +- contents/Telemetry.md | 260 ++++++++++++------ tools/blockcheck/scaffold/pages.tsv | 5 + .../units/BrighterControlAPIContext.cs | 15 + .../units/CloudEventsReferenceContext.cs | 20 ++ .../units/PostgreSQLMessageBrokerContext.cs | 74 +++++ .../scaffold/units/TelemetryContext.cs | 22 ++ 12 files changed, 663 insertions(+), 241 deletions(-) create mode 100644 tools/blockcheck/scaffold/units/BrighterControlAPIContext.cs create mode 100644 tools/blockcheck/scaffold/units/CloudEventsReferenceContext.cs create mode 100644 tools/blockcheck/scaffold/units/PostgreSQLMessageBrokerContext.cs create mode 100644 tools/blockcheck/scaffold/units/TelemetryContext.cs diff --git a/contents/CloudEventsReference.md b/contents/CloudEventsReference.md index 79c56fc..d982987 100644 --- a/contents/CloudEventsReference.md +++ b/contents/CloudEventsReference.md @@ -45,79 +45,122 @@ CloudEvents supports extension attributes for additional metadata: ## CloudEvents Across Transports -Brighter maps CloudEvents to transport-specific formats automatically. The transport layer handles the conversion based on the protocol's capabilities. +Your message mapper chooses the content mode, not the transport. The default +`JsonMessageMapper` writes **binary mode**: the attributes travel beside the body, and the body +is your request. `CloudEventJsonMessageMapper` writes **structured mode**: the body is the whole +CloudEvents envelope, with your request as its `data` — see +[Default Message Mappers](/contents/DefaultMessageMappers.md). Either way, each transport writes the +attributes where its protocol has room for them, as below. ### RabbitMQ (AMQP 0-9-1) -RabbitMQ uses **binary mode** with CloudEvents mapped to message headers: +RabbitMQ carries the attributes as message headers, prefixed `cloudEvents_`: ```csharp -// ... -var publication = new Publication +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + +var publication = new RmqPublication { Topic = new RoutingKey("orders"), - RequestType = typeof(OrderCreated), Source = new Uri("https://example.com/orders"), Type = new CloudEventsType("com.example.order.created") }; // Headers will include: -// ce_id, ce_source, ce_type, ce_specversion, ce_datacontenttype +// cloudEvents_id, cloudEvents_source, cloudEvents_type, cloudEvents_specversion, cloudEvents_time ``` +The content type travels in the AMQP `content-type` property rather than a header. The +`Paramore.Brighter.MessagingGateway.RMQ.Sync` package writes the same headers with the prefix +`cloudEvents:`. + See: [AMQP Protocol Binding for CloudEvents](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/amqp-protocol-binding.md) ### Kafka -Kafka uses **binary mode** with CloudEvents in message headers: +Kafka carries the attributes as record headers, prefixed `ce_`: ```csharp -// ... -var publication = new Publication +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +var publication = new KafkaPublication { Topic = new RoutingKey("orders"), - RequestType = typeof(OrderCreated), Source = new Uri("https://example.com/orders"), - Type = new CloudEventsType("com.example.order.created"), - PartitionKey = "customer-12345" // Kafka partition key + Type = new CloudEventsType("com.example.order.created") }; + +// Headers will include: +// ce_id, ce_source, ce_type, ce_specversion, ce_time, content-type +``` + +The partition key belongs to each message rather than to the publication. The default mapper takes +it from the request context, and Kafka writes it as the record's key: + +```csharp +using Paramore.Brighter; + +var context = new RequestContext(); +context.Bag[RequestContextBagNames.PartitionKey] = "customer-12345"; // the Kafka record key + +await commandProcessor.PostAsync(new OrderCreated(), context); ``` See: [Kafka Protocol Binding for CloudEvents](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/kafka-protocol-binding.md) +and [Using the Context Bag](/contents/UsingTheContextBag.md) ### AWS SNS/SQS -AWS SNS/SQS has limited header support, so Brighter uses **structured mode**: +Brighter writes the CloudEvents attributes together, as a JSON object in one message attribute +named `cloudeventheaders`, and the body is your request: ```csharp -// ... -var publication = new Publication +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +var publication = new SnsPublication { Topic = new RoutingKey("orders"), - RequestType = typeof(OrderCreated), Source = new Uri("https://example.com/orders"), Type = new CloudEventsType("com.example.order.created") }; -// The entire CloudEvents envelope (including data) is in the message body +// The cloudeventheaders attribute holds: +// specversion, type, souce, time, datacontenttype, dataschema, baggage, +// and subject, dataref, traceparent and tracestate when they are set ``` +The source is written under the key `souce`, as shown: a Brighter consumer reads it back, and a +consumer of your own has to look for that spelling. This is [BrighterCommand/Brighter#4458](https://github.com/BrighterCommand/Brighter/issues/4458). To put the whole envelope in the body, use +`CloudEventJsonMessageMapper`. + ### Azure Service Bus -Azure Service Bus supports **binary mode** with headers: +Azure Service Bus carries the attributes as application properties, prefixed `cloudEvents:`: ```csharp -// ... -var publication = new Publication +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; + +var publication = new AzureServiceBusPublication { Topic = new RoutingKey("orders"), - RequestType = typeof(OrderCreated), Source = new Uri("https://example.com/orders"), Type = new CloudEventsType("com.example.order.created") }; + +// Application properties will include: +// cloudEvents:id, cloudEvents:source, cloudEvents:type, cloudEvents:specversion, +// cloudEvents:time, cloudEvents:contenttype ``` -See: [HTTP Protocol Binding for CloudEvents](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/http-protocol-binding.md) (Azure Service Bus follows HTTP binding) +See: [AMQP Protocol Binding for CloudEvents](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/amqp-protocol-binding.md) (Azure Service Bus speaks AMQP 1.0) ## Further Reading diff --git a/contents/CloudEventsSupport.md b/contents/CloudEventsSupport.md index 377a887..309b1f9 100644 --- a/contents/CloudEventsSupport.md +++ b/contents/CloudEventsSupport.md @@ -27,49 +27,54 @@ CloudEvents can be transmitted in two modes, and Brighter supports both: ### Binary-Mode (Recommended) -In binary-mode, CloudEvents attributes are mapped to protocol headers, and the event data is placed in the message body. +In binary-mode, CloudEvents attributes are mapped to protocol headers, and the event data is placed in the message body. The default `JsonMessageMapper` writes binary mode, on every transport. **When to use binary mode:** -- The transport protocol supports headers (RabbitMQ, Kafka, AMQP) +- Your consumers read the attributes from headers - You want efficient serialization - You want to inspect event metadata without deserializing the body -**Example RabbitMQ message with binary CloudEvents:** +**Example RabbitMQ message with binary CloudEvents**, as `Paramore.Brighter.MessagingGateway.RMQ.Async` writes it: ```text +Properties: + content-type: application/json + Headers: - ce_id: "a89b61a2-5c5c-4d7e-8b8f-2e0f9c1d3e4f" - ce_source: "https://example.com/orders" - ce_type: "com.example.order.created" - ce_specversion: "1.0" - ce_datacontenttype: "application/json" - ce_time: "2025-01-02T10:30:00Z" + cloudEvents_id: "01a0e7d8-3aef-7360-bf91-1f2c771a9cb3" + cloudEvents_source: "https://example.com/orders" + cloudEvents_type: "com.example.order.created" + cloudEvents_specversion: "1.0" + cloudEvents_time: "2026-09-28T11:48:22.895Z" Body: - {"orderId": "12345", "customerId": "67890", "total": 99.99} + {"orderId":"12345","correlationId":null,"id":"01a0e7d8-3aef-7360-bf91-1f2c771a9cb3"} ``` +Each transport names the headers its own way — `ce_` on Kafka, one `cloudeventheaders` attribute on AWS SNS/SQS — see [CloudEvents Across Transports](/contents/CloudEventsReference.md#cloudevents-across-transports). + ### Structured Content Mode -In structured mode, both CloudEvents attributes and data are placed in the message body as a JSON object. +In structured mode, both CloudEvents attributes and data are placed in the message body as a JSON object. `CloudEventJsonMessageMapper` writes structured mode; choosing it is what selects the mode, whichever transport carries the message. **When to use structured mode:** -- The transport has insufficient header support (AWS SNS/SQS) +- A consumer reads the event from the body alone - You need to preserve all metadata in a single payload -- The protocol doesn't support custom headers well -**Example SNS/SQS message with structured CloudEvents:** +**Example message body with structured CloudEvents:** ```json { + "id": "01a0e7d8-3e13-7fbe-abce-bce954e1b9b7", "specversion": "1.0", - "type": "com.example.order.created", "source": "https://example.com/orders", - "id": "a89b61a2-5c5c-4d7e-8b8f-2e0f9c1d3e4f", - "time": "2025-01-02T10:30:00Z", + "type": "com.example.order.created", "datacontenttype": "application/json", + "dataschema": null, + "subject": null, + "time": "2026-09-28T11:48:23.699316+00:00", "data": { "orderId": "12345", - "customerId": "67890", - "total": 99.99 + "correlationId": null, + "id": "01a0e7d8-3e13-7fbe-abce-bce954e1b9b7" } } ``` @@ -338,9 +343,9 @@ See the [V10 Migration Guide](V10MigrationGuide.md) for complete migration instr ### 1. Choose the Right Content Mode -- Use **binary mode** for protocols with header support (RabbitMQ, Kafka, Azure Service Bus) -- Use **structured mode** for protocols with limited headers (AWS SNS/SQS) -- Brighter selects the appropriate mode automatically based on the transport +- Use **binary mode**, the default mapper's, when your consumers read headers; every transport Brighter supports carries the attributes beside the body +- Use **structured mode**, `CloudEventJsonMessageMapper`, when a consumer expects the whole envelope in the body +- The mapper selects the mode, not the transport ### 2. Use Meaningful CloudEvents Type diff --git a/contents/ConfiguringOpenTelemetry.md b/contents/ConfiguringOpenTelemetry.md index 2dbb699..94e3e98 100644 --- a/contents/ConfiguringOpenTelemetry.md +++ b/contents/ConfiguringOpenTelemetry.md @@ -17,8 +17,20 @@ 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` -- **Version**: Includes the Brighter version number +- **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 + +### Registering Brighter's Tracer + +Brighter writes to that source only through a tracer, an `IAmABrighterTracer`, registered in your +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. + +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 +tracer it registers is not one Brighter can find. ### Basic Configuration @@ -26,40 +38,39 @@ The following code configures OpenTelemetry to: - Enable tracing - Set the service name -- Listen to Brighter and Microsoft sources -- Export traces to Jaeger +- Register Brighter's tracer and listen to its source +- Export traces over OTLP ```csharp -using OpenTelemetry; +using System; +using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using Paramore.Brighter.Extensions.Diagnostics; const string serviceName = "MyService"; -var jaegerEndpoint = new Uri("http://localhost:14268/api/traces"); -using var tracerProvider = - Sdk.CreateTracerProviderBuilder() - .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName)) - .AddSource("paramore.brighter", "Microsoft.*") - .AddJaegerExporter(o => +var services = new ServiceCollection(); + +services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(serviceName)) + .WithTracing(tracing => tracing + .AddBrighterInstrumentation() + .AddOtlpExporter(o => { - o.Endpoint = jaegerEndpoint; - }) - .Build(); + o.Endpoint = new Uri("http://localhost:4317"); + })); ``` +The packages are `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Exporter.OpenTelemetryProtocol` +and `Paramore.Brighter.Extensions.Diagnostics`. + ### Configuration with Different Backends #### Jaeger -```csharp -// ... -.AddJaegerExporter(o => -{ - o.AgentHost = "localhost"; - o.AgentPort = 6831; -}) -``` +Jaeger receives OTLP, and OpenTelemetry has deprecated its Jaeger exporter in favour of OTLP. Point +the [OTLP exporter](#otlp-opentelemetry-protocol) at Jaeger's OTLP endpoint, port 4317 for gRPC. #### Zipkin @@ -99,22 +110,24 @@ using var tracerProvider = ### Producer Service ```csharp -using OpenTelemetry; +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.Diagnostics; using Paramore.Brighter.Observability; var builder = WebApplication.CreateBuilder(args); // Configure OpenTelemetry builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("OrderService")) .WithTracing(tracing => { tracing - .SetResourceBuilder(ResourceBuilder.CreateDefault() - .AddService("OrderService")) - .AddSource("paramore.brighter") + .AddBrighterInstrumentation() .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddOtlpExporter(o => @@ -131,7 +144,7 @@ builder.Services.AddBrighter(options => }) .AddProducers(configure => { - // Producer configuration + // ... producer registry configure.InstrumentationOptions = InstrumentationOptions.RequestInformation | InstrumentationOptions.Messaging; }) @@ -141,14 +154,22 @@ var app = builder.Build(); app.Run(); ``` +`AddAspNetCoreInstrumentation()` and `AddHttpClientInstrumentation()` come from +`OpenTelemetry.Instrumentation.AspNetCore` and `OpenTelemetry.Instrumentation.Http`. + ### Consumer Service (Dispatcher) ```csharp -using OpenTelemetry; +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.Diagnostics; using Paramore.Brighter.Observability; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; var builder = Host.CreateDefaultBuilder(args); @@ -156,12 +177,11 @@ builder.ConfigureServices(services => { // Configure OpenTelemetry services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("TaskProcessor")) .WithTracing(tracing => { tracing - .SetResourceBuilder(ResourceBuilder.CreateDefault() - .AddService("TaskProcessor")) - .AddSource("paramore.brighter") + .AddBrighterInstrumentation() .AddOtlpExporter(o => { o.Endpoint = new Uri("http://localhost:4317"); @@ -171,7 +191,7 @@ builder.ConfigureServices(services => // Configure Brighter Consumer, and how much its spans record services.AddConsumers(options => { - options.Subscriptions = subscriptions; + // ... subscriptions and channel factory // Leave out RequestBody, which records the message body and is expensive options.InstrumentationOptions = InstrumentationOptions.RequestInformation | InstrumentationOptions.Messaging; @@ -187,30 +207,37 @@ await host.RunAsync(); ## OpenTelemetry Distributed Tracing Example -A complete distributed trace across services: +The traces a producer and a consumer record for one event, measured with the in-memory transport +and Outbox. [Telemetry](/contents/Telemetry.md) describes each span. + +The request, which deposits the event in the Outbox: ```text ASP.NET Request (OrderService): "POST /api/orders" - └─> Command Processor: "CreateOrderCommand send" - └─> Handler: CreateOrderCommandHandler - └─> Deposit: "CreateOrderCommand deposit" - └─> Outbox add (MySQL) - - ─── Outbox Sweeper ─── - - └─> Clear: "clear" - └─> Outbox get (MySQL) - └─> Produce: "orders.created publish" - └─> Outbox mark dispatched (MySQL) - - ─── Message Broker (RabbitMQ) ─── - -Dispatcher (TaskService): "orders.created process" - └─> Inbox check (PostgreSQL) - └─> Command Processor: "OrderCreatedEvent send" - └─> Handler: SendEmailHandler - └─> Handler: UpdateInventoryHandler - └─> Inbox add (PostgreSQL) + └─> "CreateOrderCommand send" + └─> Handler events: CreateOrderCommandHandler + └─> "OrderCreatedEvent deposit" + └─> "add.message outbox requests" +``` + +Clearing the Outbox, which sends the message, in a trace of its own: + +```text +"paramore.brighter.clear_messages create" + └─> "retrieve.message outbox requests" + └─> "paramore.brighter.clear_messages clear" + └─> "orders.created publish" +``` + +The consumer, whose `process` span continues the trace of the `publish` span above: + +```text +"orders.created process" + └─> "OrderCreatedEvent create" + └─> "OrderCreatedEvent publish", one per handler + └─> "message.exists inbox requests" + └─> "add.message inbox requests" + └─> Handler events: SendEmailHandler ``` --- diff --git a/contents/PostgreSQLBrokerTradeOffs.md b/contents/PostgreSQLBrokerTradeOffs.md index 6934a2a..ad68078 100644 --- a/contents/PostgreSQLBrokerTradeOffs.md +++ b/contents/PostgreSQLBrokerTradeOffs.md @@ -49,7 +49,7 @@ page to read once you have decided. **Not Suitable For**: - **High-volume scenarios** (> 1000 messages/second) -- **Large messages** (PostgreSQL has practical limits for row sizes) +- **Large messages** (each message is stored whole in one table column, so large payloads add to the database's load) - **Complex routing requirements** (better served by RabbitMQ or Kafka) - **Cross-organization messaging** (where dedicated broker provides better isolation) @@ -65,8 +65,11 @@ page to read once you have decided. ### Message Size -- **Practical limit**: ~1MB per message (PostgreSQL row size limits) -- **Recommendation**: Use [Claim Check pattern](ClaimCheck.md) for large payloads +- **Limit**: Brighter stores the whole message as one value in the `content` column. With JSONB, + PostgreSQL caps that value at 268,435,455 bytes (256 MB), and the stored message is larger than + the payload it carries. We have tested payloads up to 50 MB; larger ones may fit, but a 150 MB + payload is rejected on insert +- **Recommendation**: Use [Claim Check pattern](ClaimCheck.md) for large payloads, well before that limit ### No Native Routing @@ -90,16 +93,24 @@ PostgreSQL supports two JSON data types: ### JSONB Configuration +`binaryMessagePayload` chooses the column type. Set it to `true` for JSONB, the recommended +form: + ```csharp -// ... -// Use JSONB (recommended) +using Paramore.Brighter; + var configuration = new RelationalDatabaseConfiguration( connectionString: connectionString, queueStoreTable: "brighter_messages", binaryMessagePayload: true // JSONB ); +``` + +Leave it `false`, its default, for JSON and smaller storage: + +```csharp +using Paramore.Brighter; -// Use JSON (smaller storage) var configuration = new RelationalDatabaseConfiguration( connectionString: connectionString, queueStoreTable: "brighter_messages", @@ -115,7 +126,7 @@ var configuration = new RelationalDatabaseConfiguration( |---------|------------|----------|-------|---------| | **Setup Complexity** | Low | Medium | High | Low | | **Throughput** | Low-Medium | High | Very High | Medium | -| **Message Size** | ~1MB | 128MB | ~1MB | 256KB | +| **Message Size** | tested to 50MB (JSONB) | 128MB | ~1MB | 1MiB | | **Persistence** | Database | Disk/Memory | Disk | Managed | | **Routing** | Simple | Advanced | Topic-based | Simple | | **Transactional** | Yes (local) | No | No | No | @@ -123,6 +134,9 @@ var configuration = new RelationalDatabaseConfiguration( | **Operational Cost** | Low (existing DB) | Medium | High | Pay-per-use | | **Best For** | Low volume, transactional | General messaging | Event streaming | AWS ecosystem | +AWS SQS accepts messages up to 1 MiB. Brighter's support for SQS messages over 256 KB ships in the +release after 10.7.0. + --- ## Further Reading diff --git a/contents/PostgreSQLMessageBroker.md b/contents/PostgreSQLMessageBroker.md index 40eef81..8734a3e 100644 --- a/contents/PostgreSQLMessageBroker.md +++ b/contents/PostgreSQLMessageBroker.md @@ -114,7 +114,14 @@ services.AddBrighter(options => ### Publishing Messages +`PostAsync` sends a request through its publication's producer, so here it inserts a row into the +queue store table: + ```csharp +using System; +using System.Threading.Tasks; +using Paramore.Brighter; + public class OrderService { private readonly IAmACommandProcessor _commandProcessor; @@ -126,35 +133,41 @@ public class OrderService public async Task CreateOrderAsync(CreateOrderCommand command) { - // Process order - var order = ProcessOrder(command); + // ... process the order - // Publish event var orderCreatedEvent = new OrderCreatedEvent { - OrderId = order.Id, - CustomerId = order.CustomerId, - TotalAmount = order.TotalAmount, + OrderId = command.OrderId, + CustomerId = command.CustomerId, + TotalAmount = command.TotalAmount, CreatedAt = DateTime.UtcNow }; - await _commandProcessor.PublishAsync(orderCreatedEvent); + // Write the event to the queue store table + await _commandProcessor.PostAsync(orderCreatedEvent); } } ``` +`PublishAsync` would not reach the table: it dispatches the event to handlers in this process. + --- ## Consumer Configuration ### Basic Consumer Setup +The producer writes each message under its publication's `Topic`, and a subscription reads the +messages written under its `channelName`. **The two must be the same string**: a subscription whose +`channelName` differs from the `Topic` never receives a message. + ```csharp using System; using System.Collections.Generic; using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessagingGateway.Postgres; -using Paramore.Brighter.PostgreSql; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; // Database configuration var postgresConfiguration = new RelationalDatabaseConfiguration( @@ -168,7 +181,7 @@ var postgresConfiguration = new RelationalDatabaseConfiguration( var subscriptions = new List { new PostgresSubscription( - channelName: new ChannelName("orders.created.consumer"), + channelName: new ChannelName("orders.created"), // the publication's Topic routingKey: new RoutingKey("orders.created"), bufferSize: 10, // Number of messages to retrieve at once noOfPerformers: 1, // Number of concurrent consumers @@ -197,6 +210,11 @@ services.AddConsumers(options => ### Consuming Messages ```csharp +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; + public class OrderCreatedEventHandler : RequestHandlerAsync { private readonly IEmailService _emailService; @@ -265,7 +283,7 @@ the same way here; the other seven are PostgreSQL's own. | Option | Type | Default | Description | |---|---|---|---| | `subscriptionName` | `SubscriptionName` | `none` | Names the subscription for diagnostics; read back as `Name`. | -| `channelName` | `ChannelName` | `none` | Names the queue this subscription reads. | +| `channelName` | `ChannelName` | `none` | Names the queue this subscription reads; it must match the publication's `Topic`. | | `routingKey` | `RoutingKey` | `none` | The routing key messages are written under. | | `dataType` | `Type?` | `none` | The request type messages on this queue are translated into; read back as `RequestType`. | | `getRequestType` | `Func?` | derives the type from `dataType` | Determines the request type from the message rather than from the queue. | @@ -322,14 +340,19 @@ The PostgreSQL message broker uses a **visibility timeout** mechanism to prevent 1. **Message Published**: `visible_timeout` set to `CURRENT_TIMESTAMP` 2. **Message Retrieved**: Consumer reads messages where `visible_timeout <= CURRENT_TIMESTAMP` -3. **Processing**: Message becomes invisible to other consumers (timeout not updated) +3. **Processing**: The same statement moves the message's `visible_timeout` to `CURRENT_TIMESTAMP` plus the subscription's `visibleTimeout`, so other consumers skip it 4. **Acknowledged**: Message deleted from table 5. **Timeout Expires**: If not acknowledged, message becomes visible again ### Visibility Timeout Example ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Postgres; + var subscription = new PostgresSubscription( + messagePumpType: MessagePumpType.Proactor, // Message invisible for 60 seconds after retrieval visibleTimeout: TimeSpan.FromSeconds(60) ); @@ -341,24 +364,34 @@ var subscription = new PostgresSubscription( ## Scheduled Messages -PostgreSQL message broker supports message scheduling using the visibility timeout: +A delayed post goes through Brighter's [scheduler](/contents/SchedulingAMessage.md), not through +the queue store table. `PostAsync` with a delay hands the request to the configured scheduler, and +the row is written, visible at once, when the delay has elapsed: ```csharp -// ... -// Schedule message for future delivery -var delayedEvent = new OrderReminderEvent +using System; +using Paramore.Brighter; + +var reminder = new OrderReminderEvent { OrderId = orderId, ReminderText = "Your order ships tomorrow!" }; -await _commandProcessor.PublishAsync( - TimeSpan.FromHours(24), // Deliver in 24 hours - delayedEvent -); +// The row reaches the queue store table in 24 hours +await commandProcessor.PostAsync(TimeSpan.FromHours(24), reminder); ``` -**How it works**: The `visible_timeout` is set to `CURRENT_TIMESTAMP + delay`, making the message invisible until the scheduled time. +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 +[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 +the subscription sets `requeueDelay`, Brighter moves the row's `visible_timeout` to +`CURRENT_TIMESTAMP` plus that delay, and no consumer reads it until then. --- @@ -368,15 +401,35 @@ A key advantage of PostgreSQL as a message broker is **transactional messaging** ### Using the Outbox Pattern +The Outbox shares your transaction only when you pass `DepositPostAsync` a transaction provider. +Registered as the producers' `TransactionProvider`, +`PostgreSqlEntityFrameworkTransactionProvider` hands the Outbox the transaction +your `DbContext` has open — see [PostgreSQL Outbox](PostgresOutbox.md) for that registration: + ```csharp +using System; +using System.Threading.Tasks; +using Paramore.Brighter; + public class OrderService { private readonly OrderDbContext _dbContext; + private readonly IAmATransactionConnectionProvider _transactionProvider; private readonly IAmACommandProcessor _commandProcessor; + public OrderService( + OrderDbContext dbContext, + IAmATransactionConnectionProvider transactionProvider, + IAmACommandProcessor commandProcessor) + { + _dbContext = dbContext; + _transactionProvider = transactionProvider; + _commandProcessor = commandProcessor; + } + public async Task CreateOrderAsync(CreateOrderCommand command) { - using var transaction = await _dbContext.Database.BeginTransactionAsync(); + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); try { @@ -385,9 +438,9 @@ public class OrderService _dbContext.Orders.Add(order); await _dbContext.SaveChangesAsync(); - // 2. Deposit event to Outbox (same transaction) + // 2. Deposit event to Outbox, on the same transaction var orderCreatedEvent = new OrderCreatedEvent { /* ... */ }; - await _commandProcessor.DepositPostAsync(orderCreatedEvent); + await _commandProcessor.DepositPostAsync(orderCreatedEvent, _transactionProvider); // 3. Commit transaction (atomically saves order and outbox message) await transaction.CommitAsync(); @@ -395,7 +448,7 @@ public class OrderService // 4. Clear outbox to publish message await _commandProcessor.ClearOutboxAsync(new[] { orderCreatedEvent.Id }); } - catch + catch (Exception) { await transaction.RollbackAsync(); throw; @@ -404,6 +457,9 @@ public class OrderService } ``` +Leave out `_transactionProvider` and the deposit takes its own connection: a rollback then removes +the order and leaves the message in the Outbox, to be sent for an order that does not exist. + See [Outbox Pattern](OutboxPattern.md) and [PostgreSQL Outbox](PostgresOutbox.md) for more details. --- @@ -412,13 +468,15 @@ See [Outbox Pattern](OutboxPattern.md) and [PostgreSQL Outbox](PostgresOutbox.md ### Query Queue Depth +The table records no creation time. `visible_timeout` is the nearest thing: for a message waiting +to be read, it is when the message became visible. + ```sql -- Current queue depth by queue SELECT "queue", COUNT(*) as message_count, - MIN("visible_timeout") as oldest_visible, - MAX("created_at") as newest_message + MIN("visible_timeout") as oldest_visible FROM "public"."brighter_messages" WHERE "visible_timeout" <= CURRENT_TIMESTAMP GROUP BY "queue" @@ -440,28 +498,34 @@ GROUP BY "queue"; ### Find Stuck Messages ```sql --- Messages invisible for too long (potential failures) +-- Messages visible for more than 5 minutes and still not read SELECT "id", "queue", - "created_at", "visible_timeout", - EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - "visible_timeout")) as seconds_overdue + EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - "visible_timeout")) as seconds_waiting FROM "public"."brighter_messages" WHERE "visible_timeout" < CURRENT_TIMESTAMP - INTERVAL '5 minutes' -ORDER BY "created_at"; +ORDER BY "id"; ``` ### OpenTelemetry Integration -PostgreSQL message broker operations are automatically traced when [OpenTelemetry](Telemetry.md) is configured: +The PostgreSQL producer records its sends on Brighter's spans once Brighter's tracer is registered — +see [Enabling Brighter's Spans](/contents/Telemetry.md#enabling-brighters-spans). Npgsql's own +spans come from its `Npgsql.OpenTelemetry` package: ```csharp +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using OpenTelemetry.Trace; +using Paramore.Brighter.Extensions.Diagnostics; + services.AddOpenTelemetry() .WithTracing(tracing => { tracing - .AddSource("paramore.brighter") + .AddBrighterInstrumentation() .AddNpgsql() // PostgreSQL spans .AddOtlpExporter(); }); @@ -474,6 +538,8 @@ services.AddOpenTelemetry() ### 1. Use JSONB for Production ```csharp +using Paramore.Brighter; + var configuration = new RelationalDatabaseConfiguration( connectionString: connectionString, binaryMessagePayload: true // JSONB for better performance @@ -483,7 +549,12 @@ var configuration = new RelationalDatabaseConfiguration( ### 2. Set Appropriate Visibility Timeout ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Postgres; + var subscription = new PostgresSubscription( + messagePumpType: MessagePumpType.Proactor, // 2-3x expected processing time visibleTimeout: TimeSpan.FromMinutes(5) // Handler takes ~2 minutes max ); @@ -516,38 +587,80 @@ CREATE INDEX IF NOT EXISTS idx_messages_queue_visible ### 6. Regular Cleanup -Implement cleanup for old messages (if not using auto-vacuum): +Brighter deletes a row when its message is acknowledged or rejected, so the table holds only +messages not yet handled. Deleting old rows discards undelivered messages, so do it only for +messages nothing will read: ```sql --- Delete messages older than 7 days +-- Discard messages visible for more than 7 days and never read DELETE FROM brighter_messages -WHERE "created_at" < CURRENT_TIMESTAMP - INTERVAL '7 days'; +WHERE "visible_timeout" < CURRENT_TIMESTAMP - INTERVAL '7 days'; ``` ### 7. Use Claim Check for Large Messages -For messages > 100KB, use the [Claim Check pattern](ClaimCheck.md): +For messages > 100KB, use the [Claim Check pattern](ClaimCheck.md). The claim check attaches to +your message mapper, and its threshold is in kilobytes: ```csharp -[ClaimCheck(threshold: 102400, dataStore: typeof(S3LuggageStore))] -public class ProcessLargeOrderCommand : Command +using System.Text.Json; +using Paramore.Brighter; +using Paramore.Brighter.JsonConverters; +using Paramore.Brighter.Transforms.Attributes; + +public class ProcessLargeOrderCommand() : Command(Id.Random()) +{ + public byte[] LargePayload { get; set; } = []; // Stored in the luggage store, not in the database +} + +public class ProcessLargeOrderCommandMessageMapper : IAmAMessageMapper { - public byte[] LargePayload { get; set; } // Stored in S3, not in database + public IRequestContext? Context { get; set; } + + [ClaimCheck(step: 0, thresholdInKb: 100)] + public Message MapToMessage(ProcessLargeOrderCommand request, Publication publication) + { + var header = new MessageHeader( + messageId: request.Id, + topic: publication.Topic!, + messageType: MessageType.MT_COMMAND); + + var body = new MessageBody( + JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + + return new Message(header, body); + } + + [RetrieveClaim(step: 0)] + public ProcessLargeOrderCommand MapToRequest(Message message) + { + return JsonSerializer.Deserialize( + message.Body.Value, JsonSerialisationOptions.Options)!; + } } ``` +The attribute does not name the store. You register one, such as the +[S3 Luggage Store](/contents/S3LuggageStore.md), with `UseExternalLuggageStore()` — see +[Handling Large Messages](/contents/HandlingLargeMessages.md). + ### 8. Separate Queue Tables for High Volume For high-volume queues, use dedicated tables: ```csharp +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Postgres; + // High-volume queue var highVolumeSubscription = new PostgresSubscription( + messagePumpType: MessagePumpType.Proactor, queueStoreTable: "brighter_high_volume_messages" // Separate table ); // Normal queue var normalSubscription = new PostgresSubscription( + messagePumpType: MessagePumpType.Proactor, queueStoreTable: "brighter_messages" // Shared table ); ``` @@ -562,12 +675,12 @@ var normalSubscription = new PostgresSubscription( **Solutions**: -1. Check visibility timeout hasn't expired: +1. Check for messages in flight — read by a consumer and not yet acknowledged: ```sql SELECT * FROM brighter_messages WHERE queue = 'your.queue' AND visible_timeout > CURRENT_TIMESTAMP; ``` -2. Verify consumer is running and subscriptions match queue names +2. Verify the consumer is running, and that each subscription's `channelName` is its publication's `Topic` 3. Check database connection pooling isn't exhausted 4. Review logs for consumer exceptions @@ -602,7 +715,7 @@ var normalSubscription = new PostgresSubscription( 1. Add index: `CREATE INDEX ON brighter_messages(queue, visible_timeout)` 2. Use JSONB instead of JSON -3. Increase `timeOut` to reduce polling frequency +3. Increase `emptyChannelDelay` to poll an empty queue less often 4. Consider using `bufferSize > 1` to retrieve multiple messages per poll --- diff --git a/contents/S3LuggageStore.md b/contents/S3LuggageStore.md index 0db0de1..dbc9a53 100644 --- a/contents/S3LuggageStore.md +++ b/contents/S3LuggageStore.md @@ -21,7 +21,7 @@ To use the **S3LuggageStore** you need to include the following NuGet package: See [AWS SQS Migration](/contents/AWSSQSMigrateToV10.md#migrating-from-aws-sdk-v3-to-v4) for migration guidance between v3 and v4. -We then need to configure our **S3LuggageStore** and register it with our IoC container. Our **ClaimCheckTransformer** has a dependency on **IAmAStorageProviderAsync** and at runtime, when our [** **IAmAMessageTransformerFactory**](/contents/MessageTransforms.md#message-transformer-factory) creates an instance it needs to be able to resolve that dependency. For this reason you need to register the implementation, in this case **S3LuggageStore** with the IoC container to allow it to resolve the dependency. +We then need to configure our **S3LuggageStore** and register it with our IoC container. Our **ClaimCheckTransformer** has a dependency on **IAmAStorageProviderAsync** and at runtime, when our [**IAmAMessageTransformerFactory**](/contents/MessageTransforms.md#message-transformer-factory) creates an instance it needs to be able to resolve that dependency. For this reason you need to register the implementation, in this case **S3LuggageStore** with the IoC container to allow it to resolve the dependency. We provide a builder method, **UseExternalLuggageStore()**, to help with this: @@ -58,13 +58,15 @@ You configure an **S3LuggageStore** using **S3LuggageOptions**. The connection a * **BucketRegion**: Where is the bucket? Bucket names must be unique within a region. * **Strategy**: What should we do when determining if there is a bucket for the store? * **StorageStrategy.CreateIfMissing**: We will create the bucket in the requested region (provided the credentials provided have rights to do this.) - * **StorageStrategy.Validate**: We will check if the bucket exists in the requested region. We throw an **InvalidOperationException** if it does not. - * **StorageStrategy.Assume**: We do not check for the bucket, but just assume it exists + * **StorageStrategy.Validate**: We will check if the bucket exists in the requested region. We throw an **InvalidOperationException** if it does not. + * **StorageStrategy.Assume**: We do not check for the bucket, but just assume it exists -If you choose **StorageStrategy.CreateIfMissing** or **StorageStrategy.Validate** then you must register an **IHTTPClientFactory** as we will use this to obtain an HTTP Client for use with the AWS REST API to make a check for the bucket's existence. The simplest way to do this is to use the ServiceCollection extension provided for creating an **IHTTPClientFactory**: +If you choose **StorageStrategy.CreateIfMissing** or **StorageStrategy.Validate** then you must register an **IHttpClientFactory** as we will use this to obtain an HTTP Client for use with the AWS REST API to make a check for the bucket's existence. The simplest way to do this is to use the ServiceCollection extension provided for creating an **IHttpClientFactory**: ```csharp - serviceCollection.AddHttpClient(); +using Microsoft.Extensions.DependencyInjection; + +serviceCollection.AddHttpClient(); ``` ### Bucket Creation diff --git a/contents/Telemetry.md b/contents/Telemetry.md index bd956ef..3ea8363 100644 --- a/contents/Telemetry.md +++ b/contents/Telemetry.md @@ -23,6 +23,34 @@ Brighter provides comprehensive OpenTelemetry integration for distributed tracin --- +## Enabling Brighter's Spans + +Brighter records spans only when a tracer, an `IAmABrighterTracer`, is registered in your container, +and `AddBrighter()` does not register one. `AddBrighterInstrumentation()`, from the +`Paramore.Brighter.Extensions.Diagnostics` package, registers it and adds its `ActivitySource`, +`Paramore.Brighter`, to your tracer provider: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Trace; +using Paramore.Brighter.Extensions.Diagnostics; + +var services = new ServiceCollection(); + +services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddBrighterInstrumentation() + .AddOtlpExporter()); +``` + +`AddOpenTelemetry()` comes from `OpenTelemetry.Extensions.Hosting`, and `AddOtlpExporter()` from +`OpenTelemetry.Exporter.OpenTelemetryProtocol`. Without a registered tracer, +`AddSource("paramore.brighter")` listens to a source nothing writes to, and no Brighter span +appears. [Configuring OpenTelemetry](/contents/ConfiguringOpenTelemetry.md) shows the setup for a +producer and a consumer. + +--- + ## Configurable Instrumentation V10 provides fine-grained control over which attributes are recorded to optimize performance and reduce costs. @@ -35,7 +63,7 @@ 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` | Custom attributes from the request context | +| `RequestContext` | Nothing at 10.7.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`) | @@ -52,9 +80,8 @@ var services = new ServiceCollection(); services.AddBrighter(options => { - // Command Processor spans: request ID, type and operation, plus the request context - options.InstrumentationOptions = InstrumentationOptions.RequestInformation - | InstrumentationOptions.RequestContext; + // Command Processor spans: request ID, type and operation + options.InstrumentationOptions = InstrumentationOptions.RequestInformation; }) .AddProducers(configure => { @@ -70,23 +97,27 @@ services.AddBrighter(options => ## Command Processor Spans -When Brighter operates as a Command Processor, it creates spans for each operation: +When Brighter operates as a Command Processor, it creates spans for each operation. A span is named +for the request type's name, without its namespace: ### Span Names and Operations | Operation | Span Name | Kind | Description | |-----------|-----------|------|-------------| | `send` | ` send` | Internal | Command routed to single handler | -| `publish` | ` publish` | Internal | Event routed to multiple handlers | +| `create` | ` create` | Internal | An event published to its handlers; the parent of one `publish` span per handler | +| `publish` | ` publish` | Internal | Event dispatched to one of its handlers | | `deposit` | ` deposit` | Internal | Request transformed and stored in Outbox | -| `clear` | `clear` | Internal | Messages dispatched from Outbox to broker | -| `create` | ` create` | Producer | Single message sent to broker | -| `publish` (messaging) | ` publish` | Producer | Batch of messages sent to broker | +| `scheduler` | ` scheduler` | Internal | Request handed to the scheduler, with a delay or a time | +| `create` (clear) | `paramore.brighter.clear_messages create` | Producer | Messages cleared from the Outbox; the parent of a `clear` span | +| `publish` (messaging) | ` publish` | Producer | Message sent to the broker | ### Example: Send Operation ```csharp -// Creates span: "MyNamespace.ProcessOrderCommand send" +using Paramore.Brighter; + +// Creates span: "ProcessOrderCommand send" await commandProcessor.SendAsync(new ProcessOrderCommand { OrderId = 123 }); ``` @@ -94,92 +125,115 @@ await commandProcessor.SendAsync(new ProcessOrderCommand { OrderId = 123 }); | Attribute | Type | Description | Example | |-----------|------|-------------|---------| -| `paramore.brighter.requestid` | string | Request ID | `"1234-5678-9012-3456"` | -| `paramore.brighter.requestids` | string | Batch: comma-separated IDs | `"1234..., 2345..."` | -| `paramore.brighter.requesttype` | string | Full type name | `"MyNamespace.MyCommand"` | -| `paramore.brighter.request_body` | string | Request as JSON | `{"orderId": 123}` | +| `paramore.brighter.request.id` | string | Request ID | `"01a0e7cf-3c53-75f1-8566-3fd80badd0a1"` | +| `paramore.brighter.request.type` | string | Request type's name | `"ProcessOrderCommand"` | +| `paramore.brighter.request.body` | string | Request as JSON | `{"orderId":123, ...}` | | `paramore.brighter.operation` | string | Operation performed | `"send"` | -| `paramore.brighter.spancontext.*` | varies | Custom context attributes | `spancontext.userid: "1234"` | +| `messaging.operation.type` | string | Operation performed | `"send"` | ### Adding Custom Span Attributes -You can add custom attributes via the Request Context: +At 10.7.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. ```csharp -var context = new RequestContext(); -context.Bag["paramore.brighter.spancontext.userid"] = userId; -context.Bag["paramore.brighter.spancontext.tenantid"] = tenantId; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +public class ProcessOrderHandler : RequestHandlerAsync +{ + public override async Task HandleAsync( + ProcessOrderCommand command, + CancellationToken cancellationToken = default) + { + // Recorded on the "ProcessOrderCommand send" span + Activity.Current?.SetTag("app.order_id", command.OrderId); -await commandProcessor.SendAsync(command, context); + return await base.HandleAsync(command, cancellationToken); + } +} ``` -Any context bag entries starting with `paramore.brighter.spancontext.` will be added as span attributes. - ### Handler Pipeline Events -Brighter records an event for each handler entered in the pipeline: +Brighter records an event on the span for each handler entered in the pipeline, named for the +handler: | Attribute | Type | Description | Example | |-----------|------|-------------|---------| -| `paramore.brighter.handlername` | string | Full handler type name | `"MyNamespace.MyHandler"` | -| `paramore.brighter.handlertype` | string | Sync or async | `"async"` | +| `paramore.brighter.handler.name` | string | Handler type's name | `"ProcessOrderHandler"` | +| `paramore.brighter.handler.type` | string | Sync or async | `"async"` | | `paramore.brighter.is_sink` | bool | Final handler in chain | `true` | --- ## Dispatcher (Consumer) Spans -When Brighter operates as a Dispatcher (message consumer), it creates spans for each message received: +When Brighter operates as a Dispatcher (message consumer), each message pump creates spans named for +the routing key it reads, whichever transport it reads from: ### Span Names -| Transport Type | Span Name | Kind | Description | -|---------------|-----------|------|-------------| -| Pull-based (Kafka) | ` receive` | Consumer | Message pulled from broker | -| Push-based (RabbitMQ) | ` process` | Consumer | Message pushed by broker | +| Span Name | Kind | Description | +|-----------|------|-------------| +| ` begin` | Consumer | The message pump starting | +| ` receive` | Consumer | One read of the channel, a root span, including reads that find no message | +| ` process` | Consumer | One message handled; its parent is the producer's span, carried in the message | ### Example Flow +Measured with the in-memory transport, a published event and its handler: + ```text -Dispatcher Span: "task.commands receive" (Consumer) - └─> Message Translation (sibling) - └─> Command Processor Span: "ProcessTaskCommand send" (Internal) - └─> Handler Events +order.placed publish (Producer, the sending service) + └─> order.placed process (Consumer) + └─> OrderPlaced create (Internal) + └─> OrderPlaced publish (Internal) + └─> Handler events: OrderPlacedHandler ``` ### Message Attributes | Attribute | Type | Description | Example | |-----------|------|-------------|---------| -| `messaging.system` | string | Broker type | `"rabbitmq"`, `"kafka"` | -| `messaging.destination` | string | Channel name | `"task.commands"` | -| `messaging.operation` | string | Operation type | `"receive"`, `"process"` | -| `messaging.message_id` | string | Message ID | `"msg-1234"` | -| `messaging.destination.partition.id` | string | Partition ID (Kafka) | `"0"` | -| `messaging.message.body.size` | int | Payload size in bytes | `1024` | -| `server.address` | string | Broker address | `"localhost:5672"` | +| `messaging.system` | string | Always `internal_bus` on pump spans at 10.7.0, whichever transport the pump reads | `"internal_bus"` | +| `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"` | +| `messaging.message.type` | string | Message type | `"MT_EVENT"` | +| `messaging.destination.partition.id` | string | Partition key | `"customer-12345"` | +| `messaging.message.body.size` | int | Payload size in bytes | `66` | +| `paramore.brighter.handled_count` | int | Times the message has been handled | `0` | --- ## Outbox Tracing -Outbox operations create child spans for database operations: +Outbox operations create child spans for database operations, each named +` `: ### Deposit Operation ```text -deposit span (Internal) - └─> Transform pipeline spans - └─> Outbox add span (Database) +OrderPlaced deposit (Internal) + └─> Mapper and transform events: JsonMessageMapper`1, CloudEventsTransformer + └─> add.message outbox requests (Client) ``` ### Clear Operation ```text -create/clear span (Internal) - └─> Outbox get span (Database) - └─> Produce message span (Producer) - └─> Outbox mark dispatched span (Database) +paramore.brighter.clear_messages create (Producer) + └─> retrieve.message outbox requests (Client) + └─> paramore.brighter.clear_messages clear (Producer) + └─> order.placed publish (Producer) + └─> count.outstanding_messages outbox requests (Client) + +order.placed settle (Producer), a trace of its own + └─> mark_as_dispatched.outstanding_messages outbox requests (Client) ``` ### Database Span Attributes @@ -188,76 +242,95 @@ Outbox and Inbox database operations follow [OTel Database Semantic Conventions] | Attribute | Description | Example | |-----------|-------------|---------| -| `db.system` | Database type | `"mysql"`, `"postgresql"` | -| `db.name` | Database name | `"myapp"` | -| `db.operation` | Operation type | `"outbox_add"`, `"outbox_get"` | +| `db.system` | Database type; `brighter` for the in-memory stores | `"postgresql"` | +| `db.name` | The configuration's database name, not the server's | `"Brighter"` | +| `db.table` | Table | `"outbox"` | +| `db.operation` | Operation | `"add.message"`, `"retrieve.message"`, `"mark_as_dispatched.outstanding_messages"` | +| `db.operation.name` | SQL verb, on relational stores | `"INSERT"` | +| `db.query.text` | SQL, on relational stores | `INSERT INTO {0} (...) VALUES (...)` | + +The PostgreSQL examples above are from `PostgreSqlOutbox`; `db.name` is the `databaseName` of its +`RelationalDatabaseConfiguration`, which defaults to `Brighter`. --- ## Inbox Tracing -Inbox operations create child spans for deduplication checks: +Inbox operations create child spans for deduplication checks, under the span of the request the +Inbox guards: ```text -Dispatcher receive span (Consumer) - └─> Message translation - └─> Inbox check span (Database) - └─> Command Processor send span (Internal) +order.placed process (Consumer) + └─> OrderPlaced create (Internal) + └─> OrderPlaced publish (Internal) + └─> message.exists inbox requests (Client) + └─> add.message inbox requests (Client) ``` ### Inbox Operations | Operation | Span Name | Description | |-----------|-----------|-------------| -| Check | `inbox_check` | Check if message already processed | -| Add | `inbox_add` | Record message as processed | +| Check | `message.exists
` | Check if message already processed | +| Add | `add.message
` | Record message as processed | --- ## Transform Pipeline Tracing -Transform operations (Claim Check, Compression, Encryption) create child spans for external calls: +The Claim Check transform creates a span for each call to its luggage store, named +` `, with `claim_check.*` attributes: `claim_check.operation`, and, +with `ClamCheck` set, `claim_check.provider`, `claim_check.bucket_name`, `claim_check.id` and +`claim_check.content_lenght` (the attribute is spelled that way). -### Claim Check (S3 Example) +### Claim Check ```text -deposit span (Internal) - └─> ClaimCheck transform span - └─> S3 put object span (HTTP Client) - Attributes: s3.bucket, s3.key, http.request.method +BigEvent deposit (Internal) + └─> store.message (Client) ``` ### Retrieve Claim ```text -Message translation span - └─> RetrieveClaim transform span - └─> S3 get object span (HTTP Client) - Attributes: s3.bucket, s3.key, http.request.method +big.event process (Consumer) + └─> retrieve.message (Client) + └─> delete.message (Client) ``` -External call spans follow their respective OTel conventions: -- **S3**: [Object Storage Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/object-stores/s3/) -- **HTTP**: [HTTP Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/http/) +The `delete.message` span appears because `[RetrieveClaim]` defaults to `retain: false`. The trees +were measured with the in-memory luggage store, whose provider and bucket are both `in-memory`. --- ## W3C TraceContext Propagation -Brighter automatically propagates trace context across service boundaries using [W3C TraceContext](https://w3c.github.io/trace-context/) headers. +Brighter automatically propagates trace context across service boundaries using [W3C TraceContext](https://w3c.github.io/trace-context/). ### How It Works -1. **Producer**: Brighter injects `traceparent` and `tracestate` into message headers -2. **Consumer**: Brighter extracts `traceparent` and `tracestate` to continue the trace +1. **Producer**: Brighter writes the producer span's `traceparent` and `tracestate` into the message +2. **Consumer**: Brighter reads them, and the `process` span becomes a child of the producer's span, in the same trace ### Message Headers +Each transport names the headers its own way. Measured on RabbitMQ +(`Paramore.Brighter.MessagingGateway.RMQ.Async`) and Kafka: + ```text -traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 -tracestate: congo=t61rcWkgMzE +RabbitMQ: + cloudEvents_traceparent: 00-3519372db2bb5402dc1be3b11c7fc4e5-8675f8104bc40dbe-01 + cloudevents_tracestate: congo=t61rcWkgMzE + +Kafka: + ce_traceparent: 00-c37cfd2c01adac953978b1abfb2c8d4c-3ae8fe73da7b454f-01 + ce_tracestate: congo=t61rcWkgMzE ``` +RabbitMQ also writes the trace state under `cloudevents_:tracestate`, an older spelling kept for +consumers that still read it. Brighter injects through OpenTelemetry's default propagator, which +the OpenTelemetry SDK sets up; without the SDK, nothing is written. + ### Integration with ASP.NET Brighter participates in existing traces. When called from an ASP.NET controller, the Command Processor span becomes a child of the ASP.NET request span: @@ -265,9 +338,7 @@ Brighter participates in existing traces. When called from an ASP.NET controller ```text ASP.NET Request: "POST /orders" └─> Command Processor: "ProcessOrderCommand send" - └─> Handler: OrderHandler - └─> Publish: "OrderCreatedEvent publish" - └─> Outbox add + └─> Handler events: OrderHandler ``` --- @@ -282,9 +353,11 @@ CloudEvents adds alternative attribute names following [CloudEvents Semantic Con | Messaging Convention | CloudEvents Convention | Value | |---------------------|------------------------|-------| -| `messaging.message_id` | `cloudevents.event_id` | Message ID | -| `messaging.destination` | `cloudevents.event_source` | Event source | +| `messaging.message.id` | `cloudevents.event_id` | Message ID | +| N/A | `cloudevents.event_source` | Event source | | N/A | `cloudevents.event_type` | Event type | +| N/A | `cloudevents.event_subject` | Event subject | +| N/A | `cloudevents.event_spec_version` | `"1.0"` | ### Enabling CloudEvents Conventions @@ -315,10 +388,19 @@ Set both flags and both sets of attributes will be recorded. 2. **Use Sampling**: Configure sampling in production to reduce costs: ```csharp - .SetSampler(new TraceIdRatioBasedSampler(0.1)) // Sample 10% of traces + using Microsoft.Extensions.DependencyInjection; + using OpenTelemetry.Trace; + using Paramore.Brighter.Extensions.Diagnostics; + + var services = new ServiceCollection(); + + services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddBrighterInstrumentation() + .SetSampler(new TraceIdRatioBasedSampler(0.1))); // Sample 10% of traces ``` -3. **Add Custom Attributes Judiciously**: Only add context attributes that are essential for debugging and analysis +3. **Add Custom Attributes Judiciously**: Only add attributes that are essential for debugging and analysis 4. **Monitor Trace Costs**: Large payloads and high cardinality attributes can significantly increase observability costs @@ -339,8 +421,8 @@ Set both flags and both sets of attributes will be recorded. | V9 Span Name | V10 Span Name | |--------------|---------------| | Custom handler names | ` ` | -| `Outbox.Add` | Follows database conventions | -| Transport-specific names | ` create/publish/receive/process` | +| `Outbox.Add` | `
`, such as `add.message outbox requests` | +| Transport-specific names | ` publish`, and ` begin/receive/process` on the consumer | ### Changed Attributes @@ -368,7 +450,7 @@ V9 used custom attribute names. V10 uses OTel standard conventions: **Solutions**: -- Verify Activity Source is registered: `.AddSource("paramore.brighter")` +- Verify Brighter's tracer is registered: `.AddBrighterInstrumentation()`, as in [Enabling Brighter's Spans](#enabling-brighters-spans) - Check exporter configuration and endpoint - Ensure services can reach the exporter endpoint - Check firewall rules @@ -393,7 +475,7 @@ V9 used custom attribute names. V10 uses OTel standard conventions: - Leave `RequestBody` out of `InstrumentationOptions` - Reduce sampling rate: `.SetSampler(new TraceIdRatioBasedSampler(0.1))` - Disable unnecessary attribute collection -- Use tail-based sampling to only keep interesting traces +- Use tail-based sampling to only keep interesting traces: `Paramore.Brighter.Extensions.Diagnostics` adds `SetTailSampler()` to the tracer provider builder ### Missing Attributes @@ -403,7 +485,7 @@ V9 used custom attribute names. V10 uses OTel standard conventions: - Check `Activity.IsAllDataRequested` is true (controlled by sampling) - Verify instrumentation options are configured correctly -- Ensure custom context attributes start with `paramore.brighter.spancontext.` +- Set attributes of your own on `Activity.Current` in the handler, as in [Adding Custom Span Attributes](#adding-custom-span-attributes): Brighter copies nothing from the request context --- diff --git a/tools/blockcheck/scaffold/pages.tsv b/tools/blockcheck/scaffold/pages.tsv index ed0399b..2384c67 100644 --- a/tools/blockcheck/scaffold/pages.tsv +++ b/tools/blockcheck/scaffold/pages.tsv @@ -97,3 +97,8 @@ contents/QueryHandlerDependencies.md DarkerQueryPatternsContext - contents/DarkerConfigurationReference.md DarkerConfigurationReferenceContext - contents/HowConfiguringTheDispatcherWorks.md HowConfiguringTheDispatcherWorksContext - contents/CQRSUseCasesAndPatterns.md CQRSUseCasesAndPatternsContext - +contents/BrighterControlAPI.md BrighterControlAPIContext - +contents/PostgreSQLMessageBroker.md PostgreSQLMessageBrokerContext - +contents/PostgreSQLBrokerTradeOffs.md PostgreSQLMessageBrokerContext - +contents/CloudEventsReference.md CloudEventsReferenceContext - +contents/Telemetry.md TelemetryContext - diff --git a/tools/blockcheck/scaffold/units/BrighterControlAPIContext.cs b/tools/blockcheck/scaffold/units/BrighterControlAPIContext.cs new file mode 100644 index 0000000..53e1a43 --- /dev/null +++ b/tools/blockcheck/scaffold/units/BrighterControlAPIContext.cs @@ -0,0 +1,15 @@ +// The value BrighterControlAPI.md names in its block and never declares. +// +// The page maps the Control API's endpoints on the reader's ASP.NET Core app and never shows the +// app being built. The member is typed from a pinned package and returns a default, so the call +// the block makes on it is checked against the real type. +// +// blockcheck: using static BrighterControlAPIContext; + +using Microsoft.AspNetCore.Builder; + +public static class BrighterControlAPIContext +{ + // block 1: `app.MapBrighterControlEndpoints();` + public static WebApplication app => null!; +} diff --git a/tools/blockcheck/scaffold/units/CloudEventsReferenceContext.cs b/tools/blockcheck/scaffold/units/CloudEventsReferenceContext.cs new file mode 100644 index 0000000..b7d4946 --- /dev/null +++ b/tools/blockcheck/scaffold/units/CloudEventsReferenceContext.cs @@ -0,0 +1,20 @@ +// The type and value CloudEventsReference.md names in its blocks and never declares. +// +// Every block configures a publication for the reader's own `OrderCreated` event, which the page +// never shows, and the Kafka section posts one through the reader's command processor. The value +// is typed from a pinned package and returns a default. +// +// blockcheck: using static CloudEventsReferenceContext; + +using Paramore.Brighter; + +public static class CloudEventsReferenceContext +{ + // block 3: `await commandProcessor.PostAsync(new OrderCreated(), context);` + public static IAmACommandProcessor commandProcessor => null!; +} + +// blocks 1–5: `RmqPublication`, `KafkaPublication`, +// `SnsPublication`, `AzureServiceBusPublication`; block 3: +// `new OrderCreated()` +public class OrderCreated() : Event(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/PostgreSQLMessageBrokerContext.cs b/tools/blockcheck/scaffold/units/PostgreSQLMessageBrokerContext.cs new file mode 100644 index 0000000..fd5ed56 --- /dev/null +++ b/tools/blockcheck/scaffold/units/PostgreSQLMessageBrokerContext.cs @@ -0,0 +1,74 @@ +// Types and values PostgreSQLMessageBroker.md and PostgreSQLBrokerTradeOffs.md name in their +// blocks and never declare. +// +// The pages configure a producer and a consumer for the reader's own order events, and write to +// the reader's own `DbContext`, none of which they show. Every value member is typed from a pinned +// package or the BCL and returns a default. A stub is a request, or an interface, with only the +// members a block names; `Order` is read as a type the page never shows (1.10). +// +// blockcheck: using static PostgreSQLMessageBrokerContext; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; + +public static class PostgreSQLMessageBrokerContext +{ + // broker blocks 1, 3, 8: `services.AddBrighter(…)`, `services.AddConsumers(…)`, + // `services.AddOpenTelemetry()` + public static IServiceCollection services => null!; + // broker block 9, trade-offs blocks 1, 2: `connectionString: connectionString` + public static string connectionString => null!; + // broker block 6: `await commandProcessor.PostAsync(…)`, `OrderId = orderId` + public static IAmACommandProcessor commandProcessor => null!; + public static string orderId => null!; +} + +// broker blocks 1–4, 7: the event the pages publish and handle; block 2 sets these members, +// block 4 reads `OrderId` +public class OrderCreatedEvent() : Event(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public DateTime CreatedAt { get; set; } +} + +// broker blocks 2, 7: `CreateOrderAsync(CreateOrderCommand command)`; block 2 reads these +public class CreateOrderCommand() : Command(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } +} + +// broker block 4: `await _emailService.SendOrderConfirmationAsync(@event)` +public interface IEmailService +{ + Task SendOrderConfirmationAsync(OrderCreatedEvent orderCreatedEvent); +} + +// broker block 6: `new OrderReminderEvent { OrderId = …, ReminderText = … }` +public class OrderReminderEvent() : Event(Id.Random()) +{ + public string OrderId { get; set; } = string.Empty; + public string ReminderText { get; set; } = string.Empty; +} + +// broker blocks 5, 10: `PostgresSubscription` +public class OrderEvent() : Event(Id.Random()); + +// broker block 13: `PostgresSubscription`, `PostgresSubscription` +public class HighVolumeEvent() : Event(Id.Random()); +public class NormalEvent() : Event(Id.Random()); + +// broker block 7: `_dbContext.Database`, `_dbContext.Orders.Add(order)` +public class OrderDbContext : DbContext +{ + public DbSet Orders => Set(); +} + +// broker block 7: `new Order { /* ... */ }` +public class Order; diff --git a/tools/blockcheck/scaffold/units/TelemetryContext.cs b/tools/blockcheck/scaffold/units/TelemetryContext.cs new file mode 100644 index 0000000..47f7a25 --- /dev/null +++ b/tools/blockcheck/scaffold/units/TelemetryContext.cs @@ -0,0 +1,22 @@ +// The type and value Telemetry.md names in its blocks and never declares. +// +// The page sends a `ProcessOrderCommand`, the reader's own, through the reader's command processor +// and handles it, and never shows the command. The value is typed from a pinned package and +// returns a default; the stub carries only the member the blocks name. +// +// blockcheck: using static TelemetryContext; + +using Paramore.Brighter; + +public static class TelemetryContext +{ + // block 3: `await commandProcessor.SendAsync(new ProcessOrderCommand { OrderId = 123 });` + public static IAmACommandProcessor commandProcessor => null!; +} + +// blocks 3, 4: `new ProcessOrderCommand { OrderId = 123 }`, `RequestHandlerAsync`, +// `command.OrderId` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public int OrderId { get; set; } +} From 154f78c2c2ea8fe80a63d891a0e12e53db9ea9e5 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 17:47:53 +0100 Subject: [PATCH 10/27] =?UTF-8?q?docs:=20017=20task=205.3=20=E2=80=94=20S3?= =?UTF-8?q?=20ACLs,=20Control=20API=20output,=20content=20types,=20mapper?= =?UTF-8?q?=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S3LuggageStore.md, HandlingLargeMessages.md: ACLs has no default; a missing bucket with none set throws ConfigurationException (run on LocalStack, with a control that sets ACLs and creates the bucket) - BrighterControlAPI.md: list un-indented; response captured from a V10 Dispatcher; availableTopics/topicName are subscription names; PATCH takes the subscription name, case-sensitive; custom base route run - KafkaConfiguration.md, MessageMappers.md: MessageBody takes a ContentType, not a string (as the KafkaSchemaRegistry sample does) - Routing.md, V10MigrationGuide.md, NullableReferenceTypes.md: mappers gain the member they were missing - Command/Event subclasses with no base constructor call given one; V10MigrationGuide's default-mapper example posts rather than publishes (run: PostAsync puts one JSON message on the bus, PublishAsync none) - Unit S3LuggageStoreContext.cs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/BrighterControlAPI.md | 58 ++++++++++--------- contents/HandlingLargeMessages.md | 9 ++- contents/KafkaConfiguration.md | 4 +- contents/MessageMappers.md | 2 +- contents/MigratingToNullableReferenceTypes.md | 4 +- contents/NullableReferenceTypes.md | 25 +++++++- contents/Routing.md | 5 ++ contents/S3LuggageStore.md | 5 +- contents/V10MigrationGuide.md | 25 ++++++-- tools/blockcheck/scaffold/pages.tsv | 1 + .../scaffold/units/S3LuggageStoreContext.cs | 15 +++++ 11 files changed, 114 insertions(+), 39 deletions(-) create mode 100644 tools/blockcheck/scaffold/units/S3LuggageStoreContext.cs diff --git a/contents/BrighterControlAPI.md b/contents/BrighterControlAPI.md index 026ba85..ab76bc2 100644 --- a/contents/BrighterControlAPI.md +++ b/contents/BrighterControlAPI.md @@ -24,38 +24,41 @@ using Paramore.Brighter.ServiceActivator.Control.Api; app.MapBrighterControlEndpoints(); ``` -When mapping the Brighter Control API you can pass a string to change the base route of these calls, by default it is set to `/control` +The endpoints are mapped under `/control` by default. Pass a base route to put them somewhere else — `app.MapBrighterControlEndpoints("/ops/brighter")` serves `GET /ops/brighter/status`, and `/control/status` then returns 404. ## API's Provided ### Get Node Status -You can retrieve the status of a Dispatcher node by calling `GET /control/status` +You can retrieve the status of a Dispatcher node by calling `GET /control/status`. The response contains: - - **nodeName** : The name of the node running the Dispatcher - - **availableTopics** : The Topics that this node can service - - **subscriptions** : An array of Information about currently configured subscriptions - - **topicName** : Name of Topic - - **performers** : An array of performers - - **activePerformers** : Number of currently active performers - - **expectedPerformers** : Number of expected performers - - **isHealthy** : Is this subscription healthy on this node - - **isHealthy** : Is this node Healthy - - **numberOfActivePerformers** : The Number of Performers currently running on the Node - - **timeStamp** : Timestamp of Status Event - - **executingAssemblyVersion** : The version of the running process - -``` JSON + +- **nodeName**: The name of the node running the Dispatcher — the Dispatcher's `HostName`, `Brighter` followed by a UUID unless you set one +- **availableTopics**: The **subscription names** this node services. Despite the field's name these are not topics: a subscription named `orders-subscription` on the routing key `Orders.OrderPlaced` appears as `orders-subscription` +- **subscriptions**: An array with one entry per subscription: + - **topicName**: The subscription name, as in `availableTopics` + - **performers**: The names of the subscription's open performers + - **activePerformers**: The number of open performers + - **expectedPerformers**: The number of performers the subscription is configured to run + - **isHealthy**: `true` when `activePerformers` equals `expectedPerformers` +- **isHealthy**: `true` when every subscription is healthy +- **numberOfActivePerformers**: The number of open performers across all subscriptions +- **timeStamp**: When the status was taken +- **executingAssemblyVersion**: The version of Brighter's control package, not of your application + +A node running one subscription, `orders-subscription`, with one performer: + +```json { - "nodeName": "Brightere4888035-06f4-4ef8-b928-dbd47d958538", + "nodeName": "Brighter01a0e8e2-8d96-770e-9a8b-afc8e684fc23", "availableTopics": [ - "Orders.NewOrderVersionEvent" + "orders-subscription" ], "subscriptions": [ { - "topicName": "Orders.NewOrderVersionEvent", + "topicName": "orders-subscription", "performers": [ - "Orders.NewOrderVersionEvent-0943a9d2-6a00-4cd5-a4cb-cd97106e2bbe" + "orders-subscription-01a0e8e2-8d9d-7668-a035-011682a1a192" ], "activePerformers": 1, "expectedPerformers": 1, @@ -64,14 +67,17 @@ The response contains: ], "isHealthy": true, "numberOfActivePerformers": 1, - "timeStamp": "2024-06-29T15:45:46.8910117Z", - "executingAssemblyVersion": "9.7.8+476e3ad5c683683086393b17deceea509f68566a" + "timeStamp": "2026-09-28T16:39:17.211498+00:00", + "executingAssemblyVersion": "10.7.0+c1b8af886235ba3ddc9b3a88e880c121050aec77" } ``` ### Update Performer Count -You can update the number of running performers by calling `PATCH /control/subscriptions/{{subscriptionName}}/performers/{{numberOfPerformers:int}}` +You can change the number of performers a subscription runs by calling `PATCH /control/subscriptions/{subscriptionName}/performers/{numberOfPerformers}`, where `numberOfPerformers` is an integer. The Dispatcher opens or closes performers to match, and the change shows in the next `GET /control/status`. + +`subscriptionName` is the subscription's name — the value `/control/status` reports as `topicName` — not its routing key. This returns either: + +- **200 OK** with a message such as `Active performers for orders-subscription set to 3` +- **400 BAD REQUEST** with a message such as `No such subscription Orders.OrderPlaced`, when no subscription has that name -This will return either : -- OK with a Message such as `Active performers for Orders.NewOrderVersionEvent set to 2` -- BAD REQUEST with a message such as `No such subscription Orders.NewOrderVersionCommand` +**Match the name's case exactly.** The check for an unknown name ignores case but the update does not, so `ORDERS-SUBSCRIPTION` passes the check and then fails with a 500, an `InvalidOperationException` from the Dispatcher. diff --git a/contents/HandlingLargeMessages.md b/contents/HandlingLargeMessages.md index b12f3ab..c5da52a 100644 --- a/contents/HandlingLargeMessages.md +++ b/contents/HandlingLargeMessages.md @@ -90,6 +90,7 @@ the store be provisioned by your infrastructure and Brighter merely check that i ```csharp using System.Net.Http; using Amazon; +using Amazon.S3; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.Transformers.AWS; @@ -111,7 +112,8 @@ services.AddBrighter() new AWSS3Connection(awsCredentials, RegionEndpoint.EUWest1), bucketName: "my-brighter-luggage") { - HttpClientFactory = provider.GetRequiredService() + HttpClientFactory = provider.GetRequiredService(), + ACLs = S3CannedACL.Private })); ``` @@ -151,6 +153,11 @@ message happens to be, a small one buys you no reprieve. The same eager `EnsureStoreExists()` is what provisions a *real* store, so a missing bucket or container surfaces at the same moment, under whatever `StorageOptions.Strategy` you chose. +**The S3 store needs `ACLs` to create a bucket.** It has no default, so under +`StorageStrategy.CreateIfMissing` a missing bucket with no `ACLs` set is not created — the store +throws a `ConfigurationException`, *"No ACL setup on S3Luggage Store"*. That is why the third +overload above sets `ACLs = S3CannedACL.Private`. See [S3 Luggage Store](/contents/S3LuggageStore.md). + ## Step 4: Attach the Claim Check to Your Mapper The claim check is transform middleware, so it attaches to a **message mapper** — one attribute diff --git a/contents/KafkaConfiguration.md b/contents/KafkaConfiguration.md index cfbfc54..023b298 100644 --- a/contents/KafkaConfiguration.md +++ b/contents/KafkaConfiguration.md @@ -689,8 +689,10 @@ It is worth noting the following aspects of the code sample below: * We provide two helpers, though you can pass your own settings if you prefer: * **ConfluentJsonSerializationConfig.SerdesJsonSerializerConfig()** offers default settings for JSON serialization (many of these are passed through to Json.NET). * **ConfluentJsonSerializationConfig.NJsonSchemaGeneratorSettings()** offers default settings for JSON Schema generation (such as using camelCase). +* The serializer writes a magic byte and the schema id ahead of the JSON, so the body is binary: we pass the bytes with an `application/octet-stream` **ContentType** and **CharacterEncoding.Raw**, as a round-trip through a UTF-8 string would corrupt the header. See [Message Mappers](/contents/MessageMappers.md). ``` csharp +using System.Net.Mime; using Confluent.Kafka; using Confluent.Kafka.SyncOverAsync; using Confluent.SchemaRegistry; @@ -720,7 +722,7 @@ public class GreetingEventMessageMapper : IAmAMessageMapper //This uses the Confluent JSON serializer, which wraps Newtonsoft but also performs schema registration and validation var serializer = new JsonSerializer(_schemaRegistryClient, ConfluentJsonSerializationConfig.SerdesJsonSerializerConfig(), ConfluentJsonSerializationConfig.NJsonSchemaGeneratorSettings()).AsSyncOverAsync(); var s = serializer.Serialize(request, _serializationContext); - var body = new MessageBody(s, "JSON"); + var body = new MessageBody(s, new ContentType(MediaTypeNames.Application.Octet), CharacterEncoding.Raw); header.PartitionKey = _partitionKey; var message = new Message(header, body); diff --git a/contents/MessageMappers.md b/contents/MessageMappers.md index 9a6e8a4..043997b 100644 --- a/contents/MessageMappers.md +++ b/contents/MessageMappers.md @@ -143,7 +143,7 @@ public Message MapToMessage(GreetingEvent request, Publication publication) //This uses the Confluent JSON serializer, which wraps Newtonsoft but also performs schema registration and validation var serializer = new JsonSerializer(_schemaRegistryClient, ConfluentJsonSerializationConfig.SerdesJsonSerializerConfig(), ConfluentJsonSerializationConfig.NJsonSchemaGeneratorSettings()).AsSyncOverAsync(); var s = serializer.Serialize(request, _serializationContext); - var body = new MessageBody(s, MediaTypeNames.Application.Octet, CharacterEncoding.Raw); + var body = new MessageBody(s, new ContentType(MediaTypeNames.Application.Octet), CharacterEncoding.Raw); header.PartitionKey = _partitionKey; var message = new Message(header, body); diff --git a/contents/MigratingToNullableReferenceTypes.md b/contents/MigratingToNullableReferenceTypes.md index 87ec4d5..42ee99f 100644 --- a/contents/MigratingToNullableReferenceTypes.md +++ b/contents/MigratingToNullableReferenceTypes.md @@ -61,8 +61,10 @@ string name = "default"; // ✅ **Problem**: ```csharp +using Paramore.Brighter; + // ... -public class CreateOrderCommand : Command +public class CreateOrderCommand() : Command(Id.Random()) { public string CustomerName { get; set; } } diff --git a/contents/NullableReferenceTypes.md b/contents/NullableReferenceTypes.md index fc033d0..177e878 100644 --- a/contents/NullableReferenceTypes.md +++ b/contents/NullableReferenceTypes.md @@ -218,6 +218,18 @@ public class CreateUserCommandMapper : IAmAMessageMapper { public IRequestContext? Context { get; set; } + public Message MapToMessage(CreateUserCommand request, Publication publication) + { + var header = new MessageHeader( + messageId: request.Id, + topic: publication.Topic, + messageType: MessageType.MT_COMMAND + ); + + var body = new MessageBody(JsonSerializer.Serialize(request)); + return new Message(header, body); + } + public CreateUserCommand MapToRequest(Message message) { var dto = JsonSerializer.Deserialize(message.Body.Value); @@ -269,13 +281,22 @@ public class OrderQuery Make nullability expectations explicit in documentation: ```csharp +using System; +using Paramore.Brighter; + /// /// Creates a new order. /// -/// The customer ID (required, non-null) -/// Optional notes (can be null) public class CreateOrderCommand : Command { + /// The customer ID (required, non-null) + /// Optional notes (can be null) + public CreateOrderCommand(Guid customerId, string? notes) : base(Id.Random()) + { + CustomerId = customerId; + Notes = notes; + } + public Guid CustomerId { get; } public string? Notes { get; } } diff --git a/contents/Routing.md b/contents/Routing.md index 9b2bbef..9dc60aa 100644 --- a/contents/Routing.md +++ b/contents/Routing.md @@ -31,6 +31,11 @@ public class TaskCompletedEventMapper : IAmAMessageMapper var message = new Message(header, body); return message; } + + public TaskCompletedEvent MapToRequest(Message message) + { + return JsonConvert.DeserializeObject(message.Body.Value)!; + } } ``` diff --git a/contents/S3LuggageStore.md b/contents/S3LuggageStore.md index dbc9a53..2c0d566 100644 --- a/contents/S3LuggageStore.md +++ b/contents/S3LuggageStore.md @@ -42,7 +42,8 @@ serviceCollection.AddBrighter() { BucketRegion = S3Region.EUWest1, HttpClientFactory = provider.GetService(), - Strategy = StorageStrategy.CreateIfMissing + Strategy = StorageStrategy.CreateIfMissing, + ACLs = S3CannedACL.Private })); ``` @@ -83,4 +84,4 @@ In addition we set the following properties on the bucket, which can be controll We set *Tags* on the bucket if they are provided in the **Tags** property. -We default the **ACLs** for the bucket to **S3CannedACL.Private, but you can choose to override this with another policy as described in [**S3CannedACL**](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-access-control.html#RESTCannedAccessPolicies). +**ACLs** has no default: you choose the canned ACL the bucket is created with, from the policies described in [**S3CannedACL**](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-access-control.html#RESTCannedAccessPolicies). **S3CannedACL.Private** is the usual choice. If you leave **ACLs** unset and choose **StorageStrategy.CreateIfMissing**, a missing bucket is not created: the store throws a **ConfigurationException**, *"No ACL setup on S3Luggage Store"*. A bucket that already exists needs no ACL. diff --git a/contents/V10MigrationGuide.md b/contents/V10MigrationGuide.md index 0413f11..78ae70e 100644 --- a/contents/V10MigrationGuide.md +++ b/contents/V10MigrationGuide.md @@ -148,6 +148,12 @@ public class PersonCreatedMapper : IAmAMessageMapper var body = new MessageBody(JsonSerializer.Serialize(request)); return new Message(header, body); } + + public PersonCreated MapToRequest(Message message) + { + return JsonSerializer.Deserialize(message.Body.Value) + ?? throw new InvalidOperationException("Failed to deserialize"); + } } ``` @@ -456,6 +462,7 @@ new RmqPublication 2. **Use Cloud Events headers** in your mapper (optional): ```csharp +using System; using System.Text.Json; using Paramore.Brighter; @@ -477,6 +484,12 @@ public class PersonCreatedMapper : IAmAMessageMapper var body = new MessageBody(JsonSerializer.Serialize(request)); return new Message(header, body); } + + public PersonCreated MapToRequest(Message message) + { + return JsonSerializer.Deserialize(message.Body.Value) + ?? throw new InvalidOperationException("Failed to deserialize"); + } } ``` @@ -524,18 +537,20 @@ messageMapperRegistry.Register(); **After (V10) - No Mapper Needed**: ```csharp +using Paramore.Brighter; + // No mapper registration needed for simple JSON serialization! // Brighter uses JsonMessageMapper by default // Just define your message -public class PersonCreated : Event +public class PersonCreated() : Event(Id.Random()) { - public string Name { get; set; } - public string Email { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } } -// Publish directly -await commandProcessor.PublishAsync(new PersonCreated +// Post it to the bus; the default mapper serializes it +await commandProcessor.PostAsync(new PersonCreated { Name = "Alice", Email = "alice@example.com" diff --git a/tools/blockcheck/scaffold/pages.tsv b/tools/blockcheck/scaffold/pages.tsv index 2384c67..9be9cc7 100644 --- a/tools/blockcheck/scaffold/pages.tsv +++ b/tools/blockcheck/scaffold/pages.tsv @@ -102,3 +102,4 @@ contents/PostgreSQLMessageBroker.md PostgreSQLMessageBrokerContext - contents/PostgreSQLBrokerTradeOffs.md PostgreSQLMessageBrokerContext - contents/CloudEventsReference.md CloudEventsReferenceContext - contents/Telemetry.md TelemetryContext - +contents/S3LuggageStore.md S3LuggageStoreContext - diff --git a/tools/blockcheck/scaffold/units/S3LuggageStoreContext.cs b/tools/blockcheck/scaffold/units/S3LuggageStoreContext.cs new file mode 100644 index 0000000..27c6505 --- /dev/null +++ b/tools/blockcheck/scaffold/units/S3LuggageStoreContext.cs @@ -0,0 +1,15 @@ +// The value S3LuggageStore.md names in its blocks and never declares. +// +// The page registers against a `serviceCollection` it leaves to the reader's host. The member is +// typed from a pinned package and returns a default, so the call a block makes on it is checked +// against the real type. +// +// blockcheck: using static S3LuggageStoreContext; + +using Microsoft.Extensions.DependencyInjection; + +public static class S3LuggageStoreContext +{ + // block 2: `serviceCollection.AddHttpClient();` + public static IServiceCollection serviceCollection => null!; +} From af77ae48a690fb7659e020853f065173e86dd006 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 17:49:09 +0100 Subject: [PATCH 11/27] =?UTF-8?q?spec:=20017=20task=205.3=20=E2=80=94=20ba?= =?UTF-8?q?seline=20rows=20for=20the=20blocks=202d938c8=20and=20154f78c=20?= =?UTF-8?q?built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 blocks appended; PostgreSQLMessageBroker.md #11 and Telemetry.md #4 re-admitted with their units; Telemetry.md #1 and #4's rows removed, the blocks now at #2 and #5 after the insertion above them. 288 required. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 637b461..82688c6 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -142,7 +142,6 @@ 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/PostgreSQLMessageBroker.md 11 - 280d1b7 contents/PostgreSQLTransportAndOutbox.md 1 RelationalTransportContext.cs 280d1b7 contents/PostgreSQLTransportAndOutbox.md 2 RelationalTransportContext.cs 280d1b7 contents/PostgreSQLTransportAndOutbox.md 3 RelationalTransportContext.cs 280d1b7 @@ -201,8 +200,6 @@ contents/SpannerOutbox.md 2 - 280d1b7 contents/SweeperCircuitBreaking.md 6 SweeperCircuitBreakingContext.cs a61893b contents/SweeperCircuitBreaking.md 8 SweeperCircuitBreakingContext.cs a61893b contents/SweeperCircuitBreaking.md 9 SweeperCircuitBreakingContext.cs a61893b -contents/Telemetry.md 1 - 9c57ae2 -contents/Telemetry.md 4 - 9c57ae2 contents/TestingQueryHandlers.md 1 TestingQueryHandlersContext.cs 3462412 contents/TestingQueryHandlers.md 2 TestingQueryHandlersContext.cs 3462412 contents/TestingQueryHandlers.md 3 TestingQueryHandlersContext.cs 3462412 @@ -290,3 +287,29 @@ contents/QueryPipelinePolicies.md 7 - 1cafef9 contents/HowConfiguringTheDispatcherWorks.md 3 HowConfiguringTheDispatcherWorksContext.cs f2ce226 contents/Monitoring.md 1 - f2ce226 contents/Monitoring.md 2 - f2ce226 +contents/BrighterControlAPI.md 1 BrighterControlAPIContext.cs 2d938c8 +contents/CloudEventsReference.md 1 CloudEventsReferenceContext.cs 2d938c8 +contents/CloudEventsReference.md 2 CloudEventsReferenceContext.cs 2d938c8 +contents/CloudEventsReference.md 3 CloudEventsReferenceContext.cs 2d938c8 +contents/CloudEventsReference.md 4 CloudEventsReferenceContext.cs 2d938c8 +contents/CloudEventsReference.md 5 CloudEventsReferenceContext.cs 2d938c8 +contents/NullableReferenceTypes.md 9 - 154f78c +contents/PostgreSQLBrokerTradeOffs.md 1 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLBrokerTradeOffs.md 2 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 1 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 2 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 3 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 4 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 5 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 6 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 7 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 9 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 10 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 12 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 13 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/S3LuggageStore.md 2 S3LuggageStoreContext.cs 154f78c +contents/Telemetry.md 2 TelemetryContext.cs 2d938c8 +contents/Telemetry.md 3 TelemetryContext.cs 2d938c8 +contents/Telemetry.md 5 TelemetryContext.cs 2d938c8 +contents/PostgreSQLMessageBroker.md 11 PostgreSQLMessageBrokerContext.cs 2d938c8 +contents/Telemetry.md 4 TelemetryContext.cs 2d938c8 From 8d2fb8ba5791c4b93df897eb368174b7c750f61c Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 17:54:34 +0100 Subject: [PATCH 12/27] =?UTF-8?q?docs:=20017=20task=205.3=20=E2=80=94=20FA?= =?UTF-8?q?Q's=20claim-check=20mapper,=20and=20the=20Npgsql=20EF=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FAQ.md #7: MapToMessage returned nothing and MapToRequest was missing; the mapper now maps both ways, [RetrieveClaim] on the way back - PostgreSQLMessageBroker.md, PostgresOutbox.md: the EF Core transaction provider brings the target framework's EF Core, so Npgsql's EF provider must match (9.x on net10.0 → MissingMethodException, run in session 101) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/FAQ.md | 15 ++++++++++++++- contents/PostgreSQLMessageBroker.md | 2 +- contents/PostgresOutbox.md | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/contents/FAQ.md b/contents/FAQ.md index 0702d0f..2163d38 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -282,6 +282,8 @@ Use the **Claim Check** pattern: **With transforms:** ```csharp +using System; +using System.Text.Json; using Paramore.Brighter; using Paramore.Brighter.Transforms.Attributes; @@ -289,10 +291,21 @@ public class MyMessageMapper : IAmAMessageMapper { public IRequestContext? Context { get; set; } + // Stores a body of 256 KB or more in the luggage store and sends a claim check in its place [ClaimCheck(0, thresholdInKb: 256)] public Message MapToMessage(MyEvent request, Publication publication) { - // Automatically stores payloads > 256KB externally + var header = new MessageHeader(request.Id, publication.Topic, MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request)); + return new Message(header, body); + } + + // Retrieves the body from the luggage store when the message carries a claim check + [RetrieveClaim(0)] + public MyEvent MapToRequest(Message message) + { + return JsonSerializer.Deserialize(message.Body.Value) + ?? throw new InvalidOperationException("Failed to deserialize"); } } ``` diff --git a/contents/PostgreSQLMessageBroker.md b/contents/PostgreSQLMessageBroker.md index 8734a3e..335659f 100644 --- a/contents/PostgreSQLMessageBroker.md +++ b/contents/PostgreSQLMessageBroker.md @@ -404,7 +404,7 @@ A key advantage of PostgreSQL as a message broker is **transactional messaging** The Outbox shares your transaction only when you pass `DepositPostAsync` a transaction provider. Registered as the producers' `TransactionProvider`, `PostgreSqlEntityFrameworkTransactionProvider` hands the Outbox the transaction -your `DbContext` has open — see [PostgreSQL Outbox](PostgresOutbox.md) for that registration: +your `DbContext` has open — see [PostgreSQL Outbox](PostgresOutbox.md) for that registration. The provider's package brings the EF Core of your target framework, EF Core 10 on `net10.0`, so `Npgsql.EntityFrameworkCore.PostgreSQL` must be the same major version — its 9.x provider fails there with a `MissingMethodException`: ```csharp using System; diff --git a/contents/PostgresOutbox.md b/contents/PostgresOutbox.md index ac7004c..5bcf0f7 100644 --- a/contents/PostgresOutbox.md +++ b/contents/PostgresOutbox.md @@ -133,6 +133,8 @@ The `TransactionProvider` depends on how you manage your database transactions. - Use `PostgreSqlTransactionProvider` for ADO.NET-based transaction management. - Use `PostgreSqlEntityFrameworkTransactionProvider` if you are using Entity Framework Core, where `T` is your `DbContext`. +`Paramore.Brighter.PostgreSql.EntityFrameworkCore` brings the EF Core of your target framework — EF Core 10 on `net10.0` — so reference the matching major version of `Npgsql.EntityFrameworkCore.PostgreSQL`. On `net10.0`, its 9.x provider fails at runtime with a `MissingMethodException`. + ### **Example with Entity Framework Core** For more detailed information on integrating with Entity Framework Core, please see the [EF Core Outbox documentation](/contents/EFCoreOutbox.md). From aec11c1fe675203762c565ba376aef15d45d69c7 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 17:56:29 +0100 Subject: [PATCH 13/27] =?UTF-8?q?spec:=20017=20task=205.3=20=E2=80=94=20th?= =?UTF-8?q?e=20transports,=20external=20bus=20and=20tracing=20pages,=20rec?= =?UTF-8?q?orded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILT 265 → 288; pagelint 585 → 562; pages with nothing BUILT 48 → 44. The maintainer's five rulings; 23 defect ledger rows; seven blocks that stay FAILED on the pin; a split and two insertions; the Jaeger block removed. Three questions put to the maintainer. 31 of 42 tasks ticked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 124 +++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index ee754ee..886d8db 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1722,7 +1722,7 @@ everywhere. Page list: § *The tranches*, phase 5 table. - Input: those sections' phase 5 rows; 5.1's verdicts - Output: each page whole; baseline rows; ledger rows as 4.2 -- [ ] **Task 5.3:** Repair the tranche pages in *Transports*, *Using an External Bus* and *Health Checks and Observability* +- [x] **Task 5.3:** Repair the tranche pages in *Transports*, *Using an External Bus* and *Health Checks and Observability* - Input: those sections' phase 5 rows; 5.1's verdicts - Output: each page whole; baseline rows; ledger rows as 4.2 @@ -2056,6 +2056,94 @@ by the `using`s above it: `PolicyRetryAndCircuitBreaker.md:326` → `:359`. Repa | Polly v8 order | `AddRetry().AddTimeout(100 ms)`, 300 ms work → **4** attempts | `AddTimeout().AddRetry()` → **1** attempt | | One composed pipeline in place of two attributes | `AddCircuitBreaker().AddRetry(3)`, always failing → sends 1, 2 **4** attempts each, send 3 `BrokenCircuitException`, **0** | two `[UseResiliencePipeline]` on the method → `CS0579` | + +**Task 5.3 — the *Transports*, *Using an External Bus* and *Health Checks and Observability* pages.** +Six pages, all changed. **BUILT 265 → 288** (+23): **22** `FAILED -> BUILT` and two new keys BUILT, +less `Telemetry.md` #1's `BUILT -> FAILED`, which is the old #1 renumbered by a block inserted above +it (§ *Splits*). The rest of the AC2 diff, joined on page and ordinal against the report at +`5129c20`: `Telemetry.md` #6 `- -> FAILED` (the same insertion), `CloudEventsReference.md` #5 (a block inserted above it) and +`PostgreSQLBrokerTradeOffs.md` #2 (a split) `- -> BUILT`, `ConfiguringOpenTelemetry.md` #7 `FAILED -> +-`: its Jaeger block, old #2, was removed and the five after it moved up (§ *Blocks removed*). **985 → 987 blocks.** `pagelint` **585 → 562** (−23, per page against a +worktree at `5129c20`): `PostgreSQLMessageBroker.md` −10, `CloudEventsReference.md` −4, +`Telemetry.md` −3, and −1 each on `ConfiguringOpenTelemetry.md`, `MigratingToNullableReferenceTypes.md`, +`NullableReferenceTypes.md`, `PostgreSQLBrokerTradeOffs.md`, `S3LuggageStore.md`, +`V10MigrationGuide.md`. Pages with nothing BUILT **48 → 44**, by requirements' `awk` and a Python +join agreeing: `BrighterControlAPI.md`, `CloudEventsReference.md`, `PostgreSQLBrokerTradeOffs.md`, +`S3LuggageStore.md`. Repair `2d938c8` (WIP, session 101), `154f78c` and `8d2fb8b`; baseline `af77ae4`. + +- **Five units.** `BrighterControlAPIContext.cs` (`app`), `CloudEventsReferenceContext.cs`, + `PostgreSQLMessageBrokerContext.cs` (mapped to both PostgreSQL pages), `TelemetryContext.cs`, and + `S3LuggageStoreContext.cs` (`serviceCollection`). None is a type a page tells the reader to write + (rule 1, by reading). `PostgreSQLMessageBroker.md` #11 and `Telemetry.md` #4, BUILT before, are + re-admitted with their units. `--report` → *"45 units checked, 0 violations"* +- **The maintainer's five rulings, 2026-09-28.** (1) Tracing: rewrite `Telemetry.md`, + `PostgreSQLMessageBroker.md` #8 and, off the tranche, `ConfiguringOpenTelemetry.md`. (2) No pin + change: the tracing blocks that need `OpenTelemetry.Extensions.Hosting` and + `Paramore.Brighter.Extensions.Diagnostics` stay FAILED as a pin limit, each compiled in scratch + against the released packages at **0** errors. (3) Fix `CloudEventsSupport.md`, off the tranche; + the **11** non-generic subscriptions without `messagePumpType` are recorded, not repaired + (§ *Defect ledger*). (4) File the `souce` bug: BrighterCommand/Brighter#4458, linked from + `CloudEventsReference.md`. (5) `PostgreSQLBrokerTradeOffs.md`'s size: tested to 50 MB, larger may + fit, 150 MB rejected; AWS SQS is 1 MiB in its comparison table, with Brighter's support for SQS + messages over 256 KB said to ship in the release after 10.7.0 +- **Twenty-three defects** (§ *Defect ledger*), twelve on the two PostgreSQL pages. Five recur off + the tranche and were repaired there: `PostgresOutbox.md`, where the EF Core + provider's package is installed, now states the Npgsql version it needs; `HandlingLargeMessages.md`'s S3 store, + created with no `ACLs`; a `string` content type given to `MessageBody` on `KafkaConfiguration.md` and + `MessageMappers.md`; mappers missing a member of `IAmAMessageMapper` on `Routing.md`, + `V10MigrationGuide.md` (#3, #18), `NullableReferenceTypes.md` (#7) and `FAQ.md` (#7, whose + `MapToMessage` also returned nothing); `Command`/`Event` + subclasses with no base-constructor call on `NullableReferenceTypes.md` (#9, now BUILT), + `MigratingToNullableReferenceTypes.md` (#4) and `V10MigrationGuide.md` (#20), whose example also + published where it meant to post +- **`--explain` on every block the diff touches** (`git diff -U0 5129c20` hunks against fence + ranges): **39** blocks, **23** BUILT. It found the three constructor blocks without `using`s, now + given them. The **16** FAILED are the six pin-limited blocks below, `S3LuggageStore.md` #1, and on + the off-tranche pages names their pages never show and two blocks that put a type before a + statement (`MigratingToNullableReferenceTypes.md` #4, `V10MigrationGuide.md` #20), not repaired +- **Behaviour, run with controls**, released 10.7.0 packages, net10.0, one process per case: + + | Claim | Case → result | Control → result | + |---|---|---| + | `PostgreSQLMessageBroker.md`: consumer and publication names | `channelName` equal to the publication's `Topic` → **1** handled | the page's names → **0**: the consumer reads `queue = ChannelName`, the producer writes `queue = Topic` | + | Sending to the broker | `PostAsync` → a row in the table | `PublishAsync` → no row; it runs local handlers | + | *Scheduled Messages* | `PostAsync(delay)` → the scheduler fires; the row appears at t≈6 s, visible at once | `PublishAsync(delay)` → a local handler, no row | + | What `visible_timeout` delays | a deferred message with `requeueDelay` 20 s → visible again at 19.5 s | `requeueDelay` 0 → handled 3×, the row deleted on reject | + | The outbox in an EF Core transaction | with the transaction provider, rollback → **0** outbox rows | without it → **1** | + | Message size, `PostgreSQLBrokerTradeOffs.md` | 1 KB, 5 MB, 50 MB round-trip | 150 MB → Npgsql `54000`, *"total size of jsonb object elements exceeds the maximum of 268435455 bytes"*; 200 MB → *"JSON value of length 209715200 is too large"*. 100 MB posted and not received within 6 s, unresolved, so the page claims 50 | + | `CloudEventsReference.md`: headers on the wire | RMQ.Async → `cloudEvents_id/source/specversion/time/type`, the content type in the AMQP property; Kafka → `ce_*` and `content-type` | a structured-mode mapper → the envelope in the body. SNS and ASB by reading | + | The Kafka partition key | `RequestContextBagNames.PartitionKey` in the context bag → the record key | no bag entry → empty key | + | `Telemetry.md`: spans need a registered tracer | `AddBrighterInstrumentation()` → **34** spans; a manual `BrighterTracer` with `AddSource("paramore.brighter")` → **34**, the name case-insensitive | `AddSource` alone → **0**; a wrong source → **0**; `Sdk.CreateTracerProviderBuilder().AddBrighterInstrumentation()` → **0** Brighter spans | + | Trace propagation | the OTel SDK initialised → RMQ `cloudEvents_traceparent`, `cloudevents_tracestate`; Kafka `ce_traceparent`, `ce_tracestate` | a bare `ActivityListener` → Brighter writes neither: it injects through `Propagators.DefaultTextMapPropagator` | + | `S3LuggageStore.md`: `ACLs`, on LocalStack | `ACLs` unset, bucket missing → `ConfigurationException`, *"No ACL setup on S3Luggage Store"*, no bucket | `ACLs = S3CannedACL.Private` → bucket created; `ACLs` unset, bucket present → accepted | + | `BrighterControlAPI.md` against a V10 Dispatcher | `GET /control/status` → the page's JSON, captured; `PATCH …/orders-subscription/performers/3` → 200, 3 performers | by routing key → 400 *"No such subscription"*; wrong case → **500**, `InvalidOperationException` from `Dispatcher.SetActivePerformers`; `baseRoute` `/ops/brighter` → served there, `/control/status` 404 | + | `V10MigrationGuide.md` #20: the default mapper | `PostAsync` → **1** message, `application/json` | `PublishAsync` → **0** | + + Every table in `Telemetry.md` was rewritten from captured spans. Every SQL block in + `PostgreSQLMessageBroker.md` was run against the table +- **The blocks that stay FAILED compile where their world exists** (§ *Blocks that stay FAILED*): + the six pin-limited tracing blocks — `Telemetry.md` #1, #6, `ConfiguringOpenTelemetry.md` #1, #5, + #6 and `PostgreSQLMessageBroker.md` #8 — in scratch against + `OpenTelemetry.Extensions.Hosting` and `Paramore.Brighter.Extensions.Diagnostics`; `S3LuggageStore.md` + #1 against `Paramore.Brighter.Transformers.AWS.V4` 10.7.0 — each **0** errors. + `ConfiguringOpenTelemetry.md` #2–#4 are exporter fragments marked `// ...` +- **Put to the maintainer, not repaired:** `DefaultMessageMappers.md` #4, an Avro mapper that also + passes `CharacterEncoding.Raw` as `MessageBody`'s content type, but whose constructor + (`AvroMessageMapper(…)`) does not parse and whose Confluent calls do not match that API; + whether `PublishAsync` shown as sending to a transport is swept across the corpus; and whether the + Control API's wrong-case 500 is filed +- **`attr_mismatch.py` → 7**, before the baseline rows +- **Baseline:** 24 rows and 2 re-admissions; `Telemetry.md` #1 and #4's rows removed. `--report` → + exit **0**, *"987 blocks: 288 BUILT, 682 FAILED, 17 SKIPPED"*, baseline 288, 0 findings +- `linkcheck` 165 files, 0 broken; `versioncheck` 0 stale of 18 across 5; `symbolcheck` 0 findings, + 22 entries, 3 silenced, `--verify-list` clean; `optioncheck` 0 mismatches across 59 tables, 519 rows; + shape, redirects and `--verify` 161 / 77 / 161; `pagelint --changed origin/master` 0 errors. + **Pages changed: 17** (`git diff --name-only 5129c20..HEAD -- contents`): the **6** tranche pages + and **11** by ruling or recurrence — `CloudEventsSupport.md`, `ConfiguringOpenTelemetry.md`, + `FAQ.md`, `HandlingLargeMessages.md`, `KafkaConfiguration.md`, `MessageMappers.md`, + `MigratingToNullableReferenceTypes.md`, `NullableReferenceTypes.md`, `PostgresOutbox.md`, + `Routing.md`, `V10MigrationGuide.md` + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2381,6 +2469,13 @@ is rewritten against the tables below. | `ParameterizedQueryPatterns.md` | 6 | `CS0246` `SearchProductsQuery`, `ProductDto` | same-page: block 5 declares both; *"**Handler with multiple optional criteria:**"* | 5 | | `ProjectionQueryPatterns.md` | 3 | `CS1513`, `CS0103` `Select` | parse — a fragment: the `.Select(…)` of block 2's handler with no receiver, under *"Database-computed fields"*. The reader has the whole in block 2 | 5 | | `QueryHandlerDependencies.md` | 3 | `CS0246` `Program`; `CS0103` `builder` | instrument, as `DarkerConfigurationReference.md` #1; `builder` is not stubbed, since no BUILT block would name it. Builds as a `Program.cs` against Darker 4.1.1, **0** errors | 5 | +| `Telemetry.md` | 1 | `CS0234` `Paramore.Brighter.Extensions.Diagnostics`; `CS1061` `AddOpenTelemetry` | pin: needs `OpenTelemetry.Extensions.Hosting` and `Paramore.Brighter.Extensions.Diagnostics`, which `refs.csproj` does not carry; ruled no pin change (5.3, ruling 2). Compiles in scratch against the released packages, **0** errors | 5 | +| `Telemetry.md` | 6 | as #1 | pin: needs `OpenTelemetry.Extensions.Hosting` and `Paramore.Brighter.Extensions.Diagnostics`, which `refs.csproj` does not carry; ruled no pin change (5.3, ruling 2). Compiles in scratch against the released packages, **0** errors | 5 | +| `PostgreSQLMessageBroker.md` | 8 | as `Telemetry.md` #1 | pin: needs `OpenTelemetry.Extensions.Hosting` and `Paramore.Brighter.Extensions.Diagnostics`, which `refs.csproj` does not carry; ruled no pin change (5.3, ruling 2). Compiles in scratch against the released packages, **0** errors | 5 | +| `ConfiguringOpenTelemetry.md` | 1 | as `Telemetry.md` #1 | off the tranche, rewritten by ruling 1. pin: needs `OpenTelemetry.Extensions.Hosting` and `Paramore.Brighter.Extensions.Diagnostics`, which `refs.csproj` does not carry; ruled no pin change (5.3, ruling 2). Compiles in scratch against the released packages, **0** errors | 5 | +| `ConfiguringOpenTelemetry.md` | 5 | as `Telemetry.md` #1 | as #1 | 5 | +| `ConfiguringOpenTelemetry.md` | 6 | as `Telemetry.md` #1 | as #1 | 5 | +| `S3LuggageStore.md` | 1 | `CS0234` `Paramore.Brighter.Transformers.AWS.V4`; `CS0246` `S3LuggageStore`, `S3LuggageOptions`, `AWSS3Connection` | pin (D3): the pin carries the V3 AWS transformer package, not `.V4`. Compiles in scratch against `Paramore.Brighter.Transformers.AWS.V4` 10.7.0, **0** errors, with `credentials` a parameter | 5 | ## Splits @@ -2391,6 +2486,9 @@ is rewritten against the tables below. | `SchedulingAMessage.md` | 7, 8, 9 | 8, 9, 10 | not a split: a block inserted at #7, the #4414 workaround. All three were FAILED at `c7329bb` and are BUILT now, so the AC2 diff reads #7–#9 as `FAILED -> BUILT` and #10 as a new key | 3.4 | | `AgreementDispatcherRouting.md` | 11 | 11, 12 | the ✅ and ❌ routing lambdas were one fence. #12's database is a type no block names, so no unit may supply it; apart, #11 builds and #12 is listed. #12 is a new key in the AC2 diff | 5.2 | | `QueryPipelinePolicies.md` | — | 7 | not a split: a block appended after the page's last, the retry-and-breaker wrap. A new key, BUILT | 5.2 | +| `PostgreSQLBrokerTradeOffs.md` | 1 | 1, 2 | the JSONB and JSON schemas were one fence; each now builds. #2 is a new key, BUILT | 5.3 | +| `CloudEventsReference.md` | 3, 4 | 4, 5 | not a split: a block inserted at #3, the Kafka partition key set per message. Old #3 (SNS) and #4 (Azure Service Bus) are now #4 and #5; all five build, so the AC2 diff reads #3, #4 `FAILED -> BUILT` and #5 as a new key | 5.3 | +| `Telemetry.md` | 1–5 | 2–6 | not a split: a block inserted at #1, *Enabling Brighter's Spans*, FAILED on the pin. Old #1 and #4, BUILT, are now #2 and #5, their rows moved; so the AC2 diff reads #1 `BUILT -> FAILED` and #6 as a new key | 5.3 | ## Blocks removed @@ -2406,6 +2504,7 @@ after-report's keys and cannot show these, so they are listed here.* | `AnalyzerSupport.md` | 6 | FAILED | BRT008's fixed form | 2.4 | | `AnalyzerSupport.md` | 7 | BUILT | the `using` for the code fix's `Partitioner` | 2.4 | | `AnalyzerSupport.md` | 8 | FAILED | the BRT007 pragma, rewritten as BRT001 in the new block 1 | 2.4 | +| `ConfiguringOpenTelemetry.md` | 2 | FAILED | the Jaeger exporter block — OpenTelemetry deprecated the exporter for OTLP, and the page now points the OTLP exporter at Jaeger (ruling 1). Old #3–#7 are now #2–#6, FAILED before and after, so the AC2 diff shows only #7 `FAILED -> -` | 5.3 | Old block 1 (BRT006's warning case, BUILT) also went; its address now holds the new pragma block, BUILT, re-admitted at `ec38400`. @@ -2478,6 +2577,29 @@ BUILT, re-admitted at `ec38400`. | A Polly v8 pipeline's strategies said to wrap *"inner to outer"* in the order added, and `MyComprehensivePipeline` added timeout, retry, breaker. The first added is the outermost, so its 10 s timeout covered every retry together | run, both orders | `PolicyRetryAndCircuitBreaker.md` | `grep -rnE "order they.re added\|inner to outer" contents/`, and a scan of every `TryAddBuilder` chain | **1** sentence, 1 chain | **0** — `PolicyFallback.md`'s chain was right | 5.2, rewriting the stacked attributes | | `await` in a handler not marked `async` (`CS4032`) | compiled | `FeatureSwitches.md` #2 | — | **1** | **0** | 5.2, `--explain` | | A monitored handler that throws: the monitor's `ExceptionThrown` event carries the `Exception`, which `System.Text.Json` cannot serialize, so the caller gets `NotSupportedException` in place of the handler's exception. And `[MonitorAsync]` cannot send through `ControlBusSenderFactory`'s sender, which registers no async mapper. Both on Brighter `master` too | `MonitorEvent.cs:74`, `ControlBusSenderFactory.cs:56`; run, controls non-throwing and sync — **upstream, BrighterCommand/Brighter#4453, #4454**, filed 5.2 by the maintainer's word | `Monitoring.md` states both and links them | `grep -rn 'issues/445[34]' contents/` | **2** | **stated** | 5.2, running the rewritten page | +| A PostgreSQL consumer's `channelName` different from the publication's `Topic`: the consumer reads `queue = ChannelName`, the producer writes `queue = Topic`, so nothing is received | run: **0** handled → **1** | `PostgreSQLMessageBroker.md` | reading every `PostgresSubscription` on the two PostgreSQL pages | **1** | **0** | 5.3, running the page | +| `PublishAsync` said to send to the PostgreSQL broker; it runs local handlers and writes no row | run: `PostAsync` → a row; `PublishAsync` → none | `PostgreSQLMessageBroker.md` | `grep -n PublishAsync` on the two PostgreSQL pages | **2** lines, both sending with it | **2** lines, both saying it does not reach the table; the corpus-wide sweep is put to the maintainer | 5.3, running the page | +| *Scheduled Messages* said a delay sets `visible_timeout`; `PostAsync(delay)` goes through the scheduler and the row appears when it fires, visible at once. `visible_timeout` delays only a requeue | run: row at t≈6 s; `requeueDelay` 20 s → visible at 19.5 s, control 0 → handled 3× | `PostgreSQLMessageBroker.md` | `grep -n 'CURRENT_TIMESTAMP + delay'` | **1** | **0** | 5.3, running the page | +| *"(timeout not updated)"* during processing: retrieval moves `visible_timeout` on by the subscription's `visibleTimeout` | read, `PostgreSqlMessageConsumer` at 10.7.0; run | `PostgreSQLMessageBroker.md` | `grep -rn 'timeout not updated' contents/` | **1** | **0** | 5.3, running the page | +| The outbox write said to join an EF Core transaction without the transaction provider | run: rollback with the provider → **0** outbox rows; without → **1** | `PostgreSQLMessageBroker.md` | reading the page's outbox sections | **1** | **0** | 5.3, running the page | +| `[ClaimCheck(threshold:, dataStore:)]` — no such parameters; the attribute is `ClaimCheck(int step, int thresholdInKb)` on a mapper | `ClaimCheckAttribute.cs:43` | `PostgreSQLMessageBroker.md` | `grep -rnE 'ClaimCheck\([^)]*dataStore:' contents/` | **1** | **0** | 5.3, `--explain` | +| `PostgresSubscription` without `messagePumpType` → `ConfigurationException`, *"You must set a message pump type"* | run, with the generic `Subscription` and `KafkaSubscription` as controls, which build | `PostgreSQLMessageBroker.md` | a Python scan of every `new PostgresSubscription<` call on the page | **4** of 5 | **0** of 5 | 5.3, running the page | +| The same, on **non-generic** subscriptions (`Subscription`, `KafkaSubscription`, `RmqSubscription` on RMQ.Async, `AzureServiceBusSubscription`, `SqsSubscription`) | run, as above | `AgreementDispatcher.md:122`, `CloudEventsSupport.md:185`, `DynamicMessageDeserialization.md:71`, `:210`, `:237`, `FAQ.md:254`, `:637`, `RoutingMultipleMessageTypes.md:104`, `:132`, `:195`, `:309` (lines at `5129c20`) | reading | **11** | **recorded, not repaired** — ruled 2026-09-28. The RMQ ones are to be checked as RMQ.Async: RMQ.Sync defaults to Reactor | 5.3, ruling 3 | +| SQL naming a `created_at` column the broker's table does not have | every SQL block run against the table | `PostgreSQLMessageBroker.md` | `grep -rn created_at contents/` | **4** | **0** | 5.3, running the SQL | +| *"Increase `timeOut` to reduce polling frequency"*; the empty-queue poll interval is `emptyChannelDelay` | read, `Subscription.cs` at 10.7.0 | `PostgreSQLMessageBroker.md` | ``grep -rn 'Increase `timeOut`' contents/`` | **1** | **0** | 5.3, reading | +| `Paramore.Brighter.PostgreSql.EntityFrameworkCore` 10.7.0 resolves EF Core 10, so Npgsql's EF provider must be 10.x | run: 9.0.4 → `MissingMethodException` | `PostgreSQLMessageBroker.md` | `grep -rn 'Npgsql.EntityFrameworkCore' contents/` | **0** | **2**, stated on the page and on `PostgresOutbox.md`, where the package is installed | 5.3, running the page | +| Message size: the page gave no measured limit, and AWS SQS as 256 KB | run: 50 MB round-trips, 150 MB rejected by Npgsql `54000`; SQS by the maintainer (ruling 5) | `PostgreSQLBrokerTradeOffs.md` | reading its comparison table | **1** | **0** | 5.3, running the page | +| Tracing configured with `AddSource("Paramore.Brighter…")` alone. `AddBrighter()` registers no `IAmABrighterTracer`, so no Brighter span is ever recorded; `AddBrighterInstrumentation()` registers one | run: `AddSource` alone → **0** spans; `AddBrighterInstrumentation()` → **34** | `Telemetry.md`, `ConfiguringOpenTelemetry.md`, `PostgreSQLMessageBroker.md` #8 | `grep -rnE 'AddSource\("[Pp]aramore\.[Bb]righter' contents/` | **5** | **2**, both sentences saying `AddSource` alone records nothing | 5.3, running the page | +| Span names, attributes and propagation headers in `Telemetry.md`'s tables | captured from spans, RMQ and Kafka, the SDK initialised | `Telemetry.md` | reading every table | every table | rewritten from the capture | 5.3, running the page | +| The Jaeger exporter, deprecated by OpenTelemetry and not in the pin | read | `ConfiguringOpenTelemetry.md` | `grep -rn Jaeger contents/` | **4** | **3**, all saying to point OTLP at Jaeger | 5.3, ruling 1 | +| CloudEvents header names and the content mode on the wire | captured from RMQ.Async and Kafka; SNS and ASB read | `CloudEventsReference.md`, `CloudEventsSupport.md` | reading every header list on both pages | both pages | rewritten from the capture | 5.3, running the page | +| AWS writes the CloudEvents source as `souce` | read; **upstream, BrighterCommand/Brighter#4458**, filed on the maintainer's word | `CloudEventsReference.md` states it and links it | `grep -rn souce contents/` | **0** | **2**, the statement | 5.3, reading | +| *"We default the **ACLs** … to **S3CannedACL.Private**"*: `ACLs` is `null`, and `CreateIfMissing` on a missing bucket throws | `S3LuggageOptions.cs:70`, `S3LuggageStore.cs:130` in both AWS packages; run on LocalStack, controls above | `S3LuggageStore.md`, `HandlingLargeMessages.md` (its store set no `ACLs`) | `grep -rn 'S3LuggageOptions' contents/` → 2 pages, every constructor read | **2** | **0** | 5.3, reading | +| The Control API's status JSON was V9's, its list indented into one paragraph, its route written `{{subscriptionName}}`, and `availableTopics`/`topicName` described as topics — they are subscription names | `ApiExtensions.cs`, `DispatcherExtensions.cs` at 10.7.0; run against a V10 Dispatcher | `BrighterControlAPI.md` | `grep -rn 'control/status' contents/` → the one page | **1** page | **0** | 5.3, running the page | +| A `string` passed as `MessageBody`'s content type, which is a `System.Net.Mime.ContentType` with no conversion from `string` | `MessageBody.cs:124` | `KafkaConfiguration.md:723`, `MessageMappers.md:146` | `grep -rnE 'new MessageBody\([^)]*, *("\|MediaTypeNames)' contents/`, and a scan of every `new MessageBody(` call's arguments | **2** | **0** — the scan found a third, `DefaultMessageMappers.md` #4, put to the maintainer with the rest of that block | 5.1, carried | +| A mapper missing a member of `IAmAMessageMapper`, with no `// ...` to say so | `IAmAMessageMapper.cs` | `Routing.md` #1, `V10MigrationGuide.md` #3, #18, `NullableReferenceTypes.md` #7, `FAQ.md` #7 (whose `MapToMessage` also returned nothing; it gains `[RetrieveClaim]` on the way back) | reading, from 5.1's list | **5** | **0** | 5.1, carried | +| A `Command` or `Event` subclass that never calls a base constructor; neither has a parameterless one | `Command.cs:68`, `Event.cs:68` | `NullableReferenceTypes.md` #9, `MigratingToNullableReferenceTypes.md` #4, `V10MigrationGuide.md` #20 | `grep -rnE 'class \w+ *: *(Command\|Event) *$\|…\{'` → 16 lines, each read; a Python scan of every C# fence for a base call | **3** | **0**; `V10MigrationGuide.md` #1 is the skipped V9 form | 5.1, carried | +| `V10MigrationGuide.md` #20: the default mapper shown by `PublishAsync` | run: `PostAsync` → **1** message, `application/json`; `PublishAsync` → **0** | `V10MigrationGuide.md` | the corpus sweep is put to the maintainer | **1** | **0** | 5.3, rewriting the block | ## Friction ledger From 07d878b3ab4e4cf8b00db5975daf59fb830eb4cf Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:08:20 +0100 Subject: [PATCH 14/27] blockcheck: grow the pin by Microsoft.Extensions.TimeProvider.Testing 99 PackageReferences, 543 reference assemblies. FakeTimeProvider is what InMemoryScheduler.md's rewritten ITimerProvider block (task 5.4) sets as the scheduler's clock. Measured alone, no page changed: 987 blocks, 288 BUILT, and no verdict moves. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/refs/refs.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blockcheck/refs/refs.csproj b/tools/blockcheck/refs/refs.csproj index 8b3033f..415a047 100644 --- a/tools/blockcheck/refs/refs.csproj +++ b/tools/blockcheck/refs/refs.csproj @@ -221,6 +221,11 @@ + + + From aeb65f4e340a8bcf492c1796b10864f2d5ee9f2a Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:18:39 +0100 Subject: [PATCH 15/27] blockcheck: grow the pin by Confluent.SchemaRegistry.Serdes.Avro 100 PackageReferences, 545 reference assemblies (Avro.dll and the serdes). The Avro serializers DefaultMessageMappers.md's rewritten mapper calls (task 5.4, the maintainer's ruling on the Avro block), at 2.15.0, the Confluent release Paramore.Brighter.MessagingGateway.Kafka 10.7.0 depends on. Measured alone, no page changed: 987 blocks, 288 BUILT, and no verdict moves. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/refs/refs.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/blockcheck/refs/refs.csproj b/tools/blockcheck/refs/refs.csproj index 415a047..c723557 100644 --- a/tools/blockcheck/refs/refs.csproj +++ b/tools/blockcheck/refs/refs.csproj @@ -226,6 +226,12 @@ Microsoft.Extensions and carries a net9.0 build. --> + + + From b4c3ae035037d2cc56311138de8910ea7c409c89 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:26:23 +0100 Subject: [PATCH 16/27] =?UTF-8?q?docs:=20017=20task=205.4=20=E2=80=94=20Ti?= =?UTF-8?q?meProvider,=20the=20Order=20write=20model,=20Avro,=20PublishAsy?= =?UTF-8?q?nc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InMemoryScheduler.md: ITimerProvider 4 -> 0. The scheduler creates its timers from InMemorySchedulerFactory.TimeProvider; block #5 is now a FakeTimeProvider test, run with controls (advance 5m -> handled before Advance returns; none, 4:59 and 2.5s wall clock -> not). The package it named, Paramore.Brighter.InMemoryScheduler, is a NuGet 404. CQRSWithBrighterAndDarker.md: the Order write model is shown (#7, BUILT). Behind it, Id = command.Id was CS0029 against the read side's Guid key, and #2 passed a CancellationToken positionally as RequestContext (CS1503). BrighterSchedulerSupport.md: the scheduled overloads listed the request before the delay and dropped RequestContext and args; now as 10.7.0. DefaultMessageMappers.md: the Avro mapper rewritten against Confluent's API (maintainer's ruling) as an async mapper constrained to ISpecificRecord, run end to end against a schema registry. A sync mapper is bypassed by PostAsync, and as the default a non-Avro type throws. PublishAsync sweep (maintainer's ruling): three places relied on PublishAsync reaching a mapper or a bus, now PostAsync; one practice that published dummy events to warm caches removed. CloudEvents extension properties are written only by CloudEventJsonMessageMapper, measured, and said so on three pages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/BrighterSchedulerSupport.md | 20 ++- contents/CQRSWithBrighterAndDarker.md | 92 +++++++++++- .../CommandProcessorConfigurationReference.md | 2 +- contents/DefaultMessageMappers.md | 132 +++++++++++++----- contents/DispatchingARequest.md | 10 +- contents/DynamicMessageDeserialization.md | 15 +- contents/FAQ.md | 2 +- contents/InMemoryScheduler.md | 57 +++++--- contents/UsingTheContextBag.md | 2 +- contents/V10MigrationGuide.md | 7 +- 10 files changed, 252 insertions(+), 87 deletions(-) diff --git a/contents/BrighterSchedulerSupport.md b/contents/BrighterSchedulerSupport.md index 034164a..e84caf2 100644 --- a/contents/BrighterSchedulerSupport.md +++ b/contents/BrighterSchedulerSupport.md @@ -38,14 +38,22 @@ Common scenarios for message scheduling include: The `IAmACommandProcessor` interface provides methods for scheduling delayed messages: ```csharp +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + public interface IAmACommandProcessor { - Task SendAsync(T command, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest; - Task SendAsync(T command, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest; - Task PublishAsync(T @event, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest; - Task PublishAsync(T @event, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest; - Task PostAsync(T request, DateTimeOffset at, CancellationToken cancellationToken = default) where T : class, IRequest; - Task PostAsync(T request, TimeSpan delay, CancellationToken cancellationToken = default) where T : class, IRequest; + // ... the immediate overloads, and the synchronous Send, Publish and Post + + Task SendAsync(DateTimeOffset at, TRequest command, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task SendAsync(TimeSpan delay, TRequest command, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task PublishAsync(DateTimeOffset at, TRequest @event, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task PublishAsync(TimeSpan delay, TRequest @event, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task PostAsync(DateTimeOffset at, TRequest request, RequestContext? requestContext = null, Dictionary? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task PostAsync(TimeSpan delay, TRequest request, RequestContext? requestContext = null, Dictionary? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; } ``` diff --git a/contents/CQRSWithBrighterAndDarker.md b/contents/CQRSWithBrighterAndDarker.md index c786c9f..90044e8 100644 --- a/contents/CQRSWithBrighterAndDarker.md +++ b/contents/CQRSWithBrighterAndDarker.md @@ -107,7 +107,7 @@ The level of separation you choose depends on your application's complexity and ### Command Handler Example -Here's a brief example of a Brighter command handler. For complete details, see [Dispatching Requests](/contents/DispatchingARequest.md): +Here's a brief example of a Brighter command handler. `Order`, `OrderItem`, `OrderStatus`, `OrderPlacedEvent` and `IOrderRepository` are the write model, shown in full in [Example: E-Commerce Order System](#example-e-commerce-order-system). For complete details, see [Dispatching Requests](/contents/DispatchingARequest.md): ```csharp using Paramore.Brighter; @@ -115,6 +115,7 @@ using Paramore.Brighter.Logging.Attributes; using Paramore.Brighter.Policies.Attributes; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -158,18 +159,23 @@ public class PlaceOrderCommandHandler : RequestHandlerAsync // Create order var order = new Order { + Id = Guid.Parse(command.Id), CustomerId = command.CustomerId, Items = command.Items, - Status = OrderStatus.Placed, - PlacedAt = DateTime.UtcNow + Status = OrderStatus.Pending, + OrderDate = DateTime.UtcNow }; await _orderRepository.AddAsync(order, cancellationToken); // Publish event (for eventual consistency with read model) await _commandProcessor.PublishAsync( - new OrderPlacedEvent(order.Id, order.CustomerId), - cancellationToken); + new OrderPlacedEvent( + order.Id, + order.CustomerId, + order.OrderDate, + order.Items.Sum(i => i.Quantity * i.UnitPrice)), + cancellationToken: cancellationToken); return await base.HandleAsync(command, cancellationToken); } @@ -646,6 +652,79 @@ public class OrderItemDto } ``` +**Write Model:** + +The handler writes these entities, and in this example the read side queries the same tables: + +```csharp +using Paramore.Brighter; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +public class Order +{ + public Guid Id { get; set; } + public int CustomerId { get; set; } + public Customer Customer { get; set; } + public DateTime OrderDate { get; set; } + public OrderStatus Status { get; set; } + public List Items { get; set; } = new(); +} + +public class OrderItem +{ + public int ProductId { get; set; } + public Product Product { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } +} + +public enum OrderStatus { Pending, Shipped, Delivered, Cancelled } + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } +} + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } + public int StockQuantity { get; set; } +} + +public interface IOrderRepository +{ + Task AddAsync(Order order, CancellationToken cancellationToken = default); +} + +public interface IProductRepository +{ + Task GetByIdAsync(int productId, CancellationToken cancellationToken = default); +} + +// Raised once the order is saved, so the read model can catch up +public class OrderPlacedEvent : Event +{ + public OrderPlacedEvent(Guid orderId, int customerId, DateTime orderDate, decimal totalAmount) + : base(Id.Random()) + { + OrderId = orderId; + CustomerId = customerId; + OrderDate = orderDate; + TotalAmount = totalAmount; + } + + public Guid OrderId { get; } + public int CustomerId { get; } + public DateTime OrderDate { get; } + public decimal TotalAmount { get; } +} +``` + **Command Handler:** ```csharp using Paramore.Brighter; @@ -699,9 +778,10 @@ public class PlaceOrderCommandHandler : RequestHandlerAsync } // Create order (write model) + // A Brighter Id is a string; Id.Random() makes it a UUID, which the order keeps as its key var order = new Order { - Id = command.Id, + Id = Guid.Parse(command.Id), CustomerId = command.CustomerId, OrderDate = DateTime.UtcNow, Status = OrderStatus.Pending, diff --git a/contents/CommandProcessorConfigurationReference.md b/contents/CommandProcessorConfigurationReference.md index b3c63e4..6a61ebd 100644 --- a/contents/CommandProcessorConfigurationReference.md +++ b/contents/CommandProcessorConfigurationReference.md @@ -791,7 +791,7 @@ ones you meet first. | `Topic` | `RoutingKey?` | `null` | The topic or routing key messages are published to. | | `Type` | `CloudEventsType` | `empty` | The CloudEvents type used for routing and policy. | | `DefaultHeaders` | `IDictionary?` | `null` | Headers the default mappers add to every message. | -| `CloudEventsAdditionalProperties` | `IDictionary?` | `null` | Non-standard CloudEvents attributes serialised alongside the standard ones. | +| `CloudEventsAdditionalProperties` | `IDictionary?` | `null` | Non-standard CloudEvents attributes serialised alongside the standard ones in a structured-mode envelope, by `CloudEventJsonMessageMapper<>`. The default `JsonMessageMapper<>` does not write them. | | `ReplyTo` | `string?` | `null` | The queue a sender listens on under Request-Reply. | `Source` reads back with a trailing slash, because `Uri` normalises it. `Type` is empty on a diff --git a/contents/DefaultMessageMappers.md b/contents/DefaultMessageMappers.md index 516c75e..143a717 100644 --- a/contents/DefaultMessageMappers.md +++ b/contents/DefaultMessageMappers.md @@ -82,10 +82,13 @@ services.AddBrighter(options => // Brighter will use JsonMessageMapper automatically ``` -When you publish an `OrderCreated` event: +When you post an `OrderCreated` event to the external bus: ```csharp -await _commandProcessor.PublishAsync(new OrderCreated +using System; +using Paramore.Brighter; + +await _commandProcessor.PostAsync(new OrderCreated { Id = Guid.NewGuid().ToString(), CustomerId = "12345", @@ -122,56 +125,107 @@ While default mappers handle most scenarios, you still need custom `IAmAMessageM ### 1. Non-JSON Serialization Formats -If you need a format other than JSON (Avro, ProtoBuf, XML, etc.) You can register your own default message mapper for these: +If you need a format other than JSON (Avro, ProtoBuf, XML, etc.), write a mapper for it. This one uses Confluent's schema registry serializers for Avro, from the `Confluent.SchemaRegistry.Serdes.Avro` package. Confluent's `AvroSerializer` serializes classes that implement `ISpecificRecord` — the ones `avrogen` generates from a schema — so the mapper is constrained to them: ```csharp -public class AvroMessageMapper : IAmAMessageMapper where T : class, IRequest +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Avro.Specific; +using Confluent.Kafka; +using Confluent.SchemaRegistry; +using Confluent.SchemaRegistry.Serdes; +using Paramore.Brighter; +using Paramore.Brighter.Extensions; + +public class AvroMessageMapperAsync(ISchemaRegistryClient schemaRegistry) : IAmAMessageMapperAsync + where T : class, IRequest, ISpecificRecord { - - private ISchemaRegistryClient _schemaRegistry; - private IEnumerable> _config; - public IRequestContext? Context { get; set; } - public AvroMessageMapper(ISchemaRegistryClient schemaRegistry, IEnumerable> config) - { - _schemaRegistry = schemaRegistry; - _config = config; - } - - public Message MapToMessage(T request, Publication publication) + public async Task MapToMessageAsync(T request, Publication publication, CancellationToken cancellationToken = default) { var header = new MessageHeader( messageId: request.Id, - topic: publication.Topic, - messageType: MessageType.MT_EVENT - ); + topic: publication.Topic!, + messageType: request.RequestToMessageType()); - // Serialize using Avro - var avroSerializer = new AvroSerializer(_schemaRegistry, _config); - var body = new MessageBody( - avroSerializer.SerializeAsync(request).AsSyncOverAsync(), - CharacterEncoding.Raw - ); + // Registers the schema on first use, and writes Confluent's wire format: + // a magic byte and the schema id, then the Avro-encoded record + var bytes = await new AvroSerializer(schemaRegistry).SerializeAsync( + request, + new SerializationContext(MessageComponentType.Value, publication.Topic!.Value)); + var body = new MessageBody(bytes, new ContentType(MediaTypeNames.Application.Octet), CharacterEncoding.Raw); return new Message(header, body); } - public T MapToRequest(Message message) + public async Task MapToRequestAsync(Message message, CancellationToken cancellationToken = default) { - var avroDeserializer = new AvroDeserializer(); - return avroDeserializer.DeserializeAsync(message.Body.Bytes).AsSyncOverAsync(); + var request = await new AvroDeserializer(schemaRegistry).DeserializeAsync( + message.Body.Bytes, + isNull: false, + new SerializationContext(MessageComponentType.Value, message.Header.Topic.Value)); + + // The Id travels in the message header, not in the Avro record + request.Id = message.Id; + return request; } } +``` + +`avrogen` generates `OrderShipped` from a schema as a partial class, in the namespace the schema declares: + +```json +{ + "type": "record", + "name": "OrderShipped", + "namespace": "Orders", + "fields": [ + { "name": "OrderId", "type": "string" }, + { "name": "Carrier", "type": "string" } + ] +} +``` + +Add the other half, in the same namespace, to make it a Brighter event. `RequestToMessageType` accepts only a command or an event, so implement `IEvent` or `ICommand`, not bare `IRequest`: + +```csharp +using Paramore.Brighter; + +namespace Orders; + +// avrogen generates the half of this class that implements ISpecificRecord +public partial class OrderShipped : IEvent +{ + public Id Id { get; set; } = Id.Random(); + public Id? CorrelationId { get; set; } +} +``` + +Brighter resolves mappers from the service container, which supplies the mapper's `ISchemaRegistryClient`. Register the mapper for each type you serialize with Avro: + +```csharp +using Confluent.SchemaRegistry; +using Microsoft.Extensions.DependencyInjection; +using Orders; +using Paramore.Brighter.Extensions.DependencyInjection; + +services.AddSingleton( + new CachedSchemaRegistryClient(new SchemaRegistryConfig { Url = "http://localhost:8081" })); -// Register as your default mapper services.AddBrighter(options => { }) - .AutoFromAssemblies( - [typeof(OrderCreated).Assembly], - defaultMessageMapper: typeof(AvroMessageMapper<>) - ); + .AutoFromAssemblies([typeof(OrderShipped).Assembly]) + .MapperRegistry(mappers => + { + mappers.RegisterAsync>(); + }); ``` +`PostAsync` uses this mapper, and your other requests keep the default JSON mapper. `Post` does not use it: it maps with a synchronous `IAmAMessageMapper`, so a type that has only this asynchronous mapper is sent as JSON by the synchronous default. + +You can make it the default instead, with `asyncDefaultMessageMapper: typeof(AvroMessageMapperAsync<>)` in `AutoFromAssemblies`, but only if every request you post is generated from an Avro schema. The default mapper is closed over each request type Brighter maps, and for a type that is not an `ISpecificRecord`, `PostAsync` throws `ArgumentException` because the type violates the mapper's constraint. + ### 2. Transform Pipelines When you need message transformation (Claim Check, Compression, Encryption, PII removal, etc.), you must use a custom mapper with transform attributes. See [Message Transforms](/contents/MessageTransforms.md) for the pipeline and worked examples. @@ -226,19 +280,23 @@ services.AddBrighter(options => { }) ### Custom Default Mapper (e.g., Avro) ```csharp +using Orders; +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) .AddProducers(configure => { }) .AutoFromAssemblies( - [typeof(OrderCreated).Assembly], - defaultMessageMapper: typeof(AvroMessageMapper<>), - asyncDefaultMessageMapper: typeof(AvroMessageMapper<>) + [typeof(OrderShipped).Assembly], + asyncDefaultMessageMapper: typeof(AvroMessageMapperAsync<>) ); -// All messages use Avro serialization by default +// PostAsync maps every request with Avro, so every request must be an ISpecificRecord ``` ### Mixed: Default + Custom Mappers ```csharp +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { }) .AddProducers(configure => { }) .AutoFromAssemblies( @@ -248,7 +306,7 @@ services.AddBrighter(options => { }) .MapperRegistry(mappers => { // Specific messages with transforms - mappers.Regiter(); + mappers.Register(); mappers.Register(); }); ``` diff --git a/contents/DispatchingARequest.md b/contents/DispatchingARequest.md index 476a841..5b8003d 100644 --- a/contents/DispatchingARequest.md +++ b/contents/DispatchingARequest.md @@ -114,7 +114,13 @@ public class OrderController : ControllerBase ### Example: Publishing Events with CloudEvents Extensions +CloudEvents extension properties reach the wire only in a structured-mode envelope, so this example assumes `OrderCreatedEvent` is mapped by `CloudEventJsonMessageMapper<>`; the default `JsonMessageMapper<>` ignores them. `PostAsync` sends the event to the external bus. `PublishAsync` would dispatch it to handlers in this process, and no message would be mapped at all. + ```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using Paramore.Brighter; + public class EventPublisher { private readonly IAmACommandProcessor _commandProcessor; @@ -131,8 +137,8 @@ public class EventPublisher ["region"] = order.ShippingAddress.Region }; - // Publish event with context - await _commandProcessor.PublishAsync( + // Post the event to the external bus, with the context + await _commandProcessor.PostAsync( new OrderCreatedEvent { OrderId = order.Id, diff --git a/contents/DynamicMessageDeserialization.md b/contents/DynamicMessageDeserialization.md index be78a7f..d65e25f 100644 --- a/contents/DynamicMessageDeserialization.md +++ b/contents/DynamicMessageDeserialization.md @@ -183,20 +183,7 @@ Only use dynamic when needed: - Message evolution scenarios - CloudEvents-based integration -### 5. Cache Performance-Critical Paths - -If performance is critical, pre-warm the pipeline cache: - -```csharp -// Send one message of each type at startup to warm caches -await _commandProcessor.PublishAsync(new TaskCreated { /* ... */ }); -await _commandProcessor.PublishAsync(new TaskUpdated { /* ... */ }); -await _commandProcessor.PublishAsync(new TaskCompleted { /* ... */ }); - -// Subsequent messages will use cached pipelines -``` - -### 6. Document Type Mappings +### 5. Document Type Mappings Document which CloudEvents types map to which Request types: diff --git a/contents/FAQ.md b/contents/FAQ.md index 2163d38..5ff8cd6 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -224,7 +224,7 @@ See: [Outbox Support](/contents/BrighterOutboxSupport.md) ### When should I use `SendAsync` or `PublishAsync` vs External Bus? -**c`SendAsync` or `PublishAsync:** +**`SendAsync` or `PublishAsync`:** - Avoids blocking I/O - Increases throughput (thread reuse) diff --git a/contents/InMemoryScheduler.md b/contents/InMemoryScheduler.md index e4fe7f0..279fcd8 100644 --- a/contents/InMemoryScheduler.md +++ b/contents/InMemoryScheduler.md @@ -29,9 +29,9 @@ The **InMemory Scheduler** is a lightweight, timer-based scheduling implementati ## What is the InMemory Scheduler? -The InMemory Scheduler uses .NET's `ITimerProvider` internally to schedule delayed execution of messages. When you schedule a message: +The InMemory Scheduler creates its timers from a .NET `TimeProvider` — `TimeProvider.System` unless you set `InMemorySchedulerFactory.TimeProvider`. When you schedule a message: -1. Brighter creates an in-memory timer for the specified delay +1. Brighter creates an in-memory timer for the specified delay, with `TimeProvider.CreateTimer` 2. The timer fires at the scheduled time 3. Brighter dispatches your message to the appropriate handler 4. The timer is removed from memory @@ -47,7 +47,7 @@ CommandProcessor.SendAsync(delay, command) ↓ InMemoryScheduler ↓ -ITimerProvider.CreateTimer(delay) +TimeProvider.CreateTimer(delay) ↓ [Timer stored in memory] ↓ @@ -197,36 +197,57 @@ else brighter.AutoFromAssemblies(); ``` -### Configuration with Custom Timer Provider +### Controlling Time in Tests -The InMemory Scheduler uses `ITimerProvider` internally. You can provide a custom implementation for testing: +The scheduler measures every delay against its `TimeProvider`, so a test can replace the clock rather than wait for it. `FakeTimeProvider`, from the `Microsoft.Extensions.TimeProvider.Testing` package, only moves when you call `Advance`: ```csharp -public class FakeTimerProvider : ITimerProvider +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +var timeProvider = new FakeTimeProvider(); + +var services = new ServiceCollection(); +services.AddBrighter() + .UseScheduler(new InMemorySchedulerFactory { TimeProvider = timeProvider }) + .AsyncHandlers(registry => registry.RegisterAsync()); + +var commandProcessor = services.BuildServiceProvider() + .GetRequiredService(); + +await commandProcessor.SendAsync(TimeSpan.FromMinutes(5), new SendReminder()); + +timeProvider.Advance(TimeSpan.FromMinutes(5)); // SendReminderHandler has run when this returns + +public class SendReminder() : Command(Id.Random()); + +public class SendReminderHandler : RequestHandlerAsync { - public ITimer CreateTimer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period) + public override Task HandleAsync(SendReminder command, CancellationToken cancellationToken = default) { - // Custom timer implementation for testing - return new FakeTimer(callback, state, dueTime, period); + // Your reminder logic here + return base.HandleAsync(command, cancellationToken); } } - -// Use in tests -services.AddBrighter(options => { ... }) - .UseScheduler(new InMemorySchedulerFactory(new FakeTimerProvider())) - .AutoFromAssemblies(); ``` +`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). + ## InMemory Scheduler NuGet Package -To use the InMemory Scheduler, install the NuGet package: +The InMemory Scheduler has no package of its own: `InMemoryScheduler` and `InMemorySchedulerFactory` are in `Paramore.Brighter`, and `UseScheduler` comes with the dependency-injection package you already use to configure Brighter: ```bash -dotnet add package Paramore.Brighter.InMemoryScheduler +dotnet add package Paramore.Brighter.Extensions.DependencyInjection ``` -**Package**: `Paramore.Brighter.InMemoryScheduler` - ## InMemory Scheduler Code Examples ### Basic Scheduling diff --git a/contents/UsingTheContextBag.md b/contents/UsingTheContextBag.md index 9d2f862..86db2d6 100644 --- a/contents/UsingTheContextBag.md +++ b/contents/UsingTheContextBag.md @@ -162,7 +162,7 @@ public class EventPublishingHandler : RequestHandler } ``` -These properties will be serialized as CloudEvent extensions in the generated message envelope. +These properties are serialized as CloudEvents extensions when the message is mapped into a structured-mode envelope, by `CloudEventJsonMessageMapper<>`. The default mapper, `JsonMessageMapper<>`, ignores them. `PublishAsync` maps nothing — it dispatches to handlers in this process — so they reach the wire only through `Post` or `PostAsync`. ### Originating Message diff --git a/contents/V10MigrationGuide.md b/contents/V10MigrationGuide.md index 78ae70e..dd33b04 100644 --- a/contents/V10MigrationGuide.md +++ b/contents/V10MigrationGuide.md @@ -757,6 +757,11 @@ dotnet test 1. **Test with InMemory components** (fast): ```csharp +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Xunit; + [Fact] public async Task Should_Process_Message_With_V10_Components() { @@ -766,7 +771,7 @@ public async Task Should_Process_Message_With_V10_Components() var commandProcessor = serviceProvider.GetRequiredService(); // Act - await commandProcessor.PublishAsync(new PersonCreated + await commandProcessor.PostAsync(new PersonCreated { Name = "Alice", Email = "alice@example.com" From cfd964e9789ddd63ef47cda813613c46d338a533 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:26:51 +0100 Subject: [PATCH 17/27] =?UTF-8?q?spec:=20017=20task=205.4=20=E2=80=94=20ba?= =?UTF-8?q?seline=20rows=20for=20the=20blocks=20b4c3ae0=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five rows: InMemoryScheduler.md #5, CQRSWithBrighterAndDarker.md #7, BrighterSchedulerSupport.md #1, DefaultMessageMappers.md #4 and #5. --report exit 0: 989 blocks, 293 BUILT, 679 FAILED, 17 SKIPPED; baseline 293, 0 findings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 82688c6..e371b66 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -313,3 +313,8 @@ contents/Telemetry.md 3 TelemetryContext.cs 2d938c8 contents/Telemetry.md 5 TelemetryContext.cs 2d938c8 contents/PostgreSQLMessageBroker.md 11 PostgreSQLMessageBrokerContext.cs 2d938c8 contents/Telemetry.md 4 TelemetryContext.cs 2d938c8 +contents/InMemoryScheduler.md 5 - b4c3ae0 +contents/CQRSWithBrighterAndDarker.md 7 - b4c3ae0 +contents/BrighterSchedulerSupport.md 1 - b4c3ae0 +contents/DefaultMessageMappers.md 4 - b4c3ae0 +contents/DefaultMessageMappers.md 5 - b4c3ae0 From e74250266b283ae1c224ae9007c3efa312318803 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:28:55 +0100 Subject: [PATCH 18/27] =?UTF-8?q?docs:=20017=20task=205.4=20=E2=80=94=20th?= =?UTF-8?q?e=20Control=20API's=20wrong-case=20500,=20filed=20as=20#4465?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filed on the maintainer's word (2026-09-28) and linked where the page tells readers to match the subscription name's case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/BrighterControlAPI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/BrighterControlAPI.md b/contents/BrighterControlAPI.md index ab76bc2..0fa5936 100644 --- a/contents/BrighterControlAPI.md +++ b/contents/BrighterControlAPI.md @@ -80,4 +80,4 @@ You can change the number of performers a subscription runs by calling `PATCH /c - **200 OK** with a message such as `Active performers for orders-subscription set to 3` - **400 BAD REQUEST** with a message such as `No such subscription Orders.OrderPlaced`, when no subscription has that name -**Match the name's case exactly.** The check for an unknown name ignores case but the update does not, so `ORDERS-SUBSCRIPTION` passes the check and then fails with a 500, an `InvalidOperationException` from the Dispatcher. +**Match the name's case exactly.** The check for an unknown name ignores case but the update does not, so `ORDERS-SUBSCRIPTION` passes the check and then fails with a 500, an `InvalidOperationException` from the Dispatcher. This is reported as [#4465](https://github.com/BrighterCommand/Brighter/issues/4465). From 608615a08688940a0f65833da28dceab53c9888e Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:31:31 +0100 Subject: [PATCH 19/27] =?UTF-8?q?spec:=20017=20task=205.4=20=E2=80=94=20Ti?= =?UTF-8?q?meProvider,=20Order,=20Avro=20and=20the=20PublishAsync=20sweep,?= =?UTF-8?q?=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § Phase 5 as executed gains 5.4's entry: the maintainer's three rulings, two pin growths measured alone, the behaviour runs and their controls, BUILT 288 -> 293, pagelint 562 -> 553, pages with nothing BUILT 44 -> 42. Four § Blocks that stay FAILED rows, two § Splits rows, one § Blocks removed row, ten § Defect ledger rows. Task 5.4 ticked: 32 of 42. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 124 +++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 886d8db..4e80278 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1726,7 +1726,7 @@ everywhere. Page list: § *The tranches*, phase 5 table. - Input: those sections' phase 5 rows; 5.1's verdicts - Output: each page whole; baseline rows; ledger rows as 4.2 -- [ ] **Task 5.4:** Repair `ITimerProvider` and the unshown `Order` +- [x] **Task 5.4:** Repair `ITimerProvider` and the unshown `Order` - Input: `grep -rn ITimerProvider contents/` (4 lines at `c7329bb`, all `InMemoryScheduler.md`); `design.md` § *API Resolved* (`InMemorySchedulerFactory.TimeProvider`); `CQRSWithBrighterAndDarker.md`'s `Id = command.Id` block @@ -2144,6 +2144,111 @@ join agreeing: `BrighterControlAPI.md`, `CloudEventsReference.md`, `PostgreSQLBr `MigratingToNullableReferenceTypes.md`, `NullableReferenceTypes.md`, `PostgresOutbox.md`, `Routing.md`, `V10MigrationGuide.md` +**Task 5.4 — `ITimerProvider`, the unshown `Order`, and the three rulings on 5.3's questions.** +**`grep -rn ITimerProvider contents/ | wc -l` → 0**, read from a file (4 at `aec11c1`). +`CQRSWithBrighterAndDarker.md` now shows `Order`: the write model is a new block, #7, BUILT; the +handler that assigns `Id = command.Id` is #8 and stays FAILED, same-page. **BUILT 288 → 293** (+5), +every move `FAILED -> BUILT` or a new key: `InMemoryScheduler.md` #5, `BrighterSchedulerSupport.md` +#1, `DefaultMessageMappers.md` #4 and #5, and `CQRSWithBrighterAndDarker.md` #7 (inserted). The rest of the +AC2 diff, aligned by each fence's first non-`using` line against `aec11c1` rather than by ordinal: +`CQRSWithBrighterAndDarker.md` #8–#13 are old #7–#12 renumbered by the insertion; +`DefaultMessageMappers.md` #4–#6 are old #4 split, and #7–#15 are old #5–#13; and +`DynamicMessageDeserialization.md` old #7 is removed, with #7 and #8 being old #8 and #9. All of them +were FAILED on both sides (§ *Splits*, § *Blocks removed*). **987 → 989 blocks.** `pagelint` +**562 → 553** (−9): `DefaultMessageMappers.md` −4 (#2, #10, #11, and old #4 split into three blocks that +carry `using`s), and −1 each on `InMemoryScheduler.md` #5, `BrighterSchedulerSupport.md` #1, +`DispatchingARequest.md` #3, `V10MigrationGuide.md` #26, and the removed block. Pages with nothing +BUILT **44 → 42**, by the requirements' `awk` and a Python join agreeing: `BrighterSchedulerSupport.md`, +`DefaultMessageMappers.md`. Pin `07d878b`, `aeb65f4`; repair `b4c3ae0`, `e742502`; baseline `cfd964e`. + +- **The maintainer's three rulings, 2026-09-28**, on 5.3's questions. (1) Rewrite + `DefaultMessageMappers.md`'s Avro block against Confluent's API, in 5.4. (2) Sweep `PublishAsync` + across the corpus and repair every instance that means the bus, keeping those that are in-process + events. (3) File the Control API's wrong-case 500. It is BrighterCommand/Brighter#4465, `Bug`, + `0 - Backlog`, linked from `BrighterControlAPI.md` +- **The pin grew twice, against 5.1's "no pin change"**, each change in its own commit and measured + alone with no page changed: no verdict moved either time. `Microsoft.Extensions.TimeProvider.Testing` + 10.10.0 (`FakeTimeProvider`, latest stable, with a net9.0 build) went in at `07d878b`, and + `Confluent.SchemaRegistry.Serdes.Avro` 2.15.0 (the Confluent release that + `Paramore.Brighter.MessagingGateway.Kafka` 10.7.0 depends on) at `aeb65f4`. That makes **100** + PackageReferences and **545** reference assemblies, against 542 +- **`InMemoryScheduler.md`.** Two sentences, the pipeline diagram and block #5 now name + `TimeProvider`, and block #5 is a `FakeTimeProvider` test. The old block also passed a + `FakeTimerProvider` to a constructor that `InMemorySchedulerFactory` does not have. The page said to + install `Paramore.Brighter.InMemoryScheduler`, which returns 404 on NuGet; the scheduler is in + `Paramore.Brighter`, and `UseScheduler` is in the DI package. The test registers its handler + with `AsyncHandlers`, because `AutoFromAssemblies()` hit #4414 on the first run and the page links + `SchedulingAMessage.md`'s workaround +- **`CQRSWithBrighterAndDarker.md`.** Showing `Order` surfaced two hidden defects. `Id = command.Id` + is `CS0029` against the read side's `Guid` key: `Id` converts implicitly only to `string` + (`Id.cs:95`). And #2 passed a `CancellationToken` in `PublishAsync`'s `RequestContext?` slot, + which is `CS1503`. #2 was also written against a different, unshown `Order` (`PlacedAt`, + `OrderStatus.Placed`, a two-argument event); it now uses the model #7 shows, and its prose links there. + Both `PublishAsync` calls on the page stay: they raise an in-process event to update the read model +- **`BrighterSchedulerSupport.md` #1** listed the six scheduled overloads with the request before the + time, and without `RequestContext` or Post's `args`. It is now as 10.7.0 declares them + (`IAmACommandProcessor.cs:98`, `:111`, `:171`, `:190`, `:261`, `:282`). No call site in the corpus + followed the wrong order. The scan read every `Send`/`Publish`/`Post`/`DepositPost` call's arguments +- **`DefaultMessageMappers.md`, the Avro block, by ruling.** It is now an `IAmAMessageMapperAsync` + constrained to `ISpecificRecord`, calling `AvroSerializer.SerializeAsync(T, SerializationContext)` + and `AvroDeserializer(registry).DeserializeAsync`. The `Id` travels in the header, and the content + type is `application/octet-stream` (Confluent's wire format, as in `samples/TaskQueue/KafkaSchemaRegistry`). + The page now shows the `.avsc`, and the avrogen partial's other half implements `IEvent`, because + `RequestToMessageType` throws for a bare `IRequest`. The partial is in the schema's namespace, since + avrogen refuses a schema without one. The example type is `OrderShipped`, because the page's + `OrderCreated` is a different shape. Registration is per type with `RegisterAsync`, and the default + form, #10, carries the consequence in a comment. #11's `mappers.Regiter<` (`CS1061`) is fixed +- **The `PublishAsync` sweep, by ruling.** `calls.py` read every `Publish`/`PublishAsync` call in a C# + fence (**13** at `aec11c1`, **7** now), and a prose grep read every line tying either to a bus, + broker, queue, topic, transport or the wire. Four places relied on `PublishAsync` reaching a mapper or + the bus. `DefaultMessageMappers.md` #2 said the default mapper serialises a published event. + `DispatchingARequest.md` #3 set CloudEvents extensions for a publish. `V10MigrationGuide.md` #26 + asserted that a published event lands on the `InternalBus`. Those three now post. + `DynamicMessageDeserialization.md`'s practice *"Cache Performance-Critical Paths"* published three + dummy events to warm the mapper cache, and it is removed: `PublishAsync` maps nothing, and + `TransformPipelineBuilder`'s cache is keyed per mapper type and per direction, so posting warms only + the producer's side and puts junk on a topic. The 7 calls left are 2 declarations, 2 in-process + read-model events, a scheduled local event (`AwsScheduler.md`) and 2 test-double verifications. The + sweep also found that **CloudEvents extension properties are written only by + `CloudEventJsonMessageMapper<>`**. Measured: the default `JsonMessageMapper<>` drops them from both + the request context and `Publication`. `DispatchingARequest.md`, `UsingTheContextBag.md` and + `CommandProcessorConfigurationReference.md`'s `CloudEventsAdditionalProperties` row now say so. + `FAQ.md`'s heading, which had a stray `c` and an unclosed code span, is fixed +- **`--explain` on every block the diff touches** (`git diff -U0 aec11c1` hunks against fence + ranges): **13** blocks, **5** BUILT. The **8** FAILED are `CQRSWithBrighterAndDarker.md` #2 and + #8, and `DefaultMessageMappers.md` #6 and #10, all same-page and each compiled with its page's blocks (below). + The other four fail only on names their pages never show: `DefaultMessageMappers.md` #2 and #11, + `DispatchingARequest.md` #3, and `V10MigrationGuide.md` #26, none of them tranche pages. Each got + its `using`s, so `pagelint --changed origin/master` reports **0** errors +- **Behaviour, run with controls**, against the released 10.7.0 packages on net10.0: + + | Claim | Case → result | Control → result | + |---|---|---| + | `InMemoryScheduler.md`: the scheduler's clock is its `TimeProvider` | `FakeTimeProvider`, `SendAsync(5 min)`, `Advance(5 min)` → handled **1**, before `Advance` returns | no advance → **0**; advance 4:59 → **0**, then +1 s → **1**; 2.5 s of wall clock → **0**. Both TimeProvider.Testing 10.7.0 and 10.10.0 | + | `CQRSWithBrighterAndDarker.md` #8: `Guid.Parse(command.Id)` | 10,000 `Id.Random()` → all parse | `new Id("order-1")` → `FormatException` | + | `DefaultMessageMappers.md` #4–#6, the page's own blocks and `.avsc`, avrogen 1.12.2, cp-schema-registry 7.9.0, `InternalBus` | `RegisterAsync` + `PostAsync` → 16 bytes, magic byte 0, octet-stream, `MT_EVENT`; round trip → the record and its `Id`; subject `-value` registered | `Post` → **JSON** (the sync default; the async mapper is not used); another request type → JSON through the default. The earlier sync mapper under `PostAsync` → JSON too | + | #10, the Avro default | `PostAsync` of an `ISpecificRecord` → Avro | a type that is not one → `ArgumentException`, *"violates the constraint of type 'T'"* | + | The `Id` in the header | `MapToRequestAsync` → the `Id` restored | the record alone → a different `Id` | + | `RequestToMessageType` | the partial as `IEvent` → maps | as bare `IRequest` → `ArgumentException`, *"can only map Commands and Events"* | + | CloudEvents extensions (`DispatchingARequest.md`, `UsingTheContextBag.md`) | `PostAsync` + `CloudEventJsonMessageMapper<>` → in the envelope, from the context bag and from `Publication` | `JsonMessageMapper<>` → absent from body and header, both ways; `PublishAsync` → **0** messages, the local handler runs | + +- **The blocks that stay FAILED compile where their world exists** (§ *Blocks that stay FAILED*): + `CQRSWithBrighterAndDarker.md` #6, #7 and #8 together → **0** errors, and #2 with #7 → **0**. The + control, #8 with the old `Id = command.Id`, gives `CS0029`, and #2 with the positional token gives + `CS1503`. `DefaultMessageMappers.md` #4, #5 and #6 with avrogen's half → **0**; without it, + `CS0311` +- **`attr_mismatch.py` → 7**, before the baseline rows +- **Baseline:** 5 rows. `--report` → exit **0**, *"989 blocks: 293 BUILT, 679 FAILED, 17 SKIPPED"*, + baseline 293, 45 units, 0 findings +- `linkcheck` 165 files, 0 broken; `versioncheck` 0 stale of 18 across 5; `symbolcheck` 0 findings, + 22 entries, 3 silenced, `--verify-list` clean; `optioncheck` 0 mismatches across 59 tables, 519 rows; + shape, redirects and `--verify` 161 / 77 / 161. **Pages changed: 11** + (`git diff --name-only aec11c1..HEAD -- contents`): `InMemoryScheduler.md` and + `CQRSWithBrighterAndDarker.md` for P0-7, plus `BrighterControlAPI.md`, `BrighterSchedulerSupport.md`, + `CommandProcessorConfigurationReference.md`, `DefaultMessageMappers.md`, `DispatchingARequest.md`, + `DynamicMessageDeserialization.md`, `FAQ.md`, `UsingTheContextBag.md` and `V10MigrationGuide.md`, + by ruling or recurrence + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2476,6 +2581,10 @@ is rewritten against the tables below. | `ConfiguringOpenTelemetry.md` | 5 | as `Telemetry.md` #1 | as #1 | 5 | | `ConfiguringOpenTelemetry.md` | 6 | as `Telemetry.md` #1 | as #1 | 5 | | `S3LuggageStore.md` | 1 | `CS0234` `Paramore.Brighter.Transformers.AWS.V4`; `CS0246` `S3LuggageStore`, `S3LuggageOptions`, `AWSS3Connection` | pin (D3): the pin carries the V3 AWS transformer package, not `.V4`. Compiles in scratch against `Paramore.Brighter.Transformers.AWS.V4` 10.7.0, **0** errors, with `credentials` a parameter | 5 | +| `CQRSWithBrighterAndDarker.md` | 2 | `CS0246` `Order`, `OrderItem`, `IOrderRepository`, `OrderPlacedEvent`; `CS0103` `OrderStatus` | same-page: #7 shows the write model, and the prose above #2 links there. #2 with #7 → **0** errors | 5 | +| `CQRSWithBrighterAndDarker.md` | 8 | `CS0246` `PlaceOrderCommand`, `IOrderRepository`, `IProductRepository`, `Order`, `OrderItem`, `OrderPlacedEvent`; `CS0103` `OrderStatus` | same-page: #6 declares the command, #7 the write model. #6–#8 together → **0** errors; the task's *"showing `Order`"* | 5 | +| `DefaultMessageMappers.md` | 6 | `CS0246` `Orders`, `OrderShipped`, `AvroMessageMapperAsync<>`; `CS0103` `services` | same-page: #4 declares the mapper, #5 half of `OrderShipped`; avrogen generates the other half from the page's `.avsc`. #4–#6 with it → **0**; without it `CS0311` | 5 | +| `DefaultMessageMappers.md` | 10 | `CS0246` `Orders`, `OrderShipped`, `AvroMessageMapperAsync<>`; `CS0103` `services` | same-page, as #6; run as the Avro default (5.4's table) | 5 | ## Splits @@ -2489,6 +2598,8 @@ is rewritten against the tables below. | `PostgreSQLBrokerTradeOffs.md` | 1 | 1, 2 | the JSONB and JSON schemas were one fence; each now builds. #2 is a new key, BUILT | 5.3 | | `CloudEventsReference.md` | 3, 4 | 4, 5 | not a split: a block inserted at #3, the Kafka partition key set per message. Old #3 (SNS) and #4 (Azure Service Bus) are now #4 and #5; all five build, so the AC2 diff reads #3, #4 `FAILED -> BUILT` and #5 as a new key | 5.3 | | `Telemetry.md` | 1–5 | 2–6 | not a split: a block inserted at #1, *Enabling Brighter's Spans*, FAILED on the pin. Old #1 and #4, BUILT, are now #2 and #5, their rows moved; so the AC2 diff reads #1 `BUILT -> FAILED` and #6 as a new key | 5.3 | +| `CQRSWithBrighterAndDarker.md` | — | 7 | not a split: the write model inserted above the handler, so old #7–#12 are #8–#13, FAILED both sides. A new key, BUILT | 5.4 | +| `DefaultMessageMappers.md` | 4 | 4, 5, 6 | the Avro mapper rewritten as three fences: the mapper (#4, BUILT), the avrogen partial (#5, BUILT) and its registration (#6, same-page). Old #5–#13 are #7–#15, FAILED both sides | 5.4 | ## Blocks removed @@ -2505,6 +2616,7 @@ after-report's keys and cannot show these, so they are listed here.* | `AnalyzerSupport.md` | 7 | BUILT | the `using` for the code fix's `Partitioner` | 2.4 | | `AnalyzerSupport.md` | 8 | FAILED | the BRT007 pragma, rewritten as BRT001 in the new block 1 | 2.4 | | `ConfiguringOpenTelemetry.md` | 2 | FAILED | the Jaeger exporter block — OpenTelemetry deprecated the exporter for OTLP, and the page now points the OTLP exporter at Jaeger (ruling 1). Old #3–#7 are now #2–#6, FAILED before and after, so the AC2 diff shows only #7 `FAILED -> -` | 5.3 | +| `DynamicMessageDeserialization.md` | 7 | FAILED | *"Cache Performance-Critical Paths"*: three `PublishAsync` calls of dummy events to warm the mapper cache. `PublishAsync` maps nothing, and posting warms only the producer's transform cache while putting junk on a topic. The page is unchanged from `c7329bb` to `aec11c1`, so this is #7 there too; old #8 and #9 are #7 and #8 | 5.4 | Old block 1 (BRT006's warning case, BUILT) also went; its address now holds the new pragma block, BUILT, re-admitted at `ec38400`. @@ -2600,6 +2712,16 @@ BUILT, re-admitted at `ec38400`. | A mapper missing a member of `IAmAMessageMapper`, with no `// ...` to say so | `IAmAMessageMapper.cs` | `Routing.md` #1, `V10MigrationGuide.md` #3, #18, `NullableReferenceTypes.md` #7, `FAQ.md` #7 (whose `MapToMessage` also returned nothing; it gains `[RetrieveClaim]` on the way back) | reading, from 5.1's list | **5** | **0** | 5.1, carried | | A `Command` or `Event` subclass that never calls a base constructor; neither has a parameterless one | `Command.cs:68`, `Event.cs:68` | `NullableReferenceTypes.md` #9, `MigratingToNullableReferenceTypes.md` #4, `V10MigrationGuide.md` #20 | `grep -rnE 'class \w+ *: *(Command\|Event) *$\|…\{'` → 16 lines, each read; a Python scan of every C# fence for a base call | **3** | **0**; `V10MigrationGuide.md` #1 is the skipped V9 form | 5.1, carried | | `V10MigrationGuide.md` #20: the default mapper shown by `PublishAsync` | run: `PostAsync` → **1** message, `application/json`; `PublishAsync` → **0** | `V10MigrationGuide.md` | the corpus sweep is put to the maintainer | **1** | **0** | 5.3, rewriting the block | +| `ITimerProvider` named as the InMemory scheduler's timer seam, with a `FakeTimerProvider : ITimerProvider` passed to `new InMemorySchedulerFactory(…)`. There is no such interface, and the factory has no constructor parameters: the seam is `InMemorySchedulerFactory.TimeProvider`, a `System.TimeProvider` | `InMemorySchedulerFactory.cs:37`, `InMemoryScheduler.cs:244`; run, `FakeTimeProvider` with controls | `InMemoryScheduler.md` | `grep -rn ITimerProvider contents/` | **4** | **0** | 5.4, P0-7 | +| `dotnet add package Paramore.Brighter.InMemoryScheduler`, a package that does not exist: the scheduler and its factory are in `Paramore.Brighter`, and `UseScheduler` is in the DI package | NuGet flat container → **404**; `git ls-tree 10.7.0 src/` has no such project | `InMemoryScheduler.md` | `grep -rnE 'Paramore\.Brighter\.InMemoryScheduler\b' contents/` | **2** | **0** | 5.4, P0-7 | +| `Order` never shown, and behind it `Id = command.Id` assigns a Brighter `Id` to the read side's `Guid` key, `CS0029`. The handler now uses `Guid.Parse(command.Id)`, run on 10,000 `Id.Random()` with a control | `Id.cs:95` (the implicit conversion is to `string` only) | `CQRSWithBrighterAndDarker.md` (#8; #2 was written against a different unshown `Order`) | `grep -rn 'Id = command\.Id' contents/` | **3** | **2**: `PolicyFallback.md`'s two are other properties on types that page never shows | 5.4, P0-7 | +| A `CancellationToken` passed positionally as `PublishAsync`'s second argument, which is `RequestContext?`: `CS1503`, hidden behind `CS0246` | `IAmACommandProcessor.cs:154` | `CQRSWithBrighterAndDarker.md` #2 | `calls.py`, every `Send`/`Publish`/`Post`/`DepositPost` call whose non-first positional argument names a token | **1** call | **0** | 5.4, compiling #2 with the write model | +| The six scheduled overloads listed request-first, `(T command, DateTimeOffset at, CancellationToken)`, without `RequestContext` or Post's `args`. At 10.7.0 the time comes first | `IAmACommandProcessor.cs:98`, `:111`, `:171`, `:190`, `:261`, `:282` | `BrighterSchedulerSupport.md` #1 | `grep -rnE '\(T(Request)? (command\|@event\|request), (DateTimeOffset\|TimeSpan)' contents/`; `calls.py`, any call whose second argument is a time | **6** lines; **0** calls | **0** | 5.4, the positional-token scan | +| The Avro mapper: `CharacterEncoding.Raw` as `MessageBody`'s content type; a constructor written `AvroMessageMapper(…)`, which does not parse; `SerializeAsync(request).AsSyncOverAsync()`, which is not Confluent's API; and `new AvroDeserializer()` with no registry. Rewriting it exposed three more. A sync mapper is bypassed by `PostAsync`, which maps with the async default. As the default mapper, any type that is not an `ISpecificRecord` throws. And `RequestToMessageType` rejects a bare `IRequest` | Confluent.SchemaRegistry.Serdes.Avro 2.15.0; `MessageType.cs:48`; `MessageMapperRegistry.cs` `ResolveClosedDefault`; run end to end against a schema registry | `DefaultMessageMappers.md` (#4, and #10, which named `AvroMessageMapper<>` as both defaults) | `grep -rn 'AvroMessageMapper<' contents/` | **5** | **0** | 5.3 (put to the maintainer); rewritten 5.4, by ruling | +| `mappers.Regiter<…>`, `CS1061` | — | `DefaultMessageMappers.md` #11 | `grep -rn 'Regiter\b' contents/` | **1** | **0** | 5.4, reading #10's neighbour | +| `PublishAsync` relied on to reach a mapper or the bus: the default mapper said to serialise a published event, CloudEvents extensions set for a publish, a test asserting that a published event lands on the `InternalBus`, and dummy events published to warm a mapper cache. `PublishAsync` dispatches to handlers in this process | run: `PublishAsync` → **0** messages, the local handler runs; `PostAsync` → **1** (sessions 101, 102 and 5.4) | `DefaultMessageMappers.md` #2, `DispatchingARequest.md` #3, `V10MigrationGuide.md` #26, `DynamicMessageDeserialization.md` (old #7, removed) | `calls.py` over every `Publish`/`PublishAsync` call; a prose grep tying either to a bus, broker, queue, topic, transport or the wire | **13** calls, **6** of them this defect | **7** calls, **0** of them: 2 declarations, 2 in-process read-model events, a scheduled local event, 2 test-double verifications | 5.3 (put to the maintainer); swept 5.4, by ruling | +| CloudEvents extension properties said to reach the message whatever the mapper. Only `CloudEventJsonMessageMapper<>` (and the CloudEvents transform's JSON form, by reading) writes them. The default `JsonMessageMapper<>` drops both the context-bag and the `Publication` properties | `CloudEventJsonMessageMapper.cs:71`, `:80`; `CloudEventsTransformer.cs:293`; run, both sources, both mappers | `DispatchingARequest.md`, `UsingTheContextBag.md`, `CommandProcessorConfigurationReference.md` | `grep -rn -i 'CloudEventsAdditionalProperties\|extension propert' contents/` | **3** claims | **0**: each names the mapper that writes them | 5.4, running the `PublishAsync` repair's context | +| A heading with a stray `c` and an unclosed code span, `**c` + backtick + `SendAsync…` | — | `FAQ.md` | read, beside a `PublishAsync` hit | **1** | **0** | 5.4, the sweep | ## Friction ledger From f4cfd88a62a1272e49db29936a81f3f98cc946ee Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:56:07 +0100 Subject: [PATCH 20/27] =?UTF-8?q?docs:=20017=20task=205.5=20=E2=80=94=20th?= =?UTF-8?q?e=20E4=20attribute=20mismatches,=20and=20what=20stood=20beside?= =?UTF-8?q?=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five sync attributes on async handlers, each accepted by the compiler and each a ConfigurationException when the pipeline is built, now the async form: HowServiceActivatorWorks.md (UseInboxAsync), PipelineValidation.md x2, PolicyRetryAndCircuitBreaker.md, ReactorAndProactor.md and V10MigrationGuide.md (UseResiliencePipelineAsync). attr_mismatch.py 7 -> 1: the deliberate Before (error) example, now wrong in prose as well. PipelineValidation.md: the step-order examples also named an argument before a positional one, and the Before comments called step 0 inner, against the page's own rule. The published After (fixed) reported an error under ValidatePipelines; run, it now reports none, the Before one warning. The example warning message now spells attribute names as the validator does. V10MigrationGuide.md: the pipeline registry went to the obsolete PolicyRegistry (a type mismatch) and was built with a generic builder Brighter never reads; now ResiliencePipelineRegistry, from AddBrighterDefault (without it, ConfigurationException, run). Section 5 listed IRequestContext members 10.7.0 does not have (PartitionKey, CustomHeaders, Guid Id, ISpan) and omitted five it does; now as 10.7.0, with partition key and headers set through the Bag, run with a control. IRequestContext.InstrumentationOptions said "added in 10.7.0"; it is after 10.7.0, and now marked Not in a released package yet. ReactorAndProactor.md: said mappers have no async variants and advised Task.Run wrappers. A Proactor maps with IAmAMessageMapperAsync, and a pump given only the other kind of mapper uses the default one silently, run both ways with controls. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/HowServiceActivatorWorks.md | 16 ++- contents/PipelineValidation.md | 44 +++++-- contents/PolicyRetryAndCircuitBreaker.md | 2 +- contents/ReactorAndProactor.md | 32 +++-- contents/V10MigrationGuide.md | 148 +++++++++++++++++------ 5 files changed, 179 insertions(+), 63 deletions(-) diff --git a/contents/HowServiceActivatorWorks.md b/contents/HowServiceActivatorWorks.md index eefd8f3..0bd8a3e 100644 --- a/contents/HowServiceActivatorWorks.md +++ b/contents/HowServiceActivatorWorks.md @@ -363,11 +363,19 @@ deadLetterChannelName: new ChannelName("my.channel.dlq") Messages may be delivered more than once. Use the Inbox pattern: ```csharp -[UseInbox(step: 0, contextKey: typeof(MyCommand), onceOnly: true)] -public override async Task HandleAsync(MyCommand command, CancellationToken ct) +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Inbox.Attributes; + +public class MyCommandHandler : RequestHandlerAsync { - // Your idempotent logic here - return await base.HandleAsync(command, ct); + [UseInboxAsync(step: 0, contextKey: typeof(MyCommand), onceOnly: true)] + public override async Task HandleAsync(MyCommand command, CancellationToken ct = default) + { + // Your idempotent logic here + return await base.HandleAsync(command, ct); + } } ``` diff --git a/contents/PipelineValidation.md b/contents/PipelineValidation.md index 9e5802e..c3d0353 100644 --- a/contents/PipelineValidation.md +++ b/contents/PipelineValidation.md @@ -58,7 +58,7 @@ public handler types. Make the class public so the pipeline builder can find it Async handler uses sync attribute 'RejectMessageOnErrorAttribute' at step 0 — this will throw a ConfigurationException at pipeline build time -'RejectMessageOnError' at step 5 is after 'UseResiliencePipeline' at step 3 — +'RejectMessageOnErrorAttribute' at step 5 is after 'UseResiliencePipelineAttribute' at step 3 — in Brighter, lower step values are outer wrappers, so the backstop will never execute on failure @@ -240,7 +240,7 @@ When `AddConsumers()` is used, validation is deferred to the `ServiceActivatorHo ### Async Handler with Sync Attributes -An async handler must use async versions of pipeline attributes. +An async handler must use async versions of pipeline attributes. The example below is wrong: `RejectMessageOnError` is the sync attribute, and the compiler accepts it on `HandleAsync` all the same. `ValidatePipelines()` reports it as an error at startup; without validation, the pipeline throws `ConfigurationException` the first time a request reaches the handler. **Before** (error): @@ -277,17 +277,45 @@ The backstop attribute should have a lower step number than the resilience pipel **Before** (warning): ```csharp -[UseResiliencePipeline(step: 0, "RetryPipeline")] // runs first (inner) -[RejectMessageOnErrorAsync(step: 1)] // runs second (outer) — too late! -public override async Task HandleAsync(OrderCreated command, ...) +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; +using Paramore.Brighter.Reject.Attributes; + +public class OrderHandler : RequestHandlerAsync +{ + [UseResiliencePipelineAsync("RetryPipeline", step: 0)] // runs first (outer) + [RejectMessageOnErrorAsync(step: 1)] // runs second (inner) — too late! + public override async Task HandleAsync(OrderCreated command, + CancellationToken cancellationToken = default) + { + // ... + return await base.HandleAsync(command, cancellationToken); + } +} ``` **After** (fixed): ```csharp -[RejectMessageOnErrorAsync(step: 0)] // runs first (outermost) -[UseResiliencePipeline(step: 1, "RetryPipeline")] // runs second (inner) -public override async Task HandleAsync(OrderCreated command, ...) +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; +using Paramore.Brighter.Reject.Attributes; + +public class OrderHandler : RequestHandlerAsync +{ + [RejectMessageOnErrorAsync(step: 0)] // runs first (outermost) + [UseResiliencePipelineAsync("RetryPipeline", step: 1)] // runs second (inner) + public override async Task HandleAsync(OrderCreated command, + CancellationToken cancellationToken = default) + { + // ... + return await base.HandleAsync(command, cancellationToken); + } +} ``` In Brighter, lower step numbers are outer wrappers. The backstop needs to be outermost so it catches exceptions from the resilience pipeline and any handlers inside it. diff --git a/contents/PolicyRetryAndCircuitBreaker.md b/contents/PolicyRetryAndCircuitBreaker.md index f56e488..2803b9e 100644 --- a/contents/PolicyRetryAndCircuitBreaker.md +++ b/contents/PolicyRetryAndCircuitBreaker.md @@ -356,7 +356,7 @@ using Paramore.Brighter.Policies.Attributes; public class MyCancellableHandler : RequestHandlerAsync { - [UseResiliencePipeline("MyRetryPipeline", step: 1)] + [UseResiliencePipelineAsync("MyRetryPipeline", step: 1)] public override async Task HandleAsync( MyCommand command, CancellationToken cancellationToken = default) diff --git a/contents/ReactorAndProactor.md b/contents/ReactorAndProactor.md index cd96d6b..e16f7f4 100644 --- a/contents/ReactorAndProactor.md +++ b/contents/ReactorAndProactor.md @@ -70,7 +70,7 @@ With a Proactor your handlers, mappers and middleware should be async. ## Handler and Mapper Requirements -**Critical:** Your choice of Reactor or Proactor determines which handler and mapper types you must use. Mixing sync and async implementations will cause runtime errors. +**Critical:** Your choice of Reactor or Proactor determines which handler and mapper types you must use. Mixing sync and async handlers or middleware will cause runtime errors; a mapper of the wrong kind is skipped without an error, as [Proactor Message Mappers](#proactor-message-mappers) shows. ### Reactor Pattern Requirements @@ -160,18 +160,22 @@ public class MyCommandHandlerAsync : RequestHandlerAsync ``` #### Proactor Message Mappers -Message mappers remain synchronous (they don't perform I/O), but the mapper is called from an async context: +Implement `IAmAMessageMapperAsync` (not `IAmAMessageMapper`), with asynchronous `MapToMessageAsync` and `MapToRequestAsync` methods: ```csharp using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Paramore.Brighter; -public class MyCommandMessageMapper : IAmAMessageMapper +public class MyCommandMessageMapperAsync : IAmAMessageMapperAsync { public IRequestContext? Context { get; set; } - // Same synchronous implementation as Reactor - public Message MapToMessage(MyCommand request, Publication publication) + public Task MapToMessageAsync( + MyCommand request, + Publication publication, + CancellationToken cancellationToken = default) { var header = new MessageHeader( messageId: request.Id, @@ -179,25 +183,33 @@ public class MyCommandMessageMapper : IAmAMessageMapper messageType: MessageType.MT_COMMAND ); var body = new MessageBody(JsonSerializer.Serialize(request)); - return new Message(header, body); + return Task.FromResult(new Message(header, body)); } - public MyCommand MapToRequest(Message message) + public Task MapToRequestAsync( + Message message, + CancellationToken cancellationToken = default) { - return JsonSerializer.Deserialize(message.Body.Value); + return Task.FromResult(JsonSerializer.Deserialize(message.Body.Value)!); } } ``` -**Note:** Message mappers don't have async variants because they typically don't perform I/O operations—they just transform data structures. If your mapper needs to perform async I/O (e.g., reading from a claim check store), use a custom mapper with synchronous wrapper methods that call `Task.Run()` or similar. +**A Proactor does not fall back to your synchronous mapper.** If a request type has only an `IAmAMessageMapper`, a Proactor maps it with the default asynchronous mapper instead, and nothing reports the substitution: your mapper never runs. The reverse holds too: a Reactor maps a type that has only an `IAmAMessageMapperAsync` with the default synchronous mapper. If a request type is consumed by both kinds of pump, give it both mappers. #### Proactor Middleware/Attributes Use asynchronous handler attributes and middleware: ```csharp +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Logging.Attributes; +using Paramore.Brighter.Policies.Attributes; + public class MyCommandHandlerAsync : RequestHandlerAsync { - [UseResiliencePipeline("RetryPipeline", step: 1)] // Async resilience pipeline + [UseResiliencePipelineAsync("RetryPipeline", step: 1)] // Async resilience pipeline [RequestLoggingAsync(step: 0, timing: HandlerTiming.Before)] // Async logging public override async Task HandleAsync( MyCommand command, diff --git a/contents/V10MigrationGuide.md b/contents/V10MigrationGuide.md index dd33b04..839cb2d 100644 --- a/contents/V10MigrationGuide.md +++ b/contents/V10MigrationGuide.md @@ -248,7 +248,7 @@ var subscription = new Subscription( ### 4. Polly Resilience Pipeline -**Breaking Change**: `TimeoutPolicyAttribute` is obsolete. Use `UseResiliencePipeline` attribute. +**Breaking Change**: `TimeoutPolicyAttribute` is obsolete. Use `UseResiliencePipeline` on a synchronous handler and `UseResiliencePipelineAsync` on an async one. **Before (V9)**: @@ -272,26 +272,41 @@ public class MyHandler : RequestHandlerAsync 1. **Define a Resilience Pipeline**: ```csharp -var resiliencePipelineRegistry = new ResiliencePipelineRegistry(); -resiliencePipelineRegistry.TryAddBuilder>( +using System; +using Paramore.Brighter.Extensions; +using Polly; +using Polly.Registry; +using Polly.Retry; + +var resiliencePipelineRegistry = new ResiliencePipelineRegistry() + .AddBrighterDefault(); + +resiliencePipelineRegistry.TryAddBuilder( "MyPipeline", - (builder, context) => - { - builder.AddTimeout(TimeSpan.FromSeconds(5)); - builder.AddRetry(new RetryStrategyOptions + (builder, _) => builder + .AddTimeout(TimeSpan.FromSeconds(5)) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(100) - }); - }); + })); ``` +`AddBrighterDefault` adds the pipelines Brighter itself requires. Assigning your own registry +means Brighter does not add them for you, and without them the command processor throws +`ConfigurationException`, so start from it. + 2. **Use the new attribute**: ```csharp +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Policies.Attributes; + public class MyHandler : RequestHandlerAsync { - [UseResiliencePipeline(policy: "MyPipeline", step: 1)] + [UseResiliencePipelineAsync(policy: "MyPipeline", step: 1)] public override async Task HandleAsync( MyCommand command, CancellationToken cancellationToken = default) @@ -305,71 +320,121 @@ public class MyHandler : RequestHandlerAsync 3. **Register the pipeline** with Brighter: ```csharp +using Paramore.Brighter.Extensions.DependencyInjection; + services.AddBrighter(options => { - options.PolicyRegistry = resiliencePipelineRegistry; + options.ResiliencePipelineRegistry = resiliencePipelineRegistry; }); ``` +`PolicyRegistry` is the obsolete Polly v7 `IPolicyRegistry`, not this registry. + **See also**: [Policy Retry and Circuit Breaker Documentation](/contents/PolicyRetryAndCircuitBreaker.md) ### 5. Request Context Interface Changes -**Breaking Change**: `IRequestContext` interface has new properties. +**Breaking Change**: `IRequestContext` has changed shape. + +**Changed Properties**: -**New Properties**: +- `Bag` is a `ConcurrentDictionary`; in V9 it was a `Dictionary` +- `Policies`, the Polly v7 registry, is obsolete; use `ResiliencePipeline` +- `Policies` and `FeatureSwitches` are nullable -- `PartitionKey`: Set message partition keys dynamically -- `CustomHeaders`: Add custom headers via request context -- `ResilienceContext`: Integration with Polly resilience pipeline -- `OriginatingMessage`: Access the original message (for consumers) +**New Members**: + +- `Destination`: route a request through a particular producer ([Destination Override](/contents/UsingTheContextBag.md#destination-override)) +- `OriginatingMessage`: the message a consumer received ([Originating Message](/contents/UsingTheContextBag.md#originating-message)) +- `ResiliencePipeline`: the Polly v8 `ResiliencePipelineRegistry` ([Resilience Pipeline Registry](/contents/UsingTheContextBag.md#resilience-pipeline-registry)) +- `ResilienceContext`: Polly's context for the current execution ([Resilience Context](/contents/UsingTheContextBag.md#resilience-context)) +- `Span`: the current OpenTelemetry `Activity` ([OpenTelemetry Span](/contents/UsingTheContextBag.md#opentelemetry-span)) +- `CreateCopy()`: a copy of the context for a new pipeline **Migration**: Most code should not be affected unless you implement `IRequestContext` directly. **If you implement `IRequestContext`** (rare): ```csharp +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using Paramore.Brighter; +using Paramore.Brighter.FeatureSwitch; +using Polly; +using Polly.Registry; + public class MyRequestContext : IRequestContext { - public Guid Id { get; set; } - public ISpan Span { get; set; } - public Dictionary Bag { get; set; } + // V10: a ConcurrentDictionary, where V9 had a Dictionary + public ConcurrentDictionary Bag { get; } = new(); + public IAmAFeatureSwitchRegistry? FeatureSwitches { get; set; } - // V10: Add new properties - public string? PartitionKey { get; set; } - public Dictionary CustomHeaders { get; set; } = new(); - public ResilienceContext? ResilienceContext { get; set; } + [Obsolete("Migrate to ResiliencePipeline")] + public IPolicyRegistry? Policies { get; set; } + + // V10: new members + public ProducerKey? Destination { get; set; } public Message? OriginatingMessage { get; set; } + public ResiliencePipelineRegistry? ResiliencePipeline { get; set; } + public ResilienceContext? ResilienceContext { get; set; } + public Activity? Span { get; set; } + + public IRequestContext CreateCopy() + { + var copy = new MyRequestContext + { + FeatureSwitches = FeatureSwitches, + ResiliencePipeline = ResiliencePipeline, + OriginatingMessage = OriginatingMessage, + Span = Span + }; + foreach (var item in Bag) copy.Bag[item.Key] = item.Value; + return copy; + } } ``` -**Using new properties**: +**Partition keys and custom headers are not properties.** `IRequestContext` has no +`PartitionKey` and no `CustomHeaders`. Put them in the `Bag` under the well-known keys +`RequestContextBagNames.PartitionKey` and `RequestContextBagNames.Headers`, and pass the +context with the request. Brighter's default message mappers read both: ```csharp -public class MyHandler : RequestHandlerAsync +using System.Collections.Generic; +using System.Threading.Tasks; +using Paramore.Brighter; + +public class OrderService(IAmACommandProcessor commandProcessor) { - public override async Task HandleAsync( - MyCommand command, - CancellationToken cancellationToken = default) + public async Task PlaceOrderAsync(OrderPlaced orderPlaced) { + var context = new RequestContext(); + // Set partition key for message routing - Context.PartitionKey = command.TenantId; + context.Bag[RequestContextBagNames.PartitionKey] = orderPlaced.TenantId; // Add custom headers - Context.CustomHeaders["X-Correlation-Id"] = command.CorrelationId; - - // Access originating message (for consumers) - if (Context.OriginatingMessage != null) + context.Bag[RequestContextBagNames.Headers] = new Dictionary { - var receivedTimestamp = Context.OriginatingMessage.Header.TimeStamp; - } + ["x-tenant-id"] = orderPlaced.TenantId + }; - return await base.HandleAsync(command, cancellationToken); + await commandProcessor.PostAsync(orderPlaced, requestContext: context); } } ``` -#### `InstrumentationOptions` (added in 10.7.0) +A custom mapper reads them only if it asks: `Context.GetPartitionKey()` and `Context.GetHeaders()`, +in `Paramore.Brighter.Extensions`. See [Partition Key](/contents/UsingTheContextBag.md#partition-key) +and [Custom Headers](/contents/UsingTheContextBag.md#custom-headers). + +#### `InstrumentationOptions` (after 10.7.0) + +> **Not in a released package yet.** `IRequestContext.InstrumentationOptions` ships **after +> Brighter 10.7.0**, which is the current release. It is on Brighter's development branch and in +> no version you can install today, so an implementation built against 10.7.0 compiles without +> it; treat what follows as the change as it will ship. **Breaking Change**: `IRequestContext` gains a required `InstrumentationOptions` member. @@ -384,16 +449,19 @@ the member is plain and abstract: any third-party or test type implementing the fails to compile until it adds one line. ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Observability; + public class MyRequestContext : IRequestContext { // ... other members - // 10.7.0: add this + // After 10.7.0: add this public InstrumentationOptions InstrumentationOptions { get; set; } = InstrumentationOptions.All; } ``` -The shipped `RequestContext` already implements it, defaulting to +The shipped `RequestContext` implements it on the development branch, defaulting to `InstrumentationOptions.All`, so code that uses the shipped context needs no change. ### 6. Request Id and CorrelationId type change From 5c681814b26d37eb7dbb2d38f544ec598e9dd8a8 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:56:30 +0100 Subject: [PATCH 21/27] =?UTF-8?q?spec:=20017=20task=205.5=20=E2=80=94=20ba?= =?UTF-8?q?seline=20rows=20for=20the=20blocks=20f4cfd88=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index e371b66..bd33876 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -318,3 +318,5 @@ contents/CQRSWithBrighterAndDarker.md 7 - b4c3ae0 contents/BrighterSchedulerSupport.md 1 - b4c3ae0 contents/DefaultMessageMappers.md 4 - b4c3ae0 contents/DefaultMessageMappers.md 5 - b4c3ae0 +contents/V10MigrationGuide.md 9 - f4cfd88 +contents/V10MigrationGuide.md 12 - f4cfd88 From ac44085b1bfb95acde8ad95d8ee432dd28e8d093 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 19:59:05 +0100 Subject: [PATCH 22/27] =?UTF-8?q?spec:=20017=20task=205.5=20=E2=80=94=20th?= =?UTF-8?q?e=20E4=20attribute=20mismatches,=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 97 ++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 4e80278..1bf1360 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1734,7 +1734,7 @@ everywhere. Page list: § *The tranches*, phase 5 table. block BUILT or showing `Order`; both in § *Defect ledger* - Notes: a sentence claiming how the scheduler uses time is run, with a control (P0-10). -- [ ] **Task 5.5:** Repair the E4 attribute mismatches not already repaired +- [x] **Task 5.5:** Repair the E4 attribute mismatches not already repaired - Input: `probe/attr_mismatch.py` output now; `design.md` § *E4* (verdict per hit) and § *API Resolved* - Output: `attr_mismatch.py > out; echo $?` → **1** with exactly `PipelineValidation.md:250` in @@ -2249,6 +2249,92 @@ BUILT **44 → 42**, by the requirements' `awk` and a Python join agreeing: `Bri `DynamicMessageDeserialization.md`, `FAQ.md`, `UsingTheContextBag.md` and `V10MigrationGuide.md`, by ruling or recurrence +**Task 5.5 — the E4 attribute mismatches, and what stood beside them.** **`attr_mismatch.py` 7 → 1, +exit 1**, the one hit `PipelineValidation.md:250`, the deliberate *Before (error)* example, on its +line; `--plant` → **OK**, exit 0. Six sync attributes on `HandleAsync` became the async form: +`HowServiceActivatorWorks.md` #16 (`UseInboxAsync`), `PipelineValidation.md` #9 and #10, +`PolicyRetryAndCircuitBreaker.md` #14, `ReactorAndProactor.md` #6 and `V10MigrationGuide.md` #10 +(`UseResiliencePipelineAsync`). **Second method:** a Python scan for any of the paired names without +`Async` whose next non-attribute line is `HandleAsync` reads **8 → 2**. The two left are `:250` and +`V10MigrationGuide.md` #8's `[TimeoutPolicy]`, the skipped *Before (V9)* form, whose attribute has +no pair at 10.7.0 and which `attr_mismatch.py` does not read. **BUILT 293 → 295** (+2), both +`FAILED -> BUILT` and nothing else moved in the AC2 diff against `608615a`'s report: +`V10MigrationGuide.md` #9 (the registry) and #12 (an `IRequestContext` implementation). **989 blocks** +on both sides, no fence added or removed. `pagelint` **553 → 543** (−10), per page against a worktree +at `608615a`: `V10MigrationGuide.md` −6 (#9–#14), `PipelineValidation.md` −2 (#9, #10), +`HowServiceActivatorWorks.md` −1 (#16), `ReactorAndProactor.md` −1 (#6; #5 already carried its +`using`s). Pages with nothing BUILT **42 → 41**, `V10MigrationGuide.md` leaving: pages with a FAILED block and +no BUILT one, by `comm` over the two `awk` lists and by a Python join, agreeing. A count of every page +without a BUILT block reads 43 → 42, because it also takes in a page whose blocks are all SKIPPED. +Repair `f4cfd88`; baseline `5c68181`. + +- **`:250` says it is wrong in prose.** The sentence under *Async Handler with Sync Attributes* now + says the example is wrong, that the compiler accepts it, that `ValidatePipelines()` reports it as an + error, and that without validation the pipeline throws `ConfigurationException` on the first request +- **`PipelineValidation.md` #9, #10 kept what they demonstrate.** The attribute's kind changed, and + `(step: 0, "RetryPipeline")` — a named argument ahead of a positional one, out of position — became + `("RetryPipeline", step: 0)`. #9's comments called step 0 *inner* and step 1 *outer*, against the + page's own *"lower step numbers are outer wrappers"* and the validator's text; now *outer* and + *inner*. Both fragments are now whole handlers with `using`s, as #7 and #8 are, so `pagelint + --changed` can read them. The example warning message spelt the attribute names without the + `Attribute` suffix the validator prints (`AttributeType.Name`, captured below) +- **`V10MigrationGuide.md` § 4 had two defects beside its attribute.** #9 built the pipeline with + `TryAddBuilder>`, a generic builder whose pipeline Brighter + never looks up, and #11 assigned the registry to `PolicyRegistry`, the obsolete Polly v7 + `IPolicyRegistry` (a type mismatch). Now `AddBrighterDefault()` then `TryAddBuilder(name, + …)`, as `PolicyRetryAndCircuitBreaker.md` shows, into `ResiliencePipelineRegistry`. The sentence + under #9 names what happens without `AddBrighterDefault`, run below +- **`V10MigrationGuide.md` § 5, the open `IRequestContext` row, closed.** The section listed + `PartitionKey` and `CustomHeaders` as new properties and #12 implemented `Guid Id`, `ISpan Span` + and a `Dictionary` `Bag`; 10.7.0 has none of those shapes (`git show 10.7.0:src/Paramore.Brighter/IRequestContext.cs`) + and has five members the section omitted: `Destination`, `ResiliencePipeline`, `Span` as an + `Activity`, `FeatureSwitches`, `CreateCopy()`. The section now lists what changed against V9 + (`9.9.13`'s interface: `Bag`, `Policies`, `FeatureSwitches`) and links each new member to + `UsingTheContextBag.md`. #12 implements 10.7.0's interface and is BUILT; its `CreateCopy` copies + what the shipped `RequestContext.CreateCopy` copies. #13 was a handler setting properties that do + not exist; it is now a service that puts the partition key and a header in the `Bag` under + `RequestContextBagNames` and passes the context to `PostAsync`, run below +- **`IRequestContext.InstrumentationOptions` said *"added in 10.7.0"*.** It is not in the tag + (`grep -c Instrumentation` → 0) and not on NuGet past 10.7.0; it is in `release_notes.md`'s + *Master* section, added by `0950f2864`. **Forthcoming, not dead**, so by the 2026-09-05 ruling it + stays, marked: the heading reads *(after 10.7.0)* and the section carries `> **Not in a released + package yet.**`, worded as the Replay On Seen pages word theirs. #14 gained its two `using`s; + `--explain` found `InstrumentationOptions` needs `Paramore.Brighter.Observability` +- **`ReactorAndProactor.md` said mappers have no async variants.** *"Message mappers remain + synchronous"* under *Proactor Message Mappers*, and a note advising `Task.Run()` wrappers for async + I/O. At 10.7.0 the Proactor takes `IAmAMessageMapperRegistryAsync` (`Proactor.cs:63`) and the + Reactor `IAmAMessageMapperRegistry` (`Reactor.cs:63`). #5 is now an `IAmAMessageMapperAsync`, + and the section says what the run showed: a pump given only the other kind of mapper uses the + default one, silently. The page's own `:43` already said mappers should be async under a + Proactor; `:73`, *"Mixing sync and async implementations will cause runtime errors"*, now + excepts mappers and links the section. Found reading around the E4 hit at `:200` +- **`--explain` on every block the diff touches:** **13**, 2 BUILT. The 11 FAILED name only page + types and values: `MyCommand`, `OrderCreated`, `OrderPlaced`, `SomeAsyncOperation`, `services`, + `resiliencePipelineRegistry`; #14 adds the `CS0535`s its `// ... other members` declares. `pagelint + --changed origin/master` → **0** errors +- **Behaviour, run with controls**, against the released 10.7.0 packages on net10.0 (`valrun`, + `pumprun`, `regrun`, `ctxrun` under the session scratchpad): + + | Claim | Case → result | Control → result | + |---|---|---| + | `PipelineValidation.md` #7 is an error | `Validate()` → 1 error, *"Async handler uses sync attribute 'RejectMessageOnErrorAttribute' at step 0"*; without validation, `PublishAsync` → `ConfigurationException`, *"All handlers in an async pipeline must derive from IHandleRequestsAsync"* | #8 → 0 errors, 0 warnings; publishes | + | #9 is a warning, #10 is clean | repaired #9 → 0 errors, **1** warning, *"'RejectMessageOnErrorAsyncAttribute' at step 1 is after 'UseResiliencePipelineAsyncAttribute' at step 0"*; repaired #10 → 0, 0 | as published, #9 → 1 error **and** the warning; #10, the *After (fixed)*, → **1 error** | + | A pump uses only its own kind of mapper | Proactor, sync mapper only → **the default mapper** mapped it; Reactor, async mapper only → **the default mapper** | Proactor, async mapper → the custom async mapper; Reactor, sync mapper → the custom sync mapper. `AddConsumers` + `AutoFromAssemblies` + `InMemoryChannelFactory`, as `InMemoryTransport.md` configures it | + | `V10MigrationGuide.md` #9: start from `AddBrighterDefault` | own registry without it → `ConfigurationException`, *"missing the CommandProcessor.OutboxProducer resilience pipeline"* | with it → the `[UseResiliencePipelineAsync]` handler runs | + | #13: the `Bag` keys reach the message | #13 verbatim through `PostAsync` → `PartitionKey` `tenant-42`, header `x-tenant-id` `tenant-42` | `PostAsync` with no context → `PartitionKey` empty, header absent | + +- **`--report`** before the baseline rows → exit 1, 2 findings, both *"BUILT, not in the baseline"*; + after → exit **0**, *"989 blocks: 295 BUILT, 677 FAILED, 17 SKIPPED"*, baseline 295, 45 units, + 0 findings. `linkcheck` 165, 0 broken; `versioncheck` 0 of 18; `symbolcheck` 0, 22 entries, + 3 silenced, `--verify-list` clean; `optioncheck` 0, 59 tables, 519 rows; shape, redirects, + `--verify` 161 / 77 / 161. **Pages changed: 5** (`git diff --name-only 608615a..HEAD -- + contents`), the five E4 pages +- **Not repaired, found on the way, for the maintainer:** `PipelineValidation.md`'s Replay rule row + and its two Replay example messages describe a forthcoming feature without the *Not in a released + package yet* marker; `MessageMappers.md`, the mapper's home page, never mentions + `IAmAMessageMapperAsync`; and `UsingTheContextBag.md`'s handler examples set `Context.Bag` + keys without posting, which is the claim #13 above replaced rather than a run of it + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2631,7 +2717,7 @@ BUILT, re-admitted at `ec38400`. | `public Guid Id { get; set; }` on a request — hides `IRequest.Id`, which is an `Id` | `IRequest.cs:47` | `ImplementingAsyncHandler.md` | `grep -rn 'public Guid Id\b' contents/` | **2** | **1** — the other is not this defect (next row) | 2.3, reading | | `app.UseEndpoints(...)` on a `WebApplication` with no `app.UseRouting()` — throws `InvalidOperationException` at startup | run against 10.7.0 packages, net10.0; the control is the page's old block | `HealthChecks.md`, `BrighterControlAPI.md` | `grep -rn 'UseEndpoints' contents/` | **2** | **0** | 2.4, running the block | | BRT006–BRT008 (Kafka partitioner analyzers, code fixes) documented as shipped — in no release; only on BrighterCommand/Brighter#4255, open. Also *"includes code fixes"*: 10.7.0 has no code-fix project | `git ls-tree 10.7.0 src/Paramore.Brighter.Analyzer/Analyzers/` → 3 analyzers, BRT001–005 | `AnalyzerSupport.md` | `grep -rln 'BRT00[678]\|code fix' contents/` | **1** | **0** — removed, maintainer's ruling (a) | 2.4, verifying the page's reference code | -| `IRequestContext` implemented with `Guid Id`, `ISpan Span`, `Dictionary Bag`, `CustomHeaders` — at 10.7.0 the interface has no `Id` and no `CustomHeaders`, `Span` is an `Activity`, `Bag` a `ConcurrentDictionary` | `IRequestContext.cs` | `V10MigrationGuide.md:320` | — | **1** | **open — phase 5**, which holds that page for its E4 repair | 2.3, the row above's grep | +| `IRequestContext` implemented with `Guid Id`, `ISpan Span`, `Dictionary Bag`, `CustomHeaders` — at 10.7.0 the interface has no `Id` and no `CustomHeaders`, `Span` is an `Activity`, `Bag` a `ConcurrentDictionary`; the section also listed `PartitionKey` and `CustomHeaders` as new properties and set both on `Context` | `IRequestContext.cs` | `V10MigrationGuide.md` § 5, #12, #13 | `grep -rnE 'Context\??\.(PartitionKey\|CustomHeaders)\b\|`CustomHeaders`\|ISpan Span\|Guid Id \{ get; set; \}' contents/` → 6 at `608615a`, 2 now, both read and right (the corrective sentence; an entity's key) | **5** | **0** | 2.3, the row above's grep; closed in 5.5 | | `MapToMessage(TRequest request)` — V9's mapper signature; V10's takes a `Publication` | `IAmAMessageMapper.cs:33` | `Compression.md`, `Routing.md`, `KafkaConfiguration.md`, `MessageTransforms.md`, `ImplementingExternalBus.md`, `V10MigrationGuide.md`, `MessageMappers.md`, `OutboxArchiver.md`, `NullableReferenceTypes.md` (`string? topic = null`, found by the second method) | `grep -rnE 'MapToMessage\([A-Za-z<>]+ [a-z][A-Za-z]*(, string\? topic = null)?\)' contents/` | **14** lines, 10 pages | **2** — both skipped V9 forms | 2.5, `--explain` | | A mapper class without `IRequestContext? Context { get; set; }` — `CS0535` | `IAmAMessageMapper.cs:31` | 11 pages | mapper blocks declaring `: IAmAMessageMapper<` with no `IRequestContext? Context` | **17** | **1** — the skipped V9 form | 2.5, `--explain` after the signature fix | | `requeueCount: N` described as N requeues (*"Times a message is requeued"*, *"On the 4th failure"*) — it is N handlings, N−1 requeues; `0` behaves as `1` | `Message.cs:161`, `HandledCount >= requeueCount`; run, table in § *Phase 2 as executed* | 17 pages, including all 12 option tables | `grep -rnE 'requeued before it is treated\|exceed(s\|ed\|ing)? the requeue count\|Requeue up to 3\|Retry up to 3\|4th failure\|RequeueCount. is exceeded\|retries (remain\|exhausted)\|retry a message before\|number of requeue attempts\|requeue count exceeded\)\|When the count is exceeded' contents/` | **25** | **0** | 2.5, running | @@ -2722,6 +2808,13 @@ BUILT, re-admitted at `ec38400`. | `PublishAsync` relied on to reach a mapper or the bus: the default mapper said to serialise a published event, CloudEvents extensions set for a publish, a test asserting that a published event lands on the `InternalBus`, and dummy events published to warm a mapper cache. `PublishAsync` dispatches to handlers in this process | run: `PublishAsync` → **0** messages, the local handler runs; `PostAsync` → **1** (sessions 101, 102 and 5.4) | `DefaultMessageMappers.md` #2, `DispatchingARequest.md` #3, `V10MigrationGuide.md` #26, `DynamicMessageDeserialization.md` (old #7, removed) | `calls.py` over every `Publish`/`PublishAsync` call; a prose grep tying either to a bus, broker, queue, topic, transport or the wire | **13** calls, **6** of them this defect | **7** calls, **0** of them: 2 declarations, 2 in-process read-model events, a scheduled local event, 2 test-double verifications | 5.3 (put to the maintainer); swept 5.4, by ruling | | CloudEvents extension properties said to reach the message whatever the mapper. Only `CloudEventJsonMessageMapper<>` (and the CloudEvents transform's JSON form, by reading) writes them. The default `JsonMessageMapper<>` drops both the context-bag and the `Publication` properties | `CloudEventJsonMessageMapper.cs:71`, `:80`; `CloudEventsTransformer.cs:293`; run, both sources, both mappers | `DispatchingARequest.md`, `UsingTheContextBag.md`, `CommandProcessorConfigurationReference.md` | `grep -rn -i 'CloudEventsAdditionalProperties\|extension propert' contents/` | **3** claims | **0**: each names the mapper that writes them | 5.4, running the `PublishAsync` repair's context | | A heading with a stray `c` and an unclosed code span, `**c` + backtick + `SendAsync…` | — | `FAQ.md` | read, beside a `PublishAsync` hit | **1** | **0** | 5.4, the sweep | +| A sync handler attribute on `HandleAsync` (E4). The compiler accepts it; the pipeline throws `ConfigurationException` when built, and `ValidatePipelines()` reports it | `PipelineBuilder.cs:431`; `HandlerPipelineValidationRules.cs:110`; run | `HowServiceActivatorWorks.md` #16, `PipelineValidation.md` #9, #10, `PolicyRetryAndCircuitBreaker.md` #14, `ReactorAndProactor.md` #6, `V10MigrationGuide.md` #10 | `attr_mismatch.py`; a Python scan, paired name without `Async` then `HandleAsync` → 8, 2 now (below) | **6** | **0**; `PipelineValidation.md:250` deliberate, now wrong in prose; `V10MigrationGuide.md` #8, the skipped V9 form | design E4; 5.5 | +| A named argument ahead of a positional one, out of position: `[UseResiliencePipeline(step: 0, "RetryPipeline")]` | `UseResiliencePipelineAttribute(string policy, int step)` | `PipelineValidation.md` #9, #10 | `grep -rnE '\[\w+\(\w+: *[^,()]+, *"' contents/` | **2** | **0** | 5.1, carried | +| *Before (warning)*'s comments called step 0 *inner* and step 1 *outer*; lower steps are outer | `HandlerPipelineValidationRules.cs:76`, `:57` | `PipelineValidation.md` #9 | `grep -rnE 'step: 0.*\(inner\)' contents/` | **1** | **0** | 5.5, reading `:280` | +| The example backstop warning named `'RejectMessageOnError'` and `'UseResiliencePipeline'`; the validator prints `AttributeType.Name` | `HandlerPipelineValidationRules.cs:74`; run | `PipelineValidation.md:61` | `grep -rnE "'(RejectMessageOnError\|UseResiliencePipeline)' at step" contents/` | **1** | **0** | 5.5, the run | +| A resilience registry assigned to `PolicyRegistry`, the obsolete Polly v7 `IPolicyRegistry`; and built with `TryAddBuilder>`, a typed builder Brighter never looks up | `BrighterOptions.cs:56`, `:59` | `V10MigrationGuide.md` #9, #11 | `grep -rnE 'PolicyRegistry *= *\w*[Rr]esilience' contents/`; `grep -rn 'TryAddBuilder<' contents/` | **2** | **0** | 5.5, `--explain` of § 4 | +| `IRequestContext.InstrumentationOptions` *"added in 10.7.0"*: it is after 10.7.0, forthcoming, and was unmarked | `IRequestContext.cs` at 10.7.0 (0 hits); `release_notes.md` *Master*; NuGet's latest is 10.7.0 | `V10MigrationGuide.md` | `grep -rn 'added in 10\.7\.0' contents/`; `grep -rnE '(added\|new\|introduced\|since) (in )?(Brighter )?(V?10\.7(\.0)?)' contents/` → 0 more | **1** | **0**, marked *Not in a released package yet* | 5.5, reading § 5 | +| Mappers said to have no async variants, with `Task.Run()` wrappers advised; a Proactor maps with `IAmAMessageMapperAsync` and skips a sync-only mapper for the default, silently | `Proactor.cs:63`, `Reactor.cs:63`; run both ways | `ReactorAndProactor.md` #5 and its note | `grep -rniE "mappers? (don't\|do not\|never) have async\|mappers? remain synchronous\|mappers? (are\|stay) synchronous" contents/` | **2** lines | **0** | 5.5, reading around `:200` | ## Friction ledger From 6ddcf7bb18d5cc761e1e708c33d582a0682e652f Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 20:10:46 +0100 Subject: [PATCH 23/27] =?UTF-8?q?docs:=20017=20task=205.5,=20second=20pass?= =?UTF-8?q?=20=E2=80=94=20Replay=20marked,=20async=20mappers,=20the=20cont?= =?UTF-8?q?ext=20you=20post=20with?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By the maintainer's ruling, the three items 5.5 found and left. PipelineValidation.md: the Replay rule, its two example messages and the Replay Without Causation Tracking section describe a feature after 10.7.0 (no causation code in the tag); now marked Not in a released package yet. The deliberate :250 example keeps its line. MessageMappers.md: the mapper's home page never mentioned IAmAMessageMapperAsync. A new section shows one and says which path uses which kind: Post, DepositPost and a Reactor map with the sync mapper; PostAsync, DepositPostAsync and a Proactor with the async one; a type with only the other kind goes through the default mapper, silently. Run for all four calls. UsingTheContextBag.md: five examples set the partition key, headers or CloudEvents extensions on a handler's own Context and posted nothing, or passed them to SendAsync. Run: the keys act only on the context passed to the call that makes the message; a handler posting without one gets a fresh context, and a caller's context does not carry through. Each example now posts with the context it fills, and the page says why. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/MessageMappers.md | 32 ++++++++++++++++++ contents/PipelineValidation.md | 8 +++-- contents/UsingTheContextBag.md | 62 ++++++++++++++++++++++++++-------- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/contents/MessageMappers.md b/contents/MessageMappers.md index 043997b..2eca28e 100644 --- a/contents/MessageMappers.md +++ b/contents/MessageMappers.md @@ -57,6 +57,38 @@ public class GreetingMadeMessageMapper : IAmAMessageMapper } ``` +## Synchronous and Asynchronous Message Mappers + +**IAmAMessageMapper\** is the synchronous form. **IAmAMessageMapperAsync\** is the asynchronous one, with **MapToMessageAsync()** and **MapToRequestAsync()**, for a mapper that does I/O, such as calling a schema registry: + +```csharp +using System.Net.Mime; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +public class GreetingMadeMessageMapperAsync : IAmAMessageMapperAsync +{ + public IRequestContext? Context { get; set; } + + public Task MapToMessageAsync(GreetingMade request, Publication publication, + CancellationToken cancellationToken = default) + { + var header = new MessageHeader(messageId: request.Id, topic: new RoutingKey("GreetingMade"), messageType: MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request), new ContentType(MediaTypeNames.Application.Json), CharacterEncoding.UTF8); + return Task.FromResult(new Message(header, body)); + } + + public Task MapToRequestAsync(Message message, CancellationToken cancellationToken = default) + { + return Task.FromResult(JsonSerializer.Deserialize(message.Body.Value)!); + } +} +``` + +**Each path uses only its own kind of mapper.** `Post` and `DepositPost`, and a consumer on a `Reactor`, map with `IAmAMessageMapper`. `PostAsync` and `DepositPostAsync`, and a consumer on a `Proactor`, map with `IAmAMessageMapperAsync`. If a request type has a mapper only of the other kind, that path maps it with the [default message mapper](/contents/DefaultMessageMappers.md) instead, and nothing reports the substitution: your mapper never runs. If your code reaches a request type both ways, implement both interfaces, on one class or two. For choosing a pump, see [Proactor Message Mappers](/contents/ReactorAndProactor.md#proactor-message-mappers). + ## Brighter Message Structure Brighter divides a message into two parts: diff --git a/contents/PipelineValidation.md b/contents/PipelineValidation.md index c3d0353..86c6a22 100644 --- a/contents/PipelineValidation.md +++ b/contents/PipelineValidation.md @@ -47,9 +47,9 @@ These checks apply to all Brighter applications, including those that only use t | Handler type visibility | Error | Handler class must be `public`. Brighter only discovers public handler types — a non-public handler will silently not be found by the pipeline builder. | | Sync/async attribute consistency | Error | Async handlers (`IHandleRequestsAsync`) must use async attributes (e.g. `RejectMessageOnErrorAsyncAttribute`). Sync handlers must use sync attributes. A mismatch will throw a `ConfigurationException` at pipeline build time. | | Backstop attribute ordering | Warning | Backstop error-handling attributes (`RejectMessageOnError`, `DeferMessageOnError`, `DontAckOnError`) should be at the outermost position (lowest step number). If a backstop has a higher step number than a resilience pipeline attribute, it will never execute on failure. | -| Replay requires causation tracking | Error and Warning | A pipeline using `OnceOnlyAction.Replay` needs an Inbox and an Outbox that both implement the causation-tracking role interfaces *and* whose live schemas support it. A store that does not implement the interface is an Error; an un-migrated schema, a missing Outbox, or a probe that could not reach the store is a Warning. Only pipelines configured for `Replay` are checked. See [Replay On Seen](/contents/ReplayOnSeen.md). | +| Replay requires causation tracking | Error and Warning | **Not in a released package yet**: Replay On Seen, and this rule, ship after Brighter 10.7.0. A pipeline using `OnceOnlyAction.Replay` needs an Inbox and an Outbox that both implement the causation-tracking role interfaces *and* whose live schemas support it. A store that does not implement the interface is an Error; an un-migrated schema, a missing Outbox, or a probe that could not reach the store is a Warning. Only pipelines configured for `Replay` are checked. See [Replay On Seen](/contents/ReplayOnSeen.md). | -**Example error messages:** +**Example error messages** (the last two come from the Replay rule, which ships after Brighter 10.7.0; 10.7.0 does not report them): ```text Handler type 'MyNamespace.OrderHandler' is not public — Brighter only supports @@ -369,6 +369,10 @@ new RmqSubscription(...) ### Replay Without Causation Tracking +> **Not in a released package yet.** Replay On Seen ships **after Brighter 10.7.0**, which is +> the current release. `OnceOnlyAction.Replay` and the validation rule this section describes are +> on Brighter's development branch and are in no version you can install today. + A handler configured with `OnceOnlyAction.Replay` needs an Inbox and an Outbox that both track Causation Ids, *and* live schemas that can store them. Validation checks each store in turn. Only a store that does not implement the role interface is an Error — an un-migrated schema is a Warning, so the host starts cleanly and replay silently does nothing. The usual cause is provisioning one box and forgetting the other. diff --git a/contents/UsingTheContextBag.md b/contents/UsingTheContextBag.md index 86db2d6..f5b2520 100644 --- a/contents/UsingTheContextBag.md +++ b/contents/UsingTheContextBag.md @@ -49,10 +49,13 @@ Internally we use the **Context Bag** in a number of the Quality of Service supp You can set the **RequestContext** explicitly when calling `Send`, `Publish`, or `DepositPost` methods. This allows you to set properties of the **RequestContext** for transmission to the **RequestHandler** instead of having a new context created by the **RequestContextFactory** for that pipeline. ```csharp -public class OrderController : ControllerBase -{ - private readonly IAmACommandProcessor _commandProcessor; +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) { var context = new RequestContext(); @@ -66,8 +69,8 @@ public class OrderController : ControllerBase }; // Pass context explicitly - await _commandProcessor.SendAsync( - new CreateOrderCommand { OrderId = request.OrderId }, + await commandProcessor.PostAsync( + new OrderCreated { OrderId = request.OrderId }, requestContext: context ); @@ -76,6 +79,13 @@ public class OrderController : ControllerBase } ``` +**The partition key, headers and CloudEvents extensions act on the context you post with.** A +message mapper reads them when `Post`, `PostAsync`, `DepositPost` or `DepositPostAsync` turns the +request into a message, and it reads the context passed to that call. A context passed to `Send` +or `Publish` reaches the handlers, but a handler that then posts without passing a context gets a +new, empty one, so the keys do not carry through. Likewise, setting them on a handler's own +`Context` does nothing unless the handler passes that context to its post. + ### Partition Key The **PartitionKey** allows you to control message routing to specific partitions in messaging systems like Kafka, Azure Service Bus, or AWS Kinesis. This is useful for ensuring related messages are processed in order. @@ -83,12 +93,18 @@ The **PartitionKey** allows you to control message routing to specific partition **Setting Partition Key via Context Bag:** ```csharp -public class TenantAwareHandler : RequestHandler +using Paramore.Brighter; + +public class TenantAwareHandler(IAmACommandProcessor commandProcessor) : RequestHandler { public override MyCommand Handle(MyCommand command) { + var context = new RequestContext(); + // Set partition key for message routing - Context.Bag[RequestContextBagNames.PartitionKey] = command.TenantId; + context.Bag[RequestContextBagNames.PartitionKey] = command.TenantId; + + commandProcessor.Post(new TenantWorkDone(command.TenantId), requestContext: context); return base.Handle(command); } @@ -113,12 +129,21 @@ Context.Bag[RequestContextBagNames.PartitionKey] = new PartitionKey("customer-12 You can add custom headers to messages dynamically via the Request Context. These headers are merged with any static headers configured on the Publication. ```csharp -public class OrderProcessingHandler : RequestHandler +using System; +using System.Collections.Generic; +using Paramore.Brighter; + +public class OrderProcessingHandler(IAmACommandProcessor commandProcessor, IOrderService orderService) + : RequestHandler { public override ProcessOrderCommand Handle(ProcessOrderCommand command) { + // Process order... + orderService.Process(command); + // Add custom headers dynamically - Context.Bag[RequestContextBagNames.Headers] = new Dictionary + var context = new RequestContext(); + context.Bag[RequestContextBagNames.Headers] = new Dictionary { ["x-custom-header"] = "runtime-value", ["x-timestamp"] = DateTime.UtcNow, @@ -126,8 +151,7 @@ public class OrderProcessingHandler : RequestHandler ["x-processing-region"] = Environment.GetEnvironmentVariable("REGION") }; - // Process order... - _orderService.Process(command); + commandProcessor.Post(new OrderProcessed(command.OrderId), requestContext: context); return base.Handle(command); } @@ -145,18 +169,24 @@ public class OrderProcessingHandler : RequestHandler When using CloudEvents, you can add custom extension properties via the Request Context. ```csharp -public class EventPublishingHandler : RequestHandler +using System.Collections.Generic; +using Paramore.Brighter; + +public class EventPublishingHandler(IAmACommandProcessor commandProcessor) : RequestHandler { public override PublishEventCommand Handle(PublishEventCommand command) { // Add CloudEvents extension properties - Context.Bag[RequestContextBagNames.CloudEventsAdditionalProperties] = new Dictionary + var context = new RequestContext(); + context.Bag[RequestContextBagNames.CloudEventsAdditionalProperties] = new Dictionary { ["myextension"] = "value", ["numericExtension"] = 42, ["businessContext"] = command.BusinessContext }; + commandProcessor.Post(new BusinessEventRaised(command.BusinessContext), requestContext: context); + return base.Handle(command); } } @@ -446,12 +476,14 @@ Context.Span.SetAttribute("custom.id", id); ### 5. Use Explicit RequestContext for Important Metadata ```csharp -// Good - explicit context with important routing information +using Paramore.Brighter; + +// Good - explicit context with important routing information, on the call that makes the message var context = new RequestContext(); context.Bag[RequestContextBagNames.PartitionKey] = tenantId; context.Bag[RequestContextBagNames.Headers] = criticalHeaders; -await _commandProcessor.SendAsync(command, requestContext: context); +await commandProcessor.PostAsync(orderCreated, requestContext: context); ``` ## Related Documentation From 4fc404079a0102bcd81beb8cf2dbe6da4979e37b Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 20:11:08 +0100 Subject: [PATCH 24/27] =?UTF-8?q?spec:=20017=20task=205.5=20=E2=80=94=20Me?= =?UTF-8?q?ssageMappers.md's=20baselined=20block=20moved=20#3=20->=20#4=20?= =?UTF-8?q?by=206ddcf7b's=20insert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- tools/blockcheck/baseline.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index bd33876..773858e 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -117,7 +117,6 @@ contents/MSSQLTransportInboxAndOutbox.md 7 RelationalTransportContext.cs 280d1b7 contents/MSSQLTransportInboxAndOutbox.md 8 RelationalTransportContext.cs 7edaada contents/MSSQLTransportInboxAndOutbox.md 9 RelationalTransportContext.cs 7edaada contents/MSSQLTransportInboxAndOutbox.md 10 RelationalTransportContext.cs 280d1b7 -contents/MessageMappers.md 3 MessageMappersContext.cs 280d1b7 contents/MigratingToNullableReferenceTypes.md 1 MigratingToNullableReferenceTypesContext.cs 280d1b7 contents/MigratingToNullableReferenceTypes.md 2 MigratingToNullableReferenceTypesContext.cs 280d1b7 contents/MigratingToNullableReferenceTypes.md 3 MigratingToNullableReferenceTypesContext.cs 280d1b7 @@ -320,3 +319,4 @@ contents/DefaultMessageMappers.md 4 - b4c3ae0 contents/DefaultMessageMappers.md 5 - b4c3ae0 contents/V10MigrationGuide.md 9 - f4cfd88 contents/V10MigrationGuide.md 12 - f4cfd88 +contents/MessageMappers.md 4 MessageMappersContext.cs 6ddcf7b From dc3e5bd2cb1445fac737ac7fe2c03336ce6639ce Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 20:12:53 +0100 Subject: [PATCH 25/27] =?UTF-8?q?docs:=20017=20task=205.5,=20second=20pass?= =?UTF-8?q?=20=E2=80=94=20Replay's=20other=20two=20mentions,=20and=20#4's?= =?UTF-8?q?=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Replay recurrence grep found two more pages naming OnceOnlyAction.Replay as available with no marker: DispatcherConfigurationReference.md (ActionOnExists) and BrighterOutboxSupport.md (the Sweeper). Both now say it ships after 10.7.0. UsingTheContextBag.md #4 wrote the PartitionKey object to Context under an example that now fills its own context. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- contents/BrighterOutboxSupport.md | 2 +- contents/DispatcherConfigurationReference.md | 2 +- contents/UsingTheContextBag.md | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/contents/BrighterOutboxSupport.md b/contents/BrighterOutboxSupport.md index e31d4cd..2719d0b 100644 --- a/contents/BrighterOutboxSupport.md +++ b/contents/BrighterOutboxSupport.md @@ -209,7 +209,7 @@ Without the Sweeper, you have two risks: - An explicit attempt to clear the Outbox by calling `ClearOutbox` on the `CommandProcessor` can fail. Although it is protected by a Polly resilience policy, if that policy still does not succeed in clearing the Outbox, the messages will linger there. Running a Sweeper means they are eventually sent. - Some transports, notably RabbitMQ and Kafka, support callbacks to inform the caller that a message has been sent. This happens asynchronously, so at the point of calling `Post` or `ClearOutbox` you do not yet know whether the message was sent; instead you must await the callback. Because the calling code has moved on, the response always returns on a new thread, which has no context for the original call and cannot interactively notify you that the operation failed. However, if you have a Sweeper, the message — still in your Outbox — will be sent. -There is a third case, and it makes the Sweeper unavoidable rather than merely advisable. If you configure an Inbox with `OnceOnlyAction.Replay`, a duplicate request makes Brighter clear the dispatched marker on the messages the original handling produced — the `Dispatched` column on a relational Outbox — putting them back in the Sweeper's path. That is the *only* way a replayed message ever leaves your application: replay resets rows, it never dispatches anything itself, so without a Sweeper the messages are marked outstanding and stay there. See [Replay On Seen](/contents/ReplayOnSeen.md). +There is a third case, and it makes the Sweeper unavoidable rather than merely advisable. If you configure an Inbox with `OnceOnlyAction.Replay` — **not in a released package yet**, it ships after Brighter 10.7.0 — a duplicate request makes Brighter clear the dispatched marker on the messages the original handling produced — the `Dispatched` column on a relational Outbox — putting them back in the Sweeper's path. That is the *only* way a replayed message ever leaves your application: replay resets rows, it never dispatches anything itself, so without a Sweeper the messages are marked outstanding and stay there. See [Replay On Seen](/contents/ReplayOnSeen.md). ## Outbox Configuration diff --git a/contents/DispatcherConfigurationReference.md b/contents/DispatcherConfigurationReference.md index a992ae1..9ce4c00 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**, 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**, 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. * **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/UsingTheContextBag.md b/contents/UsingTheContextBag.md index f5b2520..32ee5e7 100644 --- a/contents/UsingTheContextBag.md +++ b/contents/UsingTheContextBag.md @@ -114,7 +114,9 @@ public class TenantAwareHandler(IAmACommandProcessor commandProcessor) : Request **Or using a PartitionKey object:** ```csharp -Context.Bag[RequestContextBagNames.PartitionKey] = new PartitionKey("customer-1234"); +using Paramore.Brighter; + +context.Bag[RequestContextBagNames.PartitionKey] = new PartitionKey("customer-1234"); ``` **Important Notes:** From 5981b12fd181d4e5df716f24f3b9d1c4e994ead0 Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 20:12:53 +0100 Subject: [PATCH 26/27] =?UTF-8?q?spec:=20017=20task=205.5,=20second=20pass?= =?UTF-8?q?=20=E2=80=94=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 58 ++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 1bf1360..19faed8 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -2329,11 +2329,55 @@ Repair `f4cfd88`; baseline `5c68181`. 3 silenced, `--verify-list` clean; `optioncheck` 0, 59 tables, 519 rows; shape, redirects, `--verify` 161 / 77 / 161. **Pages changed: 5** (`git diff --name-only 608615a..HEAD -- contents`), the five E4 pages -- **Not repaired, found on the way, for the maintainer:** `PipelineValidation.md`'s Replay rule row - and its two Replay example messages describe a forthcoming feature without the *Not in a released - package yet* marker; `MessageMappers.md`, the mapper's home page, never mentions - `IAmAMessageMapperAsync`; and `UsingTheContextBag.md`'s handler examples set `Context.Bag` - keys without posting, which is the claim #13 above replaced rather than a run of it +- **Found on the way and put to the maintainer:** `PipelineValidation.md`'s Replay rule row and its + two Replay example messages describe a forthcoming feature without the *Not in a released package + yet* marker; `MessageMappers.md`, the mapper's home page, never mentions `IAmAMessageMapperAsync`; + and `UsingTheContextBag.md`'s handler examples set `Context.Bag` keys without posting. **Ruled + 2026-09-28: into phase 5**, the second pass below + +**Task 5.5, second pass — the three items, by ruling.** Pages changed: **5**, `PipelineValidation.md`, +`MessageMappers.md`, `UsingTheContextBag.md`, and `DispatcherConfigurationReference.md` and +`BrighterOutboxSupport.md`, found by the Replay recurrence grep. **BUILT 295 → 295**; **989 → 990 blocks**, one inserted: +`MessageMappers.md` #2, the async mapper, FAILED on its page type `GreetingMade`. The AC2 diff against +the first pass's report reads `MessageMappers.md` #3 `BUILT -> FAILED`, #4 `FAILED -> BUILT` and #7 +as a new key: the insert renumbered old #2–#6 to #3–#7, and old #3's baseline row moved to #4 +(§ *Splits*). `pagelint` **543 → 537**, all on `UsingTheContextBag.md` (#2–#6 and #18, per page +against a worktree at `ac44085`); the new block carries its `using`s. Pages with nothing BUILT **41**, +unmoved. `attr_mismatch.py` **1**, exit 1, still **`PipelineValidation.md:250`**: the Replay note first +went in as a line after the example messages and moved the hit to `:252`; it now sits in the existing +**Example error messages** line, so nothing above `:250` changed length. `--plant` OK. Repair +`6ddcf7b`, `dc3e5bd`; baseline `4fc4040`. `--report` → exit **0**, *"990 blocks: 295 BUILT, 678 FAILED, +17 SKIPPED"*, baseline 295, 0 findings; `dc3e5bd` moved no verdict. + +- **`PipelineValidation.md`, Replay.** `git grep -il causation 10.7.0 -- src` → **0** files; on + `master`, `Validation/HandlerPipelineValidationRules.cs` and `PipelineValidator.cs`. The rule's row, + the line introducing the example messages and § *Replay Without Causation Tracking* now say it + ships after 10.7.0; the section carries the callout as the Replay On Seen pages word it. The other + rules on the page are in the tag (5.5's run reported two of them); `UseBoxProvisioning` and + `AddMsSqlOutbox`, in the section's blocks, are too +- **`MessageMappers.md`** gains *Synchronous and Asynchronous Message Mappers*: an + `IAmAMessageMapperAsync` beside the page's sync one, and which path uses which, linking + `DefaultMessageMappers.md` and `ReactorAndProactor.md`'s section. Written from the run below, all + four producer calls, with the two pumps from the first pass +- **`UsingTheContextBag.md`.** #3 (partition key), #5 (headers) and #6 (CloudEvents extensions) set + keys on the handler's own `Context` and posted nothing; #2 and #18 filled a context and passed it to + `SendAsync`, which makes no message. Each now fills a `RequestContext` and passes it to the `Post` + or `PostAsync` that makes the message, and a paragraph under *Setting Request Context Explicitly* + says why, from the run. The Bag-key snippets under *Well-Known Context Bag Keys* and the practices + are left: they show the key's name, not where it takes effect. *"Headers set here take precedence + over static header configurations"* stays, now run (`DictionaryExtensions.Merge`, the context's + entry overwrites the publication's) +- **`--explain` on the seven touched blocks** (#4 as well, `context` a value): page types and values only (`CreateOrderRequest`, + `OrderCreated`, `MyCommand`, `TenantWorkDone`, `IOrderService`, `ProcessOrderCommand`, + `OrderProcessed`, `PublishEventCommand`, `BusinessEventRaised`, `GreetingMade`; `tenantId`, + `criticalHeaders`, `commandProcessor`, `orderCreated`). `pagelint --changed origin/master` → **0** errors +- **Behaviour, run with controls** (`bagrun`, `bagrun2`, `maprun`), 10.7.0, net10.0, `InternalBus`: + + | Claim | Case → result | Control → result | + |---|---|---| + | Keys act only on the context passed to the post | handler fills its own `Context.Bag`, `PostAsync(…, requestContext: Context as RequestContext)` → partition key and header on the message | same handler, `PostAsync` with no context → neither. A caller's context passed to `SendAsync` reaches the handler (its key is present) and still → neither, when the handler posts without one | + | #3's shape, sync | `Post(…, requestContext: context)` inside `Handle` → partition key `tenant-7`, `x-order-priority` `high` over the publication's `DefaultHeaders` value | `Post` with no context → no partition key, the publication's `publication-default` | + | Each call uses only its own kind of mapper | `Post`, `DepositPost` + `ClearOutbox` on a sync-only type → the custom sync mapper; `PostAsync`, `DepositPostAsync` + `ClearOutboxAsync` on an async-only type → the custom async mapper | the crossed four → **the default mapper (JSON)**, all four | --- @@ -2685,6 +2729,7 @@ is rewritten against the tables below. | `CloudEventsReference.md` | 3, 4 | 4, 5 | not a split: a block inserted at #3, the Kafka partition key set per message. Old #3 (SNS) and #4 (Azure Service Bus) are now #4 and #5; all five build, so the AC2 diff reads #3, #4 `FAILED -> BUILT` and #5 as a new key | 5.3 | | `Telemetry.md` | 1–5 | 2–6 | not a split: a block inserted at #1, *Enabling Brighter's Spans*, FAILED on the pin. Old #1 and #4, BUILT, are now #2 and #5, their rows moved; so the AC2 diff reads #1 `BUILT -> FAILED` and #6 as a new key | 5.3 | | `CQRSWithBrighterAndDarker.md` | — | 7 | not a split: the write model inserted above the handler, so old #7–#12 are #8–#13, FAILED both sides. A new key, BUILT | 5.4 | +| `MessageMappers.md` | 2–6 | 3–7 | not a split: the async mapper inserted at #2. Old #3, BUILT, is now #4, its row moved (old row removed, new row at `6ddcf7b`); so the AC2 diff reads #3 `BUILT -> FAILED`, #4 `FAILED -> BUILT` and #7 as a new key. The rest FAILED both sides | 5.5 | | `DefaultMessageMappers.md` | 4 | 4, 5, 6 | the Avro mapper rewritten as three fences: the mapper (#4, BUILT), the avrogen partial (#5, BUILT) and its registration (#6, same-page). Old #5–#13 are #7–#15, FAILED both sides | 5.4 | ## Blocks removed @@ -2815,6 +2860,9 @@ BUILT, re-admitted at `ec38400`. | A resilience registry assigned to `PolicyRegistry`, the obsolete Polly v7 `IPolicyRegistry`; and built with `TryAddBuilder>`, a typed builder Brighter never looks up | `BrighterOptions.cs:56`, `:59` | `V10MigrationGuide.md` #9, #11 | `grep -rnE 'PolicyRegistry *= *\w*[Rr]esilience' contents/`; `grep -rn 'TryAddBuilder<' contents/` | **2** | **0** | 5.5, `--explain` of § 4 | | `IRequestContext.InstrumentationOptions` *"added in 10.7.0"*: it is after 10.7.0, forthcoming, and was unmarked | `IRequestContext.cs` at 10.7.0 (0 hits); `release_notes.md` *Master*; NuGet's latest is 10.7.0 | `V10MigrationGuide.md` | `grep -rn 'added in 10\.7\.0' contents/`; `grep -rnE '(added\|new\|introduced\|since) (in )?(Brighter )?(V?10\.7(\.0)?)' contents/` → 0 more | **1** | **0**, marked *Not in a released package yet* | 5.5, reading § 5 | | Mappers said to have no async variants, with `Task.Run()` wrappers advised; a Proactor maps with `IAmAMessageMapperAsync` and skips a sync-only mapper for the default, silently | `Proactor.cs:63`, `Reactor.cs:63`; run both ways | `ReactorAndProactor.md` #5 and its note | `grep -rniE "mappers? (don't\|do not\|never) have async\|mappers? remain synchronous\|mappers? (are\|stay) synchronous" contents/` | **2** lines | **0** | 5.5, reading around `:200` | +| Forthcoming Replay validation shown unmarked: the rule's row, two example messages and a section, with no *Not in a released package yet* | `git grep -il causation 10.7.0 -- src` → 0; on `master`, the validation rules | `PipelineValidation.md` | `grep -rln 'OnceOnlyAction.Replay' contents/` → **8** pages, each read beside `grep -c 'Not in a released package yet'`: five marked already; `DispatcherConfigurationReference.md` (`ActionOnExists`) and `BrighterOutboxSupport.md` (the Sweeper) named Replay as an option with no marker, and now say it ships after 10.7.0 | **3** pages | **0** | 5.5, ruled into phase 5 | +| The mapper's home page names only `IAmAMessageMapper`; nothing there says `PostAsync`, `DepositPostAsync` and a Proactor use `IAmAMessageMapperAsync` and fall back to the default mapper silently | run, all four calls and both pumps | `MessageMappers.md` | `grep -c 'IAmAMessageMapperAsync' contents/MessageMappers.md` | **0** | **3** | 5.5, ruled into phase 5 | +| Partition key, headers or CloudEvents extensions set on a handler's own `Context` with nothing posted, or on a context passed to `SendAsync`; they act only on the context passed to the call that makes the message | `CommandProcessor.cs:1533` (`requestContext ?? _requestContextFactory.Create()`); run | `UsingTheContextBag.md` #2, #3, #5, #6, #18 | `grep -rnE 'Context\.Bag\[RequestContextBagNames\.(PartitionKey\|Headers\|CloudEvents)' contents/` → 3 lines, the page's key snippets, each read; `:117`, *"Or using a PartitionKey object"*, wrote to `Context` under an example that now fills `context`, and follows it; `grep -rnE 'SendAsync\([^)]*requestContext' contents/` | **5** | **0** | 5.5, ruled into phase 5 | ## Friction ledger From 45e9da5e5c53af50fef45fbec3c9729bcd527abc Mon Sep 17 00:00:00 2001 From: iancooper Date: Mon, 28 Sep 2026 20:39:04 +0100 Subject: [PATCH 27/27] =?UTF-8?q?spec:=20017=20task=205.6=20=E2=80=94=20ph?= =?UTF-8?q?ase=205=20closed:=20gates,=20AC2,=20targets,=20README=20rows=20?= =?UTF-8?q?2=20and=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine gates exit 0 against 5.1's prediction; BUILT 295 (1 over the ceiling, reconciled), pages with nothing BUILT 41, pin 98 -> 100. Two Splits rows and 5.2's unit count corrected to what was measured. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016Am29wrQUS81NVh8dUyNuy --- spec/017-compile_repairs/tasks.md | 129 +++++++++++++++++++++++++++++- tools/README.md | 14 +++- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/spec/017-compile_repairs/tasks.md b/spec/017-compile_repairs/tasks.md index 19faed8..4916814 100644 --- a/spec/017-compile_repairs/tasks.md +++ b/spec/017-compile_repairs/tasks.md @@ -1744,7 +1744,7 @@ everywhere. Page list: § *The tranches*, phase 5 table. names an argument before a positional one. A page that shows a mismatch deliberately says it is wrong in prose, not only in a `// wrong` comment. -- [ ] **Task 5.6:** Close phase 5 — checks, figures, PR +- [x] **Task 5.6:** Close phase 5 — checks, figures, PR - Input: 5.1's prediction; the PR's diff - Output: as 2.6, for phase 5; plus BUILT against **≥ 250** and pages with nothing BUILT against **≤ 60**, both read before phase 6 walks them @@ -1930,7 +1930,7 @@ SKIPPED, not because it built. blocks on these pages stayed FAILED in the first pass (**6** after the second), and **7** of their **10** hard blocks built; one is SKIPPED and two stay FAILED (`ParameterizedQueryPatterns.md` #4, same-page once its `using` is right, and `ProjectionQueryPatterns.md` #3, a fragment). 20 + 7 + the appended block = **28** -- **Six units.** `AgreementDispatcherRoutingContext.cs` (the scenarios' requests and handlers, +- **Five units.** `AgreementDispatcherRoutingContext.cs` (the scenarios' requests and handlers, `services`, `registry`), `DarkerQueryPatternsContext.cs` (one EF Core model for four pages: `AggregationQueryPatterns.md`, `ProjectionQueryPatterns.md`, `ParameterizedQueryPatterns.md`, `QueryHandlerDependencies.md`), `DarkerConfigurationReferenceContext.cs`, @@ -2379,6 +2379,127 @@ went in as a line after the example messages and moved the hit to `:252`; it now | #3's shape, sync | `Post(…, requestContext: context)` inside `Handle` → partition key `tenant-7`, `x-order-priority` `high` over the publication's `DefaultHeaders` value | `Post` with no context → no partition key, the publication's `publication-default` | | Each call uses only its own kind of mapper | `Post`, `DepositPost` + `ClearOutbox` on a sync-only type → the custom sync mapper; `PostAsync`, `DepositPostAsync` + `ClearOutboxAsync` on an async-only type → the custom async mapper | the crossed four → **the default mapper (JSON)**, all four | +**Task 5.6 — phase 5 closed, 2026-09-28, branch at `5981b12`.** Every gate run bare, exit code +read before its output: + +| # | Gate | Exit | Read | Predicted (5.1) | Agrees? | +|---:|---|---:|---|---|---| +| 1 | `linkcheck` | 0 | 165 files, 0 broken | none | **yes** | +| 2 | `pagelint` | 0 | 0 errors, **537** warnings across 66 pages, 162 pages | 0 errors; 616 → between 582 and 573, then up to −8 from P0-7, recurrences explained | **yes** — 35 in the band, 8 from P0-7, 36 more explained below | +| 3 | shape | 0 | 161 pages, 12 sections, widest 12 of 20, deepest 4 of 4 | none | **yes** | +| 4 | redirects | 0 | 77 entries, 7858 bytes | none | **yes** | +| 5 | `versioncheck` | 0 | 0 stale of 18, across 5 pages | none, scope held at 18 across 5 | **yes** | +| 6 | `optioncheck` | 0 | 0 mismatches, 59 tables, 519 rows | none | **yes** | +| 7 | `--verify` | 0 | 161 predicted = 161 published | none | **yes** | +| 8 | `symbolcheck` | 0 | 0 findings, 22 entries, 161 pages, 3 silenced; `--verify-list` exit 0 | none; `S3LuggageStore.md`'s two opt-outs kept | **yes** | +| 9 | `blockcheck` | 0 | 990: **295** BUILT, 678 FAILED, 17 SKIPPED; 0 findings; **545** reference assemblies; baseline 295; **45** units, 0 violations; 67 pages mapped | BUILT 246–294; SKIPPED 16 plus accepted reasons; 542, no pin change; exit 0; 0 violations | **no — 1 above the ceiling, and the pin grew**, explained below | +| — | `attr_mismatch.py` | 1 | **1**, `PipelineValidation.md:250`; `--plant` exit 0, OK | 7 → 1, exit 1, `:250` | **yes** | +| — | `grep -rn ITimerProvider contents/` | — | **0** lines, read from a file | 4 → 0 | **yes** | + +`pagelint --changed origin/master` → exit **0**, 0 errors. `origin/master` is `976e0e0`, the merge of +phase 4. + +**Gate 9, reconciled.** Phase 5 moved **61** blocks into the baseline: 28 in 5.2, 3 in its second +pass, 23 in 5.3, 5 in 5.4, 2 in 5.5. Against the report regenerated at `976e0e0` in a worktree +(*"983 blocks: 234 BUILT"*), **50** are on the tranche's 16 pages and **11** off it. Of the 62 blocks +FAILED on the tranche at `976e0e0`, **48** built, **13** stay FAILED and **1** is SKIPPED; the other +two tranche gains are new fences, `CloudEventsReference.md`'s inserted block and +`PostgreSQLBrokerTradeOffs.md` #2, the split. Against 5.1's ceiling of 294, which counted every +reachable block, every hard block but `S3LuggageStore.md` #1, and `InMemoryScheduler.md` #5: + +- **−11**, ceiling blocks that did not build: `BuildingAPipeline.md` #2, #3, #4, + `DarkerConfigurationReference.md` #1, #2, `ParameterizedQueryPatterns.md` #4, + `ProjectionQueryPatterns.md` #3, `QueryHandlerDependencies.md` #3, `Telemetry.md` #6 (old #5), + `PostgreSQLMessageBroker.md` #8, each in § *Blocks that stay FAILED*; and + `DarkerAndBrighterPipelines.md` #1, SKIPPED with an accepted reason +- **+2**, the tranche's two new fences, BUILT +- **+10**, off the tranche and beside `InMemoryScheduler.md` #5, which the ceiling held: + `Monitoring.md` #1, #2 (ruling), `QueryPipelinePolicies.md` #7 (appended), `NullableReferenceTypes.md` + #9 (recurrence), `CQRSWithBrighterAndDarker.md` #7 (the `Order` write model, which 5.1 left out), + `BrighterSchedulerSupport.md` #1, `DefaultMessageMappers.md` #4, #5 (ruling), `V10MigrationGuide.md` + #9, #12 (beside the attributes) + +294 − 11 + 2 + 10 = **295**. **The pin: said, no change; measured, 98 → 100 `PackageReference`s** +(`grep -c`), 542 → 545 reference assemblies — `Microsoft.Extensions.TimeProvider.Testing` and +`Confluent.SchemaRegistry.Serdes.Avro`, for 5.4's two repairs, each its own commit measured alone, +no verdict moved (`07d878b`, `aeb65f4`). **Units 35 → 45**: five in 5.2, five in 5.3 (`git diff +--name-status 976e0e0..HEAD -- tools/blockcheck/scaffold/units`, ten `A`); pages mapped 53 → 67. + +**Every FAILED block on the 16 tranche pages is listed**: `after.tsv`'s FAILED keys on those pages +are **15** — the 13 above and two new fences, `AgreementDispatcherRouting.md` #12 (split) and +`Telemetry.md` #1 (inserted, FAILED on the pin) — and `comm` of them against § *Blocks that stay +FAILED* is silent. Corpus-wide, too: every listed row is FAILED in the report. + +**Gate 2, reconciled.** Per-page warnings at `976e0e0` (a worktree) against the branch, **−79** in +three groups, and the six task entries above sum to it (17 + 14 + 23 + 9 + 10 + 6): + +- **−35 on 11 tranche pages**, which is inside 5.1's band (616 − 35 = 581). Six + `AgreementDispatcherRouting.md` blocks, #12 among them, open with `// ...`: they declare their + omission, so they still warn, which is the proviso 5.1 wrote. The one warning left on + `PostgreSQLMessageBroker.md` is #11, BUILT and untouched, as predicted +- **−19 on 6 P0-7 pages.** **8** are the P0-7 blocks 5.1 named, all of them: `InMemoryScheduler.md` + #5, `HowServiceActivatorWorks.md` #16, `PipelineValidation.md` #9, #10, + `PolicyRetryAndCircuitBreaker.md` #14, `ReactorAndProactor.md` #6, `V10MigrationGuide.md` #10, #12. + The other **11**: `PolicyRetryAndCircuitBreaker.md` −5 (the public-handlers ruling), + `V10MigrationGuide.md` −6 (#9, #11, #13, #14 beside the attributes, in 5.5; one block each by + 5.3's and 5.4's recurrences, #26 the latter) +- **−25 on 13 pages outside both**, each in its task's entry: `UsingTheContextBag.md` −6 (ruling), + `DefaultMessageMappers.md` −4 (ruling), `FeatureSwitches.md` −4 (ruling), `Monitoring.md` −2 + (ruling), and −1 each on `AgreementDispatcher.md`, `BrighterSchedulerSupport.md`, + `ConfiguringOpenTelemetry.md`, `DispatchingARequest.md`, `DynamicMessageDeserialization.md` (the + removed block), `MigratingToNullableReferenceTypes.md`, `MigratingToPollyV8.md`, + `NullableReferenceTypes.md`, `PolicyFallback.md` + +616 − 79 = **537**, across 66 pages, down from 76. + +**AC2, against a `before.tsv` regenerated from `c7329bb` in a worktree** (exit 0, *"989 blocks: 101 +BUILT, 872 FAILED, 16 SKIPPED"*, 989 rows): the diff prints **207** lines. **194** are `FAILED -> +BUILT`. The other **13**, each read against § *Splits* or accepted: + +- `FAILED -> SKIPPED` `DarkerAndBrighterPipelines.md` #1 — the accepted skip, *"a method signature + shown for comparison, with no body or class to compile in"* +- `BUILT -> FAILED` `Telemetry.md` #1 and `MessageMappers.md` #3 — each a BUILT block renumbered by + an insertion above it; both rows moved (§ *Splits*) +- ` -> BUILT` `CloudEventsReference.md` #5, `PostgreSQLBrokerTradeOffs.md` #2, + `QueryPipelinePolicies.md` #7, `SchedulingAMessage.md` #10 — new keys (§ *Splits*) +- ` -> FAILED` `AgreementDispatcherRouting.md` #12, `CQRSWithBrighterAndDarker.md` #13, + `DefaultMessageMappers.md` #14, #15, `MessageMappers.md` #7, `Telemetry.md` #6 — new keys, each + the tail of an insertion or a split (§ *Splits*) + +Nine `c7329bb` keys are absent from the report: `AnalyzerSupport.md` #2–#8, +`ConfiguringOpenTelemetry.md` #7 and `DynamicMessageDeserialization.md` #9, the shifts § *Blocks +removed* explains. **Two § *Splits* rows said less than the diff reads, and are now whole:** +`CQRSWithBrighterAndDarker.md`'s called its inserted #7 *"a new key"*, where the ordinal diff reads +#7 `FAILED -> BUILT` and #13 as the new key; `DefaultMessageMappers.md`'s did not say what the diff +reads. So 194 counts some inserted fences as `FAILED -> BUILT` on an old key, which is why phase 5's +own moves were each counted by aligning fences rather than by this diff. **Control, both ways:** the +report against itself prints **0** lines; a copy with `AgreementDispatcherRouting.md` #5 set FAILED +prints **206**, having lost that one; a copy with `AWSSQSConfiguration.md` #1 set FAILED prints +exactly one line beside the known 13 that is not `FAILED -> BUILT`, `BUILT -> FAILED +contents/AWSSQSConfiguration.md 1`. + +**The targets, both read before phase 6 walks them.** **BUILT ≥ 250: met, at 295.** **Pages with +nothing BUILT ≤ 60: met, at 41** — requirements' `awk` (`comm -23` of the FAILED and BUILT page +lists) and a Python join over the same report, **41** both. **Said, by 5.1: at most 51, as low as +45. Measured: 41**, four below. All 12 tranche pages with nothing BUILT left the list — +`DarkerAndBrighterPipelines.md` by its skip, not by building — which is 5.1's 45; the other four are +off the tranche: `BrighterSchedulerSupport.md`, `DefaultMessageMappers.md`, `Monitoring.md` and +`V10MigrationGuide.md`. 5.1 set `V10MigrationGuide.md` aside because none of its touched blocks was +predicted to build; #9 and #12 did, in 5.5. No page joined the list. + +**Every behavioural block was run with its control** (P0-10): the tables under 5.2, 5.3, 5.4 and +5.5, against released 10.7.0 packages (Darker 4.1.1) on net10.0, with a real broker or registry +where a claim needed one. + +**Upstream, filed in phase 5 on the maintainer's word:** BrighterCommand/Brighter#4453, #4454 (5.2's +second pass), #4458 (5.3), #4465 (5.4). Each is stated on its page. + +**The PR changes 53 pages** (`git diff --name-only origin/master..HEAD -- contents | wc -l`): **15** +of the 16 tranche pages (`AggregationQueryPatterns.md` was made whole by its unit alone) and **38** +outside it, by P0-7, recurrence and ruling. Beside them: `tools/README.md` (rows 2 and 9, and a +phase 5 paragraph), the baseline, `refs.csproj`, `pages.tsv` (53 → 67 pages mapped), 10 new units, +and this file. + --- ## Phase 6 — Acceptance *(8 tasks, one PR, no page touched)* @@ -2728,9 +2849,9 @@ is rewritten against the tables below. | `PostgreSQLBrokerTradeOffs.md` | 1 | 1, 2 | the JSONB and JSON schemas were one fence; each now builds. #2 is a new key, BUILT | 5.3 | | `CloudEventsReference.md` | 3, 4 | 4, 5 | not a split: a block inserted at #3, the Kafka partition key set per message. Old #3 (SNS) and #4 (Azure Service Bus) are now #4 and #5; all five build, so the AC2 diff reads #3, #4 `FAILED -> BUILT` and #5 as a new key | 5.3 | | `Telemetry.md` | 1–5 | 2–6 | not a split: a block inserted at #1, *Enabling Brighter's Spans*, FAILED on the pin. Old #1 and #4, BUILT, are now #2 and #5, their rows moved; so the AC2 diff reads #1 `BUILT -> FAILED` and #6 as a new key | 5.3 | -| `CQRSWithBrighterAndDarker.md` | — | 7 | not a split: the write model inserted above the handler, so old #7–#12 are #8–#13, FAILED both sides. A new key, BUILT | 5.4 | +| `CQRSWithBrighterAndDarker.md` | — | 7 | not a split: the write model inserted above the handler, BUILT, so old #7–#12 are #8–#13, FAILED both sides. The AC2 diff reads #7 `FAILED -> BUILT`, the new block on old #7's key, and #13 as a new key, FAILED | 5.4 | | `MessageMappers.md` | 2–6 | 3–7 | not a split: the async mapper inserted at #2. Old #3, BUILT, is now #4, its row moved (old row removed, new row at `6ddcf7b`); so the AC2 diff reads #3 `BUILT -> FAILED`, #4 `FAILED -> BUILT` and #7 as a new key. The rest FAILED both sides | 5.5 | -| `DefaultMessageMappers.md` | 4 | 4, 5, 6 | the Avro mapper rewritten as three fences: the mapper (#4, BUILT), the avrogen partial (#5, BUILT) and its registration (#6, same-page). Old #5–#13 are #7–#15, FAILED both sides | 5.4 | +| `DefaultMessageMappers.md` | 4 | 4, 5, 6 | the Avro mapper rewritten as three fences: the mapper (#4, BUILT), the avrogen partial (#5, BUILT) and its registration (#6, same-page). Old #5–#13 are #7–#15, FAILED both sides. So the AC2 diff reads #4, #5 `FAILED -> BUILT` and #14, #15 as new keys, FAILED | 5.4 | ## Blocks removed diff --git a/tools/README.md b/tools/README.md index 565432e..09b46d4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -63,17 +63,27 @@ resolves **542 reference assemblies**. `pagelint` fell 658 → **616**: 36 on th 6 on two pages outside the tranche: `BrighterBasicConfiguration.md`, by a defect's recurrence, and `SweeperCircuitBreaking.md`, by the maintainer's ruling. +**Rows 2 and 9 moved again at `5981b12`, spec 017 phase 5**, the remaining pages with one hard block +each, and the recorded falsehoods (`ITimerProvider`, the unshown `Order`, the attribute mismatches). +The baseline went 234 → **295**, compiled with **45 scaffold units**; the corpus is **990 blocks**: +five blocks added where a page needed them, three fences split into seven, and two removed with the +content they illustrated. The pin grew by two packages to **100** — +`Microsoft.Extensions.TimeProvider.Testing` and `Confluent.SchemaRegistry.Serdes.Avro` — so it +resolves **545 reference assemblies**; neither moved a verdict. `pagelint` fell 616 → **537**: 35 on +the repaired pages, 19 on six pages the falsehoods were on, and 25 on 13 pages outside both, by +defects' recurrences and the maintainer's rulings. + | # | Gate | Command | Expected at `412fd34` | |---:|---|---|---| | 1 | `linkcheck` | `python3 tools/linkcheck.py` | **165 files, 0 broken** | -| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 616 warnings, 162 pages** — at `ca6a0b2`; it read **658** at `2223fcb`, **706** at `5407298`, **743** at `b941837`, **744** at `3be2a78` and **757** at `412fd34` | +| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 537 warnings, 162 pages** — at `5981b12`; it read **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, 519 rows** | | 7 | `--verify` | `python3 tools/urlmap.py --verify` | **161 predicted = 161 published** | | 8 | `symbolcheck` | `python3 tools/symbolcheck.py` | **0 findings — 22 entries, 161 pages, 3 silenced** — at `3be2a78`; it read **5 entries, 1 silenced** at `412fd34` | -| 9 | `blockcheck` | `python3 tools/blockcheck.py --report` | **983 blocks: 234 BUILT, 733 FAILED, 16 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 16 skipped**, against **542 reference assemblies** with **35 scaffold units checked, 0 violations** — at `ca6a0b2`; it read **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` | **990 blocks: 295 BUILT, 678 FAILED, 17 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 17 skipped**, against **545 reference assemblies** with **45 scaffold units checked, 0 violations** — at `5981b12`; it read **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:**