Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docs/documents/aspnetcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,15 +466,20 @@ 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. Because the document is streamed in that one round
trip, a `304` on `StreamOne<T>` saves response bandwidth but not the read.
ETag adds no extra query. The version is read off the row _before_ the document
payload, so a `304` skips buffering a body it would only discard — but the row
itself still comes back from Postgres, so a `304` on `StreamOne<T>` saves the
response and the copy, 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<T>` 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`
`EventProjection` are **not** aggregate-projection targets, so Marten does not
force numeric revisions on them; they keep the plain-document default and emit a
quoted GUID ETag that changes on every projection write. That is a usable cache
validator, but it is not a stream version and does not line up with what
`StreamAggregate<T>` serves. For a `SingleStreamProjection`
target the revision is the source stream's version, so serving the read model
through `StreamOne<T>` produces the same ETag that `StreamAggregate<T>` would
serve for the stream itself; for an Inline-lifecycle projection, clients can
Expand Down
18 changes: 18 additions & 0 deletions docs/documents/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,24 @@ same session. Prefer using `UpdateRevision()` if you try to continuously update
`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; the only difference is the column type and member width: `IRevisioned` stores its version in an `integer` (`mt_version`) column, while `ILongVersioned` uses a `bigint` column. A `MultiStreamProjection`-derived document that implements `IRevisioned` (int) will overflow on the `bigint → int` read once its version exceeds `Int32` — use `ILongVersioned` for those.
:::

::: warning A document cannot use both flavors
Guid optimistic concurrency and numeric revisioning both store their value in the same physical
`mt_version` column, so a document type can only have one of them. Marten throws an
`InvalidDocumentException` at bootstrap if a mapping ends up with both enabled, rather than letting
it reach the database as DDL with two `mt_version` columns.

The two ways to trip this are worth knowing, because neither reads as "I asked for both":

- Calling `UseOptimisticConcurrency(true)` on a type that already has numeric revisions — because it
implements `IRevisioned`/`ILongVersioned`, carries a `[Version]` member, or is an
aggregate-projection target (Marten forces numeric revisions on those). Fluent configuration runs
after those policies and layers on top of them rather than replacing them.
- Calling `UseNumericRevisions(true)` and then `UseOptimisticConcurrency(true)` on the same type.

Remove the call for the flavor you do not want. Aggregate-projection targets always use numeric
revisions and cannot opt into `UseOptimisticConcurrency`.
:::

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using EventSourcingTests.Aggregation;
using JasperFx.Events.Projections;
using Marten.Exceptions;
using Marten.Metadata;
using Marten.Testing.Documents;
using Marten.Testing.Harness;
using Shouldly;
Expand Down Expand Up @@ -49,4 +51,26 @@ public void switching_a_plain_document_from_numeric_revisions_to_optimistic_conc
Should.Throw<InvalidDocumentException>(
() => theStore.Options.Storage.MappingFor(typeof(Target)));
}

[Fact]
public void interface_driven_revisions_plus_optimistic_concurrency_fails_fast()
{
// The likeliest real-world route into the invalid state, and the one neither test above
// covers: nothing in the configuration says "numeric revisions" — VersionedPolicy turns
// them on because the document implements IRevisioned, and the fluent call then re-enables
// the Guid version on top. Before the guard this reached the database as DDL with two
// mt_version columns.
StoreOptions(opts => opts.Schema.For<RevisionedDoc>().UseOptimisticConcurrency(true));

var ex = Should.Throw<InvalidDocumentException>(
() => theStore.Options.Storage.MappingFor(typeof(RevisionedDoc)));

ex.Message.ShouldContain(nameof(RevisionedDoc));
}

public class RevisionedDoc: IRevisioned
{
public Guid Id { get; set; }
public int Version { get; set; }
}
}
8 changes: 8 additions & 0 deletions src/IssueService/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;
using IssueService.Controllers;
using JasperFx.Events;
using JasperFx.Events.Projections;
using Marten;
using Marten.Events.Projections;
using Marten.Testing.Harness;
Expand Down Expand Up @@ -56,7 +57,14 @@ public void ConfigureServices(IServiceCollection services)
}
else
{
// NOTE: Order is only a projection target under Guid stream identity, so the
// /minimal/order-doc endpoint only carries a revision-derived ETag on hosts built
// from this branch. Tests asserting that ETag must use the Guid-identity fixture.
options.Projections.Snapshot<Order>(SnapshotLifecycle.Inline);

// An EventProjection (not an aggregate projection) so its output document is NOT
// swept into numeric revisions by ProjectionDocumentPolicy.
options.Projections.Add<OrderTouchProjection>(ProjectionLifecycle.Inline);
}

return options;
Expand Down
62 changes: 62 additions & 0 deletions src/IssueService/StreamingMinimalEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using System;
using System.Linq;
using IssueService.Controllers;
using JasperFx.Events;
using Marten;
using Marten.AspNetCore;
using Marten.Events.Projections;
using Marten.Metadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
Expand Down Expand Up @@ -68,6 +71,30 @@ public static IEndpointRouteBuilder MapStreamingMinimalEndpoints(this IEndpointR
(Guid id, IQuerySession session)
=> new StreamOne<RevisionedIssueNote>(session.Query<RevisionedIssueNote>().Where(x => x.Id == id)));

// EmitETag = false opt-out on a numeric-revision document — proves the opt-out short-circuits
// before the revision flavor is ever consulted, not just for the Guid flavor.
app.MapGet("/minimal/revisioned/{id:guid}/no-etag",
(Guid id, IQuerySession session)
=> new StreamOne<RevisionedIssueNote>(session.Query<RevisionedIssueNote>().Where(x => x.Id == id))
{
EmitETag = false
});

// Plain document using the 64-bit revision flavor via ILongVersioned — the shape a
// MultiStreamProjection target takes, where the revision is a per-document counter rather
// than a stream version, and the mt_version column stays bigint.
app.MapGet("/minimal/long-versioned/{id:guid}",
(Guid id, IQuerySession session)
=> new StreamOne<LongVersionedIssueNote>(
session.Query<LongVersionedIssueNote>().Where(x => x.Id == id)));

// Document written by an EventProjection. ProjectionDocumentPolicy only forces numeric
// revisions onto *aggregate* projection targets, so this one keeps the default Guid
// version metadata — see the Alba test for what that means for its ETag.
app.MapGet("/minimal/event-projection-doc/{id:guid}",
(Guid id, IQuerySession session)
=> new StreamOne<OrderTouch>(session.Query<OrderTouch>().Where(x => x.Id == id)));

// --- StreamMany<T> ---

app.MapGet("/minimal/issues/open",
Expand Down Expand Up @@ -206,3 +233,38 @@ public class RevisionedIssueNote: IRevisioned
public string Name { get; set; }
public int Version { get; set; }
}

/// <summary>
/// A plain (non-projection) document using the 64-bit revision flavor via
/// <see cref="ILongVersioned"/> — the shape a <c>MultiStreamProjection</c> target takes. Its
/// <c>mt_version</c> column stays <c>bigint</c> (unlike <see cref="RevisionedIssueNote"/>, which
/// #4614 narrows to <c>integer</c>), so the pair covers both widths the revision read must handle.
/// </summary>
public class LongVersionedIssueNote: ILongVersioned
{
public Guid Id { get; set; }
public string Name { get; set; }
public long Version { get; set; }
}

/// <summary>
/// Output document of <see cref="OrderTouchProjection"/>, an <c>EventProjection</c> rather than an
/// aggregate projection. <c>ProjectionDocumentPolicy</c> only forces numeric revisions onto
/// aggregate targets, so this type is left with whatever versioning a plain document gets.
/// </summary>
public class OrderTouch
{
public Guid Id { get; set; }
public string Description { get; set; }
}

/// <summary>
/// An <c>EventProjection</c> (not an aggregate projection) writing <see cref="OrderTouch"/>
/// documents keyed by stream id. Declared <c>partial</c> so the JasperFx.Events source generator
/// can emit its dispatcher for the conventional <c>Create</c> method.
/// </summary>
public partial class OrderTouchProjection: EventProjection
{
public OrderTouch Create(IEvent<OrderPlaced> e)
=> new() { Id = e.StreamId, Description = e.Data.Description };
}
165 changes: 165 additions & 0 deletions src/Marten.AspNetCore.Testing/streaming_result_types_tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,171 @@ await theHost.Scenario(s =>
});
}

[Fact]
public async Task stream_one_emits_revision_etag_for_long_versioned_document()
{
// ILongVersioned keeps the default bigint mt_version column, where IRevisioned narrows it
// to integer (#4614). Pinning both proves the revision read copes with either width, and
// that a multi-stream-shaped target emits its own per-document counter as a valid ETag.
var note = new LongVersionedIssueNote { Id = Guid.NewGuid(), Name = "long rev" };
await using (var session = theHost.Services.GetRequiredService<IDocumentStore>().LightweightSession())
{
session.Store(note);
await session.SaveChangesAsync();
}

var result = await theHost.Scenario(s =>
{
s.Get.Url($"/minimal/long-versioned/{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/long-versioned/{note.Id}");
s.WithRequestHeader("If-None-Match", "\"1\"");
s.StatusCodeShouldBe(304);
});
}

[Fact]
public async Task stream_one_returns_404_without_etag_for_a_revisioned_document()
{
// The 404 branch runs before any ETag is formatted, but that was only pinned on the Guid
// path — a projection-target miss must not leak a header either.
var result = await theHost.Scenario(s =>
{
s.Get.Url($"/minimal/order-doc/{Guid.NewGuid()}");
s.StatusCodeShouldBe(404);
});

result.Context.Response.Headers.ContainsKey("ETag").ShouldBeFalse();
}

[Fact]
public async Task stream_one_suppresses_etag_on_a_revisioned_document_when_emit_etag_is_false()
{
var note = new RevisionedIssueNote { Id = Guid.NewGuid(), Name = "opted out" };
await using (var session = theHost.Services.GetRequiredService<IDocumentStore>().LightweightSession())
{
session.Store(note);
await session.SaveChangesAsync();
}

var result = await theHost.Scenario(s =>
{
s.Get.Url($"/minimal/revisioned/{note.Id}/no-etag");
s.StatusCodeShouldBe(200);
});

result.Context.Response.Headers.ContainsKey("ETag").ShouldBeFalse();

// The opt-out must still serve the document, not just drop the header.
result.ReadAsJson<RevisionedIssueNote>().Name.ShouldBe("opted out");
}

[Fact]
public async Task stream_one_emits_a_guid_etag_for_an_event_projection_output_document()
{
// ProjectionDocumentPolicy only forces numeric revisions onto *aggregate* projection
// targets. An EventProjection's output keeps the plain-document default, which is Guid
// version metadata — so it does emit an ETag, just not a stream-derived one. That ETag
// is opaque and changes on every projection write, so it is only safe as a cache
// validator, never as a stream-version equivalent.
var orderId = Guid.NewGuid();
await using (var session = theHost.Services.GetRequiredService<IDocumentStore>().LightweightSession())
{
session.Events.StartStream<Order>(orderId, new OrderPlaced("touched", 5.00m));
await session.SaveChangesAsync();
}

var result = await theHost.Scenario(s =>
{
s.Get.Url($"/minimal/event-projection-doc/{orderId}");
s.StatusCodeShouldBe(200);
});

var etag = result.Context.Response.Headers["ETag"].ToString();
etag.ShouldNotBeNullOrEmpty();

// A Guid ETag, not the stream version the aggregate target would have served.
Guid.TryParse(etag.Trim('"'), out _).ShouldBeTrue();
etag.ShouldNotBe("\"1\"");

await theHost.Scenario(s =>
{
s.Get.Url($"/minimal/event-projection-doc/{orderId}");
s.WithRequestHeader("If-None-Match", etag);
s.StatusCodeShouldBe(304);
});
}

[Fact]
public async Task stream_one_with_revision_etag_executes_a_single_db_command()
{
// Companion to stream_one_with_etag_executes_a_single_db_command, which only covered the
// Guid flavor. The revision flavor is the projection read-model path — the hot one — so
// pin that it also resolves the document AND its ETag in ONE round trip.
var store = theHost.Services.GetRequiredService<IDocumentStore>();

var orderId = Guid.NewGuid();
await using (var session = store.LightweightSession())
{
session.Events.StartStream<Order>(orderId, new OrderPlaced("single command", 1.00m));
await session.SaveChangesAsync();
}

await using var query = store.QuerySession();
var logger = new CommandCountingLogger();
query.Logger = logger;

// Warm up storage-existence checks on this session so they don't count against us.
await query.Query<Order>().Where(x => x.Id == Guid.NewGuid())
.StreamJsonFirstOrDefault(new MemoryStream());
logger.Count = 0;

var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } };

await query.Query<Order>().Where(x => x.Id == orderId)
.WriteSingle(context, emitETag: true);

context.Response.StatusCode.ShouldBe(200);
context.Response.Headers["ETag"].ToString().ShouldBe("\"1\"");
logger.Count.ShouldBe(1);
}

[Fact]
public async Task stream_one_does_not_buffer_the_document_body_on_a_304()
{
// A conditional-request hit reads the version off the row and then declines the payload,
// so nothing is copied into the response buffer. The read itself still happens — the row
// comes back either way — but the document copy does not.
var store = theHost.Services.GetRequiredService<IDocumentStore>();

var orderId = Guid.NewGuid();
await using (var session = store.LightweightSession())
{
session.Events.StartStream<Order>(orderId, new OrderPlaced(new string('x', 20_000), 1.00m));
await session.SaveChangesAsync();
}

await using var query = store.QuerySession();

var body = new MemoryStream();
var context = new DefaultHttpContext { Response = { Body = body } };
context.Request.Headers["If-None-Match"] = "\"1\"";

await query.Query<Order>().Where(x => x.Id == orderId).WriteSingle(context, emitETag: true);

context.Response.StatusCode.ShouldBe(StatusCodes.Status304NotModified);
context.Response.Headers["ETag"].ToString().ShouldBe("\"1\"");
context.Response.ContentLength.ShouldBe(0);
body.Length.ShouldBe(0);
}

// ───────────────────────── StreamMany<T> ─────────────────────────

[Fact]
Expand Down
Loading
Loading