diff --git a/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs b/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs new file mode 100644 index 0000000000..9c1756488c --- /dev/null +++ b/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs @@ -0,0 +1,278 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using JasperFx; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Aggregation; +using Marten.Events.Projections; +using Marten.Metadata; +using Marten.Storage.Metadata; +using Marten.Testing.Harness; +using Npgsql; +using Shouldly; +using Weasel.Core; +using Weasel.Postgresql; +using Xunit; + +namespace CoreTests.Bugs; + +/// +/// #4614 — Marten 8 → 9 upgrade was migrating the mt_version column on +/// SingleStreamProjection aggregate document tables from integer to +/// bigint. That widening was a side-effect of RevisionColumn +/// becoming MetadataColumn<long> in #3733; the .NET-side +/// IRevisioned.Version was reverted to int in #4533, but the column +/// width was not. The fix splits the variant in two: IRevisioned-backed +/// documents (the V8 default; what SingleStreamProjection aggregates use) +/// get integer; ILongVersioned-backed documents (MultiStreamProjection's +/// shape, where Version is a global event-sequence number) keep bigint. +/// +/// Migration is non-destructive in both directions: +/// +/// V8 schema (integer) + IRevisioned (desired integer) — no migration. +/// V8 schema (integer) + ILongVersioned (desired bigint) — widen to bigint. +/// 9.x deployment already migrated to bigint + IRevisioned (desired integer) +/// — tolerated, no force-narrow (a USING cast would risk silent data loss). +/// +/// +public class Bug_4614_revision_column_int_for_IRevisioned: OneOffConfigurationsContext +{ + // ---- Fresh-creation tests: new tables match the V8 column width ---- + + [Fact] + public async Task fresh_table_for_IRevisioned_uses_integer_column() + { + StoreOptions(opts => opts.Schema.For()); + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + var actualType = await readVersionColumnType(typeof(RevisionedDoc)); + actualType.ShouldBe("integer"); + } + + [Fact] + public async Task fresh_table_for_ILongVersioned_uses_bigint_column() + { + StoreOptions(opts => opts.Schema.For()); + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + var actualType = await readVersionColumnType(typeof(LongVersionedDoc)); + actualType.ShouldBe("bigint"); + } + + // ---- Migration tolerance — the regression's user-visible surface ---- + + [Fact] + public async Task V8_integer_column_for_IRevisioned_is_not_migrated_to_bigint() + { + // Stage 1: stand up the schema, then deliberately seed the V8 shape — a real V8 + // deployment's table for an IRevisioned document had `mt_version integer`. On the + // current code (post-fix), the desired type is integer too, so apply should detect + // no work to do and leave the column alone — i.e. NO `ALTER COLUMN … TYPE bigint` + // gets emitted on the V8 → V9 upgrade. + StoreOptions(opts => opts.Schema.For()); + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.CreateCommand( + $"alter table {SchemaName}.mt_doc_revisioneddoc alter column mt_version type integer using mt_version::integer") + .ExecuteNonQueryAsync(); + } + + // Second apply must be a no-op for this column — the desired-vs-actual diff sees + // "integer integer", not "bigint integer", and emits nothing. + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + (await readVersionColumnType(typeof(RevisionedDoc))).ShouldBe("integer"); + } + + [Fact] + public async Task V8_integer_column_for_ILongVersioned_still_widens_to_bigint() + { + // The legitimate V8 → V9 widening path is preserved for ILongVersioned-typed + // documents (the only docs that legitimately need a bigint column going forward). + StoreOptions(opts => opts.Schema.For()); + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.CreateCommand( + $"alter table {SchemaName}.mt_doc_longversioneddoc alter column mt_version type integer using mt_version::integer") + .ExecuteNonQueryAsync(); + } + + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + (await readVersionColumnType(typeof(LongVersionedDoc))).ShouldBe("bigint"); + } + + [Fact] + public async Task existing_9x_bigint_column_for_IRevisioned_is_tolerated_not_narrowed() + { + // The reverse-direction safety: a deployment that already migrated to V9-with-bigint + // (before this fix) MUST NOT get force-narrowed to integer on the next apply — a + // `USING mt_version::integer` cast would silently truncate any out-of-range value. + // The diff treats bigint-actual + integer-desired as compatible (no SQL emitted). + StoreOptions(opts => opts.Schema.For()); + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.CreateCommand( + $"alter table {SchemaName}.mt_doc_revisioneddoc alter column mt_version type bigint") + .ExecuteNonQueryAsync(); + } + + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + (await readVersionColumnType(typeof(RevisionedDoc))).ShouldBe("bigint"); + } + + // ---- CRUD round-trip on both shapes ---- + + [Fact] + public async Task IRevisioned_round_trip_insert_update_read() + { + StoreOptions(opts => opts.Schema.For()); + + var doc = new RevisionedDoc { Id = Guid.NewGuid(), Name = "alpha" }; + + await using (var session = theStore.LightweightSession()) + { + session.UpdateRevision(doc, 1); + await session.SaveChangesAsync(); + } + + await using (var session = theStore.LightweightSession()) + { + doc.Name = "beta"; + session.UpdateRevision(doc, 2); + await session.SaveChangesAsync(); + } + + await using (var query = theStore.QuerySession()) + { + var loaded = await query.LoadAsync(doc.Id); + loaded.ShouldNotBeNull(); + loaded.Name.ShouldBe("beta"); + loaded.Version.ShouldBe(2); + } + } + + [Fact] + public async Task ILongVersioned_round_trip_insert_update_read() + { + StoreOptions(opts => opts.Schema.For()); + + var doc = new LongVersionedDoc { Id = Guid.NewGuid(), Name = "alpha" }; + + await using (var session = theStore.LightweightSession()) + { + session.UpdateRevision(doc, 1); + await session.SaveChangesAsync(); + } + + await using (var session = theStore.LightweightSession()) + { + doc.Name = "beta"; + session.UpdateRevision(doc, 2); + await session.SaveChangesAsync(); + } + + await using (var query = theStore.QuerySession()) + { + var loaded = await query.LoadAsync(doc.Id); + loaded.ShouldNotBeNull(); + loaded.Name.ShouldBe("beta"); + loaded.Version.ShouldBe(2L); + } + } + + // ---- SingleStreamProjection registration auto-picks the right variant ---- + + [Fact] + public async Task SingleStreamProjection_of_IRevisioned_document_gets_integer_column() + { + StoreOptions(opts => + { + opts.Events.AddEventType(); + opts.Projections.Add(ProjectionLifecycle.Inline); + }); + + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + (await readVersionColumnType(typeof(NamedAggregate))).ShouldBe("integer"); + + // Round-trip through the actual projection path to prove the integer column + // works end-to-end: append event → inline projection writes the doc → read back. + var streamId = Guid.NewGuid(); + await using (var session = theStore.LightweightSession()) + { + session.Events.StartStream(streamId, new NameChanged("first")); + await session.SaveChangesAsync(); + } + + await using (var session = theStore.LightweightSession()) + { + session.Events.Append(streamId, new NameChanged("second")); + await session.SaveChangesAsync(); + } + + await using (var query = theStore.QuerySession()) + { + var agg = await query.LoadAsync(streamId); + agg.ShouldNotBeNull(); + agg.Name.ShouldBe("second"); + agg.Version.ShouldBe(2); + } + } + + // ---- helper ---- + + private async Task readVersionColumnType(Type docType) + { + await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + var tableName = "mt_doc_" + docType.Name.ToLowerInvariant(); + var dataType = (string?)await conn.CreateCommand( + "select data_type from information_schema.columns where table_schema = :s and table_name = :t and column_name = 'mt_version'") + .With("s", SchemaName) + .With("t", tableName) + .ExecuteScalarAsync(); + return dataType ?? throw new InvalidOperationException( + $"mt_version column not found on {SchemaName}.{tableName}"); + } +} + +public class RevisionedDoc: IRevisioned +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Version { get; set; } +} + +public class LongVersionedDoc: ILongVersioned +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public long Version { get; set; } +} + +public record NameChanged(string Name); + +public class NamedAggregate: IRevisioned +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Version { get; set; } +} + +public partial class NamedAggregateProjection: SingleStreamProjection +{ + public void Apply(NameChanged @event, NamedAggregate agg) => agg.Name = @event.Name; +} diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs index e7b7efd8f7..58574c2365 100644 --- a/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs +++ b/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs @@ -150,8 +150,13 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata // THEN COALESCE((select version from .mt_streams where id = ?), 1) // ELSE ? END (3 slots: ?=0 check, subquery id, explicit revision) // UseVersionFromMatchingStream + conjoined: same with extra ? for tenant_id (4 slots) - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; + // #4614: parameter type follows the column width (integer vs bigint). + var revisionDbType = _descriptor.RevisionBinder.ColumnDbType; + var revisionValue = revisionDbType == NpgsqlDbType.Integer + ? (object)checked((int)Revision) + : Revision; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; slot++; if (_descriptor.UseVersionFromMatchingStream) @@ -168,8 +173,8 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata } } - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; return slot + 1; } diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs index a7f8fab4cd..421be4db41 100644 --- a/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs +++ b/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs @@ -134,12 +134,16 @@ public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) } else if (_descriptor.ConcurrencyMode == ConcurrencyMode.Numeric) { - // WHERE (? = 0 or {table}.mt_version < ?) — bind raw - // Revision to both slots. - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; - parameters[slot + 1].Value = Revision; - parameters[slot + 1].NpgsqlDbType = NpgsqlDbType.Bigint; + // WHERE (? = 0 or {table}.mt_version < ?) — bind raw Revision to both slots. + // #4614: parameter type tracks the column width (integer/bigint). + var revisionDbType = _descriptor.RevisionBinder!.ColumnDbType; + var revisionValue = revisionDbType == NpgsqlDbType.Integer + ? (object)checked((int)Revision) + : Revision; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; + parameters[slot + 1].Value = revisionValue; + parameters[slot + 1].NpgsqlDbType = revisionDbType; } } @@ -177,10 +181,15 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata ReferenceEquals(binder, _descriptor.RevisionBinder)) { // SET mt_version = CASE WHEN ? = 0 THEN current+1 ELSE ? END - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; - parameters[slot + 1].Value = Revision; - parameters[slot + 1].NpgsqlDbType = NpgsqlDbType.Bigint; + // #4614: parameter type tracks the column width (integer/bigint). + var revisionDbType = _descriptor.RevisionBinder.ColumnDbType; + var revisionValue = revisionDbType == NpgsqlDbType.Integer + ? (object)checked((int)Revision) + : Revision; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; + parameters[slot + 1].Value = revisionValue; + parameters[slot + 1].NpgsqlDbType = revisionDbType; return slot + 2; } diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs index 16290b9fed..f8c857bf6e 100644 --- a/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs +++ b/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs @@ -188,8 +188,14 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata // UseVersionFromMatchingStream + conjoined: same with extra ? for tenant_id (4 slots) // The ON CONFLICT SET / WHERE branches always reference {table}.id // directly so they keep their 4 revision slots downstream. - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; + // #4614: the parameter type follows the column width (integer for IRevisioned, + // bigint for ILongVersioned) so the CASE expression's branch types align. + var revisionDbType = _descriptor.RevisionBinder.ColumnDbType; + var revisionValue = revisionDbType == NpgsqlDbType.Integer + ? (object)checked((int)Revision) + : Revision; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; slot++; if (_descriptor.UseVersionFromMatchingStream) @@ -206,8 +212,8 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata } } - parameters[slot].Value = Revision; - parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint; + parameters[slot].Value = revisionValue; + parameters[slot].NpgsqlDbType = revisionDbType; return slot + 1; } diff --git a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs index 4752975570..633a20c57a 100644 --- a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs +++ b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs @@ -30,16 +30,28 @@ internal sealed class DocumentRevisionBinder: IDocumentMetadataBinder? _setter; + private readonly NpgsqlDbType _columnDbType; public DocumentRevisionBinder(MemberInfo? revisionMember) + : this(revisionMember, NpgsqlDbType.Bigint) { + } + + public DocumentRevisionBinder(MemberInfo? revisionMember, NpgsqlDbType columnDbType) + { + // #4614: the mt_version column comes in two widths — bigint (default, used for + // ILongVersioned docs) or integer (used for IRevisioned docs, restored Marten 8 + // behavior). The parameter NpgsqlDbType has to match the column type so Postgres + // doesn't refuse the bind on the strict VALUES (CASE … END) path. + _columnDbType = columnDbType; + if (revisionMember is not null) { - // #4526/#4528: the mt_version column is bigint, but the document member is - // either an int (IRevisioned) or a long (ILongVersioned). For an int member - // downcast the loaded long; this can overflow for huge MultiStreamProjection - // event sequences — those documents should use ILongVersioned (documented - // caveat). + // #4526/#4528: regardless of column width, the document member is either int + // (IRevisioned) or long (ILongVersioned). Read-side conversion (long → int) + // handles the rare overflow path; the integer column can't overflow into an + // int member, but a bigint column with an int member can. Those docs should + // use ILongVersioned (documented caveat). if (revisionMember.GetRawMemberType() == typeof(int)) { var intSetter = LambdaBuilder.Setter(revisionMember); @@ -52,14 +64,24 @@ public DocumentRevisionBinder(MemberInfo? revisionMember) } } + public NpgsqlDbType ColumnDbType => _columnDbType; + public string ColumnName => Marten.Schema.SchemaConstants.VersionColumn; public string ValueSql => "?"; public void BindParameter(NpgsqlParameter parameter, TDoc document, IMartenSession session) { - parameter.Value = 0L; - parameter.NpgsqlDbType = NpgsqlDbType.Bigint; + if (_columnDbType == NpgsqlDbType.Integer) + { + parameter.Value = 0; + parameter.NpgsqlDbType = NpgsqlDbType.Integer; + } + else + { + parameter.Value = 0L; + parameter.NpgsqlDbType = NpgsqlDbType.Bigint; + } } public void Apply(DbDataReader reader, int columnOrdinal, TDoc document, IMartenSession session) @@ -67,6 +89,8 @@ public void Apply(DbDataReader reader, int columnOrdinal, TDoc document, IMarten if (_setter is null) return; if (reader.IsDBNull(columnOrdinal)) return; + // Npgsql widens an int column to long via the field-value type handler, so + // reading as long works for both column widths. var revision = reader.GetFieldValue(columnOrdinal); _setter(document, revision); } @@ -83,8 +107,10 @@ public Task WriteToBulkAsync(NpgsqlBinaryImporter writer, TDoc document, { // Bulk path defaults to revision 1 — matches the codegen // BulkLoader.GenerateBulkWriterCodeAsync's hard-coded "write - // (long)1" for RevisionArgument. + // (long)1" for RevisionArgument. The parameter type follows the column. _setter?.Invoke(document, 1L); - return writer.WriteAsync(1L, NpgsqlDbType.Bigint, cancellation); + return _columnDbType == NpgsqlDbType.Integer + ? writer.WriteAsync(1, NpgsqlDbType.Integer, cancellation) + : writer.WriteAsync(1L, NpgsqlDbType.Bigint, cancellation); } } diff --git a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs index 53fb559236..5525c55309 100644 --- a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs +++ b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs @@ -62,11 +62,16 @@ public static DocumentStorageDescriptor Build( if (mapping.Metadata.Revision.Enabled) { - // Numeric revisions: mt_version is bigint. Revision and - // Version columns share the same physical column name so - // never both enabled — the validation in DocumentMapping - // enforces it. - revisionBinder = new DocumentRevisionBinder(mapping.Metadata.Revision.Member); + // Numeric revisions: mt_version is bigint (default) or integer (#4614, + // when IRevisioned-backed). Revision and Version columns share the same + // physical column name so never both enabled — the validation in + // DocumentMapping enforces it. The binder's parameter NpgsqlDbType has to + // match the column width so the CASE-in-VALUES bind doesn't trip Postgres' + // strict type check. + var revisionDbType = mapping.Metadata.Revision is Marten.Storage.Metadata.RevisionColumnInt32 + ? NpgsqlTypes.NpgsqlDbType.Integer + : NpgsqlTypes.NpgsqlDbType.Bigint; + revisionBinder = new DocumentRevisionBinder(mapping.Metadata.Revision.Member, revisionDbType); writeBinders.Add(revisionBinder); // RevisionColumn.ShouldSelect: Member != null OR (!QueryOnly && UseNumericRevisions) diff --git a/src/Marten/Metadata/VersionedPolicy.cs b/src/Marten/Metadata/VersionedPolicy.cs index b8e1fa33e1..39e2ea0218 100644 --- a/src/Marten/Metadata/VersionedPolicy.cs +++ b/src/Marten/Metadata/VersionedPolicy.cs @@ -2,6 +2,7 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using Marten.Schema; +using Marten.Storage.Metadata; using System.Diagnostics.CodeAnalysis; namespace Marten.Metadata; @@ -21,6 +22,12 @@ public void Apply(DocumentMapping mapping) else if (mapping.DocumentType.CanBeCastTo()) { + // #4614: swap the default (bigint) Revision column for the integer variant. + // IRevisioned was the Marten 8 default, and the per-stream version it backs + // (SingleStreamProjection) is comfortably in Int32 range. Restoring the + // narrower column here means a V8→V9 upgrade no longer migrates the table + // from integer to bigint, and new schemas match the V8 shape exactly. + mapping.Metadata.Revision = new RevisionColumnInt32(); mapping.UseNumericRevisions = true; mapping.Metadata.Revision.Enabled = true; mapping.Metadata.Revision.Member = mapping.DocumentType.GetProperty(nameof(IRevisioned.Version)); @@ -28,7 +35,7 @@ public void Apply(DocumentMapping mapping) // #4528: ILongVersioned is the 64-bit revision variant for documents projected // from a MultiStreamProjection, where Version is the global event sequence number - // and can exceed Int32. + // and can exceed Int32. Keeps the default RevisionColumn (bigint). else if (mapping.DocumentType.CanBeCastTo()) { mapping.UseNumericRevisions = true; diff --git a/src/Marten/Schema/Arguments/RevisionArgument.cs b/src/Marten/Schema/Arguments/RevisionArgument.cs index 740ba253ee..8a6ea352ea 100644 --- a/src/Marten/Schema/Arguments/RevisionArgument.cs +++ b/src/Marten/Schema/Arguments/RevisionArgument.cs @@ -12,3 +12,20 @@ public RevisionArgument() Column = SchemaConstants.VersionColumn; } } + +/// +/// 32-bit (integer) revision argument for the IRevisioned column variant (#4614). The +/// codegen UpsertFunction path uses this to declare the `revision` parameter as +/// `integer` in `mt_upsert_*` functions whose target column is `integer`, so Postgres +/// can bind without an implicit-cast mismatch. +/// +internal class RevisionArgumentInt32: UpsertArgument +{ + public RevisionArgumentInt32() + { + Arg = "revision"; + PostgresType = "integer"; + DbType = NpgsqlDbType.Integer; + Column = SchemaConstants.VersionColumn; + } +} diff --git a/src/Marten/Schema/DocumentMetadataCollection.cs b/src/Marten/Schema/DocumentMetadataCollection.cs index d1396582ff..8a56045e4d 100644 --- a/src/Marten/Schema/DocumentMetadataCollection.cs +++ b/src/Marten/Schema/DocumentMetadataCollection.cs @@ -11,7 +11,13 @@ public DocumentMetadataCollection(DocumentMapping parent) } public MetadataColumn Version { get; } = new VersionColumn(); - public MetadataColumn Revision { get; } = new RevisionColumn(); + + // #4614: VersionedPolicy reassigns this to RevisionColumnInt32 when the document + // type implements IRevisioned (int) so the mt_version column is `integer` rather + // than `bigint`, restoring the Marten 8 schema shape. ILongVersioned (long) docs + // keep the default RevisionColumn (bigint). The setter is internal because the + // variant choice is policy-driven, not user-driven. + public MetadataColumn Revision { get; internal set; } = new RevisionColumn(); public MetadataColumn LastModified { get; } = new LastModifiedColumn(); public MetadataColumn CreatedAt { get; } = new CreatedAtColumn(); public MetadataColumn TenantId { get; } = new TenantIdColumn(); diff --git a/src/Marten/Storage/DocumentTable.cs b/src/Marten/Storage/DocumentTable.cs index 75436f133a..c1f58157fe 100644 --- a/src/Marten/Storage/DocumentTable.cs +++ b/src/Marten/Storage/DocumentTable.cs @@ -197,6 +197,10 @@ internal ISelectableColumn[] SelectColumns(StorageStyle style) // Bug_3946 against MultiStreamProjection + conjoined tenancy. ISelectableColumn? version = columns.OfType().SingleOrDefault(); version ??= columns.OfType().SingleOrDefault(); + // #4614: the integer revision variant is a sibling class, not a subclass of + // RevisionColumn — has to be matched explicitly so the version slot stays + // canonical in the SELECT projection for IRevisioned-backed documents. + version ??= columns.OfType().SingleOrDefault(); var answer = new List(); diff --git a/src/Marten/Storage/Metadata/DocumentMetadata.cs b/src/Marten/Storage/Metadata/DocumentMetadata.cs index 974d26b017..e492539c3b 100644 --- a/src/Marten/Storage/Metadata/DocumentMetadata.cs +++ b/src/Marten/Storage/Metadata/DocumentMetadata.cs @@ -30,6 +30,18 @@ public DocumentMetadata(object id) /// public long CurrentRevision { get; internal set; } + /// + /// The current numeric revision when the document is mapped to the 32-bit (integer) + /// mt_version column variant (i.e. when its type implements ). + /// Shares storage with ; the int accessor is a view onto the + /// same persisted value, narrowed for the Int32 metadata-column read path (#4614). + /// + public int CurrentRevisionInt32 + { + get => (int)CurrentRevision; + internal set => CurrentRevision = value; + } + /// /// Timestamp of the last time this document was modified /// diff --git a/src/Marten/Storage/Metadata/RevisionColumn.cs b/src/Marten/Storage/Metadata/RevisionColumn.cs index ac7299fd06..4709b36a3a 100644 --- a/src/Marten/Storage/Metadata/RevisionColumn.cs +++ b/src/Marten/Storage/Metadata/RevisionColumn.cs @@ -9,6 +9,20 @@ namespace Marten.Storage.Metadata; +// #4614: the mt_version column for documents using numeric revisions comes in two +// physical widths. SingleStreamProjection-projected documents implement IRevisioned +// (int) and want `integer` — the per-stream version is bounded by the stream's event +// count and was the Marten 8 default. MultiStreamProjection-projected documents +// implement ILongVersioned (long) and want `bigint` because the version is sourced +// from the global event sequence number and can exceed Int32. VersionedPolicy chooses +// the right variant when binding the column to the document type. + +/// +/// The 64-bit (bigint) revision column variant. Used when the document type +/// implements . Tolerates a pre-existing +/// integer column on disk by widening it (non-destructively) to bigint — +/// preserves the Marten 8 → 9 upgrade path for ILongVersioned documents. +/// internal class RevisionColumn: MetadataColumn, ISelectableColumn { public RevisionColumn(): base(SchemaConstants.VersionColumn, x => x.CurrentRevision) @@ -67,3 +81,76 @@ public override void WriteMetadataInUpdateStatement(ICommandBuilder builder, Doc builder.Append(" + 1"); } } + +/// +/// The 32-bit (integer) revision column variant — the Marten 8 default that +/// restores when the document type implements +/// . Tolerates a pre-existing bigint +/// column on disk (already-on-9.x deployments are not force-narrowed); the +/// schema diff treats either width as acceptable to avoid lossy migrations. +/// +internal class RevisionColumnInt32: MetadataColumn, ISelectableColumn +{ + public RevisionColumnInt32(): base(SchemaConstants.VersionColumn, x => x.CurrentRevisionInt32) + { + AllowNulls = false; + DefaultExpression = "0"; + Enabled = false; + ShouldUpdatePartials = true; + } + + internal override UpsertArgument ToArgument() + { + return new RevisionArgumentInt32(); + } + + // Same dual-acceptance as the long variant: either int member (IRevisioned, natural + // here) or long member (a user who manually wires ILongVersioned onto a SingleStream + // doc). DocumentRevisionBinder reconciles the member width with the column width. + protected override bool IsAcceptableMemberType(Type memberType) + => memberType == typeof(long) || memberType == typeof(int); + + public bool ShouldSelect(DocumentMapping mapping, StorageStyle storageStyle) + { + if (Member != null) + { + return true; + } + + return storageStyle != StorageStyle.QueryOnly && mapping.UseNumericRevisions; + } + + public override string AlterColumnTypeSql(Table table, TableColumn changeActual) + { + // #4614: existing 9.x deployments already migrated mt_version to bigint when + // RevisionColumn was the only variant. Now that IRevisioned-backed documents + // want integer again, we deliberately tolerate the wider column on disk + // rather than emit a lossy `USING mt_version::integer` narrowing cast — any + // legitimate IRevisioned data is comfortably in Int32 range, but Postgres + // refuses to verify that without scanning the table and a forced narrow risks + // silent data loss if anything slipped through. Tolerate => no-op. + if (changeActual.Type.EqualsIgnoreCase("bigint")) + { + return string.Empty; + } + + // uuid (Guid concurrency mode) — drop and recreate as integer. + return $"ALTER TABLE {table.Identifier.QualifiedName} DROP COLUMN {Name};{AddColumnSql(table)}"; + } + + public override bool CanAlter(TableColumn actual) + { + // bigint is reported as acceptable so the diff machinery routes it through + // AlterColumnTypeSql above (which then no-ops) rather than re-creating the + // column with potential constraint loss. + return actual.Type.EqualsIgnoreCase("uuid") || actual.Type.EqualsIgnoreCase("bigint"); + } + + public override void WriteMetadataInUpdateStatement(ICommandBuilder builder, DocumentSessionBase session) + { + builder.Append(SchemaConstants.VersionColumn); + builder.Append(" = "); + builder.Append(SchemaConstants.VersionColumn); + builder.Append(" + 1"); + } +}