diff --git a/src/EventTests/ShardNameTests.cs b/src/EventTests/ShardNameTests.cs index 7036a5b..c7690bf 100644 --- a/src/EventTests/ShardNameTests.cs +++ b/src/EventTests/ShardNameTests.cs @@ -128,6 +128,63 @@ public void try_parse_round_trips_high_water_mark() parsed!.Identity.ShouldBe(ShardState.HighWaterMark); } + // jasperfx#618 + [Fact] + public void high_water_mark_for_tenant_round_trips() + { + var name = ShardName.HighWaterMarkFor("acme"); + + name.Name.ShouldBe(ShardState.HighWaterMark); + name.TenantId.ShouldBe("acme"); + name.ShardKey.ShouldBe(ShardName.All); + name.Identity.ShouldBe("HighWaterMark:acme"); + + ShardName.TryParse(name.Identity, out var parsed).ShouldBeTrue(); + parsed!.Name.ShouldBe(ShardState.HighWaterMark); + parsed.TenantId.ShouldBe("acme"); + parsed.Identity.ShouldBe(name.Identity); + parsed.ShouldBe(name); + } + + // jasperfx#618: the whole point of the fix -- the tenant must NOT land in the shard key slot, + // and two tenants' marks must not be the same value + [Fact] + public void per_tenant_high_water_marks_are_distinct_from_each_other_and_the_global_mark() + { + var global = ShardName.HighWaterMarkFor(); + var acme = ShardName.HighWaterMarkFor("acme"); + var other = ShardName.HighWaterMarkFor("other"); + + global.Identity.ShouldBe(ShardState.HighWaterMark); + global.TenantId.ShouldBeNull(); + + acme.ShouldNotBe(other); + acme.ShouldNotBe(global); + acme.ShardKey.ShouldBe(ShardName.All); + acme.ShardKey.ShouldNotBe("acme"); + } + + [Fact] + public void is_high_water_mark() + { + ShardName.HighWaterMarkFor().IsHighWaterMark.ShouldBeTrue(); + ShardName.HighWaterMarkFor("acme").IsHighWaterMark.ShouldBeTrue(); + ShardName.Compose("Trips", "All", "acme").IsHighWaterMark.ShouldBeFalse(); + } + + // jasperfx#618: anything else in the HighWaterMark namespace is bookkeeping we do not + // understand -- reject it rather than hand back a name whose Identity is a different string + [Theory] + [InlineData("HighWaterMark:")] + [InlineData("HighWaterMark:acme:extra")] + [InlineData("HighWaterMark:V2:All")] + [InlineData("HighWaterMark:V2:All:acme")] + public void try_parse_rejects_unrecognized_high_water_shapes(string text) + { + ShardName.TryParse(text, out var parsed).ShouldBeFalse(); + parsed.ShouldBeNull(); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/JasperFx.Events/Projections/ShardName.cs b/src/JasperFx.Events/Projections/ShardName.cs index 743c2c6..1d87cca 100644 --- a/src/JasperFx.Events/Projections/ShardName.cs +++ b/src/JasperFx.Events/Projections/ShardName.cs @@ -62,9 +62,14 @@ public ShardName(string name, string shardKey, uint version, string? tenantId) RelativeUrl = $"{name}/{shardKey}{urlSuffix}".ToLowerInvariant(); } + // The high-water mark is addressed by a bare, well-known identity rather than the + // Name:ShardKey grammar -- but ONLY when it is store-global. jasperfx#618: a per-tenant + // high-water row is a real, persisted row shape (Marten writes HighWaterMark:{tenant} on + // every vectorized per-tenant poll), and flattening those to the store-global constant made + // every tenant's mark claim to be the store's, silently. if (name == ShardState.HighWaterMark) { - Identity = ShardState.HighWaterMark; + Identity = TenantId == null ? ShardState.HighWaterMark : $"{ShardState.HighWaterMark}:{TenantId}"; } } @@ -91,12 +96,40 @@ public static ShardName Compose(string name, string? shardKey = All, string? ten return new ShardName(name, string.IsNullOrEmpty(shardKey) ? All : shardKey, version, tenantId); } + /// + /// Compose the high-water progression identity for a tenant partition — HighWaterMark + /// store-global, HighWaterMark:{tenant} for a tenant. This is the supported way to + /// address the per-tenant high-water rows an event store writes under per-tenant event + /// partitioning, and it round-trips through . See jasperfx#618. + /// + /// Tenant partition. Null/empty is the store-global mark. + public static ShardName HighWaterMarkFor(string? tenantId = null) + { + return new ShardName(ShardState.HighWaterMark, All, 1, tenantId); + } + + /// + /// True when this name addresses a high-water progression row rather than a projection or + /// subscription shard — either the store-global mark or one tenant's mark + /// ( tells them apart). High-water rows are bookkeeping: they never + /// belong to a registered projection and never advance like a shard, so a consumer walking + /// the progression table must exclude them. See jasperfx#618. + /// + public bool IsHighWaterMark => Name == ShardState.HighWaterMark; + /// /// Parse a shard string back into a . /// Understands every form produced by /: /// Name:ShardKey, Name:ShardKey:Tenant, Name:V{n}:ShardKey, and /// Name:V{n}:ShardKey:Tenant. A leading V{digits} segment is interpreted as a /// version marker; otherwise the trailing segment of a 3-part identity is the tenant. + /// + /// The high-water progression rows have their own grammar — HighWaterMark and + /// HighWaterMark:{tenant} (see ) — and are the only + /// names that do not carry a shard key. Any other HighWaterMark-prefixed string is + /// rejected rather than forced into the generic grammar, because the result would not + /// round-trip its own . + /// /// public static bool TryParse(string? text, out ShardName? shardName) { @@ -113,6 +146,21 @@ public static bool TryParse(string? text, out ShardName? shardName) } var parts = text.Split(':'); + + // jasperfx#618: HighWaterMark:{tenant} is a real persisted row shape. Parsing it through the + // generic Name:ShardKey branch put the tenant id in the ShardKey slot, left TenantId null, + // and collapsed Identity back to the bare store-global constant -- true, plus a wrong answer. + if (parts[0] == ShardState.HighWaterMark) + { + if (parts.Length != 2 || string.IsNullOrEmpty(parts[1])) + { + return false; + } + + shardName = HighWaterMarkFor(parts[1]); + return true; + } + switch (parts.Length) { case 2: