Skip to content

No supported way to force async projections to catch up under Wolverine-managed event subscription distribution (marten#4904 left the active path to the coordinator) #3697

Closed

Description

@erdtsieck

Under UseWolverineManagedEventSubscriptionDistribution there is no supported way to force async projections to catch up in an integration test, so every suite has to reimplement "quiesce the agents, then force catch-up" — and racing the still-running daemon while doing so.

Background

marten#4904 changed IHost.ForceAllMartenDaemonActivityToCatchUpAsync() to a passive read-only wait when the store runs in DaemonMode.ExternallyManaged, which is the mode Wolverine puts Marten in under Wolverine-managed event-subscription distribution. It no longer builds projections on demand, so a "caught up" wait can return before the read models exist, and the issue explicitly left the active path ("quiesce the external agents, then force catch-up") to the external coordinator. As far as we can tell Wolverine does not implement that path, so there is nothing to call.

What we ended up doing

Our integration suite (sharded multi-tenant store, UseTenantPartitionedEvents, agents distributed by Wolverine) drives it by hand:

var coordinator = services.GetRequiredService<IProjectionCoordinator>();
await coordinator.PauseAsync();               // stop agent assignment so CatchUp is the sole writer
try
{
    foreach (var daemon in await coordinator.AllDaemonsAsync())
    {
        await daemon.StopAllAsync();
        await daemon.CatchUpAsync(cancellation);
    }
}
finally
{
    await coordinator.StartAsync(cancellation);
}

It works, but "sole writer" is not actually guaranteed: the running daemon and the forced catch-up still overlap briefly, and per test run we see a handful of

  • ProgressionProgressOutOfOrderException ("multiple processes try to process the projection"), and
  • 23505: duplicate key value violates unique constraint "pk_mt_event_progression", Key (name)=(<projection>:V<n>:All:<tenant>) already exists — the same race on the first progression row of a shard, where the batch still INSERTs rather than UPDATEs.

Both are transient (a retried catch-up updates the row that now exists), so we retry a few times and then swallow exactly those two. Every suite that uses Wolverine-managed distribution presumably needs the same private workaround, including the knowledge of which exceptions are "just the race".

To be clear: we are not asking you to make the duplicate key go away in our workaround — we are asking for the supported path so the workaround is unnecessary.

Ask

Something like IProjectionCoordinator.ForceCatchUpAsync(CancellationToken) (or making Marten's ForceAllMartenDaemonActivityToCatchUpAsync delegate to the coordinator when the store is ExternallyManaged) that:

  1. suspends agent assignment cluster-wide for the duration, so the forced catch-up really is the only writer of each progression row;
  2. drives every locally reachable daemon to catch up, including per-(database, tenant) shards;
  3. restores the previous assignment state afterwards.

Even without cluster-wide coordination, a single-node/test-oriented variant would remove the two failure modes above, because the insert/update race only exists while both writers are live.

Happy to test a prerelease against a suite that reproduces this a few times per run.

Versions

WolverineFx 6.24.0, Marten 9.20.2, JasperFx(.Events) 2.36.2, .NET 10, PostgreSQL 17.

Activity

  1. jeremydmiller commented on Jul 30, 2026

    @jeremydmiller
    Member

    Thanks for the detailed writeup — the two exceptions you're catching turned out to be the most useful part of it.

    We already implement this path; it just wasn't reachable from where you're calling. Wolverine added the coordinator-driven catch-up in GH-3349, but only wired it up as a TrackActivity() stage — PauseThenCatchUpOnMartenDaemonActivity(). If your suite isn't driving the work through a tracked session, there was nothing to call, which is exactly the gap you hit.

    PR #3735 adds the standalone entry point:

    await host.PauseThenCatchUpOnMartenDaemonActivityAsync();
    await host.PauseThenCatchUpOnMartenDaemonActivityAsync(CatchUpMode.AndDoNothing);  // leave them paused
    await host.PauseThenCatchUpOnMartenDaemonActivityAsync<IMyStore>();                // ancillary store

    Also available off IServiceProvider.

    On the race: it isn't one you need to retry around — it's produced by the workaround itself. daemon.CatchUpAsync() under a paused coordinator is a second writer of each progression row, which is what raises ProgressionProgressOutOfOrderException and the 23505 on pk_mt_event_progression's first insert. The supported path never calls CatchUpAsync. It resumes the agents that already own those shards and waits for non-stale data, so there is exactly one writer and neither error has anything to arise from. That also answers your ask #1 more strongly than "suspend assignment for the duration" would have — sole-writer is structural rather than best-effort.

    Ask #2 (per-(database, tenant) shards) is covered — WaitForNonStaleProjectionDataAsync walks every database in the store. Ask #3 is CatchUpMode, applied in a finally so a timeout still restores state.

    This is going out in 6.24.1 tonight. Would appreciate you running it against the suite that reproduces this a few times per run — you offered, and that's the one place we can't reproduce your sharded multi-tenant shape locally.

  2. added a commit that references this issue on Jul 30, 2026
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