Skip to content

GH-4482 follow-up: assert the external table drains, make the store guard reachable, fix the docs - #4578

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4482-drain-assertion-guard-and-docs
Sep 23, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4482-drain-assertion-guard-and-docs

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

The last four findings from the #4558 review. Independent of #4574 and #4577 — I verified all three branches merge cleanly against main and against each other, so they can land in any order.

1. The compliance suite never checked that the source rows were deleted

TestMessageSend asserted only the received envelope's Destination. A deleteManyAsync that quietly matched no rows would still commit, still call ReceivedAsync, and still go green — while the row sat in the table and was redelivered on every subsequent poll, forever.

That is the exact failure mode worth guarding on a transport whose whole job is to drain a table, and nothing was watching it. It matters most for the code #4558 introduced: Oracle's hand-rolled RAW(16) IN-list delete and SQLite's lower() literal comparison.

No polling is needed for the assertion — the poller deletes in the same transaction that stores to the inbox and only calls ReceivedAsync after that commits, so once the tracked session is done the table must be empty.

Counting rows needs one provider-specific detail, so qualifiedTableNameFor is virtual. SQLite overrides it, because SQLite has no schemas — Wolverine folds the schema name into a table-name prefix there (TablePrefixing, GH-3943).

2. The guard for a non-relational message store was unreachable

SendMessageThroughExternalTable null-checked the result of JasperFx's As<T>(), which is documented as "Does a hard cast... Will throw InvalidCastException". The branch could never run, so a RavenDb / Cosmos / multi-tenanted store got a bare InvalidCastException instead of the explanation written for it.

Now an is not pattern, throwing InvalidOperationException to match the equivalent guards in ExternalMessageTable.BuildListenerAsync and ExternalMessageTableListener. Changing the exception type is safe precisely because the old one was unreachable — nothing can be catching it.

3. The docs advertised an API that does not exist

The sample told readers to call UseOraclePersistenceAndTransport. There is no such method: Oracle has no combined helper and opts into its database queue transport fluently off PersistMessagesWithOracle instead. Corrected, with a note explaining the difference.

4. external-tables.md was hand-edited rather than generated

The snippet footer still pointed at #L233-L295 for a region that had moved to #L44-L112, and hand-added blank lines sat around the code fence. Regenerated with mdsnippets.

Only this one file is included. The tool rewrites 89 docs files repo-wide — the docs are broadly stale against source — and sweeping the other 88 in here would collide with every concurrent branch for no benefit. That backlog is worth a separate, deliberate pass.

Verification

  • dotnet build wolverine.slnx -c Release -f net9.0 — clean, 0 warnings, 0 errors.
  • external_message_tables green on all five providers, 6 tests each.
  • The new external_table_store_guard test green.

Both new assertions were negative-controlled:

  • Making the PostgreSQL delete match zero rows (and 1=0) fails all three end-to-end tests with "should have been drained" while the three migration tests still pass — precisely the hole, and confirmation the assertion is not decorative.
  • Restoring the As<T>() form makes the guard test fail with InvalidCastException, confirming that is what callers actually got.

With this, every finding from the #4558 review is addressed across #4574, #4577 and this PR.

🤖 Generated with Claude Code

…uard reachable, fix the docs

The last four findings from the #4558 review.

### The compliance suite never checked that the source rows were deleted

TestMessageSend asserted only the received envelope's Destination. A
deleteManyAsync that quietly matched no rows would still commit, still call
ReceivedAsync, and still go green -- while the row sat in the table and was
redelivered on every subsequent poll, forever. That is the exact failure mode
worth guarding on a transport whose whole job is to drain a table, and it was
invisible.

The poller deletes in the same transaction that stores to the inbox and only
calls ReceivedAsync after that commits, so the assertion needs no polling: once
the tracked session is done, the table must be empty.

Counting rows needs one provider-specific detail, so qualifiedTableNameFor is
virtual. SQLite overrides it because SQLite has no schemas -- Wolverine folds the
schema name into a table-name prefix there (TablePrefixing, GH-3943).

### The guard for a non-relational message store was unreachable

SendMessageThroughExternalTable null checked the result of JasperFx's As<T>(),
which is documented as a hard cast and throws InvalidCastException. The branch
could never run, so a RavenDb/Cosmos/multi-tenanted store got a bare
InvalidCastException instead of the explanation that was written for it. Now an
`is not` pattern, throwing InvalidOperationException to match the equivalent
guards in ExternalMessageTable.BuildListenerAsync and
ExternalMessageTableListener.

### The docs advertised an API that does not exist

The sample told readers to call UseOraclePersistenceAndTransport. There is no
such method -- Oracle has no combined helper, and opts into its database queue
transport fluently off PersistMessagesWithOracle instead.

### external-tables.md was hand edited rather than generated

The snippet footer still pointed at #L233-L295 for a region that had moved to
#L44-L112, and hand-added blank lines sat around the code fence. Regenerated
with mdsnippets. Only this one file is included: the tool rewrites 89 docs files
repo-wide, and committing the other 88 here would collide with every concurrent
branch for no benefit.

Verified: pinned `wolverine.slnx -c Release -f net9.0` clean (0 warnings), and
external_message_tables green on all five providers (6 tests each).

Both new assertions were negative-controlled. Making the PostgreSQL delete match
zero rows fails all three end-to-end tests with "should have been drained" while
the three migration tests still pass -- which is precisely the hole. Restoring
the As<T>() form makes the guard test fail with InvalidCastException, confirming
that is what callers actually got.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 9c5592c into main Sep 23, 2026
44 checks passed
This was referenced Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant