Skip to content

#420: persisted MaxConcurrentRebuildsPerDatabase default + capped rebuild fan-out regression test#463

Merged
jeremydmiller merged 4 commits into
mainfrom
feature/420-max-concurrent-rebuilds
Jul 7, 2026
Merged

#420: persisted MaxConcurrentRebuildsPerDatabase default + capped rebuild fan-out regression test#463
jeremydmiller merged 4 commits into
mainfrom
feature/420-max-concurrent-rebuilds

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Jun 19, 2026

Copy link
Copy Markdown
Member

Advances #420 (cap concurrent projection rebuilds per database) — epic #486 WS3 remainder. The --max-concurrent CLI flag + projection-level cap already landed on main; this PR adds the configured-default half, the regression test the acceptance criteria call for, and (post-2.22.0 refresh) reconciles the surface with the WS2/WS3 daemon governors.

What's here

  • DaemonSettings.MaxConcurrentRebuildsPerDatabase (int?, default null) — the configuration knob, sitting beside the shipped MaxConcurrentEventLoadsPerDatabase / MaxConcurrentBatchWritesPerDatabase governors (Per-database event-load throttle (#494 Phase A, epic #486 WS2) #495/Per-database projection batch-write governor + bounded cross-tenant rebuild default (epic #486 WS3) #496). Because Marten's ProjectionOptions derives from DaemonSettings via ProjectionGraph, this is StoreOptions.Projections.MaxConcurrentRebuildsPerDatabase — the exact surface Surface MaxConcurrentRebuildsPerDatabase on EventStoreUsage #434 and marten#4710 describe. null = "derive store-side"; zero/negative disables the cap.
  • IEventStore.MaxConcurrentRebuildsPerDatabase — store-agnostic resolved default read by the CLI rebuild path (default-interface-member so out-of-tree stores keep compiling). JasperFx.Events has no notion of a connection pool, so concrete stores override this to consult the knob and fall back to a pool-derived default (Marten/Polecat: max(1, poolSize / 8)).
  • EventStoreUsage.MaxConcurrentRebuildsPerDatabase (int?) — the descriptor field Surface MaxConcurrentRebuildsPerDatabase on EventStoreUsage #434 asked for (that issue was closed but the field never actually landed; delivered here) so CritterWatch#309's dispatcher can read the effective cap off the wire.
  • ProjectionInput.ResolveMaxDegreeOfParallelism(int? configuredDefault) — precedence: --max-concurrent CLI flag (one-off ops override) > store configured default > unbounded. Non-positive at any level is coerced to -1, because ParallelOptions.MaxDegreeOfParallelism = 0 throws and would wedge a rebuild.
  • ProjectionHost.TryRebuildShardsAsync now passes store.MaxConcurrentRebuildsPerDatabase through, and the capped fan-out is extracted to internal static RebuildProjectionsWithCapAsync(...) so the cap is independently testable.
  • Latent-bug fix: the per-cell error collection moves from List<Exception> to ConcurrentBag<Exception>. With a cap > 1 the rebuild lambda runs on multiple threads, so the prior List.Add(e) inside Parallel.ForEachAsync raced — only reachable now that the loop is actually capped/parallel.

Tests (463 green, net9.0 + net10.0)

  • Resolution precedence: flag wins over default; default applies with no flag; non-positive flag/default and null default all fall back to unbounded.
  • Instrumented peak-concurrency regression (RebuildConcurrencyCapTests): 256 cells (8 projections × 32 tenants) at cap 4 — peak concurrency, tracked via Interlocked, never exceeds 4 and actually reaches 4 (guards against a false pass where nothing ran in parallel); cap 1 serializes; non-positive cap runs unbounded without deadlock; every cell runs exactly once.
  • Surface pins (RebuildCapDefaultsTests): knob defaults null (= store-derived), descriptor field defaults null and round-trips.

Relationship to the shipped 2.22.0 governors

The WS2/WS3 governors (MaxConcurrentEventLoadsPerDatabase, MaxConcurrentBatchWritesPerDatabase) bound continuous catch-up; this cap bounds the rebuild fan-out — the batchy one-off operation. CrossTenantRebuild.RebuildEverywhereAsync already got its bounded maxParallelism = 4 default in #496; this PR does not change it.

Follow-up (out of scope here)

  • Marten/Polecat override MaxConcurrentRebuildsPerDatabase to consult the knob + derive max(1, NpgsqlConnectionPoolSize / 8), and populate the EventStoreUsage field in TryCreateUsage (marten#4710) — next PR in this train.
  • Extend the cap to the intra-projection tenant/shard cross-product fan-out in JasperFxAsyncDaemon (today's cap is projection-level).
  • Docs in rebuilding.md (two-layer concurrency model, default derivation, CLI override) + cross-refs from the multi-tenancy / UseTenantPartitionedEvents docs.

Companion CritterWatch orchestration story: JasperFx/CritterWatch#309 (depends on this cap being honored).

🤖 Generated with Claude Code

…ression test

Builds on the already-merged --max-concurrent CLI flag with the configured-default
half of #420:

- IEventStore.MaxConcurrentRebuildsPerDatabase (store-agnostic, default null =
  unbounded). Concrete stores override to a derived default; Marten/Polecat will
  derive it from the Npgsql pool size (max(1, poolSize / 8)) as follow-up — that's
  the only place a pool-size signal exists, since JasperFx.Events is store-neutral.
- ProjectionInput.ResolveMaxDegreeOfParallelism(int? configuredDefault): precedence
  is CLI flag > configured store default > unbounded. Non-positive at any level is
  coerced to -1 so a misconfigured cap can never wedge a rebuild (MaxDegreeOfParallelism
  of 0 throws).
- ProjectionHost.TryRebuildShardsAsync passes the store default through, and the
  capped fan-out is extracted to internal RebuildProjectionsWithCapAsync so the cap is
  independently regression-testable. Also switches the per-cell error collection from
  List<Exception> to ConcurrentBag — with cap > 1 the lambda runs on multiple threads,
  so the old List.Add raced (latent bug, only reachable now that the loop is capped).

Tests (21 green, net9.0 + net10.0): resolution precedence (flag/default/unbounded
+ non-positive coercion) and an instrumented peak-concurrency regression — 256 cells
(8 projections x 32 tenants) at cap 4 never exceeds 4 and actually reaches 4; cap 1
serializes; non-positive cap runs unbounded without deadlock; every cell runs once.

Follow-up (tracked, out of scope here): Marten/Polecat pool-size default derivation;
extending the cap to the intra-projection tenant/shard cross-product fan-out in
JasperFxAsyncDaemon; docs in rebuilding.md; the EnableExtendedProgressionTracking
default-derivation analysis (the cap bounds cell COUNT and is orthogonal to per-cell
progression writes, so it composes cleanly — extended tracking informs the derived
default magnitude, not the throttle mechanism).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jeremydmiller and others added 3 commits July 7, 2026 12:18
…rrent-rebuilds

# Conflicts:
#	src/JasperFx.Events/IEventStore.cs
…ncurrentRebuildsPerDatabase knob + EventStoreUsage descriptor field (delivers #434)

- DaemonSettings.MaxConcurrentRebuildsPerDatabase (int?, null = store-derived default)
  sits beside the WS2/WS3 governors; inherited by Marten as
  Options.Projections.MaxConcurrentRebuildsPerDatabase via ProjectionGraph
- EventStoreUsage.MaxConcurrentRebuildsPerDatabase so monitoring tools
  (CritterWatch#309) can read the effective cap off the wire — #434
  was closed but the field never landed; delivered here
- IEventStore.MaxConcurrentRebuildsPerDatabase xml-doc now points stores at
  the DaemonSettings knob

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tStoreUsage descriptor defaults)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 308e692 into main Jul 7, 2026
1 check passed
jeremydmiller added a commit that referenced this pull request Jul 7, 2026
…atabase rebuild cap, #420/#434/#463, epic #486 WS3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller jeremydmiller deleted the feature/420-max-concurrent-rebuilds branch July 7, 2026 18:20
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