From 82220a7b14311581c2cd37ca5e6243889afac457 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 20 May 2026 12:54:37 -0500 Subject: [PATCH] Fix #4526 / #4528: revert IRevisioned to int (V8) + add ILongVersioned (long) support Marten 9.0-alpha forked IRevisioned to long Version. jasperfx#348 keeps the canonical JasperFx.IRevisioned at int and adds JasperFx.ILongVersioned (long) for documents projected from a MultiStreamProjection whose Version is the global event sequence number. This consumes both and keeps the bigint mt_version column. Production: - Delete Marten.Metadata.IRevisioned (long); alias IRevisioned -> JasperFx.IRevisioned (int) and ILongVersioned -> JasperFx.ILongVersioned (long) in the shared dedupe file. - VersionedPolicy recognizes both IRevisioned and ILongVersioned -> UseNumericRevisions, mapping Metadata.Revision.Member. - MetadataColumn.Member gains a virtual IsAcceptableMemberType hook; RevisionColumn overrides it to accept int (IRevisioned) or long (ILongVersioned). Column stays bigint. - DocumentRevisionBinder builds a member-type-aware setter: for an int member it downcasts the loaded long (documented overflow caveat for huge event sequences); for a long member it assigns directly. - DocumentSessionBase.storeEntity gains an ILongVersioned case beside IRevisioned; revision flows as long internally (int upcasts). Tests/examples: the revision-counter docs that had adopted the alpha long signature revert to int Version (all are single-stream revision docs). Added long_versioned_revisioning with an ILongVersioned doc whose Version > Int32.MaxValue round-trips through the bigint column without truncation. Docs: concurrency.md documents IRevisioned (int) vs ILongVersioned (long) and the int-overflow caveat for MultiStreamProjection-derived documents. Full solution builds clean (net9/net10). Revision/concurrency tests: DocumentDbTests numeric + long revisioning 24/0; EventSourcing FetchForWriting/compacting/last-good 227/0. Closes #4526 and #4528. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/documents/concurrency.md | 4 ++ .../closed_shape_numeric_revisions_tests.cs | 4 +- ...verting_projection_from_inline_to_async.cs | 2 +- .../side_effects_in_aggregations.cs | 4 +- ...ug_4428_rich_storage_side_effect_events.cs | 2 +- src/DaemonTests/TestingSupport/Trip.cs | 2 +- .../Concurrency/long_versioned_revisioning.cs | 46 +++++++++++++++++++ .../Concurrency/numeric_revisioning.cs | 3 +- src/DocumentDbTests/Writing/bulk_loading.cs | 2 +- .../Aggregation/stream_compacting.cs | 6 +-- .../when_finding_the_last_good_aggregation.cs | 8 ++-- .../fetching_live_aggregates_for_writing.cs | 7 ++- ...sing_explicit_code_for_live_aggregation.cs | 4 +- .../Examples/RevisionedDocuments.cs | 2 +- .../ClosedShape/DocumentRevisionBinder.cs | 15 +++++- .../Internal/Sessions/DocumentSessionBase.cs | 3 ++ src/Marten/Metadata/IRevisioned.cs | 15 ------ src/Marten/Metadata/VersionedPolicy.cs | 10 ++++ src/Marten/Storage/Metadata/MetadataColumn.cs | 9 +++- src/Marten/Storage/Metadata/RevisionColumn.cs | 7 +++ .../marten_managed_tenant_id_partitioning.cs | 2 +- src/Shared/DedupeAliases.cs | 5 ++ 22 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/DocumentDbTests/Concurrency/long_versioned_revisioning.cs delete mode 100644 src/Marten/Metadata/IRevisioned.cs diff --git a/docs/documents/concurrency.md b/docs/documents/concurrency.md index f24417d6b8..daf705ed7a 100644 --- a/docs/documents/concurrency.md +++ b/docs/documents/concurrency.md @@ -189,6 +189,10 @@ Marten will not perfectly keep incrementing the IRevisioned.Revision number if t same session. Prefer using `UpdateRevision()` if you try to continuously update the same document from the same session! ::: +::: tip `IRevisioned` (int) vs `ILongVersioned` (long) +`IRevisioned.Version` is an `int` — the right choice for an ordinary per-document revision counter. For documents projected from a `MultiStreamProjection` whose `Version` is the global **event sequence number**, the value can exceed `Int32.MaxValue`; implement `ILongVersioned` (with a `long Version`) instead. Both opt the document into numeric revisioning and use the same `bigint` `mt_version` column; the only difference is the member width. A `MultiStreamProjection`-derived document that implements `IRevisioned` (int) will overflow on the `bigint → int` read once its version exceeds `Int32` — use `ILongVersioned` for those. +::: + or finally by adding the `[Version]` attribute to a public member on the document type to opt into the `UseNumericRevisions` behavior on the parent type with the decorated member being tracked as the version number as shown in this sample: diff --git a/src/CoreTests/Storage/Identification/closed_shape_numeric_revisions_tests.cs b/src/CoreTests/Storage/Identification/closed_shape_numeric_revisions_tests.cs index 7a8d0c25cb..a6b11a3c74 100644 --- a/src/CoreTests/Storage/Identification/closed_shape_numeric_revisions_tests.cs +++ b/src/CoreTests/Storage/Identification/closed_shape_numeric_revisions_tests.cs @@ -191,9 +191,9 @@ public async Task insert_collision_throws_DocumentAlreadyExists_under_numeric() } } -public class RevDoc: Marten.Metadata.IRevisioned +public class RevDoc: IRevisioned { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; - public long Version { get; set; } + public int Version { get; set; } } diff --git a/src/DaemonTests/Aggregations/converting_projection_from_inline_to_async.cs b/src/DaemonTests/Aggregations/converting_projection_from_inline_to_async.cs index 845fa41e49..9bf778cf29 100644 --- a/src/DaemonTests/Aggregations/converting_projection_from_inline_to_async.cs +++ b/src/DaemonTests/Aggregations/converting_projection_from_inline_to_async.cs @@ -81,7 +81,7 @@ public async Task start_as_inline_move_to_async_and_just_continue() public class SimpleAggregate : IRevisioned { // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public Guid Id { get; set; } diff --git a/src/DaemonTests/Aggregations/side_effects_in_aggregations.cs b/src/DaemonTests/Aggregations/side_effects_in_aggregations.cs index e545018d3a..bd5a14229d 100644 --- a/src/DaemonTests/Aggregations/side_effects_in_aggregations.cs +++ b/src/DaemonTests/Aggregations/side_effects_in_aggregations.cs @@ -400,7 +400,7 @@ public class SideEffects1: IRevisioned public int B { get; set; } public int C { get; set; } public int D { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public record WasDeleted(Guid Id); @@ -460,7 +460,7 @@ public class SideEffects2: IRevisioned public int B { get; set; } public int C { get; set; } public int D { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public class RecordingMessageOutbox: IMessageOutbox diff --git a/src/DaemonTests/Bugs/Bug_4428_rich_storage_side_effect_events.cs b/src/DaemonTests/Bugs/Bug_4428_rich_storage_side_effect_events.cs index f0756842ab..0926b16f4e 100644 --- a/src/DaemonTests/Bugs/Bug_4428_rich_storage_side_effect_events.cs +++ b/src/DaemonTests/Bugs/Bug_4428_rich_storage_side_effect_events.cs @@ -85,7 +85,7 @@ public class Bug4428Counter: IRevisioned public Guid Id { get; set; } public int Increments { get; set; } public int Bonuses { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public partial class Bug4428CounterProjection: SingleStreamProjection diff --git a/src/DaemonTests/TestingSupport/Trip.cs b/src/DaemonTests/TestingSupport/Trip.cs index 445f0e4106..d7c4e05a77 100644 --- a/src/DaemonTests/TestingSupport/Trip.cs +++ b/src/DaemonTests/TestingSupport/Trip.cs @@ -44,7 +44,7 @@ public override string ToString() return $"{nameof(Id)}: {Id}, {nameof(EndedOn)}: {EndedOn}, {nameof(Traveled)}: {Traveled}, {nameof(State)}: {State}, {nameof(Active)}: {Active}, {nameof(StartedOn)}: {StartedOn}"; } - public long Version { get; set; } + public int Version { get; set; } } diff --git a/src/DocumentDbTests/Concurrency/long_versioned_revisioning.cs b/src/DocumentDbTests/Concurrency/long_versioned_revisioning.cs new file mode 100644 index 0000000000..f1bc055dae --- /dev/null +++ b/src/DocumentDbTests/Concurrency/long_versioned_revisioning.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Marten.Schema; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace DocumentDbTests.Concurrency; + +public class long_versioned_revisioning: OneOffConfigurationsContext +{ + [Fact] + public void infer_numeric_revisioning_from_ILongVersioned() + { + var mapping = (DocumentMapping)theStore.Options.Storage.FindMapping(typeof(LongVersionedDoc)); + mapping.UseNumericRevisions.ShouldBeTrue(); + mapping.Metadata.Revision.Enabled.ShouldBeTrue(); + mapping.Metadata.Revision.Member.Name.ShouldBe("Version"); + } + + [Fact] + public async Task round_trips_a_version_greater_than_int32() + { + // #4528: an ILongVersioned document carries the 64-bit revision (e.g. a + // MultiStreamProjection's event sequence number). The bigint mt_version column + // must round-trip a value > Int32.MaxValue without the truncation an int + // IRevisioned member would suffer. + var doc = new LongVersionedDoc { Id = Guid.NewGuid(), Name = "big" }; + var bigVersion = (long)int.MaxValue + 12345; + + theSession.UpdateRevision(doc, bigVersion); + await theSession.SaveChangesAsync(); + + await using var query = theStore.QuerySession(); + var loaded = await query.LoadAsync(doc.Id); + loaded.ShouldNotBeNull(); + loaded.Version.ShouldBe(bigVersion); + } +} + +public class LongVersionedDoc: ILongVersioned +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public long Version { get; set; } +} diff --git a/src/DocumentDbTests/Concurrency/numeric_revisioning.cs b/src/DocumentDbTests/Concurrency/numeric_revisioning.cs index cd64c0df3a..f913390066 100644 --- a/src/DocumentDbTests/Concurrency/numeric_revisioning.cs +++ b/src/DocumentDbTests/Concurrency/numeric_revisioning.cs @@ -16,7 +16,6 @@ using Shouldly; using Weasel.Core; using Xunit; -using IRevisioned = Marten.Metadata.IRevisioned; namespace DocumentDbTests.Concurrency; @@ -567,7 +566,7 @@ public class RevisionedDoc: IRevisioned public Guid Id { get; set; } public string Name { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public class OtherRevisionedDoc diff --git a/src/DocumentDbTests/Writing/bulk_loading.cs b/src/DocumentDbTests/Writing/bulk_loading.cs index 210de97b3c..14af117db8 100644 --- a/src/DocumentDbTests/Writing/bulk_loading.cs +++ b/src/DocumentDbTests/Writing/bulk_loading.cs @@ -707,6 +707,6 @@ public class RevisionedBulkDoc : IRevisioned { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; - public long Version { get; set; } + public int Version { get; set; } } } diff --git a/src/EventSourcingTests/Aggregation/stream_compacting.cs b/src/EventSourcingTests/Aggregation/stream_compacting.cs index 8d05e70cd3..a6c3068619 100644 --- a/src/EventSourcingTests/Aggregation/stream_compacting.cs +++ b/src/EventSourcingTests/Aggregation/stream_compacting.cs @@ -362,7 +362,7 @@ public class Letters : IRevisioned public int BCount { get; set; } public int CCount { get; set; } public int DCount { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public class LetterCounts: IRevisioned @@ -372,7 +372,7 @@ public class LetterCounts: IRevisioned public int BCount { get; set; } public int CCount { get; set; } public int DCount { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public class LetterCountsByString: IRevisioned @@ -382,7 +382,7 @@ public class LetterCountsByString: IRevisioned public int BCount { get; set; } public int CCount { get; set; } public int DCount { get; set; } - public long Version { get; set; } + public int Version { get; set; } } public partial class LetterCountsByStringProjection: SingleStreamProjection diff --git a/src/EventSourcingTests/Aggregation/when_finding_the_last_good_aggregation.cs b/src/EventSourcingTests/Aggregation/when_finding_the_last_good_aggregation.cs index ff8eeabe2f..b81e4f9759 100644 --- a/src/EventSourcingTests/Aggregation/when_finding_the_last_good_aggregation.cs +++ b/src/EventSourcingTests/Aggregation/when_finding_the_last_good_aggregation.cs @@ -82,10 +82,10 @@ public async Task finding_last_aggregate_using_string() public record DeleteYourself; -public class SimpleMaybeDeletedAggregate : Marten.Metadata.IRevisioned +public class SimpleMaybeDeletedAggregate : IRevisioned { // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public bool ShouldDelete(DeleteYourself _) => true; @@ -160,7 +160,7 @@ public override string ToString() } } -public class SimpleAsStringMaybeDeletedAggregate : Marten.Metadata.IRevisioned +public class SimpleAsStringMaybeDeletedAggregate : IRevisioned { protected bool Equals(SimpleAsStringMaybeDeletedAggregate other) { @@ -193,7 +193,7 @@ public override int GetHashCode() } // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public bool ShouldDelete(DeleteYourself _) => true; diff --git a/src/EventSourcingTests/FetchForWriting/fetching_live_aggregates_for_writing.cs b/src/EventSourcingTests/FetchForWriting/fetching_live_aggregates_for_writing.cs index 0d58f24684..a84f88806d 100644 --- a/src/EventSourcingTests/FetchForWriting/fetching_live_aggregates_for_writing.cs +++ b/src/EventSourcingTests/FetchForWriting/fetching_live_aggregates_for_writing.cs @@ -19,7 +19,6 @@ using Marten.Testing.Harness; using Shouldly; using Xunit; -using IRevisioned = Marten.Metadata.IRevisioned; namespace EventSourcingTests.FetchForWriting; @@ -776,7 +775,7 @@ public async Task work_correctly_for_multiple_calls_with_identity_map() public class SimpleAggregate : IRevisioned { // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public Guid Id { get; set; } @@ -987,7 +986,7 @@ public class SomeProjection : IRevisioned public Guid Id { get; set; } public int A { get; set; } public void Apply(EventA e) => A++; - public long Version { get; set; } + public int Version { get; set; } } public class SomeOtherProjection : IRevisioned @@ -995,5 +994,5 @@ public class SomeOtherProjection : IRevisioned public Guid Id { get; set; } public int A { get; set; } public void Apply(EventA e) => A++; - public long Version { get; set; } + public int Version { get; set; } } diff --git a/src/EventSourcingTests/Projections/using_explicit_code_for_live_aggregation.cs b/src/EventSourcingTests/Projections/using_explicit_code_for_live_aggregation.cs index 0b39da5fb5..e61c3c8fd5 100644 --- a/src/EventSourcingTests/Projections/using_explicit_code_for_live_aggregation.cs +++ b/src/EventSourcingTests/Projections/using_explicit_code_for_live_aggregation.cs @@ -97,10 +97,10 @@ public async Task does_not_create_tables() #region sample_using_simple_explicit_code_for_live_aggregation -public class CountedAggregate: Marten.Metadata.IRevisioned +public class CountedAggregate: IRevisioned { // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public Guid Id { diff --git a/src/Marten.Testing/Examples/RevisionedDocuments.cs b/src/Marten.Testing/Examples/RevisionedDocuments.cs index 37166d21e1..f4ebead3b8 100644 --- a/src/Marten.Testing/Examples/RevisionedDocuments.cs +++ b/src/Marten.Testing/Examples/RevisionedDocuments.cs @@ -93,7 +93,7 @@ public class Reservation: IRevisioned // other properties - public long Version { get; set; } + public int Version { get; set; } } #endregion diff --git a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs index 6153709c32..4752975570 100644 --- a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs +++ b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs @@ -35,7 +35,20 @@ public DocumentRevisionBinder(MemberInfo? revisionMember) { if (revisionMember is not null) { - _setter = LambdaBuilder.Setter(revisionMember); + // #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). + if (revisionMember.GetRawMemberType() == typeof(int)) + { + var intSetter = LambdaBuilder.Setter(revisionMember); + _setter = (doc, revision) => intSetter(doc, (int)revision); + } + else + { + _setter = LambdaBuilder.Setter(revisionMember); + } } } diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.cs index 76f1cd51a5..a799c803d8 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.cs @@ -423,6 +423,9 @@ private void storeEntity(T entity, IDocumentStorage storage) where T : not case IRevisioned revisioned when revisioned.Version != 0: storage.Store(this, entity, revisioned.Version); return; + case ILongVersioned longVersioned when longVersioned.Version != 0: + storage.Store(this, entity, longVersioned.Version); + return; default: // Put it in the identity map -- if necessary storage.Store(this, entity); diff --git a/src/Marten/Metadata/IRevisioned.cs b/src/Marten/Metadata/IRevisioned.cs deleted file mode 100644 index e28651f502..0000000000 --- a/src/Marten/Metadata/IRevisioned.cs +++ /dev/null @@ -1,15 +0,0 @@ -#nullable enable -namespace Marten.Metadata; - -/// -/// Optionally implement this interface on your Marten document -/// types to opt into optimistic concurrency with the version -/// being tracked on the Version property using numeric revision values -/// -public interface IRevisioned -{ - /// - /// Marten's version for this document - /// - long Version { get; set; } -} diff --git a/src/Marten/Metadata/VersionedPolicy.cs b/src/Marten/Metadata/VersionedPolicy.cs index d3bd90f4fc..b8e1fa33e1 100644 --- a/src/Marten/Metadata/VersionedPolicy.cs +++ b/src/Marten/Metadata/VersionedPolicy.cs @@ -26,6 +26,16 @@ public void Apply(DocumentMapping mapping) mapping.Metadata.Revision.Member = mapping.DocumentType.GetProperty(nameof(IRevisioned.Version)); } + // #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. + else if (mapping.DocumentType.CanBeCastTo()) + { + mapping.UseNumericRevisions = true; + mapping.Metadata.Revision.Enabled = true; + mapping.Metadata.Revision.Member = mapping.DocumentType.GetProperty(nameof(ILongVersioned.Version)); + } + if (mapping.UseOptimisticConcurrency) { mapping.Metadata.Version.Enabled = true; diff --git a/src/Marten/Storage/Metadata/MetadataColumn.cs b/src/Marten/Storage/Metadata/MetadataColumn.cs index 0e9b3f05b9..be967b2569 100644 --- a/src/Marten/Storage/Metadata/MetadataColumn.cs +++ b/src/Marten/Storage/Metadata/MetadataColumn.cs @@ -102,7 +102,7 @@ public override MemberInfo Member { if (value != null) { - if (value.GetRawMemberType() != typeof(T)) + if (!IsAcceptableMemberType(value.GetRawMemberType()!)) { throw new ArgumentOutOfRangeException(nameof(value), $"The {_memberName} member of {value.DeclaringType?.NameInCode() ?? "null"} has to be of type {typeof(T).NameInCode()}"); @@ -114,6 +114,13 @@ public override MemberInfo Member } } + /// + /// Whether a document member of the given type can back this metadata column. + /// Defaults to an exact match on ; the revision column + /// overrides this to accept both int (IRevisioned) and long (ILongVersioned). + /// + protected virtual bool IsAcceptableMemberType(Type memberType) => memberType == typeof(T); + internal override async Task ApplyAsync(IMartenSession martenSession, DocumentMetadata metadata, int index, DbDataReader reader, CancellationToken token) { diff --git a/src/Marten/Storage/Metadata/RevisionColumn.cs b/src/Marten/Storage/Metadata/RevisionColumn.cs index 1ad85b729a..ac7299fd06 100644 --- a/src/Marten/Storage/Metadata/RevisionColumn.cs +++ b/src/Marten/Storage/Metadata/RevisionColumn.cs @@ -1,3 +1,4 @@ +using System; using JasperFx.Core; using Marten.Internal.CodeGeneration; using Marten.Internal.Sessions; @@ -23,6 +24,12 @@ internal override UpsertArgument ToArgument() return new RevisionArgument(); } + // #4526/#4528: the bigint mt_version column backs either an int IRevisioned.Version + // or a long ILongVersioned.Version member. The bigint<->member conversion is handled + // by DocumentRevisionBinder (read) and the closed-shape operations (write). + protected override bool IsAcceptableMemberType(Type memberType) + => memberType == typeof(long) || memberType == typeof(int); + public bool ShouldSelect(DocumentMapping mapping, StorageStyle storageStyle) { if (Member != null) diff --git a/src/MultiTenancyTests/marten_managed_tenant_id_partitioning.cs b/src/MultiTenancyTests/marten_managed_tenant_id_partitioning.cs index e9c41df1d5..7925780754 100644 --- a/src/MultiTenancyTests/marten_managed_tenant_id_partitioning.cs +++ b/src/MultiTenancyTests/marten_managed_tenant_id_partitioning.cs @@ -311,7 +311,7 @@ public class DocThatShouldBeExempted2 public class SimpleAggregate : IRevisioned { // This will be the aggregate version - public long Version { get; set; } + public int Version { get; set; } public Guid Id { get; set; } diff --git a/src/Shared/DedupeAliases.cs b/src/Shared/DedupeAliases.cs index 16544e76c2..6552ed2491 100644 --- a/src/Shared/DedupeAliases.cs +++ b/src/Shared/DedupeAliases.cs @@ -17,6 +17,11 @@ // TenancyStyle + DeleteStyle -> JasperFx (jasperfx#327 / marten#4517) global using TenancyStyle = JasperFx.MultiTenancy.TenancyStyle; global using DeleteStyle = JasperFx.DeleteStyle; +// IRevisioned reverted to JasperFx's canonical int signature; ILongVersioned (long) +// added for MultiStreamProjection-derived docs whose Version is the event sequence +// (jasperfx#348 / marten#4526 / marten#4528). +global using IRevisioned = JasperFx.IRevisioned; +global using ILongVersioned = JasperFx.ILongVersioned; // Metadata markers -> JasperFx.Metadata (jasperfx#330 / marten#4520) global using ISoftDeleted = JasperFx.Metadata.ISoftDeleted; global using IVersioned = JasperFx.Metadata.IVersioned;