Skip to content

Fix #4614: keep mt_version as integer for IRevisioned (V8) documents#4615

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/revision-column-int-for-irevisioned
Jun 3, 2026
Merged

Fix #4614: keep mt_version as integer for IRevisioned (V8) documents#4615
jeremydmiller merged 1 commit into
masterfrom
fix/revision-column-int-for-irevisioned

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Problem

Users upgrading Marten 8 → 9 see their SingleStreamProjection aggregate document tables silently migrate mt_version from integer to bigint. The widening was unintentional — leftover from #3733 (which widened RevisionColumn to MetadataColumn<long>), then partially reverted in #4533 (IRevisioned.Version went back to int but the column itself stayed bigint).

The user-visible symptom is an ALTER TABLE … ALTER COLUMN mt_version TYPE bigint showing up on every aggregate document table at first V9 apply, with no real reason — IRevisioned-backed documents track a per-stream version that comfortably fits Int32 and was the V8 default.

Fix

The version column width now varies by the document's interface:

Interface Column Used by
IRevisioned (int) integer SingleStreamProjection aggregates — version comes from mt_streams.version (per-stream counter)
ILongVersioned (long) bigint MultiStreamProjection-projected documents — version is the global event sequence number, can exceed Int32

Discriminator is the interface choice, not the projection kind. This decouples the column-width decision from the existing UseVersionFromMatchingStream flag (which is about SQL semantics — where the version comes from — and shouldn't also control DDL).

Migration matrix

Non-destructive in both directions:

Actual Desired Action
integer integer (IRevisioned) no-op — the headline fix; V8 → V9 stops force-widening
integer bigint (ILongVersioned) ALTER … TYPE bigint — legitimate V8 → V9 widening preserved
bigint integer (IRevisioned) no-op — tolerate existing 9.x columns; no USING ::integer force-narrow that could silently truncate
bigint bigint (ILongVersioned) no-op
uuid either drop + recreate at desired width

Refusing the bigint → integer narrowing is the safety call. A USING mt_version::integer cast would truncate any out-of-range value without Postgres scanning to verify, and an IRevisioned-backed table that's been on 9.x for a while has no real reason to need its column narrowed — the wider column is harmless on disk.

Implementation

  • RevisionColumnInt32 sibling of RevisionColumn — integer column, integer parameter type, integer-aware migration policy.
  • RevisionArgumentInt32 sibling of RevisionArgument for the codegen path.
  • DocumentMetadataCollection.Revision gets an internal setter so VersionedPolicy.Apply() can swap to the Int32 variant when IRevisioned is detected on the document type.
  • DocumentRevisionBinder accepts a column NpgsqlDbType so BindParameter and WriteToBulkAsync emit integer/bigint to match the column. Read side stays as long — Npgsql widens int → long via the field-value handler.
  • ClosedShape{Insert,Update,Upsert}Operation: revision parameter slots now bind with the binder's ColumnDbType (integer for IRevisioned, bigint for ILongVersioned) so the CASE expression's branches type-align with the target column. Postgres won't auto-narrow on the strict VALUES path, so this match has to be exact.
  • DocumentTable.SelectColumns matches RevisionColumnInt32 explicitly (sibling, not subclass of RevisionColumn) so the version slot stays canonical in the SELECT projection.

Tests

8 tests in src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs:

  • Fresh IRevisioned table → integer column.
  • Fresh ILongVersioned table → bigint column.
  • V8 integer + IRevisioned → no migration (headline regression).
  • V8 integer + ILongVersioned → widens to bigint (legitimate path preserved).
  • 9.x bigint + IRevisioned → tolerated, no force-narrow (data-safety guarantee).
  • IRevisioned round-trip insert + update + read on integer column.
  • ILongVersioned round-trip insert + update + read on bigint column.
  • SingleStreamProjection<IRevisionedDoc> end-to-end through the inline projection path on the integer column.

Regression sweep

All clean:

  • CoreTests: 424 passed
  • DocumentDbTests: 999 passed
  • EventSourcingTests: 1358 passed

🤖 Generated with Claude Code

jeremydmiller added a commit that referenced this pull request Jun 3, 2026
…sions

#4610 fixed `values.Contains(p.EnumMember)` against an EnumStorage.AsString
document member by routing the case through `EnumIsOneOfWhereFragment` in
`EnumerableContains.Parse`. That worked on net9.0 but the regression test
file failed on net10.0 in CI for PR #4613 and #4615 with the same
`Writing values of 'YourEnum[]' is not supported for parameters having
NpgsqlDbType '-2147483639'` shape from the original bug.

Root cause: on net10.0 the C# compiler resolves `array.Contains(x)` to
`MemoryExtensions.Contains` (via the implicit `T[]` → `ReadOnlySpan<T>`
conversion), not `Enumerable.Contains`. The match runs through
`MemoryExtensionsContains.Parse`, not `EnumerableContains.Parse`, and the
former had the identical enum-array-as-raw-CommandParameter bug — #4610's
fix had only patched the latter.

Mirror the same fix into `MemoryExtensionsContains.Parse`. The existing
Bug_enum_asstring_array_contains regression tests now cover both parsers
because they target the user-facing shape (`values.Contains(p.Status)`) and
each .NET version routes that to a different parser.

Verified:
  - Bug_enum_asstring_array_contains: 6/6 pass on net9.0
  - Bug_enum_asstring_array_contains: 6/6 pass on net10.0
  - Full LinqTests on net10.0: 1269 passed, 0 failed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marten 9 widened the mt_version column from `integer` to `bigint` for every
projected document (#3733), then partially reverted in #4533 — the .NET
IRevisioned.Version went back to int, but the column itself stayed bigint.
Result: V8 → V9 upgrades silently migrate SingleStreamProjection aggregate
tables from `integer` to `bigint`, even though their version (the per-stream
event count) comfortably fits Int32 and was the V8 default.

This restores the V8 column width for IRevisioned-backed documents while
preserving the bigint column for the legitimate ILongVersioned shape that
MultiStreamProjection-projected documents use (Version is the global event
sequence number; can exceed Int32 under high event volume).

Discriminator: the document type's interface choice, not the projection kind.
IRevisioned → integer column; ILongVersioned → bigint column. The Marten
projection registration paths already steer users toward the right interface
(SingleStream-projected aggregates typically implement IRevisioned;
MultiStream-projected docs implement ILongVersioned), so no public knob is
introduced — the variant is policy-driven via VersionedPolicy.

Migration semantics are non-destructive in both directions:
  - V8 integer + IRevisioned (desired integer)   → no-op  (the headline fix)
  - V8 integer + ILongVersioned (desired bigint) → widen  (existing path preserved)
  - 9.x bigint + IRevisioned (desired integer)   → no-op  (tolerate; no force-narrow)
  - 9.x bigint + ILongVersioned (desired bigint) → no-op
  - uuid + numeric revisions                     → drop + recreate as desired width

Refusing to narrow bigint → integer is the safety call: Postgres won't verify
without scanning the table, and a `USING mt_version::integer` cast would
silently truncate any out-of-range value. Documents whose interface is
IRevisioned are practically bounded by Int32, but a wider on-disk column is
harmless — leave it alone.

Implementation:
  - RevisionColumnInt32 sibling of RevisionColumn — `integer` column, integer
    parameter type, integer-aware migration policy.
  - RevisionArgumentInt32 sibling of RevisionArgument for the codegen path.
  - DocumentMetadataCollection.Revision gets an internal setter so
    VersionedPolicy can swap to the Int32 variant when IRevisioned is detected.
  - DocumentRevisionBinder accepts a column NpgsqlDbType so its BindParameter
    and WriteToBulkAsync emit `integer`/`bigint` to match the column. Read
    side stays as long — Npgsql widens int→long via the field-value handler.
  - ClosedShape{Insert,Update,Upsert}Operation: revision parameter slots now
    bind with the binder's ColumnDbType (integer for IRevisioned, bigint for
    ILongVersioned) so the CASE-in-VALUES expression's branches type-align
    with the target column. Postgres won't auto-narrow on the VALUES strict
    path, so this match has to be exact.
  - DocumentTable.SelectColumns also matches RevisionColumnInt32 explicitly
    (it's a sibling, not a subclass of RevisionColumn) so the version slot
    stays canonical in the SELECT projection.

Tests (8 in CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs):
  - Fresh IRevisioned table → integer column.
  - Fresh ILongVersioned table → bigint column.
  - V8 integer + IRevisioned → no migration (headline regression).
  - V8 integer + ILongVersioned → widens to bigint (legitimate path preserved).
  - 9.x bigint + IRevisioned → tolerated, no force-narrow.
  - IRevisioned round-trip insert + update + read on integer column.
  - ILongVersioned round-trip insert + update + read on bigint column.
  - SingleStreamProjection<TIRevisionedDoc> end-to-end through the inline
    projection path on the integer column.

Regression sweeps clean: CoreTests (424 passed), DocumentDbTests (999 passed),
EventSourcingTests (1358 passed).

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.

1 participant