Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/EventTests/ShardNameTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("")]
Expand Down
50 changes: 49 additions & 1 deletion src/JasperFx.Events/Projections/ShardName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}

}
Expand All @@ -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);
}

/// <summary>
/// Compose the high-water progression identity for a tenant partition — <c>HighWaterMark</c>
/// store-global, <c>HighWaterMark:{tenant}</c> 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 cref="TryParse" />. See jasperfx#618.
/// </summary>
/// <param name="tenantId">Tenant partition. Null/empty is the store-global mark.</param>
public static ShardName HighWaterMarkFor(string? tenantId = null)
{
return new ShardName(ShardState.HighWaterMark, All, 1, tenantId);
}

/// <summary>
/// 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
/// (<see cref="TenantId" /> 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.
/// </summary>
public bool IsHighWaterMark => Name == ShardState.HighWaterMark;

/// <summary>
/// Parse a shard <see cref="Identity" /> string back into a <see cref="ShardName" />.
/// Understands every form produced by <see cref="Compose" />/<see cref="Identity" />:
/// <c>Name:ShardKey</c>, <c>Name:ShardKey:Tenant</c>, <c>Name:V{n}:ShardKey</c>, and
/// <c>Name:V{n}:ShardKey:Tenant</c>. A leading <c>V{digits}</c> segment is interpreted as a
/// version marker; otherwise the trailing segment of a 3-part identity is the tenant.
/// <para>
/// The high-water progression rows have their own grammar — <c>HighWaterMark</c> and
/// <c>HighWaterMark:{tenant}</c> (see <see cref="HighWaterMarkFor" />) — and are the only
/// names that do not carry a shard key. Any other <c>HighWaterMark</c>-prefixed string is
/// rejected rather than forced into the generic grammar, because the result would not
/// round-trip its own <see cref="Identity" />.
/// </para>
/// </summary>
public static bool TryParse(string? text, out ShardName? shardName)
{
Expand All @@ -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:
Expand Down
Loading