Skip to content

Round-trip per-tenant HighWaterMark progression rows through ShardName (#618) - #623

Merged
jeremydmiller merged 1 commit into
mainfrom
gh/618-high-water-tenant-parsing
Aug 4, 2026
Merged

Round-trip per-tenant HighWaterMark progression rows through ShardName (#618)#623
jeremydmiller merged 1 commit into
mainfrom
gh/618-high-water-tenant-parsing

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #618.

The problem

ShardName.TryParse("HighWaterMark:acme", out var parsed) returned true and a wrong answer:

parsed.Name;      // "HighWaterMark"
parsed.ShardKey;  // "acme"           <- the tenant id, in the shard-key slot
parsed.TenantId;  // null             <- tenant lost
parsed.Identity;  // "HighWaterMark"  <- does NOT round-trip the input

TryParse special-cased only the exact string ShardState.HighWaterMark, so the per-tenant form fell through to the generic two-segment branch; the constructor's own high-water case then collapsed Identity to the bare store-global constant.

HighWaterMark:{tenant} is a real persisted row shape, not hypothetical — Marten writes one per tenant on every vectorized high-water poll (marten#4717) and on the bulk-append path, and those rows come back from AllProjectionProgress interleaved with the projection shard rows. So the obvious consumer — enumerate progress, TryParse each name, group by tenant — got a name claiming to be the store-global mark for every tenant, with no error and no false to signal it.

The fix

Option 1 from the issue, plus the recognizer from option 2:

  • TryParse recognizes HighWaterMark:{tenant} and preserves the tenant.
  • The constructor only flattens Identity to the bare constant when the name is store-global, so a tenant-bearing high-water name renders HighWaterMark:{tenant} and round-trips.
  • Every other HighWaterMark-prefixed string (HighWaterMark:, HighWaterMark:a:b, HighWaterMark:V2:All) is now rejected rather than forced through the generic grammar into a name whose Identity is a different string.
  • ShardName.HighWaterMarkFor(tenantId) composes the identity and ShardName.IsHighWaterMark recognizes the bookkeeping rows — so the knowledge stops being Marten-private (HighWaterShardIdentity) and lag/status/readiness consumers have a supported path.

Behavior change

Per-tenant high-water names are now distinct values. ShardName equality is identity-only, so before this change every tenant's mark compared equal to the store-global mark — which is precisely the bug. Nothing in JasperFx constructs a ShardName with name == HighWaterMark and a tenant today (grep: the only constructions are the bare store-global form), so nothing in-tree keys on the old collapsed value.

RelativeUrl is deliberately untouched.

Tests

src/EventTests/ShardNameTests.cs: round-trip of the per-tenant form, distinctness of two tenants' marks and the global mark, the tenant staying out of the ShardKey slot, IsHighWaterMark, and rejection of the unrecognized shapes. Full EventTests suite passes (685).

Follow-on

Unblocks #619 (per-tenant projection lag) — a lag implementation routing high-water rows through ShardName today would attribute every tenant's mark to the store-global one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fuk1GybEEmohFmboJuM4Po

…rdName (#618)

`ShardName.TryParse("HighWaterMark:acme", ...)` returned true and a wrong
answer: the tenant id landed in the `ShardKey` slot, `TenantId` came back
null, and the constructor's high-water special case collapsed `Identity`
back to the bare store-global constant. A caller enumerating
`AllProjectionProgress` and grouping by tenant got a name claiming to be
the store-global mark for every tenant, with nothing to signal it.

`HighWaterMark:{tenant}` is a real persisted row shape -- Marten writes one
per tenant on every vectorized high-water poll and on the bulk-append path,
and they come back interleaved with the projection shard rows.

- Recognize the shape in `TryParse` and preserve the tenant, and stop the
  constructor flattening `Identity` when a tenant is present, so it
  round-trips.
- Reject every other `HighWaterMark`-prefixed string rather than forcing it
  through the generic grammar into a name that renders a different identity.
- Add `ShardName.HighWaterMarkFor(tenantId)` to compose the identity and
  `IsHighWaterMark` to recognize the bookkeeping rows, so the knowledge stops
  being Marten-private (`HighWaterShardIdentity`).

Behavior change worth calling out: per-tenant high-water names are now
distinct values, where they previously all compared equal to the
store-global mark. That was the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fuk1GybEEmohFmboJuM4Po
@jeremydmiller
jeremydmiller merged commit 934c8d4 into main Aug 4, 2026
1 check passed
@jeremydmiller
jeremydmiller deleted the gh/618-high-water-tenant-parsing branch August 4, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ShardName.TryParse silently mis-parses per-tenant HighWaterMark:{tenant} rows

1 participant