Skip to content

Per-tenant agent fan-out + automatic database-affine assignment by store cardinality (supersedes #3281)#3328

Merged
jeremydmiller merged 18 commits into
mainfrom
feat/per-tenant-distribution-by-cardinality
Jul 7, 2026
Merged

Per-tenant agent fan-out + automatic database-affine assignment by store cardinality (supersedes #3281)#3328
jeremydmiller merged 18 commits into
mainfrom
feat/per-tenant-distribution-by-cardinality

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Supersedes #3281 — all of @erdtsieck's commits carried with authorship intact, plus a redesigned distribution layer on top. Closes #3280. Also addresses JasperFx/marten#4806 and JasperFx/marten#4798 (the Wolverine leg). Part of the per-tenant event partitioning epic JasperFx/jasperfx#486; final car of the train after #487 (JasperFx 2.20.0) and marten#4856 (Marten 9.13.0-alpha.2).

What it does

1. Per-(shard, tenant) agent fan-out (from #3281, unchanged): when a store reports DistributesAgentsPerTenant (Marten: sharded databases + UseTenantPartitionedEvents), EventStoreAgents.SupportedAgentsAsync emits one agent per (shard × tenant) instead of one store-global agent per (shard × database) — co-located tenants with independent event sequences each get their own high-water tracking, closing the #3280 data-loss path where a lagging tenant's appends fell below the shared mark and were skipped.

2. Database-affine agent assignment — automatic, per store, no configuration. The UseDatabaseAffineAgentAssignment opt-in from #3281 is gone. Instead, each store's agents are distributed by its own topology, derived from the existing IEventStore.DatabaseCardinality:

  • StaticMultiple / DynamicMultiplegroup affinity: all agents for the same shard database land on one node (AssignmentGrid.DistributeByGroupAffinity, largest-group-first bin packing). Connection pools scale with databases, not nodes × databases (the marten#4806 production report: 128 shard DBs → Postgres 53300 exhaustion at 9% CPU).
  • Single / None → today's even + blue/green distribution, byte-for-byte.
  • Mixed apps get both simultaneously — one store's topology never affects another store's placement.

3. Capability-aware group placement. Group affinity honors blue/green node capabilities with the same semantics as the even path: candidate nodes must be capable of the group's agents; grandfathering keeps a node already running part of a group as a candidate when its startup capability snapshot predates newly-added tenant databases; minimal-disruption keeps a group where it runs unless that node exceeds its fair share.

4. Stale-agent retirement. When a shard's tenant scoping changes at runtime (a database gains its first tenant → per-tenant agents supersede the store-global agent, or a tenant is removed), the assignment evaluation now stops the superseded running agents — closing a concurrent double-processing window. Retirement is keyed on tenant-neutral shard identity, so blue/green agents the leader doesn't enumerate are deliberately untouched.

Behavior change (release note)

Existing Wolverine + multi-database Marten apps (including plain database-per-tenant, no event partitioning) move from per-agent even spreading to whole-database placement on upgrade. This matches what Marten's native HotCold MultiTenantedProjectionDistributor already does (one database's shards stay together), and is the connection-conserving behavior. Adding nodes beyond the database count no longer buys projection parallelism for multi-DB stores.

Tests

An epic side-note: these e2e tests caught that the first 9.13.0-alpha.1 publish served stale June bits (burned version number on nuget.org) — hence the alpha.2 pin.

Follow-ups (deliberately out of scope)

  • Capability snapshots persist only at node startup (pre-existing) — refreshing on heartbeat would let the grandfathering logic simplify.
  • EvaluateAssignmentsAsync calls SupportedAgentsAsync once more per cycle for retirement; consider caching for very large stores.
  • Projections unregistered from the app entirely are still not auto-stopped (matches existing behavior).

🤖 Generated with Claude Code

erdtsieck and others added 18 commits July 6, 2026 14:36
…partitioned stores

EventStoreAgents.SupportedAgentsAsync now expands each async shard into one agent URI
per (database, tenant) when the store reports DistributesAgentsPerTenant, registering
the per-tenant ShardName (ForTenant) so ResolveShardNameAsync round-trips it and the
daemon starts a per-tenant agent. Databases with no tenants keep the store-global agent.
Fixes co-located tenants on a shard sharing one high-water (lagging tenant skipped).
See #3280.
… agents per tenant

Covers SupportedAgentsAsync emitting one agent URI per tenant when
IEventStore.DistributesAgentsPerTenant is true and the database has assigned
tenants, and falling back to per-shard when it is not.
…inity)

When a sharded store sets IEventStore.GroupAgentAssignmentsByDatabase,
EventSubscriptionAgentFamily assigns event-subscription agents grouped by their
shard database (URI database segment) via a new greedy least-loaded
DistributeByGroupAffinity, keeping every agent of a database on one node and
balancing total agent count across nodes. Each node then opens connection pools
only to the databases it owns. Default path (even distribution) unchanged. Unit
test covers the grouping + balance. See JasperFx/marten#4806.
DistributeByGroupAffinity gains a maxNodesPerGroup bound: a shard database's
agents are placed on its k = min(bound, size, nodes) least-loaded nodes and
greedily balanced, so a heavy database parallelizes across up to N nodes while
its server connection ceiling stays N x pool. EventSubscriptionAgentFamily passes
the largest MaxNodesPerDatabaseForAgents among affine stores. maxNodesPerGroup=1
is the prior strict-affinity behavior. Tests cover fan-out + small-group cases.
See JasperFx/marten#4806.
…KeyOf

Covers the branch selection in EventSubscriptionAgentFamily (affine grouping
when a store opts in, even blue/green spread otherwise), that the family plumbs
the store's MaxNodesPerDatabaseForAgents bound into the grid, DatabaseKeyOf URI
grouping/fallback, and the EventStoreAgents pass-through of both flags. Fast
unit tests over an AssignmentGrid with NSubstitute stores. See JasperFx/marten#4806.

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
New section under Projection/Subscription Distribution documenting the opt-in
database-affine assignment driven by Marten's UseDatabaseAffineAgentAssignment /
DatabaseAffineAgentFanout, surfaced via IEventStore.GroupAgentAssignmentsByDatabase
and MaxNodesPerDatabaseForAgents. See JasperFx/marten#4806.

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
MartenIntegration gains UseDatabaseAffineAgentAssignment + DatabaseAffineAgentFanout,
bridged onto the Marten event store (main store only) via the existing MartenOverrides
IConfigureMarten hook, so the database-affine distribution can be configured where you
opt into Wolverine-managed distribution rather than only on StoreOptions.Events. Doc
updated to show the IntegrateWithWolverine surface. See JasperFx/marten#4806.

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
Guards the MartenOverrides bridge (resolving MartenIntegration and applying the settings
to StoreOptions.Events) against a DI-resolution regression: asserts UseDatabaseAffine-
AgentAssignment / DatabaseAffineAgentFanout set on IntegrateWithWolverine land on
Events, and that defaults are unchanged when not configured. See JasperFx/marten#4806.

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
DistributeByGroupAffinity loses the maxNodesPerGroup bound: a group (shard database)
is always placed whole on the least-loaded node, largest-first, deterministic. The
family/EventStoreAgents fan-out pass-throughs and the MartenIntegration fanout setting
go with it — the mix was speculative and never needed >1; a minimal surface is easier
to accept upstream. Tests + docs updated. See JasperFx/marten#4806.

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
…on test

- Per-tenant fan-out now dedupes tenant ids case-insensitively (matching
  TryResolveTenantDatabaseIdAsync) and skips blank ids, so a malformed usage
  descriptor cannot yield duplicate or invalid per-tenant agents. Unit-tested.

- New integration test sharded_tenant_partitioned_distribution: the #3280 regression
  end-to-end — MultiTenantedWithShardedDatabases + UseTenantPartitionedEvents with two
  tenants CO-LOCATED on one shard database under managed distribution asserts one agent
  per tenant (":all/<tenant>" URIs) and that both tenants' events project against
  their own high-water marks. Tenants are provisioned before host start; adding a
  database's FIRST tenants at runtime leaves the empty-phase store-global agent running
  alongside the new per-tenant agents until assignments reconcile — noted in the test
  as a known transition edge. Runs green locally against the companion #482 +
  marten#4799 builds; goes green in CI once those merge (stated merge order).

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
…s it from store cardinality

Drops MartenIntegration.UseDatabaseAffineAgentAssignment, the MartenOverrides
bridge onto StoreOptions.Events, the EventStoreAgents.GroupAgentAssignmentsByDatabase
passthrough (the revised JasperFx.Events contract no longer carries that member),
the opt-in branch in EventSubscriptionAgentFamily.EvaluateAssignmentsAsync, the
flag-specific test suites, and the opt-in docs section. Database-affine assignment
comes back in the next commits keyed off IEventStore.DatabaseCardinality instead
of an explicit flag. Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… per store

EventSubscriptionAgentFamily.EvaluateAssignmentsAsync now partitions the scheme's
agents per owning store (StoreKeyOf reproduces the _stores key from the agent URI's
type/name prefix): every store whose DatabaseCardinality is StaticMultiple or
DynamicMultiple gets its agents distributed with group affinity by DatabaseKeyOf,
while all remaining agents keep the even blue/green distribution — no opt-in flag.

AssignmentGrid grows Func<Uri,bool>-filtered overloads of DistributeEvenly,
DistributeEvenlyWithBlueGreenSemantics, DistributeByGroupAffinity,
AllNodesHaveSameCapabilities, MatchAgentsToCapableNodesFor and
AvailableAgentsForScheme; the existing signatures delegate to them with an
all-pass filter. Filtered passes count per-node load only over their own agent
subset so independent passes of one scheme never detach each other's agents.

Docs updated to describe the automatic behavior. Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Group placement now mirrors DistributeEvenlyWithBlueGreenSemantics' capability
semantics: homogeneous capabilities keep the capability-blind placement; when
node capabilities differ, a group's candidate nodes are those capable of every
member (via MatchAgentsToCapableNodesFor), the least-loaded candidate hosts the
whole group with the existing tie-breaks, and when no node can host the whole
group each member falls back individually to its least-loaded capable node —
an agent no node declares a capability for is left exactly as the even path
leaves it. Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shard

The assignment grid is seeded from every node's ActiveAgents, so when a database
gains its first tenant at runtime the old store-global agent is still present and
running in the grid even though SupportedAgentsAsync now enumerates per-tenant
agents instead — the distribution would keep it assigned and it would process
events concurrently with its replacements (double-processing). EvaluateAssignmentsAsync
now first detaches any agent of the scheme that (a) is no longer in the supported
set and (b) shares a tenant-neutral key — store/database/projection/shardKey/version,
tenant slot stripped per the ShardName.RelativeUrl grammar — with a supported
agent, and excludes it from both distribution passes; a detached running agent is
exactly what TryBuildAssignmentCommand turns into StopRemoteAgent. The inverse
transition (tenant removed -> its per-tenant agent superseded by the remaining
scoping) retires the same way.

Deliberately narrower than 'not in the supported set': blue/green rollouts
legitimately run agents the evaluating node's store doesn't enumerate (new
projections/versions matched via node capabilities), and a version bump changes
the version slot, not the tenant slot, so those keep running untouched.
Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ale retirement

Replaces the flag-based family suite with event_subscription_family_cardinality_assignment
(affinity auto-on for Static/DynamicMultiple stores, even spread for Single/None,
mixed-store independence, blue/green capabilities honored per pass, node-loss
failover regrouping on survivors), adds event_subscription_family_stale_agent_retirement
(store-global agent stopped when a database gains its first tenants, per-tenant agent
stopped when its tenant is removed, unrelated/other-version agents left running),
tenant_neutral_key_of grammar tests, and capability-aware DistributeByGroupAffinity
tests (group lands only on a capable node; per-member fallback parks agents no node
declares). Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…automatically

Two shard databases with two co-located tenants each across a two-node cluster:
the Marten store's multi-database cardinality alone (no opt-in) makes managed
distribution assign each database's per-(shard, tenant) agents together on one
node, and spread the two database groups one per node. Requires the Marten side's
DistributesAgentsPerTenant override, so like sharded_tenant_partitioned_distribution
this goes green once the companion Marten release ships.
Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lity snapshots

Two even-path semantics the group-affinity placement was missing, both exposed
by MartenTests' MultiTenantContext suites (spread_databases_out_via_host):

1. Grandfathering: node capability snapshots are persisted once at node startup,
   so a node that started before its store's tenant databases were provisioned
   declares no event-subscription capabilities even though it runs those agents.
   The even paths leave running agents in place regardless of capabilities; a
   node already running part of a group is now a candidate for that group.

2. Minimal disruption with a ceiling: the even paths only move agents off a node
   when it exceeds ceil(agents/nodes). Group placement reshuffled every group
   from scratch each evaluation, so a stale-capability node could be starved
   permanently once an intermediate evaluation moved its groups away. The node
   currently running a WHOLE group now keeps it unless that pushes it past the
   ceiling; only over-ceiling groups move (to the least-loaded candidate, same
   tie-breaks). The no-candidate fallback likewise leaves already-assigned
   members in place. Epic: JasperFx/jasperfx#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JasperFx 2.20.0 (#487): IEventStore.DistributesAgentsPerTenant +
hardened per-tenant StartAgentAsync. Marten 9.13.0-alpha.2 (marten#4856):
DocumentStore reports DistributesAgentsPerTenant for sharded +
tenant-partitioned stores — required by the two end-to-end distribution
tests. (alpha.1 was a burned version: nuget.org serves stale 2026-06-27
bits for it, without the override.)

Marten 9.13 also moved the storage-operation contract (marten#4820/#4821/
#4861 storage-dialect extraction): ConfigureCommand now takes
Weasel.Postgresql.ICommandBuilder + Weasel.Storage.IStorageSession, and
NoDataReturnedCall lives in Weasel.Storage — both envelope operations
adapted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit f962c18 into main Jul 7, 2026
24 of 26 checks passed
jeremydmiller added a commit that referenced this pull request Jul 7, 2026
…urn/failover e2e, #4862 regression pin (#486) (#3334)

* Consume Marten 9.13.0-alpha.3 + JasperFx 2.21.0 (epic #486 WS0/WS1)

Marten 9.13.0-alpha.3 carries the per-tenant distribution work this PR's
e2e tests exercise: marten#4864 (broadened DistributesAgentsPerTenant gate
+ single-DB descriptor TenantIds), marten#4865 (ExternallyManaged honored),
marten#4869 (per-tenant high-water advancement — unblocks the failover
floor-continuity test), and marten#4870 (native per-tenant distribution).

JasperFx/JasperFx.Events pins move 2.20.0 -> 2.21.0 to match what Marten
alpha.3 already pulls transitively — keeps the explicit pins from drifting
below the resolved graph. No envelope-operation contract drift from
marten#4872 (Weasel 9.12 metadata binders): Wolverine.Marten's
StoreIncomingEnvelope/StoreOutgoingEnvelope compile unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* E2E: runtime tenant churn under Wolverine-managed distribution (#486 WS1)

Sharded + UseTenantPartitionedEvents, single node: with tenant-a's per-tenant
agent already projecting, add tenant-b to the same shard database at RUNTIME via
Advanced.AddTenantToShardAsync. Within the assignment-evaluation window
(CheckAssignmentPeriod = 1s) the leader re-enumerates the store usage, fans out
the ":all/tenant-b" agent, tenant-b's fresh seq-1.. events project, its
HighWaterMark-independent PartitionedCounter:All:tenant-b progression row lands
in the shard database, and no store-global (tenant-less) agent ever runs
alongside the per-tenant agents (the #3328 stale-agent-retirement edge).

Tenant REMOVAL is deliberately not covered end-to-end: Marten 9.13.0-alpha.2
exposes RemoveTenantAsync/DisableTenantAsync (ShardedTenancy.cs:378/:470), but a
running store's usage never shrinks - MartenDatabase.TenantIds is add-only
(Fill at ShardedTenancy.cs:211/:364) and DescribeDatabasesAsync copies that
in-memory list, so Wolverine keeps enumerating the removed tenant's agent until
a process restart. The Wolverine half of the removal transition stays pinned by
the running_per_tenant_agent_is_stopped_when_its_tenant_is_removed unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* E2E (pinned/skipped): failover preserves per-tenant progression floors (#486 WS1)

Two nodes, sharded + UseTenantPartitionedEvents, two tenants co-located on one
shard database: append N events per tenant, record each tenant's progression
floor, kill the node owning the affine agent group, assert the survivor picks
both agents up and resumes tenant-b exactly from its floor (doc == N+M, no
skips, no rewind) after M more post-failover events.

SKIPPED, pinning a Marten 9.13.0-alpha.2 product bug found by this test: the
failover mechanics work (survivor restarts both agents, floors never rewind,
tenant-a intact), but per-tenant high-water detection FREEZES at the first
persisted HighWaterMark:<tenant> row -
HighWaterDetector.loadPerTenantStatistics
(src/Marten/Events/Daemon/HighWater/HighWaterDetector.cs:260):
    var currentMark = lastSeqId > 0 ? lastSeqId : lastValue;
Once the row exists, CurrentMark is pinned to it and max(seq_id) is never
consulted again, so ANY second batch of events per tenant never projects (not
failover-specific; sibling tests all append one batch per tenant and never see
it). Empirically confirmed: tenant-b at max seq_id 11 committed, store-global
HighWaterMark advanced to 11 (global detection + per-tenant poll trigger both
firing), HighWaterMark:tenant-b frozen at 6. Unskip on the Marten-side fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unskip the failover floor-continuity test — fixed by marten#4869 in 9.13.0-alpha.3

The skip pinned marten#4867: per-tenant high-water detection froze at the first
persisted HighWaterMark:<tenant> row, so the M post-failover tenant-b events
never projected. Marten 9.13.0-alpha.3 advances per-tenant CurrentMark past the
persisted row (marten#4869), which makes the final leg pass. The class comment
keeps a condensed history of the pinned bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Regression test for marten#4862: single-DB tenant-partitioned per-tenant distribution (#486 WS1)

Promotes the WS1 single-database fact-finding probe (found the bug 2026-07-06)
into the marten#4862 regression test. Against Marten 9.13.0-alpha.2 a
single-database store reported DistributesAgentsPerTenant = false, so managed
distribution ran ONE store-global agent whose progression floor (driven to the
leading tenant's max per-tenant seq) permanently skipped a lagging tenant's
later events at per-tenant seq 1..N — the wolverine#3280 failure mode on one
database. Marten 9.13.0-alpha.3 (marten#4864) broadens the gate to any
tenant-partitioned store, so the startup expectation flips to per-tenant
fan-out (2 tenants -> 2 agents) and the lagging-tenant projection assertions
now pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Flip single-DB distribution granularity tests to per-tenant fan-out (marten#4862/#4864)

With Marten 9.13.0-alpha.3, DistributesAgentsPerTenant is true for ANY
tenant-partitioned store, so one async projection over a single
tenant-partitioned database now fans out one subscription agent per
managed tenant instead of a single store-global "/all" agent whose lone
progression floor could silently skip a lagging tenant.

- tenant_partitioned_distribution_granularity: 1 agent -> 3 (one per
  registered tenant, default tenant included), agent URIs carry the
  tenant in the shard path
- tenant_partitioned_distribution_multinode: 2 store-global agents -> 6
  per-tenant agents (2 projections x 3 tenants), spread 3+3 across the
  cluster and all reassigned to the survivor on node loss
- AssignmentWaiter.WritePersistedActualsAsync no longer throws
  KeyNotFoundException when the database holds a node record the waiter
  isn't tracking (ghost rows from a hard-stopped prior run); it reports
  the untracked node instead so the TimeoutException diagnostic survives

Part of #486 WS1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ancillary stores: dispatch message-store shape on database cardinality, not tenancy type name

Marten 9.13.0-alpha.3 (marten#4864) gives a single-database store with
UseTenantPartitionedEvents a TenantPartitionedSingleDatabaseTenancy — a
DefaultTenancy subclass, chosen precisely so 'is DefaultTenancy' dispatch
keeps working. The ancillary IntegrateWithWolverine seam compared
Tenancy.GetType().Name == "DefaultTenancy" instead, so such a store was
misrouted to the multi-tenanted message-database path and host startup
failed with "no configured connectivity for the required master PostgreSQL
message database" after 20 broker retries (the CIMarten failure on this PR).

Both the Marten and Polecat ancillary seams now dispatch on
Tenancy.Cardinality == DatabaseCardinality.Single, mirroring the main-store
integration (WolverineOptionsMartenExtensions), which is why only ancillary
stores were affected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 14, 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

2 participants