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: