Cap the intra-projection tenant/shard cross-product rebuild fan-out with one shared per-database budget (#497)#498
Merged
Conversation
…-out with one shared per-database budget The #420 leftover (epic #486 WS3): MaxConcurrentRebuildsPerDatabase was only enforced at the projection level in the CLI path, so a single projection on a tenant-partitioned store still fanned out its per-(tenant, shard) rebuild cells without consulting the cap. - JasperFxAsyncDaemon owns ONE shared SemaphoreSlim rebuild budget per daemon (= per database), resolved at construction from DaemonSettings.MaxConcurrentRebuildsPerDatabase ?? IEventStore's store-derived default; every rebuild cell (rebuildAgent) draws a slot for the duration of its replay, so the CLI's projection-level layer and the intra-projection tenant cross-product never multiply the bound - rebuildAgent no longer holds the agent-registry _semaphore across the whole replay (that shape serialized every rebuild cell at an effective concurrency of one, making any cap > 1 unreachable); the lock now guards only the registry mutations - rebuildProjectionAllTenants fans tenants out in parallel at the budget's launch width when a budget is configured; null/non-positive keeps the historical sequential walk (and never reaches ParallelOptions.MaxDegreeOfParallelism, which throws on 0) - IProjectionDaemon grows a MaxConcurrentRebuildsPerDatabase DIM (no-op default) and ProjectionHost threads the SAME resolved --max-concurrent / store-default cap into the daemon before rebuilding - CrossTenantRebuild.RebuildEverywhereAsync's maxParallelism defaults to the daemon's budget when configured, falling back to the #496 default of 4; explicit values (including non-positive = unbounded) still win - VectorizedHighWaterMonitor._current is now lock-guarded: parallel per-tenant rebuild cells drive concurrent polls on top of the pre-existing OnNext/timer poll paths, and an unguarded Dictionary corrupts under concurrent writes - Regression tests drive the REAL daemon with a TCS-gated instrumented loader: peak concurrency over (projections x tenants) cells never exceeds the cap AND actually reaches it, every cell runs exactly once, non-positive caps stay unbounded without deadlock Closes #497 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNfeNz73Q3EdiTgSeDbo96
jeremydmiller
added a commit
that referenced
this pull request
Jul 7, 2026
…#497/#498, epic #486) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNfeNz73Q3EdiTgSeDbo96
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #497 (the #420 leftover, epic #486 WS3). Follows #463/#496.
Problem
MaxConcurrentRebuildsPerDatabasewas only enforced at the projection level in the CLI path (ProjectionHost.RebuildProjectionsWithCapAsync). A single projection on a tenant-partitioned store still fanned out its per-(tenant, shard) rebuild cells insideJasperFxAsyncDaemonwithout consulting the cap —projections rebuild --max-concurrent 2on a 100-tenant store bounded concurrent projections at 2 while each tenant cross-product ran unchecked.Worse, the intra-daemon replay concurrency was accidentally serialized at one:
rebuildAgentheld the daemon's single-slot agent-registry_semaphoreacross the entireReplayAsync, so the existingParallel.ForEachAsyncfan-outs launched wide but replayed one cell at a time — any cap > 1 was unreachable inside a database.Budget design
One shared
SemaphoreSlimbudget per daemon (= per database), acquired per rebuild cell.rebuildAgentcall. Each cell holds exactly one budget slot for the duration of its replay, regardless of which layer launched it (the CLI's projection-levelParallel.ForEachAsync, the intra-projection tenant cross-product, orCrossTenantRebuild.RebuildEverywhereAsync). Outer layers hold no slots, so the two layers share the bound instead of multiplying it (projection slots × tenant cells ≤ cap, never cap²) and nesting cannot deadlock.DaemonSettings.MaxConcurrentRebuildsPerDatabase??IEventStore.MaxConcurrentRebuildsPerDatabase(store-derived, e.g. Marten/Polecat pool-size/8) at daemon construction; the--max-concurrentCLI flag overrides per operation via a newIProjectionDaemon.MaxConcurrentRebuildsPerDatabaseDIM (no-op default) thatProjectionHostassigns with the SAME resolved value it uses for the projection-level layer.ParallelOptions.MaxDegreeOfParallelism(0 throws). With no budget,rebuildProjectionAllTenantskeeps its historical sequential tenant walk.rebuildAgentnow takes the agent-registry_semaphoreonly around the registry mutations (stop-if-running,_agentsupdate), not across the replay — that is what makes a cap > 1 actually reachable.CrossTenantRebuild.RebuildEverywhereAsync'smaxParallelismbecomesint?defaulting to the daemon's budget when configured, falling back to the Per-database projection batch-write governor + bounded cross-tenant rebuild default (epic #486 WS3) #496 default of 4; explicit values (including non-positive = unbounded) still win. Its per-tenant rebuilds draw from the same cell budget either way.VectorizedHighWaterMonitor's per-tenant readings dictionary is now lock-guarded — parallel tenant rebuild cells poll concurrently on top of the pre-existing OnNext/timer poll paths.Regression tests
CrossProductRebuildCapTestsdrives the realJasperFxAsyncDaemon(realSubscriptionAgents, substituted store/database implementingICrossTenantRebuildSource, tenant-partitioned detector) with a TCS-gated instrumented loader — cells park inside theirLoadAsyncuntil released, so over-admission is caught deterministically, no timing:Interlockedpeak concurrency never exceeds 3 and actually reaches 3; all 12 cells run exactly oncedaemon.MaxConcurrentRebuildsPerDatabase = n) replaces the budgetRebuildEverywhereAsynclaunch-width resolution matrix + a gated test that its default fan-out follows the daemon budgetBatchWriteGovernorDefaultsTeststo the refined default contractTest results
dotnet test src/EventTests/EventTests.csproj --framework net9.0→ 475 passed / 0 faileddotnet test src/EventTests/EventTests.csproj --framework net10.0→ 475 passed / 0 faileddotnet buildcleanCross-refs: marten#4884 (rebuild load tests validating tuned defaults), JasperFx/CritterWatch#309 (orchestration consumer).
🤖 Generated with Claude Code
https://claude.ai/code/session_01XNfeNz73Q3EdiTgSeDbo96