Skip to content

GH-4562: grandfather a group-affinity candidate per member, not per partition - #4563

Merged
jeremydmiller merged 2 commits into
JasperFx:mainfrom
erdtsieck:GH-4562-group-affinity-grandfathering-per-member
Sep 23, 2026
Merged

jeremydmiller merged 2 commits into
JasperFx:mainfrom
erdtsieck:GH-4562-group-affinity-grandfathering-per-member

Conversation

@erdtsieck

Copy link
Copy Markdown
Contributor

Closes #4562.

Problem

In DistributeByGroupAffinity, a node became a candidate for a whole capability partition as soon as it ran one member (|| members.Any(m => m.OriginalNode == n)). The sibling preference then picks that node, because it already hosts the database's other partition.

During a blue/green rollout, blue runs the agents of a tenant provisioned after blue started without declaring them. Only green declares those agents, so they share a partition with the new version's agents. Blue then took that whole partition, new version included. It cannot build those agents, so they never started, and the leader re-sent them to blue on every evaluation.

Fix

The grandfathering moves inside All: a node qualifies when every member is either declared by it or already running on it. A node that runs the whole partition, the stale-snapshot case the grandfathering exists for, still qualifies. A node that runs only part of it no longer does.

Tests

a_node_running_part_of_a_partition_is_not_a_candidate_for_members_it_cannot_run reproduces this. On main it fails because the bumped agent lands on the blue node.

Everything else in CoreTests.Runtime.Agents is still green (387 tests), including a_node_already_running_a_groups_agents_stays_a_candidate_despite_a_stale_capability_snapshot, an_incumbent_node_keeps_its_group_up_to_the_ceiling and the blue/green split tests. I did not run the MartenTests blue/green integration tests locally.

I also replayed a real 6-node production grid snapshot through DistributeByGroupAffinity. The affected partitions were exactly the three databases holding such a tenant.

…ot per partition

A node running one member of a capability partition became a candidate for
the whole partition, so during blue/green a blue node running a tenant it
never declared took the new version's agents with it and could never start
them.
… it is running

The per-member grandfathering removes the clause that used to carry a
partially-running group: a node running ONE member no longer stands in as a
candidate for the rest. That is the point of the fix for the blue/green case,
but it also withdraws the only thing holding together a partition that NO node
declares — a shard database provisioned after every surviving node captured its
capability snapshot (JasperFxGH-3341).

The JasperFxGH-3341 rescue below did not cover it, because it also required every
member to be unassigned. A database with two tenants placed by an earlier
evaluation and a third provisioned since fell through to the per-member
fallback and scattered one agent per node, opening a connection pool per node
to that database — the exact cost DistributeByGroupAffinity exists to avoid
(JasperFx/marten#4806). Verified: that scenario is whole on main and split on
the branch before this commit.

Every member of a partition shares one capabilityKey, which is the sorted ids
of its candidate nodes, so a partition is either wholly undeclared or wholly
declared. The unassigned clause was therefore always redundant with the
capability test, and only looked load-bearing while a single running member
covered for the rest.

Tests:
  - a_partially_running_undeclared_group_still_stays_whole reproduces it; red
    with the unassigned clause restored, green without.
  - a_node_running_part_of_a_partition_is_not_a_candidate_for_members_it_cannot_run
    now asserts all five placements rather than three. The unchanged agent that
    shares a capability set with the bumped ones moves off blue with them, which
    is the price of an indivisible partition and worth stating outright.
  - the_blue_green_partial_grandfather_split_settles_on_the_next_evaluation
    replays the result as the next evaluation's grid. It guards churn rather
    than the fix — it passes on the old predicate too, because the grid cannot
    model a node failing to BUILD what it was handed.

364 tests green in CoreTests.Runtime.Agents, 3035 in CoreTests, and
wolverine.slnx builds clean in Release on net9.0 with no warnings.

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

Copy link
Copy Markdown
Member

Thanks for the detailed writeup and the production replay — the diagnosis is right, and the fix is right. I pushed one commit to this branch (fd80264) rather than asking you to round-trip it. Here is what it does and why.

The extra commit

Moving the grandfathering inside All withdraws something the old Any(m => m.OriginalNode == n) was also doing: holding together a partition that no node declares at all. That is the GH-3341 stale-snapshot case — a shard database provisioned after every surviving node captured its capability snapshot.

The GH-3341 rescue below the candidate filter did not cover it, because it also required every member to be unassigned:

if (members.All(m => m.AssignedNode == null && m.CandidateNodes.Count == 0))

While a single running member grandfathered its node into the candidates, that extra clause never mattered — the candidate filter got there first. Per-member grandfathering removes that cover, so a database with two tenants placed by an earlier evaluation and a third provisioned since now falls through to the per-member fallback and scatters one agent per node. That opens a connection pool per node to that database, which is the cost DistributeByGroupAffinity exists to avoid (JasperFx/marten#4806).

Concretely, two nodes with divergent snapshots and neither declaring db1, node 1 running t1/t2, t3 newly provisioned:

main:     t1 -> node 1, t2 -> node 1, t3 -> node 1   (whole)
this PR:  t1 -> node 1, t2 -> node 1, t3 -> node 2   (split)

The fix drops the unassigned clause:

if (members.All(m => m.CandidateNodes.Count == 0))

That is safe because every member of a partition shares one capabilityKey — the sorted ids of its candidate nodes — so a partition is either wholly undeclared or wholly declared. The unassigned clause was always redundant with the capability test.

Worth recording for whoever reads this next: that same capabilityKey property bounds the blast radius of your change. When a partition's candidate set is non-empty it always contains the declaring nodes, so candidates can only go empty for a wholly-undeclared partition. The undeclared case above is therefore the only behaviour the per-member tightening can alter, which is why one added test closes it.

Tests

  • a_partially_running_undeclared_group_still_stays_whole — the scenario above. Red with the unassigned clause restored, green without.
  • a_node_running_part_of_a_partition_is_not_a_candidate_for_members_it_cannot_run — your test, extended from three placements to all five. The unchanged agent sharing a capability set with the bumped ones moves off blue with them; that is the price of an indivisible partition, and it is better asserted than left as an untested side effect.
  • the_blue_green_partial_grandfather_split_settles_on_the_next_evaluation — replays the result as the next evaluation's grid. This one is a churn guard, not a proof of the fix: it passes on the old predicate too, because the grid cannot model a node failing to build what it was handed. Noted in the test so it is not mistaken for coverage it does not provide.

Verification

364 tests green in CoreTests.Runtime.Agents, 3035 in CoreTests, and wolverine.slnx builds clean in Release on net9.0 with no warnings. The MartenTests blue/green tests you mentioned not running locally are covered by CI (CIMartenTenancy, CIMartenDistribution) and were green on your commit.

One note on those: blue_green_version_bump_assignment cannot catch this class of defect. Its BuildGrid() has blue running only what it advertises, so the post-snapshot-tenant shape — a node running an agent it does not declare — never occurs end to end. The placement logic is covered by the unit tests instead.

Merging once CI is green. Thanks again for chasing this down to the grid snapshot.

@jeremydmiller
jeremydmiller merged commit 0e14671 into JasperFx:main Sep 23, 2026
44 checks passed
jeremydmiller added a commit that referenced this pull request Sep 24, 2026
… breaks again (#4601)

The reported symptom -- green-only projection agents assigned to blue nodes that cannot
build them, re-sent on every evaluation for twelve hours -- reproduces at the reporter's
fleet shape against the AssignmentGrid.Distribution.cs from before GH-4563, and does not
reproduce against main. So GH-4562/#4563 fixed it; it shipped after the 6.39.1 they are
running. The suite pins that at their shape rather than the two-node minimum the existing
group-affinity tests use.

The other half of the report is not fixed by that, and this adds it: the leader said
nothing at all while re-issuing a placement the target had failed to honour, so the loop
had to be reconstructed from application logs on the receiving side. The leader now warns
when it dispatches an assignment to a node that does not advertise the agent, while some
other node does. Scoped to the assignments actually being issued -- in a settled cluster
that is none -- and latched on the count so a steady state is not re-logged every
evaluation. Agents that NO node declares are ignored: durability and listener agents are
advertised per family rather than per URI, and the GH-3341 rescue places an undeclared
agent deliberately.

AssignmentGrid.Node.Declares backs it with a lazily built set. A node in a fleet running
tens of thousands of projection agents carries thousands of capabilities, and Capabilities
is a List, so the existing TryAssign scan was linear per call; it takes the set too.


Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 24, 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

Development

Successfully merging this pull request may close these issues.

Group affinity sends new-version agents to a blue node that runs only part of their partition

2 participants