Skip to content

Replace the concrete-type inventory cast with a capability query - #170

Merged
VDBBjorn merged 5 commits into
mainfrom
arch/inventory-capability-query
Aug 6, 2026
Merged

Replace the concrete-type inventory cast with a capability query#170
VDBBjorn merged 5 commits into
mainfrom
arch/inventory-capability-query

Conversation

@VDBBjorn

@VDBBjorn VDBBjorn commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #155

The false all-clear

OrphanCheckpointHostedService scans a module's checkpoints for processors that no longer exist. To do that it needs a store that can enumerate. The resolution site asked for one by concrete type:

sp.GetKeyedService<ICheckpointStore>(serviceKey) is CachingCheckpointStore caching
    ? caching.AsInventory
    : sp.GetKeyedService<ICheckpointStore>(serviceKey) as ICheckpointInventory

AsInventory was internal, so only one type in the world could answer the question. Any other decorator over an enumerable store — a third party's, or a future one of ours — falls through to the as cast, fails it, and the service quietly receives null. It then does nothing and reports nothing.

That is the shape #155 is about: "we could not look" is indistinguishable from "nothing wrong". The orphan scan is a diagnostic, so a silent no-op is the one failure mode that never surfaces.

The change

ICheckpointInventory grows a capability query:

bool CanEnumerate => true;

The default is opt-in: implementing the interface and saying nothing more means you can enumerate, which is what every direct implementation already meant. A decorator whose inner store cannot enumerate overrides it to false, and CachingCheckpointStore now does exactly that — CanEnumerate => _inner is ICheckpointInventory — replacing the internal AsInventory property. The resolution site stops naming a type:

sp.GetKeyedService<ICheckpointStore>(serviceKey) is ICheckpointInventory { CanEnumerate: true } inv ? inv : null

ListProcessorIdsAsync keeps throwing NotSupportedException as a backstop for a caller that skips the query; the message now says which property to check first.

The seam moves from a concrete type to the interface, so anyone's decorator can answer for itself, and the answer is a fact about the store rather than a fact about which class we happened to recognise.

Tests

OrphanCheckpointTests gains the two halves of the rule, both driving the production DI site rather than a stand-in:

  • a third-party decorator over a non-enumerable store overrides CanEnumerate to false and is skipped
  • a store that implements ICheckpointInventory and does not override CanEnumerate is still asked for its inventory — the default is opt-in, not opt-out

Whole-file mutation pulled in a large pre-existing gap in CachingCheckpointStore and ServiceCollectionExtensions, so most of the test volume here is unrelated to the change itself: the flush and resync timers, external-reset detection, the monotonic position merge, DisposeAsync's drain and idempotence, fence rejection, and the AddAlberto argument guards. Those were untested before this branch and are tested now. The file also gets a name that describes its subject — CachingCheckpointStoreCoverageTestsCachingCheckpointStoreFlushAndResyncTests — and thirty-four doc comments that cited mutant line numbers now state the rule and the failure it prevents instead.

Fast suite: 1644 passed, 3 skipped, 0 failed (from 1622 on main).

About the mutation gate

Measured locally three times on this branch, adding only tests between runs, each new test hand-verified to kill by breaking the production line: 82.20 % → 81.20 % → 76.32 %. The score falls as verified kills are added.

Run 3 scores ServiceCollectionExtensions.cs:112 — the entire non-sharded RegisterModule call — as survived. Deleting that line by hand fails 62 tests. Two more of its reported survivors are null guards with tests written specifically for them.

So if the Changed code check goes red here, that number is not evidence about this branch. Full write-up with the reproduction is in #167, which is very likely the same upstream problem as #133. Nothing in this PR lowers a threshold, adds a Stryker exclude, or disables a mutant.

The resolution site in ServiceCollectionExtensions.cs named the
concrete CachingCheckpointStore type to decide whether the checkpoint
store can enumerate. A third-party decorator implementing
ICheckpointStore + ICheckpointInventory over a non-enumerable inner
had no way to express 'I cannot enumerate' — the plain
`as ICheckpointInventory` fallback always succeeded and returned a
live inventory whose ListProcessorIdsAsync returned [], producing a
false all-clear under OrphanCheckpointPolicy.Strict.

Fix: add `bool CanEnumerate => true;` as a default interface member on
ICheckpointInventory. Stores that enumerate unconditionally (both
InMemoryCheckpointStore and PostgresCheckpointStore) inherit the true
default without any change. CachingCheckpointStore overrides it to
`_inner is ICheckpointInventory`, preserving the existing opt-out
logic. The resolution site collapses to a single GetKeyedService call
with a property-pattern match:

    store is ICheckpointInventory { CanEnumerate: true } inv ? inv : null

This names no concrete type, so any decorator can express the same
opt-out through the public interface.

AsInventory deleted: the resolution site was its only production
caller, and the new pattern makes it redundant. The two tests that
used it are updated to the resolution-site pattern and
((ICheckpointInventory)caching).CanEnumerate respectively.

NotSupportedException kept in ListProcessorIdsAsync: it remains as a
backstop for callers that bypass CanEnumerate. The doc is updated to
reference CanEnumerate rather than the deleted AsInventory gateway.

Adds a regression test (ThirdPartyDecorator_CanEnumerateFalse_IsOptedOutByResolutionSite)
that fails to compile on origin/main (ICheckpointInventory.CanEnumerate
does not exist) and passes after the fix. The test exercises the PRODUCTION
resolution expression via real DI — not a local copy of the pattern —
so a revert of ServiceCollectionExtensions.cs would raise
ListProcessorIdsAsyncCallCount to 1 and the assertion would fail.
…nExtensions

Stryker baseline on arch/inventory-capability-query: 90/119 killed (75.6%).
Adds 16 targeted tests to close the coverage gaps that let mutants survive:

CachingCheckpointStoreFenceRejectionTests
- SubscribeFenceViolation_RegisteredHandler_IsCalledOnFenceRejection: kills
  the Statement mutant that removes the copy-on-write array assignment (line 182)
  and the NoCoverage invocation inside the fence-violation foreach (line 367).
- SubscribeFenceViolation_MultipleHandlers_AllAreCalled: pins the full
  enumeration of the subscriber list.
- FlushAsync_AcceptedFence_SubscribedHandlers_AreNotCalled: kills the
  LogicalNot mutant that inverts !leaseHeld (line 346); with it the violation
  path fires on every accepted write.

CachingCheckpointStoreCoverageTests (new file)
- SaveAsync_AheadUpdateFactory_NullCachedEntry_ReturnsNewPosition: kills the
  Conditional→false mutant at line 122 (Ahead null-left guard); null cache
  entry is seeded by GetAsync, then SaveAsync triggers the update factory.
- SaveAsync_DirtyEntry_KeepsHigherPosition_WhenLowerFollows: kills Math.Max→
  Math.Min at line 139; saves 200 then 100, flush must write 200.
- FlushAsync_WhenStoreMatchesPersisted_WritesThrough: kills the >= → > Equality
  mutant at line 396; equal storePosition/persistedPosition is not a reset.
- FlushAsync_WhenExternalResetDetectedDuringFlush_DropsWriteAndUpdatesCache:
  covers the ApplyExternalResetIfDetectedAsync reset path (line 390 guard,
  line 407 TryRemove).
- ResyncFromStore_AfterDetectingReset_UpdatesPersistedSoSubsequentFlushSucceeds:
  kills the Statement mutant at line 295 that removes _persisted update; a
  stale persisted value causes the next flush to false-detect another reset.
- ResyncFromStore_WhenStoreMatchesCache_DoesNotLogWarning: kills the < → <=
  Equality mutant at line 292; equal values must not log a warning.
- OnFlushTimer_WhenInnerSaveThrows_LogsError: covers the LogError call at
  line 252 via FakeTimeProvider + ThrowOnFirstSaveStore.
- OnResyncTimer_WhenInnerGetThrows_LogsError: covers the LogError call at
  line 266 via FakeTimeProvider + FailOnSecondGetStore.

AddAlbertoArgumentTests (new file)
- AddAlberto_NullServices_ThrowsArgumentNullException: kills Statement mutant
  at line 41; without the guard null falls through to NullReferenceException.
- AddAlberto_NullModuleKey_ThrowsArgumentNullException: kills Statement mutant
  at line 42; ThrowIfNullOrWhiteSpace throws ArgumentNullException for null
  whereas IdentifierRules throws plain ArgumentException.
- AddAlberto_NullConfigure_ThrowsArgumentNullException: kills Statement mutant
  at line 61; null delegate invocation throws NullReferenceException not
  ArgumentNullException.
- AddAlberto_WithoutWithControlLoop_AutoRegistersEventStoreHead: kills the
  Boolean→false mutant at line 92; checks EventStoreHead appears in the
  service descriptor list after AddAlberto without WithControlLoop.
Three rules in `CachingCheckpointStore`/`ICheckpointInventory` had no test.

`ICheckpointInventory.CanEnumerate` defaults to `true`: a store that
implements the interface and says nothing more is opted *in* to the orphan
check. Nothing pinned that. The mirror case — a decorator overriding it to
`false` — was already specified by
`ThirdPartyDecorator_CanEnumerateFalse_IsOptedOutByResolutionSite`, so the
default silently flipping to `false` would have turned every plain inventory
store into a false all-clear without failing a test.
`StoreThatDoesNotOverrideCanEnumerate_IsOptedInByResolutionSite` drives the
production DI site, the same way its mirror does.

`DisposeAsync` drains pending checkpoints through to the inner store — losing
that write loses a processor's position and replays events after a clean
shutdown — and is idempotent, so a second dispose does not re-save.

Also rewrites the doc comments added alongside these tests. They were written
from the mutation tool's point of view ("kills the Conditional→false mutant at
line 122"), which is both wrong — the files have shifted since — and a bad
description of what are real rules about monotonic merging, flush ordering and
external-reset detection. Thirty-four source line references are gone; the
prose now states the rule and the failure it prevents.

`CachingCheckpointStoreCoverageTests` becomes
`CachingCheckpointStoreFlushAndResyncTests`: "coverage" names the tool rather
than the subject and invites a maintainer to delete the file as scaffolding.
Two rules whose implementing line could be deleted with the suite still green.

`CachingCheckpointStore.RewindAsync` updates `_cache`, `_persisted` and
`_dirty` and then writes through to the inner store. Drop the write-through
and every in-process read still reports the rewound position, so nothing looks
wrong until the process restarts and the control loop resumes from the old
durable value. Rewind is the only way to move a checkpoint backwards and exists
so an operator can force a replay; losing it silently is the worst shape that
failure could take. The test asserts against the inner store, not through the
cache, because reading through the cache is exactly what hides the bug.

`AddAlberto`'s auto-registration sets `ControlLoopConfigured` before
registering. The flag is shared with `WithControlLoop`, which `WithRebuilds`
calls internally — without the assignment a module that opts into rebuilds gets
two control loops polling one log and racing on one checkpoint.
@VDBBjorn
VDBBjorn force-pushed the arch/inventory-capability-query branch from ce1fef7 to 90e35e6 Compare August 6, 2026 14:49
@VDBBjorn

VDBBjorn commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

CI note: the red `Changed code` check on this PR is not a score failure. The job was cancelled 42 minutes in (`##[error]The operation was canceled` at 15:35:59) while the runner was wedged — Stryker had reported `115 total mutants will be tested` and never got to a score. No report artifact was produced.

The mutation gate is not a required context for merge. Merging on `build-test` + `pr-policy`, both green. The separate, real problem with this gate — Stryker reporting mutants as survived that the suite demonstrably kills — is tracked in #167 with two hand-verified reproductions.

@VDBBjorn
VDBBjorn merged commit dd58502 into main Aug 6, 2026
3 of 6 checks passed
@VDBBjorn
VDBBjorn deleted the arch/inventory-capability-query branch August 6, 2026 18:00
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.

Only Alberto's own decorator can answer whether a checkpoint store can enumerate

1 participant