Repository navigation
samples: fix a fatal subscription type in the MsSql task queue, and show the Outbox and Inbox - #4331
Conversation
ReviewI checked the claims in the description against the code rather than taking them on trust, and the headline fix holds up: One blocking issue in the new Outbox/Inbox work, then some smaller notes. 🔴 Blocking — box provisioning never runs in
|
…ly record Answers the review on #4331. Both of its substantive points were right, and both were the same defect class as the Subscription<T> bug this PR set out to fix: compiles clean, dies on first contact with the database. 1. UseBoxProvisioning registers an IHostedService, and BoxProvisioningHostedService is the only caller of ProvisionAsync anywhere in src/. GreetingsSender built a bare ServiceCollection with no IHost, so nothing ever started it and the comment "creates and migrates the Outbox and Inbox tables at startup" was false. GreetingsSender is now a Host, using StartAsync so provisioning happens before the send — the shape the PostgreSQL sibling already uses and explains. 2. Each process now provisions the box it owns: the sender the Outbox, the receiver the Inbox. The receiver can therefore be started on its own. And a third the review did not find, which only running it surfaced: 3. The global InboxConfiguration on AddConsumers was silently doing nothing. It is only handed to the pipeline by the ExternalBus overloads (ServiceCollectionExtensions.cs:657-676); with no producers registered the NoExternalBus branch is taken, and that overload accepts no inbox. A consumer-only process therefore gets no Inbox and no warning. Measured: with a producer registered the Inbox recorded the message, without one it stayed empty while the table existed. GreetingEventHandler now carries [UseInbox], which does not depend on there being a bus. The InboxConfiguration registration stays, because it is what supplies the store. Related: InboxScope is referenced NOWHERE in src/ outside its own declaration (0 hits, against 43 for OnceOnlyAction), so the scope: argument is inert. Not used here. Proven by running, against SQL Server 2022 in Docker, not by building: Provisioning Outbox 'Outbox'... Provisioned Outbox 'Outbox' successfully Provisioning Inbox 'InboxMessages'... Provisioned Inbox 'InboxMessages' successfully delivery 1, same message id: handler ran 1 time delivery 2, same message id: handler ran 0 times warn: UseInboxHandler Command 01a08aef-... has already been seen InboxMessages rows after both: 1 Connection strings are unchanged from the original SQLEXPRESS ones; the Docker run used a temporary edit that is not in this diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
|
Both points were right, and the blocking one was right for the reason you gave — I verified it before changing anything: Fixed in 1.
|
ReviewI checked the claims in this PR against the source rather than taking them on trust. The central fix is real and the reasoning holds up. Notes below are ordered by what I think matters. Verified
1. The framework should be catching this, not the sampleThe bug is fixed in two files, but the class of bug is untouched and will recur in the next sample or the next user's app. Two things that already exist could close it:
A fifth rule comparing Separately: the PR says the 2. As committed, the sample cannot be run by the reader who most needs itThree files hardcode Both csprojs already reference var connectionString = builder.Configuration.GetConnectionString("Brighter")
?? @"Database=BrighterSqlQueue;Server=.\sqlexpress;Integrated Security=SSPI;";— keeps the existing default for Windows readers and makes the sample runnable on Linux/macOS/Docker without editing source. Related, and a blocker for that path: While there: this sample has no 3. Table names now agree only by commentCommit 2 split one config into two that deliberately differ (sender: outbox + queue; receiver: inbox + queue), each carrying a comment saying "the table names have to agree across the two files". The sample's whole lesson is one database, three roles. A shared 4.
|
… the sample runnable Answers the second review on #4331. It found no blocker, but four of its notes were right and two of those were defects I introduced. 1. [UseInbox] was on a handler in the shared Events project, which all four apps in this sample reference and all four scan with AutoFromAssemblies(). Only GreetingsReceiverConsole registers an inbox, so the other three registered a handler whose pipeline cannot be built — latent, because pipelines are built lazily and none of them sends a GreetingEvent in-process, but real. The handler now lives in GreetingsReceiverConsole, which owns the policy. 2. My own comment cited ServiceCollectionExtensions.cs:657-666. The branch is :655 and the inbox arguments are :665/:676, and the filename is ambiguous — there are two ServiceCollectionExtensions.cs. It now names AddEventBus, which is stable and greppable. A line-number citation that was already wrong when written is exactly the defect class this sample exists to document. 3. The committed form was not the form that ran: three files hardcoded a Windows SQLEXPRESS connection string, and the Docker verification used an uncommitted edit. Both apps now read ConnectionStrings:Brighter and fall back to the original SQLEXPRESS default, so Windows readers are unaffected and everyone else can point it at a container without editing source. The packages this needs were already referenced and unused. 4. GreetingsSender got Microsoft.Extensions.Hosting only transitively via BoxProvisioning; it now references it explicitly, since it calls Host.CreateApplicationBuilder. Re-proven end to end against SQL Server 2022 in Docker using the COMMITTED source and an environment variable, no source edit: Provisioned Outbox 'Outbox' successfully Decoupled invocation of message: Topic:greeting.event Id:01a08b63-... receiver: provisioned Inbox, delivery 1 handler ran 1 time receiver: delivery 2 same id, handler ran 0 times Command 01a08b63-5db8-711a-905d-9582f4557f77 has already been seen InboxMessages rows: 1 Left as follow-ups, per the review: a validation spec comparing a subscription against its channel factory's type, a test pinning the ChannelFactory downcast, shared table-name constants, the UTF-16 BrighterSqlQueue.sql, and a README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
|
No blocker this round, but two of these were defects I introduced and both are fixed in 4 —
|
ReviewI traced each claim in the description back through
One property worth folding into the One thing I would fix before merge, then smaller points. 1.
|
…ig story Answers the third review on #4331. Its point 1 was right and it was mine. 1. CompetingReceiverConsole started but could not process a message. Fixing the subscription type moved the failure one layer down: MsSqlSubscription<T> defaults messagePumpType to Proactor, CompetingConsumerCommandHandler is a sync RequestHandler<T>, and Brighter's own ConsumerValidationRules.PumpHandlerMatch classes that as an Error. Now Reactor, with the reason stated. 2. A third pre-existing bug, found by running what the review said nothing had exercised: CompetingSender passed [new Publication()] with no Topic, and MsSqlMessageProducerFactory.Create() throws "MS SQL Message Producer Factory: Topic is missing from the publication". That app has never started either. It now publishes Publication<CompetingConsumerCommand> on the routing key the receiver subscribes to. 3. GetConnectionString returns "" rather than null for a key that exists but is blank, which an environment variable makes easy, so both apps now test IsNullOrWhiteSpace. The comment said "appsettings or the environment"; there is no appsettings.json in this sample and a console app would not copy one, so it now says ConnectionStrings__Brighter. 4. [UseInbox] takes the Type overload rather than nameof, matching the WebAPI samples and storing FullName, which cannot collide across namespaces. 5. GreetingsSender/Program.cs was rewritten in full and lost its #region Licence block, which .agent_instructions/documentation.md requires. Restored. 6. Two comments corrected rather than softened: the receiver's provisioning must be registered BEFORE AddHostedService<ServiceActivatorHostedService>(), which is load-bearing and was invisible; and "pipeline cannot be built" overstated the shared-handler risk, since pipelines build lazily — latent, not certain. Added the property that makes keeping InboxConfiguration alongside the attribute safe rather than merely redundant: PipelineBuilder skips the global inbox attribute for a handler already carrying [UseInbox]. Re-proven for the Greetings pair against SQL Server 2022 in Docker, committed source plus an environment variable: Outbox provisioned, Inbox provisioned, delivery 1 handled once, delivery 2 of the same id handled zero times with "has already been seen", Inbox holding 1 row. NOT claimed: the Competing pair now builds and starts, and neither of its two proven defects remains — but it does not round-trip. The sender logs three Decoupled invocations with no error and nothing lands in QueueData. That is pre-existing, undiagnosed, and out of scope here; it is reported rather than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
|
Point 1 was right and it was mine — and running it turned up a third one. Fixed in 1.
|
ReviewI checked every mechanism claim in the description against
No other references to the deleted Below is what I would change or follow up on. Nothing here blocks the two fatal fixes, which are clearly right. 1. The sample still cannot be run from a clean machine — and a comment now claims otherwise
So against a Linux container you must edit source (or hand-write the DDL) before either process gets past its first query — the first symptom being
2.
|
|
Your first point was the important one and it was right. That is the third time in this PR a comment of mine has promised more than the code delivered — first the Docker proof that used an uncommitted edit, then "runs against a container without editing source", now "the receiver can be started on its own". Fixed in The sample now runs from a clean machine, and I proved it that wayI took your suggestion rather than shortening the claim: the queue table is provisioned by the sample, guarded by
Fresh SQL Server 2022 container, the Starting the receiver first is the case that could not work before. 2, 3, 4, 6 — all taken
What I am still not claimingThe Competing pair does not round-trip. Both apps start, are configurable and provision what they need, but The five follow-ups are filedThe maintainer asked for them rather than more offers:
Thank you for pushing on the runnability point rather than accepting a narrowed comment. It turned out to be the difference between a sample that documents a two-step setup and one that just works. |
Review — part 1 of 3: what I verifiedI checked the three claims in the description against the source rather than taking them on trust, and they hold up:
Worth stating in the sample, because it looks like a hazard and isn't: setting Also spot-checked and fine: |
Part 2 of 3: findings1. The default connection string will fail on the path the sample documents
Every other MSSQL connection string in the repo carries the opt-out: You tested via 2.
|
Part 3 of 3: findings, nits, verdict6.
|
|
Both headline findings were real. Fixed in 1. The documented default could not connectRight, and the diagnosis of why I missed it is the sharper half: I tested through
2. The race, in exactly the scenario the provisioner was added forAlso right, and it undercut the property this PR is built on — "either can be started alone" invites starting both. Now one batch with Measured, two receivers started concurrently against a database with zero tables: 3. Convergence, and the schema disagreementTaken in full, since I was rewriting the method anyway. The index is guarded independently, so a table that exists without its index gets one — including a table left by the old script, whose index had a different name. Both guards test 4, 5, 6, 8 and the nits
9 — deferred, by the maintainer's callAdding tests to samples is a policy change wider than a PR whose job is to support a documentation page, so it stays filed as #4339. I take the 7 — not fixed
10 — already filed
Thank you for splitting the review into parts — the two that mattered were unambiguous, and finding #1 by asking which path did the testing not take is the kind of thing I would not have got to on my own. |
ReviewI read the diff against the source rather than taking the description on trust, and the three headline claims all hold up:
Two other things I checked because the comments assert them:
The comments throughout explain why rather than what, which is exactly what a sample needs. Below is what I'd change. 1. Test coverage — the one item I'd push back on deferring
The pieces to catch it already exist:
Both are on the "Not in this PR" list. The spec is fair to defer — it is a design change with its own review surface. The test pinning the downcast is not, and it is the one that makes the fix durable. Separately, 2.
|
|
All of it taken bar one piece, in 1. The test — and the deferral was wrong on its factsI wrote "adding tests to samples is a policy change". The test is not in samples. You said where it goes —
The control is there because without it the three would also pass against a factory that threw for everything. And since a test written after the fix proves nothing until it has been seen to fail, I neutered all three Three red, control green, What I did not do: the 2. ParameterizedBoth catalog lookups now bind 3. The index nameNow a named 4. Layering — split, and you asked twice
5, 6, 7
Re-run after the moveThe move touched every application's composition root and the provisioner's SQL, so the measurements were taken again rather than carried over. Fresh SQL Server 2022 container, Six rounds, six real findings, and this one caught me refusing a test on grounds that did not survive reading my own diff. Thank you. 🤖 Generated with Claude Code |
ReviewThanks — this is a genuinely useful clean-up. The core fix is right and well evidenced: Findings below, most useful first. 1. The "known limitation" (#4338) looks explainable from the diff —
|
Answers the fifth review. Its two headline findings were both real.
1. The documented default could not connect. SampleDatabase.DefaultConnectionString
omitted TrustServerCertificate, and Microsoft.Data.SqlClient defaults Encrypt to
true from 4.0 onward — this repository pins 7.0.2 — so against a default SQL
Express instance with a self-signed certificate, login fails validation. Every
other MSSQL connection string in the repository carries the opt-out. The review's
diagnosis of WHY I missed it is the sharp part: I tested through
ConnectionStrings__Brighter against a container, which is exactly the path that
bypasses the constant. The fallback, which is what a reader hits first, was the
one path never exercised. It also notes this default was previously unreachable
because the receiver threw first, so this PR is the first time it is on the happy
path.
2. EnsureQueueTable was check-then-act and raced in precisely the scenario it was
added for. All four applications run it at startup against one shared database,
and "either can be started alone" invites starting both — two instances both read
0, both CREATE, and the loser dies with SqlException 2714. Box Provisioning is
safe here because it takes MsSqlAdvisoryLock; the queue has no equivalent. Now one
batch with IF NOT EXISTS guards, plus a catch for 2714/1913 so a genuine race
resolves as success.
3. It also no longer create-once. The index is guarded independently, so a table
that exists without its index gets one — including a table left by the old
BrighterSqlQueue.sql, whose index had a different name. And both guards test
SCHEMA_NAME() rather than a literal dbo, because GetDDL emits an unqualified
CREATE TABLE that lands in the caller's default schema: a login whose default is
not dbo would never match its own table and would re-attempt the create forever.
5. SampleDatabase.Name had no callers while the string it holds was still duplicated
in DefaultConnectionString. The default is now built from it.
6. Events declares Microsoft.Data.SqlClient rather than inheriting it transitively.
8. Both receivers call ValidatePipelines(). The subscription comment cited
ConsumerValidationRules.PumpHandlerMatch, which only runs behind that opt-in call
and was therefore describing a check that was not running. Now it is.
Plus the README the review asked to pull in rather than defer, and two nits: the
host is disposed, and an unused using is gone.
Proven on a fresh SQL Server 2022 container, the .sql run once, no manual table
creation, committed source:
two receivers started CONCURRENTLY on a database with zero tables:
instance A: 0 race errors, instance B: 0 race errors
tables: __BrighterMigrationHistory, InboxMessages, QueueData
IX_QueueData_Topic present exactly once
convergence: index dropped by hand, recreated on next start, 0 errors
round trip: delivery 1 handled once, delivery 2 of the same id handled zero times
with "has already been seen"; Inbox rows 1; 0 validation errors
Deferred on instruction: #9, a test pinning the ChannelFactory downcast. Adding
tests to samples is a policy change wider than this PR, and it is filed as #4339.
Not fixed: #7, Events now carries infrastructure as well as contracts. Noted as
non-blocking by the review and left rather than split, since all four applications
need the provisioner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
… with a test
Review 6 asked for both, and the deferral of the test rested on a
misreading of my own: I refused it as "adding tests to samples is a
policy change", but the test does not go in samples. It goes in
tests/Paramore.Brighter.MSSQL.Tests/MessagingGateway/, beside a
channel-factory test that already needs no database.
- A SampleInfrastructure project now holds QueueTableProvisioner and
SampleDatabase. Events goes back to what its name says — commands,
mappers, handler — with no SqlClient dependency, which matters because
Events is the assembly every application hands to AutoFromAssemblies.
- The catalog lookups take the table and index names as parameters. The
values are constants, so nothing is injectable today; a sample is a
copy-paste source and a reader who wires the table name to
configuration should not inherit an injection point in code holding
DDL rights. MsSqlBoxMigrationRunner:244 does the same.
- The index name is a named method with the duplication stated: it
repeats a convention owned by MsSqlQueueBuilder, and if that
convention moves, the guard stops matching and a permanently failing
CREATE INDEX looks exactly like success.
- <Nullable>enable</Nullable> on the new project. samples/
Directory.Build.props does not import the root one, so string? was a
CS8632 warning. Proven both ways: the warning fires in Events without
the property and does not fire in SampleInfrastructure with it.
- README: neither TrustServerCertificate=True nor Encrypt=false belongs
in production, and the greetings pair is the path that round-trips.
- The unused using System; in both moved files.
Four tests, no database: the three channel-creation methods each throw
ConfigurationException for a plain Subscription<T>, and the control
gets a channel for MsSqlSubscription<T>. Red-proofed by neutering the
three guards in ChannelFactory: 3 failed, the control passed, reverted.
Re-run end to end after the move, on a fresh SQL Server 2022 container:
two receivers started concurrently on a database with zero tables:
0 errors either side
tables: __BrighterMigrationHistory, InboxMessages, QueueData
IX_QueueData_Topic present exactly once
convergence: index dropped by hand, recreated on next start, 0 errors
round trip: same message id delivered twice
handled 1 time, "has already been seen" once, queue drained
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
Review 7 read the diff and predicted the "known limitation" from it. It was right, and a two-way control settles it — the same binary, one connection-string change: default (Enlist defaults true) : 3 Posts, no errors, QueueData 0 rows ...;Enlist=False : 3 Posts, no errors, QueueData 3 rows Post opens a SqlConnection, Enlist defaults to true, so the insert into the queue table joins the ambient TransactionScope — which CompetingSender deliberately abandoned, on the premise that a message is queued whether the transaction commits or aborts. True of a broker outside your database, false when the broker IS your database. Nothing in the gateway suppresses the ambient transaction. The scope now completes, and the comment says what the old one had backwards. This is the Outbox's motivation stated in one place, with GreetingsSender as the answer. MEASURED, six messages across two receivers: 2 and 4, queue drained. The competing pair round-trips for the first time, so the README's "does not round-trip" row and #4338 both go. Also from review 7: - The provisioner is two commands, not one batch. A swallowed error abandons the rest of its batch, so a table create that lost the race would have taken the index statement with it. The index is now attempted unconditionally with 1913 treated as success, which deletes the IndexName duplication rather than commenting on it — there is no name left to disagree with MsSqlQueueBuilder about. - The table name is validated against ^[A-Za-z_][A-Za-z0-9_]*$ before use. GetDDL formats it into CREATE TABLE [{0}], where a ']' closes the bracket, so "safe as written" was not the same as "safe to copy". - Comments trimmed against .agent_instructions/code_style.md, which is explicit that history and reviewer-facing rationale belong elsewhere. The changelog in BrighterSqlQueue.sql, the 12-line IndexName remark and the TrustServerCertificate essay are gone; the README carries what survived. - Three unused `using Events;`, the trailing USE/GO, and AAA structure in the new test. Not taken: moving GreetingEventHandler back into Events. Probed it — CompetingReceiverConsole starts clean with the handler and its [UseInbox] in the shared project, 0 errors, ValidatePipelines passes — so the hazard review 2 named is latent, not fatal. It stays where the policy is true and the README now explains the asymmetry, which was the reviewer's actual complaint. Re-run after all of it, fresh container, zero tables: two receivers concurrently: 0 errors, 3 tables, index once index dropped by hand: recreated on next start, 0 errors same id twice: handled 1, "already been seen" 1, queue drained competing: 6 sent, 2 and 4 handled, queue drained Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…ver opened Review 8's finding 2, and it is the same defect class as rounds 1, 3 and 5: a sentence I wanted to be true beside code that did not make it true. RelationDatabaseOutbox attaches a transaction only when the provider already has one open (RelationDatabaseOutbox.AddToOutbox), so passing the provider without opening one deposits on its own auto-committed connection and shares nothing. GreetingsSender now opens the transaction, deposits inside it, commits, and clears after — with rollback and Close in catch and finally. Proven by making the claim fail on purpose: transaction.Rollback() : Outbox 0 rows transaction.Commit() : Outbox 1 row That is the guarantee the comment describes, and it now holds. Review 8's other findings: - The three creation methods each have a positive control now, not just CreateSyncChannel — any of the other two could have regressed to an unconditional throw with every test still green. Plus the non-generic MsSqlSubscription, which the error message offers and nothing covered. Red-proofed both ways: guards neutered, the 3 throw-tests fail and the 4 controls pass; guards forced to throw, the 4 controls fail and the 3 throw-tests pass. - The test class is MsSqlChannelFactorySubscriptionTypeTests, per .agent_instructions/testing.md — [Behavior]Tests for classes, When_ for files. Two rounds asked; the file name stays. Helper methods renamed so they stop shadowing the types they construct. - Execute takes params SqlParameter[] rather than sniffing the SQL for "@queueTable". The implicit coupling worked, but this file is written to be copied. - The remarks say the caught 1913 on every start after the first is deliberate, so nobody "fixes" it with a guard that drifts. - README: both sender and receivers are hosts, so Ctrl-C when done. Re-run end to end after the transaction change: Outbox 1, Inbox 1, greeting handled once, queue drained, 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…eal exception
Review 9's finding 1, and it is mine from the previous commit — the
fourth time on this PR that a fix has introduced the next defect.
I committed the raw DbTransaction rather than going through the provider,
and left ClearOutbox inside the try. Both consequences are real:
- ClearOutbox is the one call in that block doing I/O against the
transport, so it is the likely failure. On that path the catch called
Rollback() on an already-committed SqlTransaction. Measured, with a
simulated dispatch failure after the commit:
raw transaction, ClearOutbox inside : "This SqlTransaction has completed;
it is no longer usable" — the real
exception LOST
provider methods, ClearOutbox outside: "simulated dispatch failure" — the real
exception survives
- transactionProvider.Close() was doing nothing. Commit() on the provider
clears its Transaction; committing the raw one leaves it non-null, so
HasOpenTransaction stays true, Close() skips the Dispose, and
IsSharedConnection defaults true so the connection is not closed either
(RelationalDbTransactionProvider:20-30). Verified against src.
Now GetTransaction/Commit/Rollback/Close all go through the provider, and
ClearOutbox sits after the finally, where a failure cannot reach a
rollback.
Also from review 9:
- Both senders call .ValidatePipelines(). The producer-side rule,
PublicationRequestTypeSet, is precisely what catches the Publication
with no Topic that this PR fixed by hand — so the sample now
demonstrates the guard on the half a reader composing an Outbox needs.
- The empty catch says why it is empty, at the call site a copy-paster
lands on.
- README: why databaseName: is absent, and that it is NOT inert for the
MySQL migration runner or Mongo, which read it and default it to
"Brighter".
- The control tests dispose the channels they create.
- The TransactionScope comment is three lines, not eight; the README
makes the argument at length and the sample should not.
Re-run, fresh container, zero tables: Outbox 1, Inbox 1, greeting handled
once, queue drained, 0 errors; competing 6 sent, handled 3 and 3, drained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…other handler
Review 10's finding 3, and it is the argument I wrote an hour ago turned
back on me. The README explained why GreetingEventHandler sits with its
process and called the alternative latent. CompetingConsumerCommandHandler
was still in Events and takes IAmACommandCounter, registered only in
CompetingReceiverConsole — and Host.CreateApplicationBuilder turns on
ValidateOnBuild when DOTNET_ENVIRONMENT=Development. Measured:
before, GreetingsReceiverConsole under Development:
Unable to resolve service for type 'Events.IAmACommandCounter'
after, all three applications under Development: start clean, 0 unresolved
Not latent at all for a reader with an IDE profile. The handler and
IAmACommandCounter now live in CompetingReceiverConsole, Events is
contracts only, and the README states the rule both handlers follow
rather than defending one exception.
Review 10's other findings, and the first two are also mine from the
previous commit:
1. The ValidatePipelines comments I added were wrong. ProducerValidationRules
is three specs — RequestType set, RequestType implements IRequest, wrap
transforms resolvable — and none looks at Topic; PublicationRequestTypeSet
interpolates p.Topic into its own message. The missing Topic throws from
MsSqlMessageProducerFactory.Create, before AddBrighter is reached. The
comments now say what the rules do.
2. Nothing handed Events to AutoFromAssemblies: all four called the
no-argument overload, which scans LOADED assemblies, and it worked only
because each Program.cs happened to construct a generic over an Events
type first. Reordering two lines would have silently unregistered the
mappers. All four now name their assemblies, and the README says so.
4. The README's claim that the connection string never reaches the console
is true of the receivers and an ordering accident in GreetingsSender,
which does set MinimumLevel.Debug(). Said plainly, with the ordering.
5. The container export is promoted into the setup steps — the default is
Windows-only and step 1 points everyone at localhost.
Nits: README into Brighter.slnx; three unused Serilog references dropped
from each receiver; CompetingSender injects IHostApplicationLifetime and
stops itself, so `CompetingSender 6` now exits in 1.3s instead of hanging;
test methods renamed to When_[condition]_should_[behaviour] with the file
named after one, which is the third round to ask; the 1913 catch documents
that an index of the same name but a different definition is accepted.
Re-run after the move and the AutoFromAssemblies change, either of which
could have silently unregistered a handler: Outbox 1, Inbox 1, greeting
handled once, queue drained; competing 6 sent, handled 3 and 3, drained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
… allow-list Review 11's finding 1, and it matters more than wording because of where it sat. ServiceCollectionBrighterBuilder.AutoFromAssemblies always enumerates AppDomain.CurrentDomain.GetAssemblies() and Concats the named list — verified at ServiceCollectionBrighterBuilder.cs:118-124. So a reader of the paragraph above it would have concluded that naming the assembly was the fix for the IAmACommandCounter/ValidateOnBuild trap, when the only fix is the handler move, precisely BECAUSE everything loaded is scanned anyway. Both the comment and the README now say the list guarantees Events is scanned whatever the load order, and that loaded assemblies are still scanned too. typeof(Program).Assembly dropped from both receivers: the entry assembly is always in the list. - The index guard tests what the index IS rather than what it is called: any non-primary-key index whose leading column is Topic. That duplicates no convention, cannot silently stop matching, and removes the caught-1913-every-start steady state. Measured both ways — index dropped, recreated on next start, 0 errors; an index over Topic under a DIFFERENT name (IX_LegacyTopicName) is now recognised and NOT duplicated, which the name guard could not do. - Execute retries once on 1205. Concurrent DDL surfaces as a deadlock as readily as 2714/1913, so "safe to run concurrently" was luckier than the code guaranteed. - SampleDatabase says why InboxTable departs from the framework default, and that Name is what the .sql creates rather than "the database we are connected to" — set ConnectionStrings__Brighter and the connection string wins. - README: what the competing demo looks like from outside now that the scope commits (one burst, and readpast means receivers never block), and that adding your own INSERT on a second connection inside that scope promotes to MSDTC, which is Windows-only — the thing the Outbox route avoids. - The PR description's second bullet described a snapshot from partway through the work: the base GreetingsSender has no UseBoxProvisioning call at all. Corrected, with the correction stated. - Nits: the BOM-removal blank line in CommandCounter, the double negative in GreetingEventHandler. Not done: filing the MsSqlMessageQueue connection-string logging as an issue. It is a credential leak in the library rather than a sample problem and the reviewer is right that documenting it is how it stays unfixed — but filing is the maintainer's call, so it is recorded in the reply instead. Re-run, fresh container: 3 tables, exactly one Topic index, 0 errors; convergence after a hand-drop; greeting handled once, Outbox 1, Inbox 1; competing 6 sent, handled 5 and 1, queue drained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…ying
Review 12's finding 1, and it is the fifth time on this PR that a fix has
carried the next defect. Execute re-used the same SqlParameter instances
on the retry, and SqlCommand.Dispose does not detach them. Proven with a
control, no deadlock needed:
same parameter instances on a second command : ArgumentException — "the
SqlParameter is already contained by another SqlParameterCollection"
parameters built fresh for the attempt : accepted
That ArgumentException escapes both filtered catches, so the one path
written to survive a race would have become an unhandled startup
exception — strictly worse than the code I replaced. The parameter is now
built inside ExecuteOnce, so each attempt owns its own, and the comment
says why rather than leaving it to look incidental.
Two smaller things in the same method: the comment claimed a back-off
that does not exist, and a second 1205 propagating now says so.
Also from review 12:
- GetTransaction() moved inside the try that guarantees Close(). It opens
the connection and begins the transaction, so a failure there skipped
the finally entirely — in the file that exists to be copied.
- The README promised an Inbox demonstration a reader cannot produce.
GreetingEvent takes a fresh Id per construction, so running the sender
twice is two messages and both are handled. It now says that plainly
and gives the SQL that forces a real redelivery — which is what I had
been running to measure it, and never wrote down. Followed literally:
handled 1, "has already been seen" 1.
- CompetingSender's own unused Serilog references, which I removed from
both receivers last round and missed here.
- The README still said Ctrl-C for the sender after it started stopping
itself.
- Comment tone, per .agent_instructions/documentation.md: the "it used to"
framing is the PR description, not an explanation of the code. Let git
carry what it used to do.
Re-run: 3 tables and one Topic index from two concurrent starts, 0
errors; greeting handled once; the README's own idempotence recipe
reproduces; competing 6 sent, handled 1 and 5, drained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…gging exists
No blockers this round. Five things worth having:
- QueueTableProvisioner says why it does NOT use MsSqlQueueBuilder
.GetExistsQuery, which does ship: it formats both names into the SQL
and defaults the schema to a literal dbo, which a login with another
default schema would never match. The remarks named GetDDL and
GetIndexDDL and were silent on the third, so a copy-paster would not
have known the built-in existed.
- EnsureQueueTable runs before the host is built, so in three of the four
applications there is no logging to fail into — the commonest first-run
mistake was a bare SqlException stack trace. It now says what to check.
Measured against an unreachable server:
"Could not provision 'QueueData'. Check the server is reachable and
that BrighterSqlQueue.sql has been run..."
That control also caught something worth remembering: it did NOT fire
on the first attempt, because dotnet run --no-build had handed me a
stale copy of an app I had not rebuilt.
- The README's duplication SQL takes TOP 1. As written it doubles the
whole queue, which against the competing demo's backlog is not what a
reader wants from a snippet they pasted.
- GreetingsSender resolves from a scope rather than the root provider.
AddProducers registers the transaction provider Transient, so the root
tracked a disposable until shutdown — harmless here, wrong in the
application this file is teaching someone to write.
- Tests: the shared Paramore.Brighter.Base.Test.Requests.MyEvent instead
of a private redefinition, await using for the two IAmAChannelAsync
controls, and the file named for the happy path as testing.md asks.
Re-run: 3 tables and one Topic index from two concurrent starts, greeting
handled once, competing 6 sent and handled 3 and 3, queue drained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…ched
Review 14. CompetingConsumerCommandMessageMapper built its header with
new RoutingKey("multipleconsumer.command") instead of publication.Topic,
which GreetingEventMessageMapper one directory over does correctly. It
worked only because the literal equals SampleDatabase.CompetingTopic —
the exact silent mismatch SampleDatabase's own doc comment warns about,
in the one file the centralisation missed. Verified after the fix: the
queued rows carry Topic 'multipleconsumer.command' and the pair still
round-trips.
On the design question — whether scope.Complete() deletes the sample's
teaching point — I have kept Complete() and made the experiment
reproducible instead, because the competing pair's subject is competing
consumption and an app whose logs show messages surviving a rollback
reads, to a skimmer, as Brighter losing atomicity. The README now carries
all three rows, each measured:
default connection string, no Complete() : 0 rows
default connection string, Complete() : 3 rows
...;Enlist=False, no Complete() : 3 rows
The third is the decoupling a broker outside your database gives for
free, bought back by opting out of enlistment — and losing any guarantee
that the message and your own write agree, which is what the Outbox
keeps.
Also:
- GreetingsReceiverConsole references Microsoft.Extensions.Hosting
explicitly. Dropping Serilog.Extensions.Hosting last round left it
resolving Host.CreateApplicationBuilder through a transitive, alone
among the four.
- Dead package references: Configuration.Binder from all four apps, and
DI plus both Configuration packages from Events — so "Events is
contracts only" is visible in the project file, not just the README.
- EnsureQueueTable wraps ArgumentException as well as SqlException: a
malformed connection string throws it from the SqlConnection
constructor and escaped the wrapper that exists to prevent exactly
that bare stack trace.
- builder.Logging.AddSerilog() rather than AddSingleton<ILoggerFactory>,
which worked only because last registration wins.
- README: the export block appeared twice, and the competing split is
arbitrary — 3 and 3, 5 and 1, 2 and 4 across three runs — so it says
that rather than naming a pair a reader will not reproduce.
Re-run: competing 6 sent, handled 4 and 2, queue drained; greeting
handled once, Outbox 1, Inbox 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…ted early
Review 15's two behavioural findings.
1. Execute swallows 2714 unconditionally, but the IF NOT EXISTS probe
only looks in sys.tables — and 2714 fires for ANY object-name
collision. So a view or procedure holding the name meant: probe says
absent, CREATE fails, provisioner reports success, and the reader
meets something unrelated later. Measured with a view named QueueData,
and the stale binary from the previous build handed me the before
state for free:
before : "Cannot create index on view 'QueueData' because the view
is not schema bound"
after : "'QueueData' was not created and does not exist as a table.
Something else in this database owns that name."
EnsureQueueTable now re-probes after the create, so a lost race and a
name collision stop looking the same.
2. CompetingReceiverConsole registered RunStuff after the dispatcher, and
hosted services stop in REVERSE registration order — so the line that
prints "There were N commands handled", which is the whole output of
that demo, ran before the pump had drained. Registration order
swapped. Verified after: 6 queued, 6 handler lines, count reports 6,
queue drained. I could not produce a deterministic before-state, since
it needs a message in flight at shutdown; the fix is right by the
ordering guarantee rather than by a failing run.
Also:
- RunCommandProcessor is a BackgroundService. Work belongs in
ExecuteAsync, not in the call that starts the host, and the vestigial
await Task.CompletedTask goes with it.
- The README's export was the WEAKER of the two concessions it goes on
to explain: Encrypt=false drops TLS, TrustServerCertificate=True keeps
it and skips only the certificate identity check. It now uses
Encrypt=True;TrustServerCertificate=True, matching the code default and
the README's own advice — verified verbatim against the container.
- The Outbox conclusion was stated twice in consecutive paragraphs.
Not done, and recorded in the reply for the maintainer: MsSqlQueueBuilder
.GetExistsQuery formats both names into string literals, so the
single-quote injection the sample now guards against is open in framework
code — the sample should not be re-deriving that guard. Same list as the
connection-string logging and the dead API in the gateway README.
Re-run: greeting handled once, Outbox 1, Inbox 1; competing 6 sent and
drained; the README's own connection string used throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…not have
Review 16. Three findings worth having before merge, and the first is the
same defect class as ever: a comment asserting what the code does not do.
1. "ExecuteAsync rather than StartAsync: work belongs after the host has
started" — but the body had no await, so BackgroundService.StartAsync
ran the whole scope, loop and StopApplication to completion inside the
call that starts the host. Exactly what the comment said it avoided,
in a sample whose comments are the payload. await Task.Yield() makes
the claim true, and the loop now honours stoppingToken, so Ctrl-C
during the shipped 250-message run does something.
2. The index path swallowed 1913 with none of the re-probe I had just
given the table path. The guard asks "is there a non-PK index leading
on Topic?" while GetIndexDDL creates one by NAME, so an index called
IX_QueueData_Topic over another column meant: guard passes, create
fails 1913, swallowed, and the process starts believing it has an
index it does not. Mirrored. Measured by planting exactly that:
"No index leading on Topic exists for 'QueueData', and one could not
be created. An index of that name over different columns already
exists."
3. GreetingsSender kept an sa password off stdout by accident of
construction order, and that is a property to remove rather than
document: MinimumLevel.Override for the MsSql gateway at Information,
so Brighter's own Debug output survives where it is harmless. Verified
— 0 occurrences of the password in the sender's output.
Restructuring the provisioner's error wrapper was needed for 2 to work at
all: widening it to catch InvalidOperationException (a review 16 nit)
made it swallow the precise messages the re-probes exist to produce. It
now wraps only the connect, which is the failure that has no logging to
fall into; everything after it reports itself. Both paths measured:
wrong-column index : "No index leading on Topic exists..."
unreachable server : "Could not connect in order to provision..."
And a second no-database test, which closes the second of the three
defects that made these samples unstartable: MsSqlMessageProducerFactory
.Create throwing on a topicless Publication, with a control for the
topic-present case. Red-proofed by removing the guard in src/ — 1 failed,
the control passed, reverted. Nothing in the repository touched
MsSqlMessageProducerFactory before this.
Nits: stray indentation, Convert.ToInt32 instead of unboxing a nullable
object, and the Serilog.Extensions.Logging using that should have left
with SerilogLoggerFactory.
Re-run: 3 tables and one Topic index from two concurrent starts; greeting
handled once; competing 6 sent, count reports 6, queue drained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…at was false Review 17 found no functional bugs. It found two things that teach the wrong thing and two that would bite under load. - The loop I made honour stoppingToken last round then called scope.Complete() unconditionally, so a cancelled run committed however many messages it had reached. The two disagreed about what Ctrl-C means. Complete() is now conditional. - The TransactionScope took the default 60-second timeout while the shipped profile posts 250 messages, each a round trip, inside it — reachable against a remote container, and it aborts with a TransactionAbortedException that explains nothing. Five minutes, explicitly. - My comment said Commit "lets Close actually dispose". It does not: IsSharedConnection is true, so Close skips the connection entirely and after Commit the transaction is already null. What releases the connection is the scope's disposal. The comment now says that, and says where Close does earn its place — the rollback path. - The table and index predicates were each written twice, in the guard and in the re-probe I added for them. If a pair ever drifted, the re-probe would throw against an object it had just created. One const each now, composed into both. Verified on a second start, where both re-probes run against existing objects: 0 errors. Also: a TODO(#4335) beside [UseInbox] so the dependency is greppable from the code rather than only from the README; a note that an exported password reaches shell history, child environments and the process table, with SQLCMDPASSWORD and user-secrets named; and the comments that narrated the investigation rather than the code — "the race backstop it is described as", "Measured, before and after the move" — are gone. The ones that explain a non-obvious API constraint stay. Re-run: 3 tables and one Topic index, 0 errors; second start clean; greeting handled once; competing 6 sent, count reports 6, drained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
…headers copied from a template Review 18 found nothing functional. What is left is convention, and four of the five items are settled by a document rather than by judgement. `code_style.md:40` forbids issue references in code comments, and the `TODO(#4335)` in `GreetingEventHandler` was mine, added last round answering review 17. The README keeps the link, where markdown is the right place for it. `documentation.md:92` wants a name and a year on new files. Both new test files carried no copyright line at all; `QueueTableProvisioner.cs` and `SampleDatabase.cs` carried 2014, copied from the template rather than authored. `code_style.md:42` rules out alternatives-considered rationale, and three comments pinned another assembly's internals from a sample — what producer validation does and does not cover, `IsSharedConnection`, and why the existence check is not `GetExistsQuery`. Each is trimmed to the why it was hiding. `s_identifier` now bounds the name at SQL Server's own limit. Measured with a two-way control: a 129-character name passed the old guard and fails the new one, while the shipped `QueueData` and a 128-character name still pass. `Events/acceptance_test_message_body.txt` is a stale dump of a message body that nothing references; it goes with the handlers this PR already moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL
a60b0e9 to
2bb052a
Compare
|
Four of the five conventions are done in 2.
|
|
Green at
What the log actually said, 1 of 247: It failed in database setup at The failing test is not one of mine; it arrived from A re-run of the failed jobs on the same commit passed, 24 of 24. By the rule this repository has been using — two reds on one commit is a defect, a red that clears on a re-run of the same commit is a distribution — that settles it as the latter, in the same test-isolation-racy family as the One connection I am not waving away: this PR adds 9 tests to that project, and adding tests changes the timing of a race. That is weaker than a code change and it is not nothing, so it is written down rather than argued away. And a correction to my own method, which is the part worth keeping. My first reading of the log was "no tests ran at all" — I had grepped for the execution markers, found none, and was about to conclude the step died before running. Running the same grep against a passing 🤖 Generated with Claude Code |
…annel factory Spec 0037, from issue BrighterCommand#4334. A Subscription whose type does not match the channel factory it will be handed compiles cleanly and dies when the Dispatcher builds its channels — the defect that left two samples unable to start (BrighterCommand#4331). Grounding the issue turned up a second, live defect: GcpPubSubSubscription and MqttSubscription declare ChannelFactoryType values that are IAmAMessageConsumerFactory implementations, not IAmAChannelFactory. Since CombinedChannelFactory routes on exact type equality, a GCP Pub/Sub or MQTT subscription in a multi-transport configuration can never match a registered factory and always throws. Correcting both is in scope here (FR-7, FR-8), which is what makes Error severity safe. 10 FRs, 23 ACs, every FR covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2rKx8wcHhudCuc9iCX9qz
What this fixes
Two receivers in
samples/TaskQueue/MsSqlMessagingGateway/could never have started. Both built their subscriptions asSubscription<T>, andMessagingGateway.MsSql'sChannelFactorydowncasts toMsSqlSubscriptionand throws in all three of its channel-creation methods (ChannelFactory.cs:46,:66,:88). Measured, with a control:It compiles, which is why nothing caught it. Two further applications in the same folder were also unstartable:
CompetingSenderpassed a barenew Publication()and threwConfigurationException: Topic is missing from the publication, andCompetingReceiverConsoledefaulted to a Proactor pump against a sync handler.GreetingsSenderhad no Outbox and no host. At the base commit it built a bareServiceCollection, took.Create()on a producer registry and calledPost— no Outbox, no Box Provisioning, noHost. This PR adds all three, and the ordering matters:UseBoxProvisioningregisters anIHostedService, andBoxProvisioningHostedServiceis the only caller ofProvisionAsyncinsrc/, so the sender usesStartAsyncto let provisioning complete before the send. (An earlier revision of this description said the call existed and nothing ran it; that described a snapshot from partway through the work, not the base.)The global
InboxConfigurationwas silently doing nothing. It reaches the pipeline only throughAddEventBus'sExternalBusarms; with no producers theNoExternalBusbranch is taken, and that overload accepts no inbox. Measured: 0 Inbox rows with no producer registered, 1 with one. De-duplication now comes from[UseInbox]on the handler, which does not depend on a bus;InboxConfigurationremains as what supplies the store.What this adds
One database carrying the queue, the Outbox and the Inbox, and the sample now runs from a clean machine.
SampleInfrastructure/QueueTableProvisioner.cs, guarded and idempotent. Its catalog lookups take the names as parameters — the values are constants, so nothing is injectable, but a sample is a copy-paste source. The MSSQL gateway has no provisioning path —OnMissingChannel.Createis inert there — soMsSqlQueueBuilder.GetDDL/GetIndexDDLare public precisely for callers to run. This is now their only production call site in the repository.BrighterSqlQueue.sqlis 13 lines of portable UTF-8 creating only the database. It was a 2018 SQLEXPRESS dump: UTF-16, with data files pinned toC:\Program Files\...\MSSQL12.SQLEXPRESS\, so it could not run off Windows.README.md, documenting the changed setup contract: script → database, applications → tables.ConnectionStrings:Brighter, defaulting to local SQL Express withTrustServerCertificate=True— required, sinceMicrosoft.Data.SqlClient(pinned 7.0.2) defaultsEncrypttotrue..ValidatePipelines(), so a pump/handler mismatch is a named startup error rather than a per-message failure.SampleInfrastructureproject holding the provisioner andSampleDatabase, soEventscarries contracts and no database adapter — it is the assembly every application hands toAutoFromAssemblies.tests/Paramore.Brighter.MSSQL.Tests/MessagingGateway/, needing no database: three channel-creation methods throwConfigurationExceptionfor a plainSubscription<T>, and a control gets a channel forMsSqlSubscription<T>. Red-proofed by neutering the three guards — 3 failed, the control passed.Testing
Proven by running, on a fresh SQL Server 2022 container with the
.sqlrun once, no manual table creation, committed source and one environment variable:The Competing pair now round-trips, and #4338 was this sample's own defect
It sent inside a
TransactionScopeit deliberately never completed, on the premise that a message is queued whether the transaction commits or aborts.Postopens aSqlConnection,Enlistdefaults totrue, so the insert joined that transaction and rolled back with it. True of a broker outside your database; false when the broker is your database.#4338 closes with this PR.
Filed from this PR
#4334 validation spec for subscription vs
ChannelFactoryType· #4335NoExternalBusdiscardingInboxConfiguration· #4336InboxScopeinert · #4337CommandProcessor.Call's"fpr"typo naming the wrong type · #4338 the Competing pair, closed by this PR · #4339 a test pinning theChannelFactorydowncast, now closed by the test in this PR · #4340 superseded by the README here.Why
This accompanies a Brighter documentation guide on composing MSSQL as transport, Outbox and Inbox, and the sample is what the guide points readers at. Raised separately because the subscription fix stands on its own.
🤖 Generated with Claude Code
https://claude.ai/code/session_0146UueHL6H3zGBGTYwz7GtL