ProjectionProgressStatement's per-tenant filter matches progression rows with an unescaped LIKE pattern built from the tenant id, which both over-matches on tenant ids containing _ and over-matches on tenant-less identities whose shard key happens to equal the tenant id.
src/Marten/Events/Daemon/Progress/ProjectionProgressStatement.cs:82-91:
if (TenantId != null)
{
builder.Append(whereStarted ? " and " : " where ");
// Tenant-bearing ShardName.Identity always ends in `:{tenantId}`.
// Match the trailing tenant suffix via LIKE — partition suffixes
// are valid PG identifiers so they don't contain LIKE wildcards.
builder.Append("name like ");
builder.AppendParameter("%:" + TenantId);
}
1. _ is a LIKE wildcard
The comment's premise is wrong: _ is both a perfectly valid PostgreSQL identifier character and a LIKE single-character wildcard. Underscores in tenant ids are common.
A read scoped to tenant acme_corp builds name like '%:acme_corp', which also matches the progression rows of tenants acmeXcorp, acme-corp, acme corp — any tenant differing only in that one position. Those rows are then reported as acme_corp's progress. This is the one worth fixing: it silently blends two tenants' progression under multi-tenancy, which is the exact thing the tenant filter exists to prevent.
(% in a tenant id would be worse still, matching everything, but is far less likely to occur.)
2. A shard key equal to a tenant id also matches
ShardName.Identity is {Name}:{ShardKey} or {Name}:V{n}:{ShardKey} when there's no tenant. So a projection sliced with a custom shard key named acme produces Foo:acme / Foo:V2:acme, both of which end in :acme and match a filter for tenant acme — a store-global row attributed to a tenant.
Narrower than (1), since it needs a shard key colliding with a tenant id, but it's the same root cause: suffix-matching a string grammar instead of parsing it.
Suggested fix
Suffix-compare instead of pattern-match, which fixes both at once and keeps it index-neutral (the existing LIKE '%...' can't use an index anyway):
right(name, char_length(@tenant) + 1) = ':' || @tenant
That still admits (2), so if the shard-key collision matters, filter the parsed rows on ShardName.TenantId after materialization — ShardStateSelector already builds the row, and ShardName.TryParse distinguishes Foo:V2:acme (shard key) from Foo:All:acme (tenant) correctly. Escaping the LIKE pattern (like ... escape '\' with _ and % escaped) fixes (1) only.
Worth noting the per-tenant high-water rows (HighWaterMark:{tenant}) also match this filter and are presumably meant to — whichever fix lands should keep including them, since a tenant-scoped progression read wants that tenant's mark.
Found while looking at #5170, which is about callers doing this same suffix surgery in application code. Independent of that request.
ProjectionProgressStatement's per-tenant filter matches progression rows with an unescapedLIKEpattern built from the tenant id, which both over-matches on tenant ids containing_and over-matches on tenant-less identities whose shard key happens to equal the tenant id.src/Marten/Events/Daemon/Progress/ProjectionProgressStatement.cs:82-91:1.
_is aLIKEwildcardThe comment's premise is wrong:
_is both a perfectly valid PostgreSQL identifier character and aLIKEsingle-character wildcard. Underscores in tenant ids are common.A read scoped to tenant
acme_corpbuildsname like '%:acme_corp', which also matches the progression rows of tenantsacmeXcorp,acme-corp,acme corp— any tenant differing only in that one position. Those rows are then reported asacme_corp's progress. This is the one worth fixing: it silently blends two tenants' progression under multi-tenancy, which is the exact thing the tenant filter exists to prevent.(
%in a tenant id would be worse still, matching everything, but is far less likely to occur.)2. A shard key equal to a tenant id also matches
ShardName.Identityis{Name}:{ShardKey}or{Name}:V{n}:{ShardKey}when there's no tenant. So a projection sliced with a custom shard key namedacmeproducesFoo:acme/Foo:V2:acme, both of which end in:acmeand match a filter for tenantacme— a store-global row attributed to a tenant.Narrower than (1), since it needs a shard key colliding with a tenant id, but it's the same root cause: suffix-matching a string grammar instead of parsing it.
Suggested fix
Suffix-compare instead of pattern-match, which fixes both at once and keeps it index-neutral (the existing
LIKE '%...'can't use an index anyway):That still admits (2), so if the shard-key collision matters, filter the parsed rows on
ShardName.TenantIdafter materialization —ShardStateSelectoralready builds the row, andShardName.TryParsedistinguishesFoo:V2:acme(shard key) fromFoo:All:acme(tenant) correctly. Escaping theLIKEpattern (like ... escape '\'with_and%escaped) fixes (1) only.Worth noting the per-tenant high-water rows (
HighWaterMark:{tenant}) also match this filter and are presumably meant to — whichever fix lands should keep including them, since a tenant-scoped progression read wants that tenant's mark.Found while looking at #5170, which is about callers doing this same suffix surgery in application code. Independent of that request.