Skip to content

Commit e27b7aa

Browse files
committed
docs and final work on querying non stale projection data. Closes GH-3489
1 parent 282dacb commit e27b7aa

File tree

7 files changed

+111
-15
lines changed

7 files changed

+111
-15
lines changed

docs/events/projections/async-daemon.md

+45
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,48 @@ using multi-tenancy through a database per tenant. On these spans will be these
483483

484484
There is also a counter metric called `marten.daemon.skipping` or `marten.[database name].daemon.skipping`
485485
that just emits and update every time that Marten has to "skip" stale events.
486+
487+
## Querying for Non Stale Data
488+
489+
There are some potential benefits to running projections asynchronously, namely:
490+
491+
* Avoiding concurrent updates to aggregated documents so that the results are accurate, especially when the aggregation is "multi-stream"
492+
* Putting the work of building aggregates into a background process so you don't take the performance "hit" of doing that work during requests from a client
493+
494+
All that being said, using asynchronous projections means you're going into the realm of [eventual consistency](https://en.wikipedia.org/wiki/Eventual_consistency), and sometimes
495+
that's really inconvenient when your users or clients expect up to date information about the projected aggregate data.
496+
497+
Not to worry though, because Marten will allow you to "wait" for an asynchronous projection to catch up so that you
498+
can query the latest information as all the events captured at the time of the query are processed through the asynchronous
499+
projection like so:
500+
501+
<!-- snippet: sample_using_query_for_non_stale_data -->
502+
<a id='snippet-sample_using_query_for_non_stale_data'></a>
503+
```cs
504+
var builder = Host.CreateApplicationBuilder();
505+
builder.Services.AddMarten(opts =>
506+
{
507+
opts.Connection(builder.Configuration.GetConnectionString("marten"));
508+
opts.Projections.Add<TripProjection>(ProjectionLifecycle.Async);
509+
}).AddAsyncDaemon(DaemonMode.HotCold);
510+
511+
using var host = builder.Build();
512+
await host.StartAsync();
513+
514+
// DocumentStore() is an extension method in Marten just
515+
// as a convenience method for test automation
516+
await using var session = host.DocumentStore().LightweightSession();
517+
518+
// This query operation will first "wait" for the asynchronous projection building the
519+
// Trip aggregate document to catch up to at least the highest event sequence number assigned
520+
// at the time this method is called
521+
var latest = await session.QueryForNonStaleData<Trip>(5.Seconds())
522+
.OrderByDescending(x => x.Started)
523+
.Take(10)
524+
.ToListAsync();
525+
```
526+
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/Aggregation/querying_with_non_stale_data.cs#L133-L157' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_query_for_non_stale_data' title='Start of snippet'>anchor</a></sup>
527+
<!-- endSnippet -->
528+
529+
Do note that this can time out if the projection just can't catch up to the latest event sequence in time. You may need to
530+
be both cautious with using this in general, and also cautious especially with the timeout setting.

docs/scenarios/command_handler_workflow.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ builder.Services.AddMarten(opts =>
170170
// need identity map mechanics in your commands or query handlers
171171
.UseLightweightSessions();
172172
```
173-
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/FetchForWriting/fetching_inline_aggregates_for_writing.cs#L532-L550' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_use_identity_map_for_inline_aggregates' title='Start of snippet'>anchor</a></sup>
173+
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/EventSourcingTests/FetchForWriting/fetching_inline_aggregates_for_writing.cs#L611-L629' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_use_identity_map_for_inline_aggregates' title='Start of snippet'>anchor</a></sup>
174174
<!-- endSnippet -->
175175

176176
It's pretty involved, but the key takeaway is that _if_ you are using lightweight sessions for a performance optimization

src/EventSourcingTests/Aggregation/querying_with_non_stale_data.cs

+34
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
using Marten;
99
using Marten.Events;
1010
using Marten.Events.Daemon;
11+
using Marten.Events.Daemon.Resiliency;
1112
using Marten.Events.Projections;
1213
using Marten.Testing.Harness;
14+
using Microsoft.Extensions.Configuration;
15+
using Microsoft.Extensions.Hosting;
1316
using Shouldly;
1417
using Xunit;
1518

@@ -124,4 +127,35 @@ public async Task try_to_query_for_non_stale_data_by_aggregate_type()
124127

125128
await task;
126129
}
130+
131+
public static async Task ExampleUsage()
132+
{
133+
#region sample_using_query_for_non_stale_data
134+
135+
var builder = Host.CreateApplicationBuilder();
136+
builder.Services.AddMarten(opts =>
137+
{
138+
opts.Connection(builder.Configuration.GetConnectionString("marten"));
139+
opts.Projections.Add<TripProjection>(ProjectionLifecycle.Async);
140+
}).AddAsyncDaemon(DaemonMode.HotCold);
141+
142+
using var host = builder.Build();
143+
await host.StartAsync();
144+
145+
// DocumentStore() is an extension method in Marten just
146+
// as a convenience method for test automation
147+
await using var session = host.DocumentStore().LightweightSession();
148+
149+
// This query operation will first "wait" for the asynchronous projection building the
150+
// Trip aggregate document to catch up to at least the highest event sequence number assigned
151+
// at the time this method is called
152+
var latest = await session.QueryForNonStaleData<Trip>(5.Seconds())
153+
.OrderByDescending(x => x.Started)
154+
.Take(10)
155+
.ToListAsync();
156+
157+
#endregion
158+
}
127159
}
160+
161+

src/Marten/Events/AsyncProjectionTestingExtensions.cs

+1-14
Original file line numberDiff line numberDiff line change
@@ -142,26 +142,13 @@ public static async Task WaitForNonStaleProjectionDataAsync(this IMartenDatabase
142142
var shards = database.As<MartenDatabase>().Options.Projections.AsyncShardsPublishingType(aggregationType);
143143
if (!shards.Any()) throw new InvalidOperationException($"Cannot find any registered async projection shards for aggregate type {aggregationType.FullNameInCode()}");
144144

145-
var all = shards.Concat([ShardName.HighWaterMark]).ToArray();
146145
var tracking = new Dictionary<string, long>();
147146
foreach (var shard in shards)
148147
{
149148
tracking[shard.Identity] = 0;
150149
}
151150

152-
long highWaterMark = long.MaxValue;
153-
var initial = await database.FetchProjectionProgressFor(all, token).ConfigureAwait(false);
154-
foreach (var state in initial)
155-
{
156-
if (state.ShardName == ShardState.HighWaterMark)
157-
{
158-
highWaterMark = state.Sequence;
159-
}
160-
else
161-
{
162-
tracking[state.ShardName] = state.Sequence;
163-
}
164-
}
151+
var highWaterMark = await database.FetchHighestEventSequenceNumber(token).ConfigureAwait(false);
165152

166153
if (tracking.isComplete(highWaterMark)) return;
167154

src/Marten/Storage/IMartenDatabase.cs

+7
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ Task<long> ProjectionProgressFor(ShardName name,
131131
/// <param name="token"></param>
132132
/// <returns></returns>
133133
Task<long?> FindEventStoreFloorAtTimeAsync(DateTimeOffset timestamp, CancellationToken token);
134+
135+
/// <summary>
136+
/// Fetch the highest assigned event sequence number in this database
137+
/// </summary>
138+
/// <param name="token"></param>
139+
/// <returns></returns>
140+
Task<long> FetchHighestEventSequenceNumber(CancellationToken token = default);
134141
}
135142

136143
public enum ConnectionUsage

src/Marten/Storage/MartenDatabase.EventStorage.cs

+18
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ public partial class MartenDatabase
3838
}
3939
}
4040

41+
/// <summary>
42+
/// Fetch the highest assigned event sequence number in this database
43+
/// </summary>
44+
/// <param name="token"></param>
45+
/// <returns></returns>
46+
public async Task<long> FetchHighestEventSequenceNumber(CancellationToken token = default)
47+
{
48+
await EnsureStorageExistsAsync(typeof(IEvent), token).ConfigureAwait(false);
49+
await using var conn = CreateConnection();
50+
await conn.OpenAsync(token).ConfigureAwait(false);
51+
var highest = (long)await conn
52+
.CreateCommand($"select last_value from {Options.Events.DatabaseSchemaName}.mt_events_sequence;")
53+
.ExecuteScalarAsync(token).ConfigureAwait(false);
54+
55+
return highest;
56+
}
57+
58+
4159
/// <summary>
4260
/// Fetch the current size of the event store tables, including the current value
4361
/// of the event sequence number

src/Marten/Storage/StandinDatabase.cs

+5
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ public Task<long> ProjectionProgressFor(ShardName name, CancellationToken token
233233
throw new NotImplementedException();
234234
}
235235

236+
public async Task<long> FetchHighestEventSequenceNumber(CancellationToken token = default)
237+
{
238+
throw new NotImplementedException();
239+
}
240+
236241
public NpgsqlConnection CreateConnection(ConnectionUsage connectionUsage = ConnectionUsage.ReadWrite)
237242
{
238243
throw new NotImplementedException();

0 commit comments

Comments
 (0)