Skip to content

Agent assignment record goes stale during a handoff, and an event-subscription agent can end up running on two nodes at once #4240

Description

@jeremydmiller

Summary

An event-subscription agent can end up running on two nodes at once while wolverine_nodes
credits only one of them — so nothing in the system can ever stop the extra copy. Downstream in
CritterWatch this presents as a projection that keeps building read models after an operator has
paused it, and as two async daemons writing one shard concurrently.

The repro below is in your own harness (MartenTests/Distribution). ⚠️ It reproduces the
precondition — a window where the durable assignment record disagrees with reality — but not yet the
orphan itself.
The orphan is measured downstream at ~3 in 8 runs; I could not make it fall out of
this test in 24 runs across three configurations. I have listed those negative results rather than
leaving them for you to retread, and I have kept my suspected mechanism clearly marked as a pointer,
because a 100ms-poll experiment argued against it.

What is reproducible here: the durable record goes stale during a handoff

Node 1 starts alone and owns both projection agents; nodes 2 and 3 join; the leader redistributes.
Sampling in-process AllRunningAgentUris() against Storage.Nodes.LoadAllNodesAsync() every 20ms
(change-only lines):

t+806ms    IN n1=[day/all,trip/all]                  ||  DUR n1=[day/all,trip/all]
           (nodes 2 and 3 join)
t+1,783ms  IN n1=[trip/all] | n2=[]                  ||  DUR n1=[day/all,trip/all] | n2=[]   <-- n1 STOPS day
t+1,978ms  IN n1=[trip/all] | n2=[] | n3=[]          ||  DUR n1=[day/all,trip/all] | n2=[] | n3=[]
t+2,042ms  IN n1=[trip/all] | n2=[] | n3=[]          ||  DUR n1=[trip/all] | n2=[] | n3=[]   <-- record catches up
t+2,128ms  IN n1=[trip/all] | n2=[day/all] | n3=[]   ||  DUR n1=[trip/all] | n2=[day/all]

For 259ms the agent is running on no node at all, while the durable record still says node 1 owns
it.
That is the window. My question is first of all whether it is intended.

What is NOT reproducible here: the orphan

Downstream (CritterWatch's EventStoreCoordinationTests, same Marten primary + AddMartenStore<T>()
ancillary shape, FirstHealthCheckExecution=250ms / HealthCheckPollingTime=500ms), the same window
is ~780ms, and inside it this happens:

t+621ms    IN n1=[trip]              ||  DUR n1=[day,trip]      <-- n1 STOPS day
           ... 35 consecutive 20ms samples, 778ms, no change ...
t+1,399ms  IN n1=[day,trip]          ||  DUR n1=[day,trip]      <-- n1 STARTS day AGAIN
t+1,421ms  IN n1=[day,trip] n2=[day] ||  DUR n1=[trip] n2=[day] <-- reassignment lands

Node 1 restarts the agent while the stale record still stands, the reassignment completes 22ms later
and starts it on node 2, and nothing stops node 1's copy. Steady state is two live agents and one
assignment record, stable indefinitely.

It is genuinely running, not a stale AgentStatus: after an operator pause that the system reported
as successful, seeding 5 new event streams produced 5 new read-model documents in 1 second.

Correlation downstream: 5 failures all with a duplicate present before the pause, 41 passes all
without — 46 runs.

⚠️ Pointer, not a diagnosis — and evidence against it

The obvious story is that a health-check tick lands inside the gap, NodeAgentController.HeartBeat
calls EvaluateAssignmentsAsync(nodes, …) with nodes read from the durable table, and the agent is
restored on the source from a record that has not caught up.

I do not believe that is sufficient, and here is why: dropping this test to
HealthCheckPollingTime = 100ms makes a tick inside the 259ms window near-certain, and it produced
no restart in 8 runs. So something more than "a tick fell in the gap" is required, and I have not
found it. Please treat the code reference above as a place to look, not as the cause.

Also unruled-out: what stops the agent at the start of the gap. I never confirmed whether that is the
leader's own stop command for the reassignment or something else.

Negative results, so you don't retread them

variation runs orphan?
this test as written (2 agents, 250ms/500ms) 8 no — but the 259ms gap is present
HealthCheckPollingTime = 100ms 8 no
6 agents (3 projections x 2 stores) 8 no — and the gap closes entirely; IN and DUR move in lockstep
generic IAgentFamily (FakeAgentFamily), no Marten, 3 nodes 8 no — and no gap at all; the durable record tracks in-process exactly
same, with 250ms start / 500ms stop delays on the agents 8 no

The last two are the interesting ones: with a plain agent family the durable record never lags, so
whatever opens the window looks specific to the event-subscription/Marten path rather than to agent
distribution generally.

Downstream, the reproduction is also pathologically timing-sensitive — adding 12s of settling before
the pause suppressed it 5/5, and running two probe variants in one test process suppressed it 8/8
where an exact-match filter gave 3/8. Any harness change is a variable here.

The repro

src/Persistence/MartenTests/Distribution/event_subscription_agent_orphaned_during_handoff.cs.
It asserts the orphan (so it passes today) and always prints the timeline, which is where the gap is
visible. Run it against the standard wolverine-postgresql container.

using IntegrationTests;
using JasperFx.Core;
using JasperFx.Events.Projections;
using Marten;
using MartenTests.Distribution.TripDomain;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Shouldly;
using Weasel.Postgresql;
using Wolverine;
using Wolverine.Marten;
using Wolverine.Runtime;
using Wolverine.Tracking;
using Xunit;

namespace MartenTests.Distribution;

/// <summary>
/// Reproduction: an event-subscription agent ends up RUNNING ON TWO NODES AT ONCE while the durable
/// <c>wolverine_nodes</c> table credits only one of them, so nothing can ever stop the extra copy.
///
/// <para>Shape: node 1 starts alone and owns every projection agent across a primary store and an
/// ancillary <c>AddMartenStore&lt;T&gt;()</c> store. Nodes 2 and 3 then join and the leader
/// redistributes. During the handoff an agent stops on node 1 <b>without the durable assignment
/// record changing</b>; a health-check tick then sees a record that still says node 1 owns it and
/// starts it again — correctly, given that record — and the real reassignment lands ~20ms later,
/// flipping the record to node 2 and starting it there. Nobody stops the copy that came back.</para>
///
/// <para>The health-check cadence is a MAGNIFIER, not the cause — it sets how likely a tick is to
/// land inside the gap. The gap is the defect.</para>
/// </summary>
public class event_subscription_agent_orphaned_during_handoff(ITestOutputHelper output) : IAsyncLifetime
{
    private const string PrimarySchema = "orphan_primary";
    private const string AncillarySchema = "orphan_ancillary";
    private const string Scheme = "event-subscriptions";

    private readonly List<IHost> _hosts = new();
    private readonly List<string> _timeline = new();

    public async ValueTask InitializeAsync()
    {
        await using var conn = new NpgsqlConnection(Servers.PostgresConnectionString);
        await conn.OpenAsync();
        await conn.DropSchemaAsync(PrimarySchema);
        await conn.DropSchemaAsync(AncillarySchema);
        await conn.CloseAsync();
    }

    public async ValueTask DisposeAsync()
    {
        _hosts.Reverse();
        foreach (var host in _hosts)
        {
            try
            {
                host.GetRuntime().Agents.DisableHealthChecks();
                await host.StopAsync();
                host.Dispose();
            }
            catch (Exception e)
            {
                output.WriteLine("Error shutting a host down: " + e);
            }
        }
    }

    private async Task<IHost> startHostAsync()
    {
        var host = await Host.CreateDefaultBuilder()
            .UseWolverine(opts =>
            {
                opts.Durability.Mode = DurabilityMode.Balanced;

                // Aggressive on purpose — see the class comment. The shipped defaults are 3s / 10s,
                // which makes a tick inside the handoff gap rare rather than impossible.
                opts.Durability.FirstHealthCheckExecution = 250.Milliseconds();
                opts.Durability.HealthCheckPollingTime = 500.Milliseconds();

                opts.Services.AddMarten(m =>
                {
                    m.DisableNpgsqlLogging = true;
                    m.Connection(Servers.PostgresConnectionString);
                    m.DatabaseSchemaName = PrimarySchema;
                    m.Projections.Add<TripProjection>(ProjectionLifecycle.Async);
                }).IntegrateWithWolverine(m =>
                {
                    m.UseWolverineManagedEventSubscriptionDistribution = true;
                    m.MessageStorageSchemaName = PrimarySchema;
                    m.TransportSchemaName = PrimarySchema;
                });

                opts.Services.AddMartenStore<ITripStore>(m =>
                {
                    m.DisableNpgsqlLogging = true;
                    m.Connection(Servers.PostgresConnectionString);
                    m.DatabaseSchemaName = AncillarySchema;
                    m.Projections.Add<DayProjection>(ProjectionLifecycle.Async);
                }).IntegrateWithWolverine();
            }).StartAsync();

        _hosts.Add(host);
        return host;
    }

    [Fact]
    public async Task an_event_subscription_agent_never_runs_on_two_nodes_at_once()
    {
        using var stop = new CancellationTokenSource();

        // Node 1 alone, and let it actually take both agents before anyone joins.
        await startHostAsync();
        var sampler = Task.Run(() => sampleAsync(stop.Token), TestContext.Current.CancellationToken);

        var deadline = DateTimeOffset.UtcNow.AddSeconds(60);
        while (runningUris().Distinct().Count() < 2)
        {
            DateTimeOffset.UtcNow.ShouldBeLessThan(deadline, "node 1 never took both projection agents");
            await Task.Delay(50, TestContext.Current.CancellationToken);
        }

        record("### node 1 owns both agents; nodes 2 and 3 joining");
        await startHostAsync();
        record("### node 2 started");
        await startHostAsync();
        record("### node 3 started");

        // Settle well past any legitimate transient double-run.
        await Task.Delay(15.Seconds(), TestContext.Current.CancellationToken);

        var duplicated = runningUris().GroupBy(x => x)
            .Where(g => g.Count() > 1).Select(g => g.Key).ToArray();

        await stop.CancelAsync();
        try { await sampler; } catch (OperationCanceledException) { }

        output.WriteLine("=== TIMELINE (change-only) ===");
        foreach (var line in _timeline) output.WriteLine(line);

        duplicated.ShouldBeEmpty(
            $"agent(s) {string.Join(", ", duplicated)} are running on more than one node 15s after the "
            + "cluster settled — the durable node table credits only one of them, so nothing can stop "
            + "the other. In-process: " + describeInProcess());
    }

    private IEnumerable<string> runningUris() =>
        _hosts.SelectMany(h => h.GetRuntime().Agents.AllRunningAgentUris()
            .Where(u => u.Scheme == Scheme).Select(u => u.ToString()));

    private static string Short(Uri uri) => string.Join("/", uri.Segments
        .Select(s => s.Trim('/')).Where(s => s.Length > 0).TakeLast(2));

    private string describeInProcess() => string.Join(" | ", _hosts.Select(h =>
        $"n{h.GetRuntime().Options.Durability.AssignedNodeNumber}=[" +
        string.Join(",", h.GetRuntime().Agents.AllRunningAgentUris()
            .Where(u => u.Scheme == Scheme).Select(Short).OrderBy(x => x)) + "]"));

    private void record(string line) { lock (_timeline) _timeline.Add(line); }

    private async Task sampleAsync(CancellationToken token)
    {
        var last = "";
        var start = DateTimeOffset.UtcNow;
        while (!token.IsCancellationRequested)
        {
            try
            {
                var inProc = describeInProcess();

                var nodes = await _hosts[0].GetRuntime().Storage.Nodes
                    .LoadAllNodesAsync(CancellationToken.None);
                var durable = string.Join(" | ", nodes.OrderBy(n => n.AssignedNodeNumber).Select(n =>
                    $"n{n.AssignedNodeNumber}=[" +
                    string.Join(",", n.ActiveAgents.Where(u => u.Scheme == Scheme)
                        .Select(Short).OrderBy(x => x)) + "]"));

                // Change-only: a fixed-rate dump of a stable state buries the transition.
                var line = $"IN {inProc}  ||  DUR {durable}";
                if (line != last)
                {
                    record($"t+{(DateTimeOffset.UtcNow - start).TotalMilliseconds:N0}ms  {line}");
                    last = line;
                }
            }
            catch
            {
                // A host mid-start can throw here; sampling is best-effort by design.
            }

            await Task.Delay(20, token);
        }
    }
}

Environment

  • main @ c25db7d9e
  • Wolverine + Marten managed event-subscription distribution, DurabilityMode.Balanced, Postgres
    message store, 3 nodes
  • Downstream observation: CritterWatch, Wolverine 6.31 / Marten 8.35

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions