Repository: JasperFx/wolverine (2026-07-26T15:20:28Z)

Issue:
Store-aware overload of TryRebuildRegisteredProjectionAsync (last-resort transient rebuild)
## Context
CritterWatch rebuilds a registered projection that has no currently-live agent (an Inline/Live projection, or an async projection not currently distributed / paused) via the transient-rebuild fallback:

```csharp
foreach (var family in runtime.Services.GetServices<IEventSubscriptionAgentFamily>())
{
    if (await family.TryRebuildRegisteredProjectionAsync(shardIdentity, tenantId, cancellation))
        break;
}
```

`TryRebuildRegisteredProjectionAsync(shardIdentity, tenantId)` takes **no store argument** and walks every store the family manages. When the *same projection name* is registered in more than one store (e.g. a primary + an ancillary store) **and** neither has a live agent, this last-resort path cannot narrow the rebuild to the store the operator actually clicked — the store-scoped resolution that handles the live-agent case (CritterWatch #702) has nothing to hook into here.

## Ask
A store-aware overload — e.g. `TryRebuildRegisteredProjectionAsync(storeIdentity, shardIdentity, tenantId, ct)` — so a caller that knows which store's agent URI it resolved against can scope the transient rebuild to that store.

## Notes
- Narrow edge: only bites when a projection name is shared across stores **and** no live agent exists for it in either. The live-agent path is already store-scoped downstream.
- Referenced from CritterWatch #815 (Inline rebuild) as the one residual upstream limitation of that feature.


Discussion:
Looked at the scope, I think I'd like to give this one a shot! Could I be assigned to it? 
cc @jeremydmiller 
@Shaurya2k06 Go for it
@Shaurya2k06 Actually, even though it's the easiest open thing, this is blocking a CritterWatch, so I need to take this one. I'll try to start labeling issues as "help wanted" or "good first issue". Would that work instead?
It's probably best you take this then. I'm open to trying out new issues, feel free to assign one to me!



Patch:
diff --git a/src/Wolverine/Runtime/Agents/EventSubscriptionAgentFamily.cs b/src/Wolverine/Runtime/Agents/EventSubscriptionAgentFamily.cs
index c5a9259b6..afa434e26 100644
--- a/src/Wolverine/Runtime/Agents/EventSubscriptionAgentFamily.cs
+++ b/src/Wolverine/Runtime/Agents/EventSubscriptionAgentFamily.cs
@@ -104,7 +104,39 @@ public async ValueTask<bool> TryRebuildRegisteredProjectionAsync(string shardIde
 
         return false;
     }
-    
+
+    /// <inheritdoc />
+    public async ValueTask<bool> TryRebuildRegisteredProjectionAsync(string storeIdentity, string shardIdentity,
+        string? tenantId, CancellationToken token = default)
+    {
+        if (string.IsNullOrEmpty(storeIdentity) || !_stores.TryFind(StoreKey(storeIdentity), out var agents))
+        {
+            return false;
+        }
+
+        return await agents.TryRebuildRegisteredProjectionAsync(shardIdentity, tenantId, token)
+            .ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// The identity of the event store that owns an <c>event-subscriptions://</c> agent
+    /// <paramref name="uri" />, in the form <see cref="TryRebuildRegisteredProjectionAsync(string,string,string?,CancellationToken)" />
+    /// expects. Lets a caller holding a URI from <see cref="FindAgentUriAsync" /> scope a rebuild to that
+    /// store without decomposing the URI grammar itself — the grammar (see <see cref="UriFor" />) stays in
+    /// here. Returns <c>null</c> for any URI that is not an agent URI of this scheme or that carries no
+    /// store-name segment. See GH-3618.
+    /// </summary>
+    public static string? StoreIdentityOf(Uri uri)
+    {
+        if (uri.Scheme != SchemeName || uri.Segments.Length < 2)
+        {
+            return null;
+        }
+
+        var name = uri.Segments[1].Trim('/');
+        return name.Length == 0 ? null : StoreKey($"{name}:{uri.Host}");
+    }
+
     public EventSubscriptionAgentFamily(IEnumerable<IEventStore> stores, IEnumerable<IObserver<ShardState>> observers)
     {
         _observers = observers.ToArray();
diff --git a/src/Wolverine/Runtime/Agents/IEventSubscriptionAgentFamily.cs b/src/Wolverine/Runtime/Agents/IEventSubscriptionAgentFamily.cs
index 9d81e96e1..79d56667c 100644
--- a/src/Wolverine/Runtime/Agents/IEventSubscriptionAgentFamily.cs
+++ b/src/Wolverine/Runtime/Agents/IEventSubscriptionAgentFamily.cs
@@ -34,4 +34,29 @@ public interface IEventSubscriptionAgentFamily
     /// </summary>
     ValueTask<bool> TryRebuildRegisteredProjectionAsync(string shardIdentity, string? tenantId,
         CancellationToken token = default);
+
+    /// <summary>
+    /// Same as <see cref="TryRebuildRegisteredProjectionAsync(string,string?,CancellationToken)" />, but scoped to a
+    /// single event store rather than searching every store this family manages. <paramref name="storeIdentity" /> is
+    /// the store's <c>EventStoreIdentity.ToString()</c> value (matched case-insensitively);
+    /// <see cref="EventSubscriptionAgentFamily.StoreIdentityOf" /> derives it from an agent URI that
+    /// <see cref="FindAgentUriAsync" /> already resolved, so a caller never has to decompose the URI itself.
+    ///
+    /// <para>This exists because the store-blind overload cannot tell two stores apart when the SAME projection
+    /// name is registered in more than one of them (a primary plus an ancillary store, say) and NEITHER has a live
+    /// agent — the first store with a matching registered shard wins, which may not be the store the operator asked
+    /// about. The live-agent rebuild path is already store-scoped downstream; this closes the gap on the last-resort
+    /// transient-rebuild path. See GH-3618.</para>
+    ///
+    /// <para>Returns <c>true</c> when the named store matched a registered projection and the rebuild ran;
+    /// <c>false</c> when this family does not manage <paramref name="storeIdentity" /> or that store has no matching
+    /// registered shard, so the caller can try the next family.</para>
+    ///
+    /// <para>The default implementation ignores <paramref name="storeIdentity" /> and delegates to the store-blind
+    /// overload, preserving the behavior of any pre-existing implementation of this interface. Implementations that
+    /// manage more than one store should override it.</para>
+    /// </summary>
+    ValueTask<bool> TryRebuildRegisteredProjectionAsync(string storeIdentity, string shardIdentity, string? tenantId,
+        CancellationToken token = default)
+        => TryRebuildRegisteredProjectionAsync(shardIdentity, tenantId, token);
 }


Test patch:
diff --git a/src/Persistence/MartenTests/Distribution/store_scoped_transient_rebuild_3618.cs b/src/Persistence/MartenTests/Distribution/store_scoped_transient_rebuild_3618.cs
new file mode 100644
index 000000000..e05b657ca
--- /dev/null
+++ b/src/Persistence/MartenTests/Distribution/store_scoped_transient_rebuild_3618.cs
@@ -0,0 +1,195 @@
+using IntegrationTests;
+using JasperFx.Events;
+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.Agents;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace MartenTests.Distribution;
+
+// GH-3618: the last-resort transient-rebuild path (GH-3163) took no store argument and walked every store
+// the family manages, returning on the first registered shard that matched by NAME. When the same projection
+// name is registered in more than one store AND neither has a live agent -- both Inline here -- the caller
+// could not steer the rebuild to the store the operator actually asked about; whichever store happened to be
+// enumerated first won. The store-aware overload closes that gap.
+public class store_scoped_transient_rebuild_3618 : IAsyncLifetime
+{
+    private IHost _host = null!;
+    private IDocumentStore _main = null!;
+    private IGh3618AncillaryStore _ancillary = null!;
+
+    public async Task InitializeAsync()
+    {
+        await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString))
+        {
+            await conn.OpenAsync();
+            await conn.DropSchemaAsync("gh3618_main");
+            await conn.DropSchemaAsync("gh3618_anc");
+            await conn.CloseAsync();
+        }
+
+        _host = await Host.CreateDefaultBuilder()
+            .UseWolverine(opts =>
+            {
+                opts.Durability.Mode = DurabilityMode.Solo;
+
+                // TripProjection is registered under the SAME name in BOTH stores, and Inline in both, so
+                // neither has a distributed agent to route a rebuild through. This is the ambiguity.
+                opts.Services.AddMarten(m =>
+                    {
+                        m.DisableNpgsqlLogging = true;
+                        m.Connection(Servers.PostgresConnectionString);
+                        m.DatabaseSchemaName = "gh3618_main";
+                        m.Projections.Add<TripProjection>(ProjectionLifecycle.Inline);
+                    })
+                    .IntegrateWithWolverine(x => x.UseWolverineManagedEventSubscriptionDistribution = true);
+
+                opts.Services.AddMartenStore<IGh3618AncillaryStore>(m =>
+                    {
+                        m.DisableNpgsqlLogging = true;
+                        m.Connection(Servers.PostgresConnectionString);
+                        m.DatabaseSchemaName = "gh3618_anc";
+                        m.Projections.Add<TripProjection>(ProjectionLifecycle.Inline);
+                    })
+                    .IntegrateWithWolverine(x => x.UseWolverineManagedEventSubscriptionDistribution = true);
+
+                opts.Discovery.DisableConventionalDiscovery();
+            }).StartAsync();
+
+        _main = _host.Services.GetRequiredService<IDocumentStore>();
+        _ancillary = _host.Services.GetRequiredService<IGh3618AncillaryStore>();
+    }
+
+    public async Task DisposeAsync()
+    {
+        _host.GetRuntime().Agents.DisableHealthChecks();
+        await _host.StopAsync();
+        _host.Dispose();
+    }
+
+    private EventSubscriptionAgentFamily theFamily =>
+        _host.Services.GetServices<IEventSubscriptionAgentFamily>()
+            .OfType<EventSubscriptionAgentFamily>().Single();
+
+    private static string identityOf(IDocumentStore store) => ((IEventStore)store).Identity.ToString();
+
+    private static async Task<Guid> writeATripAsync(IDocumentStore store)
+    {
+        var streamId = Guid.NewGuid();
+        await using var session = store.LightweightSession();
+        session.Events.StartStream<Trip>(streamId, new TripStarted { Day = 1 }, new TripEnded { Day = 2 });
+        await session.SaveChangesAsync();
+        return streamId;
+    }
+
+    private static async Task wipeTheReadModelAsync(IDocumentStore store)
+    {
+        await using var session = store.LightweightSession();
+        session.DeleteWhere<Trip>(_ => true);
+        await session.SaveChangesAsync();
+    }
+
+    private static async Task<int> tripCountAsync(IDocumentStore store)
+    {
+        await using var session = store.QuerySession();
+        return await session.Query<Trip>().CountAsync();
+    }
+
+    [Fact]
+    public void both_stores_are_managed_by_the_one_family()
+    {
+        // Precondition for the ambiguity to exist at all.
+        identityOf(_main).ShouldNotBe(identityOf(_ancillary));
+    }
+
+    [Fact]
+    public async Task neither_store_has_a_live_agent_for_the_inline_projection()
+    {
+        // Establishes that this is the no-live-agent path, not the store-scoped live-agent path.
+        (await theFamily.FindAgentUriAsync("Trip:All", null)).ShouldBeNull();
+    }
+
+    [Fact]
+    public async Task rebuilding_the_main_store_leaves_the_ancillary_read_model_alone()
+    {
+        await writeATripAsync(_main);
+        await writeATripAsync(_ancillary);
+
+        await wipeTheReadModelAsync(_main);
+        await wipeTheReadModelAsync(_ancillary);
+
+        (await theFamily.TryRebuildRegisteredProjectionAsync(identityOf(_main), "Trip:All", null,
+            CancellationToken.None)).ShouldBeTrue();
+
+        (await tripCountAsync(_main)).ShouldBe(1, "the store named in the rebuild should have been rebuilt");
+        (await tripCountAsync(_ancillary)).ShouldBe(0, "a store that was NOT named must be left untouched");
+    }
+
+    [Fact]
+    public async Task rebuilding_the_ancillary_store_leaves_the_main_read_model_alone()
+    {
+        // The mirror image. Asserting both directions is what proves the overload narrows by store rather
+        // than just happening to agree with the family's enumeration order.
+        await writeATripAsync(_main);
+        await writeATripAsync(_ancillary);
+
+        await wipeTheReadModelAsync(_main);
+        await wipeTheReadModelAsync(_ancillary);
+
+        (await theFamily.TryRebuildRegisteredProjectionAsync(identityOf(_ancillary), "Trip:All", null,
+            CancellationToken.None)).ShouldBeTrue();
+
+        (await tripCountAsync(_ancillary)).ShouldBe(1, "the store named in the rebuild should have been rebuilt");
+        (await tripCountAsync(_main)).ShouldBe(0, "a store that was NOT named must be left untouched");
+    }
+
+    [Fact]
+    public async Task an_unregistered_shard_in_a_known_store_reports_no_match()
+    {
+        (await theFamily.TryRebuildRegisteredProjectionAsync(identityOf(_main), "DoesNotExist:All", null,
+            CancellationToken.None)).ShouldBeFalse();
+    }
+
+    [Fact]
+    public async Task a_store_outside_this_family_reports_no_match()
+    {
+        (await theFamily.TryRebuildRegisteredProjectionAsync("someone-else:Marten", "Trip:All", null,
+            CancellationToken.None)).ShouldBeFalse(
+            "so a caller looping over families can try the next one");
+    }
+
+    /// <summary>
+    ///     Documents the gap itself. The store-blind overload is unchanged and still reports success, but it
+    ///     stops at the FIRST store with a matching registered shard -- so with the projection name shared
+    ///     across both stores it rebuilds exactly one of them, and the caller has no say in which. That is
+    ///     the whole reason the store-aware overload exists.
+    /// </summary>
+    [Fact]
+    public async Task the_store_blind_overload_rebuilds_only_one_of_the_two_stores()
+    {
+        await writeATripAsync(_main);
+        await writeATripAsync(_ancillary);
+
+        await wipeTheReadModelAsync(_main);
+        await wipeTheReadModelAsync(_ancillary);
+
+        (await theFamily.TryRebuildRegisteredProjectionAsync("Trip:All", null, CancellationToken.None))
+            .ShouldBeTrue();
+
+        var rebuilt = await tripCountAsync(_main) + await tripCountAsync(_ancillary);
+        rebuilt.ShouldBe(1,
+            "the store-blind overload returns on the first matching store, so it cannot cover both -- " +
+            "which is exactly why a caller needs to name the store it means");
+    }
+}
+
+public interface IGh3618AncillaryStore : IDocumentStore;
diff --git a/src/Testing/CoreTests/Runtime/Agents/event_subscription_family_store_identity_of.cs b/src/Testing/CoreTests/Runtime/Agents/event_subscription_family_store_identity_of.cs
new file mode 100644
index 000000000..90f4f3fcc
--- /dev/null
+++ b/src/Testing/CoreTests/Runtime/Agents/event_subscription_family_store_identity_of.cs
@@ -0,0 +1,87 @@
+using System;
+using JasperFx.Descriptors;
+using JasperFx.Events;
+using JasperFx.Events.Projections;
+using Shouldly;
+using Wolverine.Runtime.Agents;
+using Xunit;
+
+namespace CoreTests.Runtime.Agents;
+
+// GH-3618: the store-aware TryRebuildRegisteredProjectionAsync overload is keyed on an event store's
+// identity. A caller that resolved an agent URI through FindAgentUriAsync should be able to derive that key
+// from the URI it already holds instead of re-implementing the URI grammar, which is what StoreIdentityOf is
+// for. Its output has to agree exactly with how the family keys its own stores, or a store-scoped rebuild
+// silently finds nothing.
+public class event_subscription_family_store_identity_of
+{
+    [Fact]
+    public void extracts_the_store_identity_from_an_agent_uri()
+    {
+        var uri = EventSubscriptionAgentFamily.UriFor(
+            new EventStoreIdentity("main", "Marten"),
+            new DatabaseId("localhost", "postgres"),
+            ShardName.Compose("Trip", "All"));
+
+        EventSubscriptionAgentFamily.StoreIdentityOf(uri).ShouldBe("main:marten");
+    }
+
+    [Fact]
+    public void agrees_with_the_identity_string_the_family_keys_stores_by()
+    {
+        // The family keys _stores on EventStoreIdentity.ToString() (case-normalized), and BuildAgentAsync
+        // reconstructs that same key from the URI. StoreIdentityOf must land on the identical value.
+        var identity = new EventStoreIdentity("orders", "SqlServer");
+
+        var uri = EventSubscriptionAgentFamily.UriFor(identity, new DatabaseId("localhost", "orders"),
+            ShardName.Compose("Order", "All"));
+
+        EventSubscriptionAgentFamily.StoreIdentityOf(uri)
+            .ShouldBe(identity.ToString().ToLowerInvariant());
+    }
+
+    [Fact]
+    public void is_case_insensitive_about_a_store_type_with_upper_case_letters()
+    {
+        // System.Uri lowercases the authority while EventStoreIdentity preserves casing (GH-3168), so a
+        // store type like Polecat's "SqlServer" has to normalize the same way on both sides.
+        var uri = EventSubscriptionAgentFamily.UriFor(
+            new EventStoreIdentity("Orders", "SqlServer"),
+            new DatabaseId("localhost", "orders"),
+            ShardName.Compose("Order", "All"));
+
+        EventSubscriptionAgentFamily.StoreIdentityOf(uri).ShouldBe("orders:sqlserver");
+    }
+
+    [Fact]
+    public void returns_null_for_a_uri_that_is_not_this_scheme()
+    {
+        EventSubscriptionAgentFamily.StoreIdentityOf(new Uri("rabbitmq://queue/incoming")).ShouldBeNull();
+    }
+
+    [Fact]
+    public void returns_null_when_there_is_no_store_name_segment()
+    {
+        EventSubscriptionAgentFamily.StoreIdentityOf(new Uri("event-subscriptions://marten")).ShouldBeNull();
+    }
+
+    [Fact]
+    public async Task a_store_this_family_does_not_manage_reports_no_match()
+    {
+        // So a caller looping over every registered family can move on to the next one rather than
+        // treating "wrong family" as "rebuild failed".
+        var family = new EventSubscriptionAgentFamily([], []);
+
+        (await family.TryRebuildRegisteredProjectionAsync("nobody:marten", "Trip:All", null,
+            CancellationToken.None)).ShouldBeFalse();
+    }
+
+    [Fact]
+    public async Task a_null_or_empty_store_identity_reports_no_match()
+    {
+        var family = new EventSubscriptionAgentFamily([], []);
+
+        (await family.TryRebuildRegisteredProjectionAsync("", "Trip:All", null, CancellationToken.None))
+            .ShouldBeFalse();
+    }
+}