diff --git a/src/Persistence/PolecatTests/Distribution/blue_green_version_bump_assignment.cs b/src/Persistence/PolecatTests/Distribution/blue_green_version_bump_assignment.cs new file mode 100644 index 000000000..a147efbad --- /dev/null +++ b/src/Persistence/PolecatTests/Distribution/blue_green_version_bump_assignment.cs @@ -0,0 +1,128 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Polecat; +using PolecatTests.Distribution.TripDomain; +using Shouldly; +using Wolverine.Runtime.Agents; +using Xunit; + +namespace PolecatTests.Distribution; + +/// +/// The Polecat half of the GH-4562 cover that MartenTests.MultiTenancy.blue_green_version_bump_assignment +/// provides for Marten: a blue/green rollout placed through the real +/// and , +/// over agent URIs a real Polecat store advertises rather than hand-written ones. +/// +/// It is a structural twin, not a copy. The Marten version gets a group deep enough to split from +/// several tenants sharing one shard database, which Polecat cannot express -- +/// Polecat.Storage.SeparateDatabaseTenancy offers only AddTenant, so Polecat multi-tenancy +/// is one tenant per database. Here the depth comes from several PROJECTIONS on one database instead. +/// The mechanism under test is unchanged: a partition holding more than one agent, of which a blue node +/// runs some without declaring them. +/// +/// Deliberately single-database. A multi-tenanted Polecat store reports +/// Cardinality = Single with an empty Databases collection from TryCreateUsage(), +/// even though IEventStore.AllDatabases() on the same store returns every tenant database +/// (JasperFx/polecat#675). EventStoreAgents.SupportedAgentsAsync reads the usage descriptor, so +/// every agent would collapse onto the main database and there would be nothing left to group. Using one +/// database keeps this test about placement rather than about that gap; when #675 is fixed this is worth +/// revisiting as the full multi-database twin of the Marten version. +/// +public class blue_green_version_bump_assignment +{ + // The projection both fleets serve unchanged -- declared by every node, so it is its own partition + // and free to stay where it is running. + private const string Unchanged = "day"; + + // The projection being version-bumped: blue serves version 1, green version 2. Each version is a + // separate shard identity, and only the fleet running that build can BUILD its agent. + private const string Bumped = "trip"; + + // Registered on green only. This is the Polecat stand-in for the tenant provisioned after the blue + // nodes captured their capability snapshots: blue is happily RUNNING it and does not declare it. + private const string AfterBlueSnapshot = "distance"; + + [Fact] + public async Task the_bumped_version_is_not_dragged_onto_a_blue_node_by_a_grandfathered_agent() + { + var blueAgents = await AdvertisedAgentsAsync(BlueStore()); + var greenAgents = await AdvertisedAgentsAsync(GreenStore()); + + // Guard the shape this test exists for. Without it, a change that stopped versioning shard names + // would silently make both fleets advertise one and the same set, every agent would be declared + // everywhere, and the whole file would go on passing while testing nothing. + var bumpedOnGreen = Only(greenAgents, Bumped); + bumpedOnGreen.ShouldNotBeEmpty("green must advertise the bumped projection"); + bumpedOnGreen.ShouldAllBe(uri => !blueAgents.Contains(uri), + "the two fleets must advertise the bumped projection under DISJOINT identities, or there is no blue/green gap to test"); + + var grandfathered = Only(greenAgents, AfterBlueSnapshot).Single(); + blueAgents.ShouldNotContain(grandfathered, + "the stand-in for a post-snapshot tenant must be absent from blue's capabilities"); + + var grid = new AssignmentGrid(); + var blue = grid.WithNode(1, Guid.NewGuid()).HasCapabilities(blueAgents); + var green = grid.WithNode(2, Guid.NewGuid()).HasCapabilities(greenAgents); + + // Blue runs everything it declares, PLUS the agent it does not declare. That last one is what + // used to pull blue into the candidate set for a partition it can only partly run. + blue.Running(blueAgents.Append(grandfathered).ToArray()); + + grid.WithAgents(blueAgents.Concat(greenAgents).Distinct().ToArray()); + + grid.DistributeByGroupAffinity("event-subscriptions", EventSubscriptionAgentFamily.DatabaseKeyOf); + + foreach (var uri in bumpedOnGreen) + { + var host = grid.AgentFor(uri).AssignedNode; + host.ShouldNotBeNull($"{uri} was left unassigned, so nothing would build the new version"); + host.ShouldBe(green, + $"{uri} may only run on the fleet that declares it -- BuildAgentAsync throws 'Unable to find a shard' on the other one"); + } + + foreach (var uri in Only(blueAgents, Bumped)) + { + grid.AgentFor(uri).AssignedNode.ShouldBe(blue, + $"{uri} is the version the blue fleet serves and must stay there"); + } + } + + private static List Only(IEnumerable agents, string projectionName) => + agents.Where(uri => uri.Segments.Any(segment => + segment.Trim('/').StartsWith(projectionName, StringComparison.OrdinalIgnoreCase))).ToList(); + + private static async Task> AdvertisedAgentsAsync(IDocumentStore store) + { + await using var family = new EventSubscriptionAgentFamily([(IEventStore)store], []); + return await family.SupportedAgentsAsync(); + } + + // The build deployed today. + private static IDocumentStore BlueStore() => StoreFor(1, withPostSnapshotProjection: false); + + // The build rolling out: the bumped projection, and one registered since blue started. + private static IDocumentStore GreenStore() => StoreFor(2, withPostSnapshotProjection: true); + + private static IDocumentStore StoreFor(uint tripVersion, bool withPostSnapshotProjection) + { + return DocumentStore.For(opts => + { + opts.DatabaseSchemaName = "polecat_bluegreen"; + opts.ConnectionString = Servers.SqlServerConnectionString; + + // Nothing here writes or projects; the test only asks each store what it would advertise. + opts.AutoCreateSchemaObjects = AutoCreate.None; + + opts.Projections.Add(new TripProjection { Version = tripVersion }, ProjectionLifecycle.Async); + opts.Projections.Add(ProjectionLifecycle.Async); + + if (withPostSnapshotProjection) + { + opts.Projections.Add(ProjectionLifecycle.Async); + } + }); + } +}