From 287dfdce3ff89dc2f8680b1f2a35ab2acccb9107 Mon Sep 17 00:00:00 2001 From: Laurence Gillian Date: Sun, 2 Aug 2026 06:52:07 +0100 Subject: [PATCH 1/2] Emit the StreamOne ETag from the numeric revision for revisioned documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projection-target documents are forced onto numeric revisioning by ProjectionDocumentPolicy, so the Guid-only ETag gate in StreamOneWithVersion meant the common CQRS read-model shape (a SingleStreamProjection document served by StreamOne) could never emit an ETag — even though its mt_version already holds the source stream's version, the exact value StreamAggregate serves as its ETag. Widen StreamOneJsonResult to carry either flavor, piggy-back the same mt_version select in revision mode (reading the column robustly at either width per #4614), and format the ETag with the existing long overload so If-None-Match/304 behaves identically to the Guid path. Types with neither flavor enabled still emit no ETag. Documents from an EventProjection are not forced into revisioning and emit no ETag unless they opt in; the compiled-query overload does not participate. --- docs/documents/aspnetcore.md | 26 +++- src/IssueService/StreamingMinimalEndpoints.cs | 29 +++- .../streaming_result_types_tests.cs | 126 +++++++++++++++++- src/Marten.AspNetCore/QueryableExtensions.cs | 19 ++- src/Marten.AspNetCore/StreamOne.cs | 9 +- .../Sessions/QuerySession.Execution.cs | 15 +++ src/Marten/Linq/MartenLinqQueryProvider.cs | 59 +++++--- .../Services/JsonStreamingExtensions.cs | 29 ++++ 8 files changed, 274 insertions(+), 38 deletions(-) diff --git a/docs/documents/aspnetcore.md b/docs/documents/aspnetcore.md index 15af975682..c3fccc0853 100644 --- a/docs/documents/aspnetcore.md +++ b/docs/documents/aspnetcore.md @@ -466,9 +466,29 @@ still current: document), formatted as a quoted GUID, e.g. `"3f2504e0-4f89-11d3-9a0c-0305e82c3301"`. The `mt_version` value is read **inline with the document in the same single database round trip** (piggy-backed onto the streaming query), so enabling the - ETag adds no extra query. Document types whose version metadata is disabled (no - `mt_version` column) simply emit no ETag. Because the document is streamed in that - one round trip, a `304` on `StreamOne` saves response bandwidth but not the read. + ETag adds no extra query. Because the document is streamed in that one round + trip, a `304` on `StreamOne` saves response bandwidth but not the read. +- For documents using [numeric revisioning](/documents/concurrency) instead of the + Guid version — `IRevisioned`/`ILongVersioned` types, and every aggregate-projection + target (snapshots, single- and multi-stream projections), since Marten forces + numeric revisions on those — the `StreamOne` ETag is the numeric `mt_version`, + formatted as a quoted integer, e.g. `"3"`. Documents written by an + `EventProjection` are not forced into numeric revisions and emit no ETag unless + they opt into a versioning flavor themselves. For a `SingleStreamProjection` + target the revision is the source stream's version, so serving the read model + through `StreamOne` produces the same ETag that `StreamAggregate` would + serve for the stream itself; for an Inline-lifecycle projection, clients can + switch between the two read styles without invalidating their caches (an + Async-lifecycle document can lag the stream head, so the two styles may briefly + disagree). Multi-stream and `ILongVersioned` targets emit their own monotonic + per-document revision, which is a valid ETag but not a stream version. Note that + revision-derived ETags are deterministic: a projection-logic change plus rebuild + that does not advance the stream does not invalidate previously cached responses + (bumping `ProjectionVersion` does not reset the revision either), matching the + `StreamAggregate` semantics. The compiled-query overload + (`StreamOne`) does not participate in ETag/`304` handling. Document + types with neither version flavor enabled (no `mt_version` column) simply emit + no ETag. - For `StreamAggregate`, the ETag is derived from the event stream's version (a `long`), formatted as a quoted integer, e.g. `"42"`. The version is looked up before the aggregate is folded, so a cache hit (`304`) skips diff --git a/src/IssueService/StreamingMinimalEndpoints.cs b/src/IssueService/StreamingMinimalEndpoints.cs index 6a8f527018..996b3adfee 100644 --- a/src/IssueService/StreamingMinimalEndpoints.cs +++ b/src/IssueService/StreamingMinimalEndpoints.cs @@ -56,6 +56,18 @@ public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointR (Guid id, IQuerySession session) => new StreamOne(session.Query().Where(x => x.Id == id))); + // Projection-target document (numeric revisions forced by ProjectionDocumentPolicy) — + // served through StreamOne instead of StreamAggregate, the ETag is the numeric revision, + // which for a single-stream projection equals the source stream's version. + app.MapGet("/minimal/order-doc/{id:guid}", + (Guid id, IQuerySession session) + => new StreamOne(session.Query().Where(x => x.Id == id))); + + // Plain document using numeric revisions via IRevisioned — no projection involved. + app.MapGet("/minimal/revisioned/{id:guid}", + (Guid id, IQuerySession session) + => new StreamOne(session.Query().Where(x => x.Id == id))); + // --- StreamMany --- app.MapGet("/minimal/issues/open", @@ -173,11 +185,24 @@ public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointR } /// -/// A document type registered with version metadata disabled (no mt_version column), -/// used to prove emits no ETag for versionless documents. +/// A document type registered with version metadata disabled (no mt_version column of +/// either flavor — Guid version or numeric revision), used to prove +/// emits no ETag for versionless documents. /// public class VersionlessDoc { public Guid Id { get; set; } public string Name { get; set; } } + +/// +/// A plain (non-projection) document using numeric revisions via +/// , used to prove +/// derives its ETag from the numeric revision. +/// +public class RevisionedIssueNote: IRevisioned +{ + public Guid Id { get; set; } + public string Name { get; set; } + public int Version { get; set; } +} diff --git a/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs index b5eee1f570..3c13375d5f 100644 --- a/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs +++ b/src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs @@ -216,9 +216,11 @@ public async Task stream_one_suppresses_etag_when_emit_etag_is_false() [Fact] public async Task stream_one_emits_no_etag_when_version_metadata_disabled() { - // VersionlessDoc is registered with Metadata.Version.Enabled = false, so there is no - // mt_version column to derive an ETag from. EmitETag defaults to true, but the inline - // version read comes back null and no ETag (and no false 304) is produced. + // VersionlessDoc is registered with Metadata.Version.Enabled = false and carries no + // numeric revision metadata either (not a projection target, not IRevisioned), so there + // is no mt_version column of either flavor to derive an ETag from. EmitETag defaults to + // true, but the inline version read comes back null and no ETag (and no false 304) is + // produced. var doc = new VersionlessDoc { Id = Guid.NewGuid(), Name = "no version column" }; await using (var session = theHost.Services.GetRequiredService().LightweightSession()) { @@ -301,6 +303,124 @@ public void RecordSavedChanges(Marten.IDocumentSession session, Marten.Services. public void OnBeforeExecute(Npgsql.NpgsqlBatch batch) => Count++; } + // ──────────────── StreamOne ETag — numeric-revision documents ──────────────── + + [Fact] + public async Task stream_one_emits_stream_version_etag_for_projection_target_document() + { + // Order is the target of an inline single-stream projection (Projections.Snapshot), + // so ProjectionDocumentPolicy forces numeric revisions and the projection writes the source + // stream's version into mt_version. Serving the projected document through StreamOne emits + // that revision as the ETag — the same value StreamAggregate derives for the same stream. + var orderId = Guid.NewGuid(); + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Events.StartStream(orderId, new OrderPlaced("Projected Book", 12.50m)); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order-doc/{orderId}"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + result.Context.Response.Headers["ETag"].ToString().ShouldBe("\"1\""); + + var order = result.ReadAsJson(); + order.Id.ShouldBe(orderId); + order.Description.ShouldBe("Projected Book"); + } + + [Fact] + public async Task stream_one_returns_304_when_if_none_match_matches_projection_revision() + { + var orderId = Guid.NewGuid(); + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Events.StartStream(orderId, new OrderPlaced("Cached Projection", 3.00m)); + await session.SaveChangesAsync(); + } + + var second = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order-doc/{orderId}"); + s.WithRequestHeader("If-None-Match", "\"1\""); + s.StatusCodeShouldBe(304); + }); + + second.Context.Response.Headers["ETag"].ToString().ShouldBe("\"1\""); + second.ReadAsText().ShouldBeNullOrEmpty(); + } + + [Fact] + public async Task stream_one_projection_etag_changes_when_the_stream_advances() + { + var orderId = Guid.NewGuid(); + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Events.StartStream(orderId, new OrderPlaced("Evolving Book", 8.00m)); + await session.SaveChangesAsync(); + } + + var first = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order-doc/{orderId}"); + s.StatusCodeShouldBe(200); + }); + first.Context.Response.Headers["ETag"].ToString().ShouldBe("\"1\""); + + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Events.Append(orderId, new OrderShipped()); + await session.SaveChangesAsync(); + } + + // The previously-cached ETag is now stale: full body again, with the new revision. + var second = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/order-doc/{orderId}"); + s.WithRequestHeader("If-None-Match", "\"1\""); + s.StatusCodeShouldBe(200); + }); + + second.Context.Response.Headers["ETag"].ToString().ShouldBe("\"2\""); + + var order = second.ReadAsJson(); + order.Shipped.ShouldBeTrue(); + } + + [Fact] + public async Task stream_one_emits_revision_etag_for_plain_revisioned_document() + { + // Not a projection target: RevisionedIssueNote opts into numeric revisions by + // implementing IRevisioned, which also gives its mt_version column the narrower + // integer width (#4614) — proving the revision read handles both column widths. + var note = new RevisionedIssueNote { Id = Guid.NewGuid(), Name = "rev one" }; + await using (var session = theHost.Services.GetRequiredService().LightweightSession()) + { + session.Store(note); + await session.SaveChangesAsync(); + } + + var result = await theHost.Scenario(s => + { + s.Get.Url($"/minimal/revisioned/{note.Id}"); + s.StatusCodeShouldBe(200); + s.ContentTypeShouldBe("application/json"); + }); + + result.Context.Response.Headers["ETag"].ToString().ShouldBe("\"1\""); + + await theHost.Scenario(s => + { + s.Get.Url($"/minimal/revisioned/{note.Id}"); + s.WithRequestHeader("If-None-Match", "\"1\""); + s.StatusCodeShouldBe(304); + }); + } + // ───────────────────────── StreamMany ───────────────────────── [Fact] diff --git a/src/Marten.AspNetCore/QueryableExtensions.cs b/src/Marten.AspNetCore/QueryableExtensions.cs index 99175f3bfa..b5693ad7fa 100644 --- a/src/Marten.AspNetCore/QueryableExtensions.cs +++ b/src/Marten.AspNetCore/QueryableExtensions.cs @@ -19,9 +19,12 @@ public static class QueryableExtensions /// When is true (the default), the document's mt_version /// is read inline with the document in the same single round trip (piggy-backed onto the /// streaming query, analogous to the count(*) OVER() stats column) and written as a quoted - /// ETag response header. If the incoming request's If-None-Match header matches that - /// version, a 304 Not Modified is written instead, with an empty body. Document types with - /// version metadata disabled (no mt_version column) emit no ETag. + /// ETag response header — a quoted GUID under Guid optimistic concurrency, or a quoted + /// integer for numeric-revision documents (projection targets, IRevisioned types), where + /// a SingleStreamProjection target's revision is the source stream's version. If the + /// incoming request's If-None-Match header matches that value, a 304 Not Modified + /// is written instead, with an empty body. Document types with neither version nor revision + /// metadata enabled (no mt_version column) emit no ETag. /// /// /// @@ -68,10 +71,14 @@ public static async Task WriteSingle( return; } - if (result.Version.HasValue) - { - var etag = ETagHelpers.Format(result.Version.Value); + var etag = result.Version.HasValue + ? ETagHelpers.Format(result.Version.Value) + : result.Revision.HasValue + ? ETagHelpers.Format(result.Revision.Value) + : null; + if (etag != null) + { if (ETagHelpers.IfNoneMatchMatches(context, etag)) { context.Response.StatusCode = StatusCodes.Status304NotModified; diff --git a/src/Marten.AspNetCore/StreamOne.cs b/src/Marten.AspNetCore/StreamOne.cs index 0bda374aac..53f9104b0b 100644 --- a/src/Marten.AspNetCore/StreamOne.cs +++ b/src/Marten.AspNetCore/StreamOne.cs @@ -53,10 +53,11 @@ public StreamOne(IQueryable queryable) /// /// Whether to emit an ETag response header derived from the document's - /// mt_version, and honor an incoming If-None-Match request header by - /// responding 304 Not Modified with an empty body when it matches. Defaults to - /// true. Set to false to opt out if a consumer's contract cannot tolerate - /// the extra header. + /// mt_version (a quoted GUID under Guid optimistic concurrency, a quoted integer + /// for numeric-revision documents such as projection targets), and honor an incoming + /// If-None-Match request header by responding 304 Not Modified with an + /// empty body when it matches. Defaults to true. Set to false to opt out + /// if a consumer's contract cannot tolerate the extra header. /// public bool EmitETag { get; init; } = true; diff --git a/src/Marten/Internal/Sessions/QuerySession.Execution.cs b/src/Marten/Internal/Sessions/QuerySession.Execution.cs index e0553cee1f..b2a3daed36 100644 --- a/src/Marten/Internal/Sessions/QuerySession.Execution.cs +++ b/src/Marten/Internal/Sessions/QuerySession.Execution.cs @@ -102,6 +102,21 @@ internal async Task StreamOne(DbCommand command, Stream stream, Cancellati } } + internal async Task<(bool found, long? revision)> StreamOneWithRevision(DbCommand command, Stream stream, + CancellationToken token) + { + await using var reader = await ExecuteReaderAsync(command, token).ConfigureAwait(false); + + try + { + return await reader.StreamOneWithRevision(stream, token).ConfigureAwait(false); + } + finally + { + await reader.CloseAsync().ConfigureAwait(false); + } + } + internal async Task StreamMany(DbCommand command, Stream stream, CancellationToken token) { await using var reader = await ExecuteReaderAsync(command, token).ConfigureAwait(false); diff --git a/src/Marten/Linq/MartenLinqQueryProvider.cs b/src/Marten/Linq/MartenLinqQueryProvider.cs index 0d9e204696..01461d97b6 100644 --- a/src/Marten/Linq/MartenLinqQueryProvider.cs +++ b/src/Marten/Linq/MartenLinqQueryProvider.cs @@ -25,11 +25,14 @@ internal record WaitForAggregate(TimeSpan Timeout, NonStaleDataTimeoutMode Timeo /// /// Outcome of a single-document JSON stream that also read the document's mt_version -/// inline. is false when the query matched no row; -/// is null when the document type has no mt_version column (version metadata disabled) -/// or the value was SQL NULL — in which case no ETag should be emitted. +/// inline. is false when the query matched no row. At most one of +/// (Guid optimistic-concurrency mode) or +/// (numeric revision mode — projection-target documents and IRevisioned/ILongVersioned +/// types) carries a value; both are null when the document type has no mt_version column +/// (neither metadata flavor enabled) or the value was SQL NULL — in which case no ETag +/// should be emitted. /// -internal readonly record struct StreamOneJsonResult(bool Found, Guid? Version); +internal readonly record struct StreamOneJsonResult(bool Found, Guid? Version, long? Revision); internal class MartenLinqQueryProvider: IQueryProvider { @@ -272,9 +275,12 @@ public async Task StreamOne(Expression expression, Stream destination, Can /// round trip — the version column is piggy-backed onto the streaming query via /// (analogous to the count(*) OVER() stats column), /// so the ASP.NET Core StreamOne ETag support no longer needs a follow-up metadata query. - /// When the document type has no mt_version column (version - /// metadata disabled), the version column is not appended and - /// comes back null so the caller emits no ETag. + /// The column is read as a Guid when the mapping uses Guid optimistic concurrency, and as a + /// numeric revision when the mapping uses numeric revisioning (projection-target documents, + /// IRevisioned/ILongVersioned types) — same physical column, different flavor. + /// When the document type has no mt_version column (neither + /// metadata flavor enabled), the column is not appended and the result carries neither a + /// version nor a revision so the caller emits no ETag. /// public async Task StreamOneWithVersion(Expression expression, Stream destination, CancellationToken token) where T : notnull @@ -288,24 +294,37 @@ public async Task StreamOneWithVersion(Expression expres var main = statements.MainSelector; main.Limit = 1; - var versionEnabled = _session.Options.Storage.FindMapping(typeof(T)) is DocumentMapping - { - Metadata.Version.Enabled: true - }; + var mapping = _session.Options.Storage.FindMapping(typeof(T)) as DocumentMapping; - if (!versionEnabled) + if (mapping is { Metadata.Version.Enabled: true }) { - var plainCommand = statement.BuildCommand(_session); - var found = await _session.StreamOne(plainCommand, destination, token).ConfigureAwait(false); - return new StreamOneJsonResult(found, null); + main.SelectClause = new VersionSelectClause(main.SelectClause); + + var command = statement.BuildCommand(_session); + var (streamed, version) = await _session.StreamOneWithVersion(command, destination, token) + .ConfigureAwait(false); + + return new StreamOneJsonResult(streamed, version, null); } - main.SelectClause = new VersionSelectClause(main.SelectClause); + // Numeric-revision documents keep their revision in the same physical mt_version column, + // so the identical piggy-backed select serves them — the value just reads back as a + // number rather than a uuid. For a SingleStreamProjection target the revision *is* the + // source stream's version, making the resulting ETag byte-for-byte the one StreamAggregate + // serves for the same stream. + if (mapping is { Metadata.Revision.Enabled: true }) + { + main.SelectClause = new VersionSelectClause(main.SelectClause); - var command = statement.BuildCommand(_session); - var (streamed, version) = await _session.StreamOneWithVersion(command, destination, token) - .ConfigureAwait(false); + var command = statement.BuildCommand(_session); + var (streamed, revision) = await _session.StreamOneWithRevision(command, destination, token) + .ConfigureAwait(false); + + return new StreamOneJsonResult(streamed, null, revision); + } - return new StreamOneJsonResult(streamed, version); + var plainCommand = statement.BuildCommand(_session); + var found = await _session.StreamOne(plainCommand, destination, token).ConfigureAwait(false); + return new StreamOneJsonResult(found, null, null); } } diff --git a/src/Marten/Services/JsonStreamingExtensions.cs b/src/Marten/Services/JsonStreamingExtensions.cs index 4c778ac7fe..d347b1f0c0 100644 --- a/src/Marten/Services/JsonStreamingExtensions.cs +++ b/src/Marten/Services/JsonStreamingExtensions.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Globalization; using System.IO; using System.Text; using System.Text.Json; @@ -60,6 +61,34 @@ internal static async Task StreamOne(this DbDataReader reader, Stream strea return (true, version); } + /// + /// Numeric-revision counterpart of : streams the first + /// row's data column and reads the piggy-backed numeric mt_version value. + /// The column is bigint by default but integer for IRevisioned-backed + /// documents (#4614), so the value is materialized at whatever width came back and widened + /// to long rather than read through a single generic accessor. + /// + internal static async Task<(bool found, long? revision)> StreamOneWithRevision(this DbDataReader reader, + Stream stream, CancellationToken token) + { + if (!await reader.ReadAsync(token).ConfigureAwait(false)) + { + return (false, null); + } + + var dataOrdinal = reader.GetOrdinal("data"); + await reader.WriteJsonValueAsync(dataOrdinal, stream, token).ConfigureAwait(false); + + var revisionOrdinal = reader.GetOrdinal(Marten.Linq.SqlGeneration.VersionSelectClause.VersionAlias); + long? revision = await reader.IsDBNullAsync(revisionOrdinal, token).ConfigureAwait(false) + ? null + : Convert.ToInt64( + await reader.GetFieldValueAsync(revisionOrdinal, token).ConfigureAwait(false), + CultureInfo.InvariantCulture); + + return (true, revision); + } + internal static ValueTask WriteBytes(this Stream stream, byte[] bytes, CancellationToken token) { return stream.WriteAsync(bytes, token); From 31008ab13b09628f7f91be3225efe87867aa8b4c Mon Sep 17 00:00:00 2001 From: Laurence Gillian Date: Sun, 2 Aug 2026 06:52:07 +0100 Subject: [PATCH 2/2] Fail fast when both version and revision metadata are enabled on one document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompileAndValidate only checked the UseNumericRevisions/UseOptimisticConcurrency mode flags, but the metadata Enabled bits are what emit columns — and both flavors map to the same physical mt_version column. Schema.For() .UseOptimisticConcurrency(true) on a projection-target document (which ProjectionDocumentPolicy has already forced onto numeric revisions, and whose fluent overrides run after the policies) left both bits enabled, surfacing as a raw duplicate-key ArgumentException from projection storage or invalid DDL with two mt_version columns at migration time. DocumentStorageDescriptorBuilder already assumes the two can never both be enabled. Throw an actionable InvalidDocumentException naming the two settings instead. Deliberately does not make UseOptimisticConcurrency(true) clear Metadata.Revision.Enabled: that would let a projection target slip past the guard into a mapping that breaks on the second inline apply with a ConcurrencyException, which is strictly worse than failing at bootstrap. --- ...urrency_on_projection_target_fails_fast.cs | 52 +++++++++++++++++++ src/Marten/Schema/DocumentMapping.cs | 13 +++++ 2 files changed, 65 insertions(+) create mode 100644 src/EventSourcingTests/Bugs/optimistic_concurrency_on_projection_target_fails_fast.cs diff --git a/src/EventSourcingTests/Bugs/optimistic_concurrency_on_projection_target_fails_fast.cs b/src/EventSourcingTests/Bugs/optimistic_concurrency_on_projection_target_fails_fast.cs new file mode 100644 index 0000000000..a2b3a7b113 --- /dev/null +++ b/src/EventSourcingTests/Bugs/optimistic_concurrency_on_projection_target_fails_fast.cs @@ -0,0 +1,52 @@ +using EventSourcingTests.Aggregation; +using JasperFx.Events.Projections; +using Marten.Exceptions; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Bugs; + +/// +/// Companion guard to Bug_2978: the Guid version column and the numeric revision column are +/// two flavors of the same physical mt_version column, so a mapping that ends up with +/// both enabled used to surface as a raw duplicate-key ArgumentException (or invalid +/// DDL with two mt_version columns) instead of an actionable configuration error. +/// +public class optimistic_concurrency_on_projection_target_fails_fast: BugIntegrationContext +{ + [Fact] + public void use_optimistic_concurrency_on_projected_document_throws_invalid_document_exception() + { + // ProjectionDocumentPolicy forces MyAggregate onto numeric revisions, then the fluent + // override runs after the policies and re-enables the Guid version — leaving both + // flavors on. That combination can never work and must fail fast — here already at + // store bootstrap, because the projection's ValidateConfiguration materializes the + // aggregate mapping. + var ex = Should.Throw(() => StoreOptions(opts => + { + opts.Projections.Add(ProjectionLifecycle.Inline); + opts.Schema.For().UseOptimisticConcurrency(true); + })); + + ex.Message.ShouldContain("UseOptimisticConcurrency"); + ex.Message.ShouldContain("UseNumericRevisions"); + ex.Message.ShouldContain(nameof(MyAggregate)); + } + + [Fact] + public void switching_a_plain_document_from_numeric_revisions_to_optimistic_concurrency_also_fails_fast() + { + // Without any projection involved, stacking the two fluent calls leaves the revision + // metadata enabled from the first call while the second re-enables the Guid version. + StoreOptions(opts => + { + opts.Schema.For().UseNumericRevisions(true); + opts.Schema.For().UseOptimisticConcurrency(true); + }); + + Should.Throw( + () => theStore.Options.Storage.MappingFor(typeof(Target))); + } +} diff --git a/src/Marten/Schema/DocumentMapping.cs b/src/Marten/Schema/DocumentMapping.cs index 98c852b106..71aed50b48 100644 --- a/src/Marten/Schema/DocumentMapping.cs +++ b/src/Marten/Schema/DocumentMapping.cs @@ -898,6 +898,19 @@ internal void CompileAndValidate() $"{DocumentType.FullNameInCode()} cannot be configured with UseNumericRevision and UseOptimisticConcurrency. Choose one or the other"); } + // The check above only sees the two mode flags, but the metadata Enabled bits are what + // actually emit columns — and the Guid version and numeric revision flavors compete for + // the same physical mt_version column, so both enabled means a duplicate column: a raw + // duplicate-key ArgumentException from projection storage or invalid DDL at migration + // time. The usual route here is Schema.For().UseOptimisticConcurrency(true) on a + // projection-target document that ProjectionDocumentPolicy already forced onto numeric + // revisions (fluent overrides run after the policies). Fail fast with the fix instead. + if (Metadata.Version.Enabled && Metadata.Revision.Enabled) + { + throw new InvalidDocumentException( + $"{DocumentType.FullNameInCode()} has both the Guid version metadata (Metadata.Version, from UseOptimisticConcurrency) and the numeric revision metadata (Metadata.Revision, from UseNumericRevisions) enabled, but they map to the same mt_version column. Choose one or the other — fluent configuration calls accumulate rather than override each other, so remove the call for the mode you do not want. Note that projection-target documents always use numeric revisions and cannot opt into UseOptimisticConcurrency."); + } + IQueryableMember idField; if (IdStrategy is ValueTypeIdGeneration st) {