audit: phase 1 round 02 quality assumptions#516
Conversation
Captures 20 net-new falsifiable quality assumptions (Q13-Q32) across source generator correctness, event dispatcher cancellation, Polly v8 integration, FluentValidation interceptor, HttpCorrelation mutation, ASP.NET Core MapCommand/MapQuery ProblemDetails, EF/SQLite/Mongo/Cosmos outbox providers, activity/metrics cardinality, multi-targeting SSE branches, and Testcontainers reuse — exploring angles not covered by Round 01.
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #516 +/- ##
==========================================
+ Coverage 92.80% 92.85% +0.04%
==========================================
Files 164 164
Lines 6491 6491
Branches 561 561
==========================================
+ Hits 6024 6027 +3
+ Misses 307 305 -2
+ Partials 160 159 -1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Phase 1 — Round 02 — Quality Discovery
Repo Snapshot
audit/assumptions/round-01-quality.md) is not present onmainnor on any visible audit branch. Round 02 was scoped against the topical exclusions in the task brief (outbox/transports/idempotency/timeprovider) and the angles explicitly requested for new exploration.net8.0;net9.0;net10.0(Directory.Build.props). The source generator project (NetEvolve.Pulse.SourceGeneration) targetsnetstandard2.0, is shipped as a Roslyn analyzer pack (analyzers/dotnet/cs), and emits a singlePulseRegistrations.Handlers.g.csfile.MapCommand/MapQuery/MapStreamQuery, activity/metrics tagging, EF Core + SQLite + Mongo + Cosmos outbox repositories, query caching interceptor, multi-targeting#ifbranches, Testcontainers fixtures.Assumptions
Q13:
DistributedCacheQueryInterceptor<TQuery, TResponse>registered as open-genericIRequestInterceptor<,>will fail DI resolution for non-query requests (commands and stream queries) because of its generic constraintwhere TQuery : IQuery<TResponse>.Evidence:
src/NetEvolve.Pulse/QueryCachingExtensions.cs:43-45registersServiceDescriptor.Scoped(typeof(IRequestInterceptor<,>), typeof(DistributedCacheQueryInterceptor<,>)); constraint insrc/NetEvolve.Pulse/Interceptors/DistributedCacheQueryInterceptor.cs:40requiresTQuery : IQuery<TResponse>.Why it matters: With
AddQueryCaching()enabled, dispatching any command viaSendAsync<TCommand,TResponse>(e.g.TCommand : ICommand<TResponse>) callsserviceProvider.GetServices<IRequestInterceptor<TCommand,TResponse>>()inPulseMediator.ExecuteAsync(PulseMediator.cs:232). .NET DI will attempt to close the open generic; if the constraint mismatch is detected eagerly, it throwsArgumentExceptionand the command never runs. If detected only on instance creation, the failure mode is the same. Distinct from any Round-01 idempotency-flow note because it concerns DI closure semantics of constrained open generics on the request pipeline.Test idea: Register
AddQueryCaching()plus a single command handler with no validators; dispatch the command viaIMediator.SendAsync<TCommand,TResponse>and assert it succeeds without throwingArgumentExceptionfromDefaultServiceProviderFactory.Q14:
PrioritizedEventDispatcherswallowsOperationCanceledExceptionand rewraps it inAggregateException, whileParallelEventDispatcherandRateLimitedEventDispatcheruse awhen (!token.IsCancellationRequested)filter that lets cooperative cancellation surface as a trueOperationCanceledException.Evidence:
src/NetEvolve.Pulse/Dispatchers/PrioritizedEventDispatcher.cs:100catchesException exwith nowhenguard; comparesrc/NetEvolve.Pulse/Dispatchers/ParallelEventDispatcher.cs:69andsrc/NetEvolve.Pulse/Dispatchers/RateLimitedEventDispatcher.cs:114which usewhen (!token.IsCancellationRequested).Why it matters: Callers cannot rely on
try { ... } catch (OperationCanceledException)to detect cancellation when the prioritized dispatcher is active; they receive anAggregateExceptioncontaining the cancellation. Polly retry policies that classifyOperationCanceledExceptionas non-retryable will retry it under this dispatcher.Test idea: Register two prioritized event handlers that throw
OperationCanceledException(token)after cancellation. Assert thatPublishAsyncthrowsOperationCanceledException(matching the other dispatchers' contract) and notAggregateException.Q15:
ActivityAndMetricsRequestInterceptorwritesex.Messageandex.StackTraceas ActivitySource tags rather than callingActivity.AddException/RecordException, producing high-cardinality tag values and missing OpenTelemetry-standard exception events.Evidence:
src/NetEvolve.Pulse/Interceptors/ActivityAndMetricsRequestInterceptor.cs:130-135setsExceptionMessage,ExceptionStackTrace,ExceptionType,ExceptionTimestampas tags viaSetTag; no call toactivity.AddException(...).Why it matters: OpenTelemetry-aware exporters (OTLP, Azure Monitor, Honeycomb) look for the canonical
exception.*event withexception.type/exception.message/exception.stacktraceon an exception event. Storing full stack traces as activity tags inflates trace storage and bypasses sampler/exporter exception-aware handling. Identical issue likely exists inActivityAndMetricsEventInterceptorandActivityAndMetricsStreamQueryInterceptor.Test idea: Throw a known exception from a command handler with the interceptor enabled; collect via an
ActivityListener, assert that the activity contains anexceptionevent (not just custom tags) and thatex.StackTraceis not written to a tag.Q16: ASP.NET Core endpoint helpers do not translate
ValidationException(FluentValidation) orIdempotencyConflictExceptioninto RFC 7807ProblemDetailsresponses; they bubble up to the global exception handler and produce HTTP 500 by default.Evidence:
src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:60-65, 105-112, 142-149directlyawait mediator.SendAsync/QueryAsyncand wrap the result inTypedResults.Ok/NoContent— no exception filter, noIExceptionHandler, no per-exception status code mapping.IdempotencyConflictException(thrown byIdempotencyCommandInterceptor.HandleAsyncatsrc/NetEvolve.Pulse/Interceptors/IdempotencyCommandInterceptor.cs:72) andValidationException(FluentValidationRequestInterceptor.cs:75) have natural HTTP mappings (400/409) that are not produced.Why it matters: Users expect Minimal API helpers to honor
AddProblemDetails()for the common validation/conflict cases. Today the responses are inconsistent withMicrosoft.AspNetCore.Mvc.ProblemDetailsconventions and discoverability suffers.Test idea: Hit a
MapCommand-bound endpoint with an invalid command and an idempotency conflict respectively; assert the status code is 400/409 withapplication/problem+jsoncontent type whenAddProblemDetails()is configured.Q17:
FluentValidationRequestInterceptorresolves validators via_serviceProvider.GetServices<IValidator<TRequest>>()and runs them sequentially on every request — there is no async-validator-throws guard (a non-ValidationExceptionthrown by a validator escapes as the original exception, never logged) and no validator caching.Evidence:
src/NetEvolve.Pulse.FluentValidation/Interceptors/FluentValidationRequestInterceptor.cs:58-78. Theawait validator.ValidateAsync(request, cancellationToken)inside the loop is not wrapped; any non-validation exception (e.g. NRE in a custom validator) propagates as-is and bypasses theValidationExceptionbranch.Why it matters: A buggy validator that throws e.g.
InvalidOperationExceptionwill look identical to a handler failure, defeating the centralization argument and confusing observability. Sequential validators also magnify async dispatch overhead when many validators target the same type. Distinct from any Round-01 ordering claim because it focuses on exception classification, not pipeline position.Test idea: Register two
IValidator<MyCommand>, one of which throwsInvalidOperationExceptionfromValidateAsync. Assert the interceptor surfacesInvalidOperationException(current behavior) — the test pins the contract; if the intended contract is to wrap, the test would fail.Q18:
HttpCorrelationRequestInterceptormutates the incoming request DTO (request.CorrelationId = correlationId) directly, assumingCorrelationIdis a writable property. If a consumer models a request as arecordwithinit-only / read-onlyCorrelationId, the assignment fails at compile time only when the request type is exposed through the interface — the interceptor relies on the interface contract allowing setter access.Evidence:
src/NetEvolve.Pulse.HttpCorrelation/Interceptors/HttpCorrelationRequestInterceptor.cs:71performsrequest.CorrelationId = correlationId;after a null check. The mutation happens before the handler executes and persists for the lifetime of the call (no cleanup).Why it matters: For immutable command types (records with
initsetters surfaced via interface mutation), mutability ofCorrelationIdis forced by the framework. Also, mutating a caller-owned object is surprising; if the caller reuses the same DTO across multipleSendAsynccalls (uncommon but legal), the correlation ID from one call leaks into the next.Test idea: Build a
recordcommand whoseCorrelationIdsetter has side effects; dispatch twice through the same scope withIHttpCorrelationAccessorreturning two different IDs; assert the second dispatch overwrites the first rather than leaving the original. Also verify the mutation is visible to the caller afterSendAsyncreturns.Q19: The Roslyn incremental generator captures
Microsoft.CodeAnalysis.Locationinto theHandlerInfostruct and theExplicitTypeErrorrecord;HandlerInfo.Equals/GetHashCodedeliberately excludesLocationfrom equality, but pipelines further downstream (e.g.ImmutableArray<HandlerInfo>Combine/Collectcomparers default to structural equality) may still treat two compilations as different and re-runExecutebecauseLocationinstances are not stable across compilations.Evidence:
src/NetEvolve.Pulse.SourceGeneration/Models/HandlerInfo.cs:35-54excludesLocationfromEquals. However the implicitImmutableArray<HandlerInfo>equality used by Roslyn'sCombine/Selectoperators usesIEqualityComparer<T>.Defaultonly whenToverridesEquals. The struct does overrideEquals, butExplicitTypeError(src/NetEvolve.Pulse.SourceGeneration/Models/ExplicitTypeError.cs) needs verification — arecordsynthesized equality would includeLocation.Why it matters: An incremental generator that re-runs
Executeon every keystroke negates the perf benefits and may cause analyzer service starvation in large solutions. Round-01 did not cover incremental-pipeline equality.Test idea: Write a generator-cache test using
CSharpGeneratorDriver.GetRunResult().Results[0].TrackedSteps; assert that for a non-affecting edit (whitespace) theHandlerInfoandExplicitTypeErrorpipeline steps areCachedrather thanModified.Q20: Generated DI code in
PulseHandlerGenerator.GenerateSourceassumes the project'sRootNamespaceis a valid C# namespace identifier; non-identifier characters (e.g. dashes from an MSBuild-defaultRootNamespacederived from a folder name likeMy-App) produce uncompilable output.Evidence:
src/NetEvolve.Pulse.SourceGeneration/Generators/PulseHandlerGenerator.cs:628var targetNamespace = string.IsNullOrWhiteSpace(rootNamespace) ? "NetEvolve.Pulse.Generated" : rootNamespace;then emitsnamespace {targetNamespace};directly with no sanitization. The method name fallbackAdd{assemblyName!.Replace(".", string.Empty)}PulseHandlers(line 739) likewise replaces only dots — dashes and digits-prefixed assembly names are not handled.Why it matters: Projects with non-identifier folder names (very common for
dotnet new-generated tooling apps) will see the generator produce CS1031/CS1518 build errors with no diagnostic from PULSE001-006. The method-name path also breaks when assembly names start with a digit.Test idea: Run the generator against a compilation whose
RootNamespaceis"My-Project"and assembly name is"1stPartyLib"; assert the emitted file either compiles cleanly (sanitized) or that a new PULSE diagnostic is emitted explaining the bad identifier.Q21: SQLite outbox
Type.GetType(stored event-type-name)is called without anassemblyResolver, so deserialization silently returnsnulland the repository throws a genericInvalidOperationExceptionwhen the producing assembly is not currently loaded in the consumer process. There is no PluginLoadContext or fallback to scanningAppDomain.CurrentDomain.GetAssemblies().Evidence:
src/NetEvolve.Pulse.SQLite/Outbox/SQLiteOutboxRepository.cs:603-607—Type.GetType(reader.GetString(ordEventType)) ?? throw new InvalidOperationException(...).Why it matters: In a typical solution, the outbox table is read by a worker process that may not reference the producing API's events assembly. The current behavior is to crash the entire batch (not just skip the unresolvable message), preventing further outbox progress until manual intervention. Round-01's outbox concerns reportedly focused on transports/dispatch; this is a deserialization/type-resolution concern.
Test idea: Insert an outbox row whose
EventTypereferences anAssemblyQualifiedNamefor an assembly not loaded by the test host. CallGetPendingAsyncand assert either: (a) the message is skipped with a logged warning, or (b) the entire batch fails — pinning current behavior.Q22: Cosmos DB outbox uses
idas the partition key path (/id,CosmosDbOutboxOptions.DefaultPartitionKeyPath); every outbox query (GetPendingAsync,GetFailedForRetryAsync,GetPendingCountAsync,DeleteCompletedAsync) is therefore a cross-partition fan-out query whose RU cost grows linearly with partition count.Evidence:
src/NetEvolve.Pulse.CosmosDb/Outbox/CosmosDbOutboxOptions.cs:16setsDefaultPartitionKeyPath = "/id". Queries atsrc/NetEvolve.Pulse.CosmosDb/Outbox/CosmosDbOutboxRepository.cs:83-88, 109-114, 233, 251-253do not setPartitionKeyonQueryRequestOptions, so all reads are cross-partition. FurthermoreClaimMessagesAsync(line 331+) issues a per-messageReadItemAsyncthenPatchItemAsync, doubling RU cost.Why it matters: At scale (>1000 messages/sec) the cross-partition query cost dominates; users will see RU throttling (HTTP 429) and unbounded latency. Picking
/statusor/createdAt(truncated) as the partition key — or using a logical synthetic key — would localize hot paths. Round-01 did not cover Cosmos partition-key strategy.Test idea: Run a load test inserting 10k outbox messages, run a single batch poll, and capture
RequestChargefrom response headers; compare against the same load with a synthetic partition key (e.g./statusBucket).Q23: MongoDB outbox claim path executes up to
batchSizesequentialFindOneAndUpdateAsynccalls per pickup, each of which is a round-trip; there is no aggregation pipeline$set+$match, noBulkWrite, and no transaction.Evidence:
src/NetEvolve.Pulse.MongoDB/Outbox/MongoDbOutboxRepository.cs:104-119loopsbatchSizetimes callingFindOneAndUpdateAsync. With a default batch of 100 this is 100 sequential network round-trips.Why it matters: Throughput is bounded by network latency × batchSize rather than by Mongo throughput. Worker pollers cannot scale horizontally because each instance independently performs serialized round-trips. The atomic claim is preserved but at significant cost.
Test idea: Run a poll against a Mongo container with 10ms ping latency; measure wall-clock time of
GetPendingAsync(batchSize: 100); assert that it is dominated by100 * latency(current behavior) — useful as a baseline for any future bulk-claim implementation.Q24:
EntityFrameworkOutboxRepository.AddAsyncimmediately callsSaveChangesAsyncafterAddAsync(message)(line 65-66), bypassing any unit-of-work transaction owned by the application's ownDbContextfor the same scope.Evidence:
src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs:61-67—await _context.OutboxMessages.AddAsync(...)followed byawait _context.SaveChangesAsync(...)with no transaction check. Compare to the SQLite implementation atsrc/NetEvolve.Pulse.SQLite/Outbox/SQLiteOutboxRepository.cs:217-249which honors an ambientIOutboxTransactionScope.Why it matters: The EF Core outbox pattern fundamentally requires that the message persist in the same transaction as the domain entity that produced it. Forcing
SaveChangesAsyncinsideAddAsyncdefeats that guarantee whenever the caller has not yet committed their own changes — the message ships even if the businessSaveChangeslater rolls back. This contradicts the "outbox pattern" promise and is qualitatively different from any retry/dispatch concern.Test idea: Open an EF transaction, call
EventOutbox.AddAsyncfor an event, then throw before committing. Assert the outbox row is rolled back (currently: it is committed).Q25:
PulseMediator.PublishAsynccreates a fresh DI scope per call (_serviceProvider.CreateAsyncScope()at line 86) butQueryAsync/SendAsyncresolve their handler and interceptors directly from the rootIServiceProviderinjected into the mediator — meaning command/query handlers receive scoped services from the consumer's scope, while event handlers receive a brand-new scope.Evidence:
src/NetEvolve.Pulse/Internals/PulseMediator.cs:86for events vs lines 118 and 159 (_serviceProvider.GetRequiredService<...>) for queries/commands. The interceptor pipeline for queries/commands at line 232 uses the same root provider.Why it matters: When
PulseMediatoritself is registered as a singleton (or root-scoped) instead of scoped, query/command handlers will resolve with the rootIServiceProvider— a captive-dependency hazard. The asymmetry between event-scope creation and request-scope reuse is also surprising. Round-01 reportedly focused on dispatch concerns; this is a DI lifetime/scope contract question.Test idea: Register
PulseMediatoras a singleton (intentional misconfiguration), then callSendAsyncfor a command whose handler depends on a scopedDbContext. Assert whether the resolvedDbContextis shared across requests (captive) or per-scope.Q26: Source-generator-emitted code includes
namespace NetEvolve.Pulse.Generated;as a fallback (whenRootNamespaceis null), guaranteeing namespace collisions when multiple Pulse-aware assemblies coexist that both lackRootNamespace— both emitPulseRegistrationExtensionsin the same namespace and the resultingpublic static partial class PulseRegistrationExtensionsdeclarations clash across assemblies.Evidence:
src/NetEvolve.Pulse.SourceGeneration/Generators/PulseHandlerGenerator.cs:628fallback namespace; line 650 emitspublic static partial class PulseRegistrationExtensions; method name derives from assembly name (line 739) but is not part of class identity — two assemblies that both emit this class in the same namespace would collide at the partial-class-merging step only when both are referenced into a third project that consumes both.Why it matters:
partialclasses can only merge inside a single compilation, so two assemblies each definingPulseRegistrationExtensionsinNetEvolve.Pulse.Generatedwill produce a CS0101 (duplicate definition) at consumption. The differing method names (AddXxxPulseHandlers) are not enough — the class name itself collides.Test idea: Build two libraries that both depend on
NetEvolve.Pulsesource generator, neither withRootNamespaceset, both with a single[PulseHandler]type. Reference both into a console app. Assert the compile succeeds (it currently should fail with CS0101 unless the generator namespaces them per assembly).Q27: Polly interceptors throw
InvalidOperationExceptionfrom their constructor when no pipeline is registered for a given message type or globally, breaking DI graph construction at the firstSendAsyncrather than degrading gracefully.Evidence:
src/NetEvolve.Pulse.Polly/Interceptors/PollyRequestInterceptor.cs:89-94(_pipeline = ... ?? throw new InvalidOperationException(...)) andsrc/NetEvolve.Pulse.Polly/Interceptors/PollyEventInterceptor.cs:88-93. ContrastPollyStreamQueryInterceptor.cs:62-65which gracefully sets_pipeline = nulland passes through.Why it matters: A user who registers
AddPollyRequestPolicies<CommandA, ...>()but dispatchesCommandBwill seeCommandBblow up at construction with no pipeline. The behavior is inconsistent with the stream-query variant (which is the safer design). Distinct from generic policy-config concerns: this is specifically about constructor-throw vs. pass-through asymmetry across the three Polly interceptors.Test idea: Register
AddPollyRequestPolicies<CommandA, void>(...)only; dispatchCommandB(no policy registered). Assert the call either succeeds (stream-query parity) or fails with a clear "no policy for CommandB" message — current behavior producesInvalidOperationExceptionfrom constructor readingResolveService<ResiliencePipeline<TResponse>>()and the message referencesTResponse, not the command.Q28: Activity tag name
query.typefor stream queries is not prefixed withpulse.like every other tag, breaking observability dashboards that filter bypulse.*.Evidence:
src/NetEvolve.Pulse/Internals/Defaults.cs:129—internal const string StreamQueryType = "query.type";while every other tag followspulse.<area>.<field>(lines 71-134).Why it matters: Operators building dashboards over Pulse instrumentation typically wildcard on
pulse.*. The inconsistent prefix causes stream-query telemetry to silently drop out of those panels. Trivial to falsify and pin.Test idea: Snapshot-test the full tag list emitted by all three activity interceptors (request, event, stream query); assert every tag key starts with
pulse..Q29:
MapStreamQueryswallowsOperationCanceledExceptionfrom the underlying enumerator inside theFunc<Stream, Task>delegate ("Client disconnected cleanly; do not re-throw."), which prevents Polly retry policies, ASP.NET Core diagnostics middleware, and Application InsightsRequestTelemetryfrom observing client-cancellation events at all.Evidence:
src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:222-235(SSE) and:244-257(NDJSON) catchOperationCanceledExceptionand silently return.Why it matters: Distributed tracing loses the cancel reason; rate-limiter middlewares cannot count cancelled requests;
IExceptionHandleris never invoked for partial streams. Distinct from any prior cancellation concern because it lives in the HTTP-layer wrapper, not the dispatcher.Test idea: Open a stream-query endpoint with
Accept: text/event-stream, cancel the client mid-stream, and assert the server-sideActivity.Current.GetTagItem("otel.status_code")isERRORorCancelledrather than implicit OK.Q30: The
#if NET10_0_OR_GREATERbranch atEndpointRouteBuilderExtensions.cs:203returnsTypedResults.ServerSentEvents(items)but the fallback path (fornet8.0andnet9.0) hand-rolls SSE framing with hard-coded"data: "prefix and"\n\n"separator, omitting theevent:,id:, andretry:fields that real SSE clients negotiate over. The two code paths therefore produce semantically different SSE output across target frameworks of the same library binary.Evidence:
src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:203-235. On net10.0, ASP.NET Core'sTypedResults.ServerSentEventsmay include retry/id/event fields and a different keep-alive behavior; on net8/9 it does not.Why it matters: A user upgrading from net9 to net10 will observe a behavioral break in SSE responses without any code change. Round-01 did not enumerate multi-targeting branches.
Test idea: Compile the same endpoint under net9.0 and net10.0; issue the same request; diff the byte stream. Pin the differences (or fail if equal — current likely behavior is a real diff).
Q31:
OutboxOptions.EnableWalModetriggersPRAGMA journal_mode=WALon every opened connection (SQLiteOutboxRepository.CreateConnectionAsyncline 484-491), not once at startup. Repeated WAL-mode pragmas on each connection are no-ops once set, but the extra round-trip per repository operation costs ~1ms per call against a remote SQLite (e.g. over a network filesystem) and is purely waste.Evidence:
src/NetEvolve.Pulse.SQLite/Outbox/SQLiteOutboxRepository.cs:479-494— connection created and pragma issued in the same path used by everyAddAsync,GetPendingAsync,MarkAsCompletedAsync, etc.Why it matters: At sustained polling rates (every 100ms), the cumulative pragma overhead dominates other costs. WAL is also a database-level setting persisted across connections, so the repeated set is functionally redundant.
Test idea: Benchmark
MarkAsCompletedAsyncwithEnableWalMode = truevsfalseagainst a 100ms-latency SQLite (over SMB share). Assert the per-call overhead is >10% lower when WAL is set once externally.Q32: Testcontainers fixtures (
PostgreSqlContainerFixture,CosmosDbContainerFixture, etc.) do not enable.WithReuse(true)and do not pin via labels, so every test class that depends on the fixture starts and tears down a fresh container, regressing local dev-loop wall-clock time and producing flaky container-name collisions under parallel TUnit execution.Evidence:
tests/NetEvolve.Pulse.Tests.Integration/Internals/Services/PostgreSqlContainerFixture.cs:9-14— noWithReuse, noWithLabel. Same omission inCosmosDbContainerFixture.cs:9-13,RedisContainerFixture.cs,MongoDbContainerFixture.cs,MySqlContainerFixture.cs,SqlServerContainerFixture.cs.Why it matters: Slow local feedback loop; CI is unaffected but developer experience suffers. Reuse via
.WithReuse(true)(plus~/.testcontainers.properties tc.host = testcontainers.reuse.enable=true) is the canonical Testcontainers performance lever.Test idea: Time a full integration-test run for
Pulse.Tests.Integrationtwice locally; the second run should not pay container-startup cost. Currently it does.