Skip to content

Fix #4602: Query<T>() throws IndexOutOfRangeException on tenant-mapped projection documents#4603

Merged
jeremydmiller merged 1 commit into
JasperFx:masterfrom
RorySan:fix/4602-tenant-mapped-projection-query
Jun 2, 2026
Merged

Fix #4602: Query<T>() throws IndexOutOfRangeException on tenant-mapped projection documents#4603
jeremydmiller merged 1 commit into
JasperFx:masterfrom
RorySan:fix/4602-tenant-mapped-projection-query

Conversation

@RorySan

@RorySan RorySan commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Fix #4602: Query<T>() throws IndexOutOfRangeException on tenant-mapped projection documents

Fixes #4602.

A LINQ Query<T>() threw IndexOutOfRangeException: "Ordinal must be between 0 and 1" when a
document both mapped tenant_id onto a member (via ITenanted or
.Metadata(m => m.TenantId.MapTo(...))) and was a projection/aggregate target (numeric
revisions enabled by ProjectionDocumentPolicy, with no revision member). LoadAsync<T> was
unaffected — only the LINQ path broke.

Cause

The closed-shape descriptor builds one ReadBinders array shared by all four storage styles.
RevisionColumn.ShouldSelect (and VersionColumn.ShouldSelect) return false for the QueryOnly
style when the column has no member:

public bool ShouldSelect(DocumentMapping mapping, StorageStyle storageStyle)
{
    if (Member != null) return true;
    return storageStyle != StorageStyle.QueryOnly && mapping.UseNumericRevisions;
}

So QueryOnly's SELECT omits mt_version, but the shared ReadBinders still contained the
version/revision binder (it was added whenever UseNumericRevisions, matching the non-QueryOnly
shape). ClosedShapeQueryOnlySelector.ApplyMetadata walks ReadBinders assigning ordinals in
lockstep, so every binder after the absent mt_version was off by one — the tenant binder then read
one column past the end of the QueryOnly row:

System.IndexOutOfRangeException: Ordinal must be between 0 and 1
   at Npgsql.NpgsqlDataReader.IsDBNull(Int32 ordinal)
   at Marten.Internal.ClosedShape.DocumentTenantIdBinder`1.Apply(...)
   at Marten.Internal.ClosedShape.ClosedShapeQueryOnlySelector`2.ApplyMetadata(...)

LoadAsync uses the lightweight/identity-map styles, whose SELECT does include mt_version, so it
stayed in lockstep — which is why only the LINQ path broke.

Fix

Give the descriptor a QueryOnlyReadBinders array that drops the version/revision binder when it's
present only for the non-QueryOnly concurrency rule (i.e. no member), and have
ClosedShapeQueryOnlySelector read from it. It's the same array instance as ReadBinders when
nothing is dropped, so there's no extra allocation for the common case. The non-QueryOnly selectors
(lightweight, identity-map, dirty-tracking) keep using ReadBinders and are unchanged.

Tests

src/EventSourcingTests/Bugs/Bug_4602_query_tenant_mapped_projection_document.cs:

Test Tenant-mapped Projection Revision member Before After
can_query_an_ITenanted_projection_document throws
can_query_a_MapTo_projection_document throws
workaround_IRevisioned_member_realigns_the_select
control_tenant_mapped_but_not_a_projection_target
control_projection_target_but_not_tenant_mapped

Verified the two repro tests fail on master (same IndexOutOfRangeException) and pass with the fix.
Also re-ran the closed-shape numeric-revision / optimistic-concurrency tests
(CoreTests/Storage/Identification/closed_shape_*) and the DocumentDbTests concurrency + version
metadata suites (91 tests) — all green.

Standalone repro: https://github.com/RorySan/marten9-tenant-repro

A LINQ Query<T>() threw IndexOutOfRangeException "Ordinal must be between
0 and 1" when a document both mapped tenant_id onto a member (ITenanted or
.Metadata(m => m.TenantId.MapTo(...))) and was a projection/aggregate target
(numeric revisions enabled by ProjectionDocumentPolicy, no revision member).
LoadAsync was unaffected; only the LINQ path broke.

Cause: the closed-shape descriptor builds one ReadBinders array shared by all
four storage styles. Version/RevisionColumn.ShouldSelect returns false for the
QueryOnly style when the column has no member, so QueryOnly's SELECT omits
mt_version — but the shared ReadBinders still included the version/revision
binder, shifting every later ordinal so the tenant binder read past the end
of the QueryOnly row.

Fix: give the descriptor a QueryOnlyReadBinders array that drops the
version/revision binder when it's present only for the non-QueryOnly
concurrency rule (no member), and have ClosedShapeQueryOnlySelector read from
it. Same array instance as ReadBinders when nothing is dropped, so no extra
allocation for the common case. The non-QueryOnly selectors (lightweight,
identity-map, dirty-tracking) keep using ReadBinders and are unchanged.

Adds Bug_4602 regression test: the two failing combinations (ITenanted +
projection, MapTo + projection), the IRevisioned workaround, and two controls
isolating each feature alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RorySan added a commit to RorySan/marten9-tenant-repro that referenced this pull request Jun 2, 2026
Test now matches what landed in JasperFx/marten#4603: control_projection
case marks PlainDoc MultiTenanted so a conjoined projection target is valid.
PR.md rewritten as a fix+test PR with root cause and verification notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 8921251 into JasperFx:master Jun 2, 2026
jeremydmiller added a commit that referenced this pull request Jun 2, 2026
PR #4603 fixed the QueryOnly ordinal mismatch and shipped a 5-test repro
covering the headline case (tenant-mapped projection document). The bug
envelope is meaningfully wider:

  - NOT projection-target-specific. UseNumericRevisions /
    UseOptimisticConcurrency turned on directly on a non-projection
    mapping (e.g. via o.Schema.For<T>().UseNumericRevisions(true)) hits
    the same descriptor state and the same crash.
  - NOT tenant_id-specific. Any after-version metadata member mapped via
    .Metadata(m => m.X.MapTo(...)) shifts the QueryOnly ordinals:
    last_modified, created_at, correlation_id, causation_id,
    last_modified_by.
  - Affects hierarchical mappings too. doc_type sits before the version
    binder so it reads correctly, but the version → tenant_id shift
    still trips QueryOnly (error bound becomes "0 and 2" instead of
    "0 and 1" because the SELECT is one column wider).

Adds 5 probes covering this envelope, each parameterized across all
four selectors (QueryOnly, Lightweight, IdentityMap, DirtyTracking) for
a 20-test matrix. The QueryOnly case in each probe is the regression
proof; the Lightweight / IdentityMap / DirtyTracking cases are canaries
— those selectors were never broken (their SELECTs include mt_version
for the write-back path), so they pin the fix scope. A future refactor
that accidentally trimmed ReadBinders itself (instead of just
QueryOnlyReadBinders) would crash all 15 non-QueryOnly cases
immediately.

Verified on master:
  - Bare master:   5/20 fail (exactly the 5 QueryOnly cases)
  - After #4603:  20/20 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Query<T>() throws IndexOutOfRangeException "Ordinal must be between 0 and 1" for tenant-mapped projection documents

2 participants