Skip to content

Managed distribution: per-tenant agents + database-affine assignment for sharded stores (#3280, marten#4806)#3281

Closed
erdtsieck wants to merge 12 commits into
JasperFx:mainfrom
erdtsieck:feat/per-tenant-agent-distribution
Closed

Managed distribution: per-tenant agents + database-affine assignment for sharded stores (#3280, marten#4806)#3281
erdtsieck wants to merge 12 commits into
JasperFx:mainfrom
erdtsieck:feat/per-tenant-agent-distribution

Conversation

@erdtsieck

@erdtsieck erdtsieck commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Wolverine piece of #3280 and JasperFx/marten#4806. Depends on JasperFx/jasperfx#482 and pairs with JasperFx/marten#4799.

Per-tenant agents (#3280)

EventStoreAgents.SupportedAgentsAsync fans out one agent per (shard, tenant) when the store reports DistributesAgentsPerTenant — fixing sharded + tenant-partitioned stores where co-located tenants shared one high-water mark and a lagging tenant's events were skipped. Store-global and database-per-tenant stores keep one agent per (shard, database), unchanged.

Database-affine assignment (marten#4806, opt-in)

With per-tenant agents across many shard databases, the default even spread makes every node open connection pools to (nearly) every database — pools grow as nodes × databases and exhaust a shared server's max_connections (observed in production-scale testing: Postgres 53300 at ~9% DB CPU — the ceiling was connections, not hardware). When a store reports GroupAgentAssignmentsByDatabase:

  • New AssignmentGrid.DistributeByGroupAffinity(scheme, groupKey): every agent sharing a group key (the [store type]/[store name]/[database] prefix of the agent Uri) is placed whole on the least-loaded node, largest group first, with deterministic tie-breaks — so a node only connects to the databases it owns and the connection budget scales with the topology (databases), not the deployment (replicas).
  • EventSubscriptionAgentFamily.EvaluateAssignmentsAsync uses it when any registered store opts in; otherwise the existing DistributeEvenlyWithBlueGreenSemantics path is untouched. (The affine path does not apply blue/green capability matching — called out in code + docs.)
  • Convenience: IntegrateWithWolverine(m => m.UseDatabaseAffineAgentAssignment = true) flows through to StoreOptions.Events.UseDatabaseAffineAgentAssignment for the main store.

Tests & docs

Unit tests for the per-tenant fan-out, grid affinity (grouping, spreading, balancing around a heavy database), family branch selection, the Uri group key, and the IntegrateWithWolverine bridge; the distribution guide documents the opt-in.

Merge order: #482 → Marten #4799 → this.

…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 JasperFx#3280.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Wolverine’s managed event-subscription distribution to correctly create per-tenant async projection agents when using a store that requires per-tenant distribution (intended for sharded databases + tenant-partitioned events, per #3280), preventing co-located tenants from incorrectly sharing a single high-water mark.

Changes:

  • Precomputes the set of async projection shard names once per SupportedAgentsAsync call.
  • Expands async shard agent URIs to one agent per (database, tenant, shard) when _store.DistributesAgentsPerTenant and the database descriptor has known tenants; otherwise preserves the existing store-global agent behavior.
  • Registers per-tenant ShardName entries into the shard-name cache so ResolveShardNameAsync can round-trip agent URIs back to shard definitions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +101 to +109
if (_store.DistributesAgentsPerTenant && database.TenantIds.Count > 0)
{
foreach (var tenantId in database.TenantIds.Distinct())
{
var tenantShard = shardName.ForTenant(tenantId);
_shardNames = _shardNames.AddOrUpdate(tenantShard.RelativeUrl, tenantShard);
list.Add(EventSubscriptionAgentFamily.UriFor(_store.Identity, id, tenantShard));
}
}
Comment on lines +95 to +99
// wolverine#3280: under sharded databases + per-tenant event partitioning, multiple tenants are
// co-located in one shard database and each draws its own event sequence, so a single store-global
// agent per (shard, database) cannot track them — fan out one agent per (shard, tenant) instead.
// For any other store (or a database with no tenants yet) keep the one store-global agent.
void addAgentsForShard(DatabaseDescriptor database, DatabaseId id, ShardName shardName)
erdtsieck added 8 commits July 3, 2026 19:33
… 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
@erdtsieck erdtsieck changed the title Managed distribution: per-tenant agents for sharded + tenant-partitioned stores (#3280) Managed distribution: per-tenant agents + database-affine assignment for sharded stores (#3280, marten#4806) Jul 5, 2026
jeremydmiller and others added 3 commits July 5, 2026 10:01
…ntegration 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 JasperFx#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 JasperFx#482 +
  marten#4799 builds; goes green in CI once those merge (stated merge order).

Claude-Session: https://claude.ai/code/session_01LXFw8u9F71jLhMTFNi9DyX
@erdtsieck

Copy link
Copy Markdown
Contributor Author

Addressed both Copilot findings in a38bce8:

  1. Tenant-id normalization — the per-tenant fan-out now dedupes tenant ids with StringComparer.OrdinalIgnoreCase (consistent with TryResolveTenantDatabaseIdAsync) and skips blank/whitespace ids, so a malformed usage descriptor can't produce duplicate or invalid per-tenant agents. Covered by a new unit test (dedupes_tenants_case_insensitively_and_skips_blank_ids).

  2. The Managed event-subscription distribution: no per-tenant agents under MultiTenantedWithShardedDatabases + UseTenantPartitionedEvents (Phase 3) → co-located tenants share one high-water, lagging tenant's events skipped #3280 integration test — new sharded_tenant_partitioned_distribution: MultiTenantedWithShardedDatabases + UseTenantPartitionedEvents with two tenants co-located on one shard database under managed distribution, asserting one :all/<tenant> agent per tenant and that both tenants' events project against their own high-water marks (the exact failure mode of Managed event-subscription distribution: no per-tenant agents under MultiTenantedWithShardedDatabases + UseTenantPartitionedEvents (Phase 3) → co-located tenants share one high-water, lagging tenant's events skipped #3280). It runs green locally against the companion Azure Service Bus use topics with conventional routing #482 + marten#4799 builds and will go green in CI once those merge, per the stated merge order.

One observation from writing the test, noted there as a known transition edge: when a database gains its first tenants while the host is running, the store-global agent from the empty-database phase keeps running alongside the new per-tenant agents until assignments reconcile. The test provisions tenants before host start; retiring the stale agent on that transition may deserve a small follow-up.

@jeremydmiller

Copy link
Copy Markdown
Member

Thank you for this work @erdtsieck — the per-(shard, tenant) fan-out and the test coverage here are solid, and we're going to reuse them. Closing this PR only because the design underneath it is shifting on the JasperFx side and it's cleaner to restart than rebase:

  • Azure Service Bus use topics with conventional routing #482 is being revised to drop IEventStore.GroupAgentAssignmentsByDatabase entirely. Database-affine agent grouping will not be an opt-in flag: Wolverine's managed distribution will apply it always, per store, for any event store reporting a multi-database DatabaseCardinality (StaticMultiple/DynamicMultiple), while single-database stores keep today's even + blue/green distribution. That removes the app-wide flag semantics and the Marten/Wolverine configuration surface (UseDatabaseAffineAgentAssignment) altogether — less for users to configure, and it aligns Wolverine's placement with what Marten's native MultiTenantedProjectionDistributor already does (whole database per node).
  • The successor PR will also address the runtime first-tenant transition (retiring the store-global agent once per-tenant agents fan out) and add the affine end-to-end + failover coverage.

The EventStoreAgents fan-out and the test suites from this branch will be carried into the successor with authorship retained. Tracked under the per-tenant partitioning epic: JasperFx/jasperfx#486.

🤖 Generated with Claude Code

@erdtsieck

Copy link
Copy Markdown
Contributor Author

One addition for the successor PR, from running this at production scale (500 tenants / 512 sharded databases, field notes on #486): a complement to the "retire stale store-global agents on the first-tenant transition" bullet in the close-out.

The transition edge has two sides, and never-creating is cleaner than retiring-after-the-fact:

  • Retire (already noted): a store-global agent that was started while a database was empty must be stopped once per-tenant agents appear.
  • Never create (this change): under DistributesAgentsPerTenant, an empty database should yield no agent at all — not a store-global one. With per-tenant event partitioning every event lives in a tenant partition, so an empty database has nothing to process, and the store-global agent started for it is exactly the one that then lingers and double-processes next to the per-tenant agents once tenants arrive (both materialize the same per-tenant progression rows → sustained 23505 storms — we hit this hard during provisioning). If it's never created, there's nothing to retire; the tenant's agent simply appears on the next SupportedAgentsAsync evaluation.

Isolated commit (fan-out change + unit test that asserts an empty per-tenant database yields zero agents): erdtsieck/wolverine@dfeccbe97 on branch feat/per-tenant-empty-db-no-agents. Depends on the DistributesAgentsPerTenant surface, so it slots into the successor rather than standing alone. Reuse freely — authorship isn't the point, just flagging the edge so it doesn't get lost.

jeremydmiller added a commit that referenced this pull request Jul 7, 2026
…y-cardinality

Per-tenant agent fan-out + automatic database-affine assignment by store cardinality (supersedes #3281)
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.

3 participants