diff --git a/docs/data-versioning-design.md b/docs/data-versioning-design.md
new file mode 100644
index 0000000000..739224ec54
--- /dev/null
+++ b/docs/data-versioning-design.md
@@ -0,0 +1,51 @@
+# Data versioning design
+
+## What it is
+
+A **data version** is the short opaque label a query result carries so that a client asking for it again can be told "nothing has changed" instead of being sent the whole answer. On the wire it is an HTTP entity-tag: the response carries `ETag`, the client sends it back as `If-None-Match`, and a matching request is answered `304 Not Modified` with no body.
+
+One value type carries it end to end: `DataVersion` in `src/ServiceControl.Persistence/Infrastructure/DataVersion.cs`. Every persister produces one, `QueryStatsInfo.Version` carries it out of the persistence layer, and the Web API turns it into the header. It is a `readonly struct`, so `default` is a legitimate value and no variable of the type can be null.
+
+This is the primary (error) instance only. The audit instance still carries a `string ETag` on its own `QueryStatsInfo` and has not been converted.
+
+## The one rule
+
+**If a field the response renders can change without the version changing, a client caches that page for ever and nothing reveals it.** No log line, no exception, no failing test.
+
+The promise is scoped to **one URL**, because a client only ever sends a validator back to the URL that issued it. So what must never happen is one URL answering `304` when its own body would have differed. Two different URLs sharing a value is harmless: an HTTP cache is keyed on the whole URL.
+
+That scoping is what makes a backend's own token usable. RavenDB's result etag stands for the state of the index behind the query, so it moves on any write the query could see, but it says nothing about which page was asked for: every `/api/errors` URL shares one value, whatever the page, sort or filter. The EF Core persisters compose over the rows they returned, so theirs differ per page. Both satisfy the rule.
+
+## Making one
+
+| Factory | Use it for |
+| --------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `FromToken(string)` / `FromToken(long)` | a token the backend already produces, such as a RavenDB index etag or document change vector |
+| `Compose(terms)` | named terms over aggregates, where an aggregate provably moves with the fields it stands in for |
+| `OverRows(summary, rows, fields)` | a list the response renders row by row: summary terms for the whole set, plus one term per row |
+| `Combine(instances)` | one version for a result gathered from several instances |
+| `FromClient(header)` | a validator a caller sent back, in any shape an old or current instance might emit |
+
+`Compose` hashes its description through `DeterministicGuid.MakeId`, so the emitted tag is a GUID rather than the underlying values.
+
+Term names and every field inside a row are **length prefixed**. Without that, free user text carrying a delimiter could make two different results digest identically: a failure group titled `x.y` with an empty `Type` would collide with one titled `x` whose `Type` is `y`. `Format` accepts strings, `bool`, `DateTime` and `DateTimeOffset` (both by ticks) and anything `IFormattable` under the invariant culture, and **throws** on anything else, because a type whose `ToString` is not a documented function of its content would pin the version silently.
+
+`OverRows` names rows by position, so a caller whose query has no `ORDER BY` has to sort them first or the validator churns.
+
+## Absence
+
+`DataVersion.None` is `default`, and it **matches nothing, not even itself**. Two parties that both know nothing have not established that nothing changed, so an empty-string validator matching itself would answer `304` for every request.
+
+Absence propagates in the safe direction. `WithEtag` writes no header for `None`, so no header means no `If-None-Match`, which means the full body. `Combine` returns `None` as soon as any instance reports none, rather than quietly claiming to cover an instance it could not see.
+
+## Reaching the client
+
+`WithEtag` emits **every** tag weak, as `W/"…"`. Nothing here can promise the response bytes: response compression rewrites them without touching the tag, and no endpoint enables range processing, which is the one thing an exact validator would buy. RFC 9110 requires `If-None-Match` to use the weak comparison anyway, so the marking costs nothing.
+
+`NotModifiedStatusHttpHandler` turns a matching request into a `304`. It compares with `EntityTagHeaderValue.Compare(useStrongComparison: false)`, because `Equals` on that type compares strength as well as the tag and its own documentation says not to use it for this. `*` matches whenever a representation exists.
+
+## Across instances
+
+Scatter-gather endpoints merge one version per instance through `Combine`. It is keyed on instance id and sorted ordinally, so the composite is independent of the order instances answered in but still moves if two instances swap which validator they report.
+
+An API whose own instance holds none of the data drops its own empty result before aggregating, via `AggregateStatsFromRemotesOnly`. Left in, its version-less placeholder would take the whole composite to `None` and the endpoint would emit no tag at all.
diff --git a/docs/eventlog-design.md b/docs/eventlog-design.md
index 17c01c2110..b9b9f8208f 100644
--- a/docs/eventlog-design.md
+++ b/docs/eventlog-design.md
@@ -40,7 +40,7 @@ Timestamps are when the thing happened, so an item can land in the middle of the
`IEventLogDataStore` has two methods, and its XML docs are the binding contract:
- `Add(EventLogItem)` persists one item. **Identity is the store's to assign** and surfaces on `EventLogItemView.Id`. That makes `Id` opaque: a stable key within one store, not something to parse.
-- `GetEventLogItems(PagingInfo, knownVersion)` returns a `QueryResult` carrying the page, the total count independent of paging, and an `ETag`. Two obligations: the `ETag` is surfaced **verbatim**, so whatever the client echoes back arrives here unchanged and can be compared, and it **must change when retention removes items**, not only when one is added, since nothing else tells a polling client its cached page has gone stale.
+- `GetEventLogItems(PagingInfo)` returns a `QueryResult` carrying the page, the total count independent of paging, and an `ETag`. Two obligations: the `ETag` is surfaced **verbatim**, so whatever the client echoes back arrives here unchanged and can be compared, and it **must change when retention removes items**, not only when one is added, since nothing else tells a polling client its cached page has gone stale.
## Retention
diff --git a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj
index 028c471d49..3090e7f929 100644
--- a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj
+++ b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj
@@ -51,9 +51,6 @@
-
-
-
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedErrorsController.cs b/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedErrorsController.cs
index 2a6ce095f0..8f6053a386 100644
--- a/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedErrorsController.cs
+++ b/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedErrorsController.cs
@@ -5,6 +5,7 @@
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Mvc;
using Operations;
+ using Persistence.Infrastructure;
using Persistence.RavenDB;
using Raven.Client.Documents;
@@ -28,7 +29,7 @@ public async Task GetFailedErrorsCount(CancellationTok
var count = await query.CountAsync(cancellationToken);
- Response.WithEtag(stats.ResultEtag.ToString());
+ Response.WithEtag(DataVersion.FromToken(stats.ResultEtag.ToString()));
return new FailedErrorsCountReponse { Count = count };
}
diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedMessageRetriesController.cs b/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedMessageRetriesController.cs
index 705806daba..b2a432a342 100644
--- a/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedMessageRetriesController.cs
+++ b/src/ServiceControl.AcceptanceTests.RavenDB/Recoverability/MessageFailures/FailedMessageRetriesController.cs
@@ -4,6 +4,7 @@
using System.Threading.Tasks;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Mvc;
+ using Persistence.Infrastructure;
using Persistence.RavenDB;
using Raven.Client.Documents;
using ServiceControl.Recoverability;
@@ -24,7 +25,7 @@ public async Task GetFailedMessageRetriesCount
using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken);
await session.Query().Statistics(out var stats).ToListAsync(cancellationToken);
- Response.WithEtag(stats.ResultEtag.ToString());
+ Response.WithEtag(DataVersion.FromToken(stats.ResultEtag.ToString()));
return new FailedMessageRetriesCountReponse { Count = stats.TotalResults };
}
diff --git a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj
index be951002d9..34de538dff 100644
--- a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj
+++ b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj
@@ -51,9 +51,6 @@
-
-
-
diff --git a/src/ServiceControl.AcceptanceTests/WebApi/When_a_request_is_repeated_with_its_etag.cs b/src/ServiceControl.AcceptanceTests/WebApi/When_a_request_is_repeated_with_its_etag.cs
index dbc86721c0..acc17d448e 100644
--- a/src/ServiceControl.AcceptanceTests/WebApi/When_a_request_is_repeated_with_its_etag.cs
+++ b/src/ServiceControl.AcceptanceTests/WebApi/When_a_request_is_repeated_with_its_etag.cs
@@ -2,18 +2,19 @@ namespace ServiceControl.AcceptanceTests.WebApi
{
using System.Net;
using System.Net.Http;
+ using System.Net.Http.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;
- using Recoverability.MessageRedirects;
class When_a_request_is_repeated_with_its_etag : AcceptanceTest
{
- [TestCase("/api/customchecks", "GET", false)]
- [TestCase("/api/redirects", "GET", true)]
- [TestCase("/api/redirect", "HEAD", true)]
- public async Task Should_answer_not_modified(string url, string method, bool seedARedirect)
+ [TestCase("/api/customchecks", "GET")]
+ [TestCase("/api/redirects", "GET")]
+ [TestCase("/api/redirect", "HEAD")]
+ [TestCase("/api/errors/queues/addresses", "GET")]
+ public async Task Should_answer_not_modified(string url, string method)
{
Answer issued = null;
Answer repeated = null;
@@ -21,15 +22,6 @@ public async Task Should_answer_not_modified(string url, string method, bool see
await Define()
.Done(async ctx =>
{
- if (seedARedirect)
- {
- await this.Post("/api/redirects", new RedirectRequest
- {
- fromphysicaladdress = "endpointA@machine1",
- tophysicaladdress = "endpointB@machine2"
- }, status => status is not HttpStatusCode.Created);
- }
-
// Internal custom checks re-report on a timer, so the validator can move between
// the two requests.
for (var attempt = 0; attempt < 5; attempt++)
@@ -61,6 +53,42 @@ await Define()
Assert.That(repeated.TotalCount, Is.Not.Null.And.EqualTo(issued.TotalCount), $"{method} {url} did not carry its Total-Count through to the 304");
}
+ [Test]
+ public async Task Should_answer_with_a_new_etag_once_the_data_moves()
+ {
+ Answer before = null;
+ Answer after = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ before = await Ask("GET", "/api/redirects", ifNoneMatch: null);
+
+ if (before.Etag == null)
+ {
+ return false;
+ }
+
+ using var created = await HttpClient.PostAsJsonAsync("/api/redirects", new
+ {
+ FromPhysicalAddress = "SomeEndpoint@MACHINE",
+ ToPhysicalAddress = "OtherEndpoint@MACHINE"
+ });
+
+ created.EnsureSuccessStatusCode();
+
+ after = await Ask("GET", "/api/redirects", before.Etag);
+
+ return true;
+ })
+ .Run();
+
+ Assert.That(after.Status, Is.EqualTo(HttpStatusCode.OK),
+ "a redirect was added, so the client's validator is stale and it has to be sent the new list");
+ Assert.That(after.Etag, Is.Not.Null.And.Not.EqualTo(before.Etag),
+ "the body changed, so the validator has to move with it or the next poll caches the stale list forever");
+ }
+
async Task Ask(string method, string url, string ifNoneMatch)
{
using var response = await Send(method, url, ifNoneMatch);
diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs
index a15ce9da6f..ac2429c37d 100644
--- a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs
+++ b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs
@@ -1,13 +1,18 @@
namespace ServiceControl.Audit.Persistence.RavenDB.Extensions
{
+ using System.Globalization;
using Auditing.MessagesView;
using Raven.Client.Documents.Session;
static class RavenQueryStatisticsExtensions
{
- public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats)
- {
- return new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults);
- }
+ ///
+ /// RavenDB's result etag hashes the state of the index behind the query: every collection's last
+ /// document and tombstone etag, how far the index has processed, and its definition. It therefore
+ /// moves on any write the query could see, which is all a validator has to do, because a client only
+ /// ever sends one back on a request for the same URL.
+ ///
+ public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) =>
+ new(stats.ResultEtag?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, stats.TotalResults);
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
index 6c39ad103c..56ab14f906 100644
--- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
+++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
@@ -25,7 +25,7 @@ public async Task> QuerySagaHistoryById(Guid input, Can
.Statistics(out var stats)
.SingleOrDefaultAsync(x => x.SagaId == input, token: cancellationToken);
- return sagaHistory == null ? QueryResult.Empty() : new QueryResult(sagaHistory, new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults));
+ return sagaHistory == null ? QueryResult.Empty() : new QueryResult(sagaHistory, stats.ToQueryStatsInfo());
}
public async Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default)
diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs
index 0267bb3779..682e618eb9 100644
--- a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs
+++ b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs
@@ -54,6 +54,7 @@ public async Task> GetAllMessages(
}
Response.WithTotalCount(result.QueryStats.TotalCount);
+ Response.WithEtag(result.QueryStats.ETag);
return result.Results;
}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs
index d974cbb51f..02c93e381d 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs
@@ -8,6 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage;
using ServiceControl.Persistence.EFCore.DbContexts;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;
+using ServiceControl.Persistence.Infrastructure;
///
/// Resolves a message body from wherever it was stored.
@@ -28,12 +29,15 @@ public async Task TryFetch(string bodyId, CancellationToken c
return MessageBodyResult.NotFound();
}
- // Bodies are immutable per message, so the id is a stable ETag.
- var uniqueMessageId = row.UniqueMessageId.ToString();
+ // Ingestion updates the existing row rather than adding one, so the message id is unchanged
+ // and cannot serve as a version alone. LastModified is written on every upsert.
+ var version = DataVersion.Compose(
+ ("uniqueMessageId", row.UniqueMessageId),
+ ("lastModified", row.LastModified));
if (row.BodyStoredExternally)
{
- var external = await storagePersistence.ReadBody(uniqueMessageId, cancellationToken);
+ var external = await storagePersistence.ReadBody(row.UniqueMessageId.ToString(), cancellationToken);
if (external == null)
{
@@ -46,7 +50,7 @@ public async Task TryFetch(string bodyId, CancellationToken c
return MessageBodyResult.Empty();
}
- return MessageBodyResult.Available(new MessageBodyStreamContent(external.Stream, external.ContentType, external.BodySize, uniqueMessageId));
+ return MessageBodyResult.Available(new MessageBodyStreamContent(external.Stream, external.ContentType, external.BodySize, version));
}
if (row.BodyText != null)
@@ -62,7 +66,7 @@ public async Task TryFetch(string bodyId, CancellationToken c
new MemoryStream(bytes, writable: false),
row.BodyContentType ?? "text/plain",
bytes.Length,
- uniqueMessageId));
+ version));
}
if (row.BodySize == 0)
@@ -98,7 +102,8 @@ public async Task TryFetch(string bodyId, CancellationToken c
BodyText = message.BodyText,
BodyStoredExternally = message.BodyStoredExternally,
BodySize = message.BodySize,
- BodyContentType = message.BodyContentType
+ BodyContentType = message.BodyContentType,
+ LastModified = message.LastModified
})
.FirstOrDefaultAsync(cancellationToken);
@@ -109,5 +114,6 @@ sealed class BodyRow
public bool BodyStoredExternally { get; init; }
public int BodySize { get; init; }
public string? BodyContentType { get; init; }
+ public DateTime LastModified { get; init; }
}
}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs
index af870ac38b..997680763f 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs
@@ -4,6 +4,8 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ServiceControl.Contracts.CustomChecks;
+using ServiceControl.Operations;
+using ServiceControl.Persistence.EFCore.Infrastructure;
using ServiceControl.Persistence.Infrastructure;
public class CustomCheckDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), ICustomChecksDataStore
@@ -59,21 +61,31 @@ public Task>> GetStats(PagingInfo paging, string?
_ => query
};
- var page = await query
+ var checks = await query
.OrderBy(c => c.ReportedAt)
+ .ThenBy(c => c.Id)
.Skip(paging.Offset)
.Take(paging.PageSize)
+ .Select(c => new CustomCheck
+ {
+ Id = c.Id.ToString(),
+ CustomCheckId = c.CustomCheckId,
+ Category = c.Category,
+ Status = c.Status,
+ ReportedAt = c.ReportedAt,
+ FailureReason = c.FailureReason,
+ OriginatingEndpoint = new EndpointDetails
+ {
+ Name = c.OriginatingEndpointName,
+ Host = c.OriginatingEndpointHost,
+ HostId = c.OriginatingEndpointHostId
+ }
+ })
.ToListAsync(token);
- return new QueryResult>(page.Select(c => new CustomCheck
- {
- Id = c.Id.ToString(),
- CustomCheckId = c.CustomCheckId,
- Category = c.Category,
- Status = c.Status,
- ReportedAt = c.ReportedAt,
- FailureReason = c.FailureReason
- }).ToList(), new QueryStatsInfo("", page.Count, false));
+ var totalCount = await query.CountAsync(token);
+
+ return new QueryResult>(checks, checks.ToQueryStatsInfo(totalCount));
}, cancellationToken);
public Task DeleteCustomCheck(Guid id, CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (context, token) => await context.CustomChecks.AsNoTracking().Where(cc => cc.Id == id).ExecuteDeleteAsync(token), cancellationToken);
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs
index db2f53e444..c3d962f8b3 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs
@@ -5,6 +5,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
using ServiceControl.EventLog;
using ServiceControl.Persistence.Infrastructure;
using ServiceControl.Persistence.EFCore.Entities;
+using ServiceControl.Persistence.EFCore.Infrastructure;
public class EventLogDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IEventLogDataStore
{
@@ -25,35 +26,11 @@ public Task Add(EventLogItem logItem, CancellationToken cancellationToken = defa
}, cancellationToken);
public Task>> GetEventLogItems(
- PagingInfo pagingInfo, string? knownVersion = null, CancellationToken cancellationToken = default) =>
+ PagingInfo pagingInfo, CancellationToken cancellationToken = default) =>
ExecuteWithDbContext(async (dbContext, token) =>
{
var query = dbContext.EventLogItems.AsNoTracking();
- // All three aggregates in one round trip. Grouping on a constant collapses the table to a
- // single row, and an empty table yields no rows at all, hence the null coalescing.
- var stats = await query
- .GroupBy(_ => 1)
- .Select(g => new
- {
- Total = g.LongCount(),
- Newest = g.Max(e => (DateTime?)e.RaisedAt),
- HighestId = g.Max(e => (long?)e.Id)
- })
- .FirstOrDefaultAsync(token);
-
- var total = stats?.Total ?? 0;
- var version = Version(total, stats?.Newest, stats?.HighestId);
- var queryStats = new QueryStatsInfo(version, total, isStale: false);
-
- // The point of knownVersion. Everything above is index work.
- // If the caller already has the latest version, skip the rest of the query.
- // No database round trip is needed. No response body is needed.
- if (knownVersion is not null && knownVersion == version)
- {
- return QueryResult>.Unchanged(queryStats);
- }
-
var items = await query
// The key breaks ties so that items sharing a RaisedAt cannot shuffle between
// pages. IX_EventLogItems_RaisedAt_Id is declared in exactly this order.
@@ -73,11 +50,8 @@ public Task>> GetEventLogItems(
})
.ToListAsync(token);
- return new QueryResult>(items, queryStats);
- }, cancellationToken);
+ var total = await query.LongCountAsync(token);
- // Synthesised version ID to be used for an ETag. The highest key is the monotonic term: identity
- // values gap but never repeat, so an insert moves the version whatever its RaisedAt says.
- static string Version(long total, DateTime? newest, long? highestId) =>
- DeterministicGuid.MakeId($"{total}|{newest?.Ticks ?? 0}|{highestId ?? 0}").ToString();
+ return new QueryResult>(items, items.ToQueryStatsInfo(total));
+ }, cancellationToken);
}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs
index e74536a194..d434750028 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs
@@ -26,7 +26,7 @@ public Task GetFailedMessagesStats(string? status, string? modif
.FilterByStatus(status)
.FilterByLastModifiedRange(modified)
.FilterByQueueAddress(queueAddress)
- .ToQueryStatsInfo(token), cancellationToken);
+ .ToCountQueryStatsInfo("failures", token), cancellationToken);
public Task>> GetFailedMessagesByEndpoint(string? status, string endpointName, string? modified, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) =>
ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs
index c349a9a696..2907fbf2ee 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs
@@ -10,7 +10,7 @@ static class FailedMessageQueryResults
{
public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default)
{
- var stats = await source.ToQueryStatsInfo(cancellationToken);
+ var total = await source.LongCountAsync(cancellationToken);
var entities = await source
.Sort(sortInfo)
@@ -19,6 +19,6 @@ public static async Task>> ToPagedResult(th
IList results = [.. entities.Select(entity => entity.ToFailedMessageView())];
- return new QueryResult>(results, stats);
+ return new QueryResult>(results, entities.ToQueryStatsInfo(total));
}
}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
index 66a80e3eb2..e642fa7823 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
@@ -13,7 +13,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
public class GroupsDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IGroupsDataStore
{
public Task> GetUnresolvedGroupsByClassifier(string classifier, string? classifierFilter, CancellationToken cancellationToken = default) =>
- ExecuteWithDbContext(async (dbContext, token) =>
+ ExecuteWithDbContext>(async (dbContext, token) =>
{
var groups = ByClassifier(dbContext, classifier);
@@ -29,9 +29,15 @@ public Task> GetUnresolvedGroupsByClassifier(string clas
return views;
}, cancellationToken);
- public Task> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default) =>
- ExecuteWithDbContext((dbContext, token) => MostRecent(
- ByClassifier(dbContext, classifier).AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)), token), cancellationToken);
+ public Task>> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default) =>
+ ExecuteWithDbContext(async (dbContext, token) =>
+ {
+ var groups = ByClassifier(dbContext, classifier);
+
+ var views = await MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)), token);
+
+ return new QueryResult>(views, views.ToQueryStatsInfo());
+ }, cancellationToken);
public Task> GetUnresolvedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default) =>
ExecuteWithDbContext((dbContext, token) => SingleGroup(dbContext, groupId, FailedMessageStatus.Unresolved, status, modified, token), cancellationToken);
@@ -40,10 +46,12 @@ public Task> GetArchivedGroup(string groupId, stri
ExecuteWithDbContext((dbContext, token) => SingleGroup(dbContext, groupId, FailedMessageStatus.Archived, status, modified, token), cancellationToken);
public Task>> GetGroupErrors(string groupId, string? status, string? modified, SortInfo sortInfo, PagingInfo pagingInfo, CancellationToken cancellationToken = default) =>
- ExecuteWithDbContext((dbContext, token) => InGroup(dbContext, groupId, status, modified).ToPagedResult(pagingInfo, sortInfo, token), cancellationToken);
+ ExecuteWithDbContext((dbContext, token) => InGroup(dbContext, groupId, status, modified)
+ .ToPagedResult(pagingInfo, sortInfo, token), cancellationToken);
public Task GetGroupErrorsCount(string groupId, string? status, string? modified, CancellationToken cancellationToken = default) =>
- ExecuteWithDbContext((dbContext, token) => InGroup(dbContext, groupId, status, modified).ToQueryStatsInfo(token), cancellationToken);
+ ExecuteWithDbContext((dbContext, token) => InGroup(dbContext, groupId, status, modified)
+ .ToCountQueryStatsInfo("failures", token), cancellationToken);
public Task EditComment(string groupId, string comment, CancellationToken cancellationToken = default) =>
ExecuteWithDbContext(async (dbContext, token) =>
@@ -83,7 +91,8 @@ static async Task> SingleGroup(ServiceControlDbCon
.FilterByLastModifiedRange(modified))
.ToListAsync(cancellationToken);
- return new QueryResult(groups.FirstOrDefault()!, groups.ToQueryStatsInfo());
+ return new QueryResult(groups.FirstOrDefault()!,
+ groups.ToQueryStatsInfo());
}
static IQueryable WithStatus(ServiceControlDbContext dbContext, FailedMessageStatus status) =>
@@ -118,7 +127,7 @@ static async Task AttachComments(ServiceControlDbContext dbContext, IList> MostRecent(IQueryable groups, CancellationToken cancellationToken) =>
+ static async Task> MostRecent(IQueryable groups, CancellationToken cancellationToken) =>
await groups
.OrderByDescending(group => group.Last)
.Take(FailureGroupQueries.MaxGroups)
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs
index 006a69170f..50168b188f 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs
@@ -10,7 +10,7 @@ static class MessagesViewQueryResults
{
public static async Task>> ToPagedMessagesResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default)
{
- var stats = await source.ToQueryStatsInfo(cancellationToken);
+ var total = await source.LongCountAsync(cancellationToken);
var entities = await source
.SortMessages(sortInfo)
@@ -19,6 +19,6 @@ public static async Task>> ToPagedMessagesResult
IList results = [.. entities.Select(entity => entity.ToMessagesView())];
- return new QueryResult>(results, stats);
+ return new QueryResult>(results, entities.ToQueryStatsInfo(total));
}
}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs
index be2448f662..74793d79ee 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs
@@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ServiceControl.MessageFailures;
+using ServiceControl.Persistence.EFCore.Infrastructure;
using ServiceControl.Persistence.Infrastructure;
public class QueueAddressStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IQueueAddressStore
@@ -20,8 +21,8 @@ public Task>> GetAddresses(PagingInfo pagingInfo
});
var items = await query.Skip(pagingInfo.Offset).Take(pagingInfo.PageSize).ToListAsync(token);
- var eTag = DeterministicGuid.MakeId($"{items.Count}|{string.Join(",", items.Select(x => x.PhysicalAddress))}").ToString();
+ var addressCount = await query.CountAsync(token);
- return new QueryResult>(items, new QueryStatsInfo(eTag, query.Count(), false));
+ return new QueryResult>(items, items.ToQueryStatsInfo(addressCount));
}, cancellationToken);
}
\ No newline at end of file
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs
index 0bbb8bb714..ed94045e9f 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs
@@ -6,7 +6,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
using ServiceControl.Persistence.EFCore.DbContexts;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;
-using ServiceControl.Persistence.Infrastructure;
public class RetryBatchStore(IServiceScopeFactory scopeFactory, IRetryBatchSqlDialect dialect) : DataStoreBase(scopeFactory), IRetryBatchStore
{
@@ -99,7 +98,7 @@ static Guid ParseBatchId(string batchId) =>
? parsed
: throw new ArgumentException($"'{batchId}' is not a retry batch id issued by this store.", nameof(batchId));
- public Task>> GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default) =>
+ public Task GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default) =>
ExecuteWithDbContext(async (dbContext, token) =>
{
var orphaned = await dbContext.RetryBatches
@@ -109,9 +108,9 @@ public Task>> GetOrphanedBatches(string retrySessi
var messageCounts = await CountMessages(dbContext, [.. orphaned.Select(batch => batch.Id)], token);
- IList batches = [.. orphaned.Select(batch => batch.ToRetryBatch(messageCounts.GetValueOrDefault(batch.Id)))];
+ IReadOnlyList batches = [.. orphaned.Select(batch => batch.ToRetryBatch(messageCounts.GetValueOrDefault(batch.Id)))];
- return new QueryResult>(batches, new QueryStatsInfo(string.Empty, batches.Count, false));
+ return OrphanedBatches.Complete(batches);
}, cancellationToken);
public Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) =>
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs
index 1b94be8b5a..17db0a9281 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs
@@ -4,11 +4,13 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
using Microsoft.Extensions.DependencyInjection;
using ServiceControl.Persistence.EFCore.DbContexts;
using ServiceControl.Persistence.EFCore.Entities;
+using ServiceControl.Persistence.EFCore.Infrastructure;
+using ServiceControl.Persistence.Infrastructure;
using ServiceControl.Recoverability;
public class RetryHistoryDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IRetryHistoryDataStore
{
- public Task GetRetryHistory(CancellationToken cancellationToken = default) =>
+ public Task> GetRetryHistory(CancellationToken cancellationToken = default) =>
ExecuteWithDbContext(async (dbContext, token) =>
{
var historicOperations = await dbContext.HistoricRetryOperations
@@ -29,6 +31,10 @@ public Task GetRetryHistory(CancellationToken cancellationToken =
var unacknowledgedOperations = await dbContext.UnacknowledgedRetryOperations
.AsNoTracking()
+ // By the primary key, so the order is total. Without it the rows arrive in whatever order
+ // the server happens to produce, which leaves the body unstable under a stable validator.
+ .OrderBy(operation => operation.RequestId)
+ .ThenBy(operation => operation.RetryType)
.Select(operation => new UnacknowledgedRetryOperation
{
RequestId = operation.RequestId,
@@ -43,11 +49,13 @@ public Task GetRetryHistory(CancellationToken cancellationToken =
})
.ToListAsync(token);
- return new RetryHistory
+ var history = new RetryHistory
{
HistoricOperations = historicOperations,
UnacknowledgedOperations = unacknowledgedOperations
};
+
+ return new QueryResult(history, history.ToQueryStatsInfo());
}, cancellationToken);
public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime,
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs
index 7a28dac79e..fbf47b97cc 100644
--- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs
@@ -1,7 +1,6 @@
namespace ServiceControl.Persistence.EFCore.Infrastructure;
using System.Globalization;
-using Microsoft.EntityFrameworkCore;
using ServiceControl.MessageFailures;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.Infrastructure;
@@ -166,19 +165,6 @@ public static IQueryable SortMessages(this IQueryable Page(this IQueryable source, PagingInfo pagingInfo) =>
source.Skip(pagingInfo.Offset).Take(pagingInfo.Next);
- public static async Task ToQueryStatsInfo(this IQueryable source, CancellationToken cancellationToken = default)
- {
- var stats = await source
- .GroupBy(_ => 1)
- .Select(group => new { Count = group.Count(), Latest = group.Max(message => (DateTime?)message.LastModified) })
- .SingleOrDefaultAsync(cancellationToken);
-
- var count = stats?.Count ?? 0;
- var latest = stats?.Latest ?? DateTime.MinValue;
-
- return new QueryStatsInfo($"{count}-{latest.Ticks}", count, false);
- }
-
static IOrderedQueryable OrderBy(this IQueryable source, System.Linq.Expressions.Expression> keySelector, bool descending) =>
descending
? source.OrderByDescending(keySelector).ThenByDescending(message => message.UniqueMessageId)
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs
index 0767a7d4b2..3c21c23ece 100644
--- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs
@@ -1,7 +1,6 @@
namespace ServiceControl.Persistence.EFCore.Infrastructure;
using ServiceControl.Persistence.EFCore.Entities;
-using ServiceControl.Persistence.Infrastructure;
using ServiceControl.Recoverability;
static class FailureGroupQueries
@@ -22,11 +21,4 @@ into aggregate
First = aggregate.Min(message => message.FirstTimeOfFailure),
Last = aggregate.Max(message => message.LastTimeOfFailure)
};
-
- public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups)
- {
- var latest = groups.Count == 0 ? DateTime.MinValue : groups.Max(group => group.Last);
-
- return new QueryStatsInfo($"{groups.Count}-{latest.Ticks}", groups.Count, false);
- }
}
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs
new file mode 100644
index 0000000000..48c9d2dff7
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.Persistence.EFCore.Infrastructure;
+
+using Microsoft.EntityFrameworkCore;
+using ServiceControl.Contracts.CustomChecks;
+using ServiceControl.EventLog;
+using ServiceControl.MessageFailures;
+using ServiceControl.Persistence.EFCore.Entities;
+using ServiceControl.Persistence.Infrastructure;
+using ServiceControl.Recoverability;
+
+static class QueryStatsInfoExtensions
+{
+ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) =>
+ new QueryStatsInfo(
+ DataVersion.OverRows(
+ [("checks", totalCount)],
+ items,
+ check => [check.Id, check.CustomCheckId, check.Category, check.Status, check.ReportedAt, check.FailureReason]),
+ totalCount);
+
+ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) =>
+ new QueryStatsInfo(
+ DataVersion.OverRows(
+ [("items", totalCount)],
+ items,
+ item => [item.Id, item.Description, item.Severity, item.RaisedAt, item.Category, item.EventType]),
+ totalCount);
+
+ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) =>
+ new QueryStatsInfo(
+ DataVersion.OverRows(
+ [("messages", totalCount)],
+ items,
+ row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]),
+ totalCount);
+
+ // Used in a HEAD with no body. Total-Count is the whole response, so the count is the whole version.
+ public static async Task ToCountQueryStatsInfo(this IQueryable source, string name, CancellationToken cancellationToken = default)
+ {
+ var count = await source.LongCountAsync(cancellationToken);
+
+ return new QueryStatsInfo(DataVersion.Compose([(name, count)]), count);
+ }
+
+ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) =>
+ new QueryStatsInfo(
+ DataVersion.OverRows(
+ [("groups", groups.Count)],
+ groups,
+ group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last]),
+ groups.Count);
+
+ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) =>
+ new QueryStatsInfo(
+ DataVersion.OverRows(
+ [("addresses", totalCount)],
+ items,
+ address => [address.PhysicalAddress, address.FailedMessageCount]),
+ totalCount);
+}
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs
new file mode 100644
index 0000000000..881aa7bd1f
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs
@@ -0,0 +1,35 @@
+namespace ServiceControl.Persistence.EFCore.Infrastructure;
+
+using ServiceControl.Persistence.Infrastructure;
+using ServiceControl.Recoverability;
+
+static class RetryHistoryQueries
+{
+ ///
+ /// Every field of every operation in both collections, plus each collection's count.
+ ///
+ public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) =>
+ new QueryStatsInfo(DataVersion.OverRows(
+ [("historic", history.HistoricOperations.Count), ("unacknowledged", history.UnacknowledgedOperations.Count)],
+ Rows(history),
+ row => row),
+ history.HistoricOperations.Count);
+
+ static IEnumerable
/// Which page to return.
- ///
- /// The version the caller already holds, or null if it holds none. When it matches, the
- /// result is and carries no page.
- ///
- ///
- /// : the requested page, which may be empty.
- /// : the number of items in the store, independent of the
- /// page size, and populated even when nothing was modified.
- /// : an opaque cache validator surfaced verbatim as the
- /// ETag response header, so whatever a client echoes back arrives here as
- /// . It must change when retention removes items, not only when
- /// one is added, since nothing else tells a client its cached page is now wrong.
- ///
Task>> GetEventLogItems(
- PagingInfo pagingInfo, string? knownVersion = null, CancellationToken cancellationToken = default);
+ PagingInfo pagingInfo, CancellationToken cancellationToken = default);
}
}
\ No newline at end of file
diff --git a/src/ServiceControl.Persistence/IGroupsDataStore.cs b/src/ServiceControl.Persistence/IGroupsDataStore.cs
index 5f389f10fe..2d1ffc547e 100644
--- a/src/ServiceControl.Persistence/IGroupsDataStore.cs
+++ b/src/ServiceControl.Persistence/IGroupsDataStore.cs
@@ -10,7 +10,7 @@ namespace ServiceControl.Persistence
public interface IGroupsDataStore
{
Task> GetUnresolvedGroupsByClassifier(string classifier, string? classifierFilter, CancellationToken cancellationToken = default);
- Task> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default);
+ Task>> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default);
Task> GetUnresolvedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default);
Task> GetArchivedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default);
diff --git a/src/ServiceControl.Persistence/IRetryBatchStore.cs b/src/ServiceControl.Persistence/IRetryBatchStore.cs
index 3f037a3327..a21eb56e46 100644
--- a/src/ServiceControl.Persistence/IRetryBatchStore.cs
+++ b/src/ServiceControl.Persistence/IRetryBatchStore.cs
@@ -19,7 +19,7 @@ Task CreateBatch(string retrySessionId, string requestId, RetryType retr
Task MoveBatchToStaging(string batchId, CancellationToken cancellationToken = default);
- Task>> GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default);
+ Task GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default);
Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default);
Task GetCurrentForwardingBatch(CancellationToken cancellationToken = default);
diff --git a/src/ServiceControl.Persistence/IRetryHistoryDataStore.cs b/src/ServiceControl.Persistence/IRetryHistoryDataStore.cs
index 5944bc65c5..cf9b195aa4 100644
--- a/src/ServiceControl.Persistence/IRetryHistoryDataStore.cs
+++ b/src/ServiceControl.Persistence/IRetryHistoryDataStore.cs
@@ -3,11 +3,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
+ using ServiceControl.Persistence.Infrastructure;
using ServiceControl.Recoverability;
public interface IRetryHistoryDataStore
{
- Task GetRetryHistory(CancellationToken cancellationToken = default);
+ Task> GetRetryHistory(CancellationToken cancellationToken = default);
Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime,
string? originator, string? classifier, bool messageFailed, int numberOfMessagesProcessed, DateTime lastProcessed, int retryHistoryDepth,
CancellationToken cancellationToken = default);
diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs
new file mode 100644
index 0000000000..85f89b2799
--- /dev/null
+++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs
@@ -0,0 +1,160 @@
+namespace ServiceControl.Persistence.Infrastructure
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics;
+ using System.Diagnostics.CodeAnalysis;
+ using System.Globalization;
+ using System.Linq;
+
+ ///
+ /// An opaque version of a query result, sent to clients as an HTTP entity-tag.
+ ///
+ [DebuggerDisplay("{validator ?? \"None\",nq}")]
+ public readonly struct DataVersion : IEquatable
+ {
+ readonly string? validator;
+
+ DataVersion(string validator) => this.validator = validator;
+
+ public static readonly DataVersion None = default;
+
+ [MemberNotNullWhen(true, nameof(validator))]
+ public bool HasValue => validator is not null;
+
+ /// A version the backend made itself.
+ public static DataVersion FromToken(string token) =>
+ string.IsNullOrEmpty(token) ? None : new DataVersion(token);
+
+ public static DataVersion FromToken(long token) =>
+ new(token.ToString(CultureInfo.InvariantCulture));
+
+ ///
+ /// A version over the query behind the page. Every field the response shows has to be covered by a
+ /// term, measured over the same filtered set, or a change to an uncovered one leaves a client holding
+ /// a stale page.
+ ///
+ public static DataVersion Compose(params (string Name, object? Value)[]? terms) =>
+ terms is null || terms.Length == 0
+ ? None
+ : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString());
+
+ ///
+ /// A version over a list the response renders row by row. covers whatever
+ /// the response says about the list as a whole, such as the total behind Total-Count when the rows are
+ /// only one page of it, and one term per row covers the rows themselves. Every field a row shows has
+ /// to appear in , and each is length prefixed, so no value can pose as a
+ /// different set of fields. Rows are named by position, so a caller whose query has no ORDER BY has to
+ /// sort them first.
+ ///
+ public static DataVersion OverRows((string Name, object? Value)[]? summary, IEnumerable rows, Func fields)
+ {
+ ArgumentNullException.ThrowIfNull(rows);
+ ArgumentNullException.ThrowIfNull(fields);
+
+ var terms = new List<(string Name, object? Value)>(summary ?? []);
+ var row = 0;
+
+ foreach (var item in rows)
+ {
+ terms.Add((FormattableString.Invariant($"row{row++}"), Row(fields(item))));
+ }
+
+ return Compose([.. terms]);
+ }
+
+ ///
+ /// One version for a result gathered from several instances. Missing anywhere means missing overall.
+ /// Keyed on the instance, so a validator moving from one instance to another still moves the
+ /// composite.
+ ///
+ public static DataVersion Combine(IEnumerable<(string InstanceId, DataVersion Version)> versions)
+ {
+ ArgumentNullException.ThrowIfNull(versions);
+
+ var reported = new List<(string InstanceId, string Validator)>();
+
+ foreach (var (instanceId, version) in versions)
+ {
+ if (!version.HasValue)
+ {
+ return None;
+ }
+
+ reported.Add((instanceId, version.validator));
+ }
+
+ if (reported.Count == 0)
+ {
+ return None;
+ }
+
+ return Compose([.. reported
+ .OrderBy(entry => entry.InstanceId, StringComparer.Ordinal)
+ .ThenBy(entry => entry.Validator, StringComparer.Ordinal)
+ .Select(entry => (entry.InstanceId, (object)entry.Validator))]);
+ }
+
+ ///
+ /// A validator read back off the wire, in any shape an old or current instance might emit, so that
+ /// a scatter-gather can fold another instance's entity-tag into its own composite.
+ ///
+ public static DataVersion FromClient(string headerValue)
+ {
+ var value = headerValue?.Trim();
+
+ if (string.IsNullOrEmpty(value))
+ {
+ return None;
+ }
+
+ if (value.StartsWith("W/", StringComparison.Ordinal))
+ {
+ value = value[2..];
+ }
+
+ // Only a matching pair. Stripping every quote would truncate a malformed header instead of
+ // treating it as the cache miss it is.
+ if (value.Length > 1 && value[0] == '"' && value[^1] == '"')
+ {
+ value = value[1..^1];
+ }
+
+ return FromToken(value);
+ }
+
+ public bool Equals(DataVersion other) =>
+ string.Equals(validator, other.validator, StringComparison.Ordinal);
+
+ public override bool Equals(object? obj) => obj is DataVersion other && Equals(other);
+
+ public override int GetHashCode() => validator?.GetHashCode(StringComparison.Ordinal) ?? 0;
+
+ /// The validator unquoted, or an empty string for .
+ public override string ToString() => validator ?? string.Empty;
+
+ static string Describe((string Name, object? Value)[] terms) =>
+ string.Join("|", terms.Select(term => Encode(term.Name, Format(term.Value))));
+
+ static string Encode(string name, string value) => $"{name}:{Prefixed(value)}";
+
+ static string Row(object?[]? fields) =>
+ fields is null ? string.Empty : string.Concat(fields.Select(field => Prefixed(Format(field))));
+
+ static string Prefixed(string value) =>
+ $"{value.Length.ToString(CultureInfo.InvariantCulture)}:{value}";
+
+ static string Format(object? value) => value switch
+ {
+ null => string.Empty,
+ string text => text,
+ bool flag => flag.ToString(),
+ DateTime timestamp => timestamp.Ticks.ToString(CultureInfo.InvariantCulture),
+ DateTimeOffset timestamp => timestamp.UtcTicks.ToString(CultureInfo.InvariantCulture),
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ // Any other ToString is not a documented function of the content, so it could pin the
+ // version while the data moves and cache a stale page forever.
+ _ => throw new ArgumentException($"A version term cannot be built from {value.GetType()}.", nameof(value))
+ };
+ }
+}
diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs
index be3e6b8377..519217f1c5 100644
--- a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs
+++ b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs
@@ -9,18 +9,14 @@ public class QueryResult(TOut? results, QueryStatsInfo queryStatsInfo)
public string? InstanceId { get; set; }
- public QueryStatsInfo QueryStats { get; } = queryStatsInfo;
-
///
- /// The caller already holds this version, so was never fetched and is
- /// null. is still populated.
+ /// The result the scatter-gather got from its own instance.
///
- public bool NotModified { get; private init; }
+ public bool IsLocalInstance { get; set; }
- public static QueryResult Empty() => new(null, QueryStatsInfo.Zero);
+ public QueryStatsInfo QueryStats { get; } = queryStatsInfo;
- public static QueryResult Unchanged(QueryStatsInfo queryStatsInfo) =>
- new(null, queryStatsInfo) { NotModified = true };
+ public static QueryResult Empty() => new(null, QueryStatsInfo.Zero);
public static implicit operator Task>(QueryResult instance) => Task.FromResult(instance);
}
diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs
index 91abfcef4b..0cf462e9ed 100644
--- a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs
+++ b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs
@@ -1,21 +1,19 @@
namespace ServiceControl.Persistence.Infrastructure
{
- public struct QueryStatsInfo
+ public readonly struct QueryStatsInfo
{
- public readonly string ETag;
+ public readonly DataVersion Version;
public readonly long TotalCount;
public readonly long HighestTotalCountOfAllTheInstances;
- public readonly bool IsStale;
- public QueryStatsInfo(string eTag, long totalCount, bool isStale, long? highestTotalCountOfAllTheInstances = null)
+ public QueryStatsInfo(DataVersion version, long totalCount, long? highestTotalCountOfAllTheInstances = null)
{
- ETag = eTag;
+ Version = version;
TotalCount = totalCount;
- IsStale = isStale;
HighestTotalCountOfAllTheInstances = highestTotalCountOfAllTheInstances ?? totalCount;
}
- public static readonly QueryStatsInfo Zero = new QueryStatsInfo(string.Empty, 0, false);
+ public static readonly QueryStatsInfo Zero = new(DataVersion.None, 0);
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Persistence/OrphanedBatches.cs b/src/ServiceControl.Persistence/OrphanedBatches.cs
new file mode 100644
index 0000000000..5ee137fe6f
--- /dev/null
+++ b/src/ServiceControl.Persistence/OrphanedBatches.cs
@@ -0,0 +1,11 @@
+namespace ServiceControl.Persistence
+{
+ using System.Collections.Generic;
+
+ // An eventually-consistent store can miss batches its index has not caught up with yet, so the sweep
+ // keeps rechecking while that is possible.
+ public record OrphanedBatches(IReadOnlyList Batches, bool MightBeIncomplete)
+ {
+ public static OrphanedBatches Complete(IReadOnlyList batches) => new(batches, false);
+ }
+}
diff --git a/src/ServiceControl.Persistence/RetryHistory.cs b/src/ServiceControl.Persistence/RetryHistory.cs
index fffdfdbd23..888c8ae376 100644
--- a/src/ServiceControl.Persistence/RetryHistory.cs
+++ b/src/ServiceControl.Persistence/RetryHistory.cs
@@ -20,11 +20,6 @@ public void AddToHistory(HistoricRetryOperation historicOperation, int historyDe
.ToList();
}
- public string GetHistoryOperationsUniqueIdentifier()
- {
- return string.Join(',', HistoricOperations.Select(x => x.RequestId));
- }
-
public void AddToUnacknowledged(UnacknowledgedRetryOperation unacknowledgedRetryOperation)
{
UnacknowledgedOperations.Add(unacknowledgedRetryOperation);
diff --git a/src/ServiceControl.UnitTests/BodyStorage/MessageBodyResultTests.cs b/src/ServiceControl.UnitTests/BodyStorage/MessageBodyResultTests.cs
index 5c2b04f97a..39adb49c2d 100644
--- a/src/ServiceControl.UnitTests/BodyStorage/MessageBodyResultTests.cs
+++ b/src/ServiceControl.UnitTests/BodyStorage/MessageBodyResultTests.cs
@@ -4,6 +4,7 @@ namespace ServiceControl.UnitTests.BodyStorage;
using System.IO;
using NUnit.Framework;
using ServiceControl.Operations.BodyStorage;
+using ServiceControl.Persistence.Infrastructure;
[TestFixture]
public class MessageBodyResultTests
@@ -28,7 +29,7 @@ public void Body_is_not_accessible_without_content(MessageBodyState state)
[Test]
public void Body_is_accessible_when_available()
{
- var content = new MessageBodyStreamContent(Stream.Null, "text/plain", 1, "etag");
+ var content = new MessageBodyStreamContent(Stream.Null, "text/plain", 1, DataVersion.FromToken("etag"));
var result = MessageBodyResult.Available(content);
Assert.That(result.Content, Is.SameAs(content));
diff --git a/src/ServiceControl.UnitTests/HeaderAssertions.cs b/src/ServiceControl.UnitTests/HeaderAssertions.cs
index 4afb82970f..6b401cdc8f 100644
--- a/src/ServiceControl.UnitTests/HeaderAssertions.cs
+++ b/src/ServiceControl.UnitTests/HeaderAssertions.cs
@@ -2,6 +2,7 @@
{
using System.Collections.Generic;
using NUnit.Framework;
+ using ServiceControl.Persistence.Infrastructure;
public static class HeaderAssertions
{
@@ -19,5 +20,8 @@ public static void AssertHeaderMissing(this IDictionary headers,
{
Assert.That(headers.ContainsKey(key), Is.False, $"Unexpected header [{key}] found.");
}
+
+ public static bool Matches(this DataVersion one, DataVersion other) =>
+ one.HasValue && other.HasValue && one.Equals(other);
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs
new file mode 100644
index 0000000000..b5b47b6050
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs
@@ -0,0 +1,272 @@
+namespace ServiceControl.UnitTests.Infrastructure;
+
+using System;
+using NUnit.Framework;
+using ServiceControl.Persistence.Infrastructure;
+
+[TestFixture]
+public class DataVersionTests
+{
+ [Test]
+ public void None_never_matches_itself()
+ {
+ Assert.That(DataVersion.None.Matches(DataVersion.None), Is.False,
+ "two callers who both know nothing have not established that nothing changed, and matching here would answer 304 for every request");
+ }
+
+ [Test]
+ public void None_never_matches_a_real_version()
+ {
+ var real = DataVersion.FromToken("4611686018427387904");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(DataVersion.None.Matches(real), Is.False);
+ Assert.That(real.Matches(DataVersion.None), Is.False);
+ }
+ }
+
+ [Test]
+ public void Equality_stays_reflexive_so_the_struct_is_safe_in_collections()
+ {
+ // Matches carries the cache rule, Equals must not, or any dictionary or Distinct over it breaks.
+ Assert.That(DataVersion.None.Equals(DataVersion.None), Is.True);
+ }
+
+ [Test]
+ public void An_identical_token_matches()
+ {
+ Assert.That(DataVersion.FromToken("abc").Matches(DataVersion.FromToken("abc")), Is.True);
+ }
+
+ [Test]
+ public void A_different_token_does_not_match()
+ {
+ Assert.That(DataVersion.FromToken("abc").Matches(DataVersion.FromToken("abd")), Is.False);
+ }
+
+ [Test]
+ public void An_empty_token_is_absent()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(DataVersion.FromToken(null).HasValue, Is.False);
+ Assert.That(DataVersion.FromToken(string.Empty).HasValue, Is.False);
+ }
+ }
+
+ [Test]
+ public void A_numeric_token_is_rendered_invariantly()
+ {
+ Assert.That(DataVersion.FromToken(4611686018427387904L).ToString(), Is.EqualTo("4611686018427387904"));
+ }
+
+ [Test]
+ public void Compose_is_stable_across_calls()
+ {
+ var first = DataVersion.Compose(("total", 3L), ("highestId", 7L));
+ var second = DataVersion.Compose(("total", 3L), ("highestId", 7L));
+
+ Assert.That(first.Matches(second), Is.True);
+ }
+
+ [Test]
+ public void Compose_moves_when_any_term_value_moves()
+ {
+ var before = DataVersion.Compose(("total", 3L), ("highestId", 7L));
+ var after = DataVersion.Compose(("total", 3L), ("highestId", 8L));
+
+ Assert.That(before.Matches(after), Is.False);
+ }
+
+ [Test]
+ public void Compose_distinguishes_terms_that_bare_concatenation_would_collide()
+ {
+ var first = DataVersion.Compose(("a", 1L), ("b", 23L));
+ var second = DataVersion.Compose(("a", 12L), ("b", 3L));
+
+ Assert.That(first.Matches(second), Is.False);
+ }
+
+ [Test]
+ public void Compose_moves_when_a_term_goes_from_absent_to_present()
+ {
+ DateTime? absent = null;
+ DateTime? present = new DateTime(2026, 7, 31, 0, 0, 0, DateTimeKind.Utc);
+
+ var empty = DataVersion.Compose(("newest", absent));
+ var populated = DataVersion.Compose(("newest", present));
+
+ Assert.That(empty.Matches(populated), Is.False);
+ }
+
+ [Test]
+ public void Compose_with_no_terms_is_absent()
+ {
+ Assert.That(DataVersion.Compose().HasValue, Is.False);
+ }
+
+ [Test]
+ public void OverRows_moves_when_a_row_changes_under_an_unchanged_timestamp()
+ {
+ var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ var before = Page(("a", at, "Unresolved"));
+ var after = Page(("a", at, "Archived"));
+
+ Assert.That(after.Matches(before), Is.False,
+ "two writes to one row inside a single clock tick leave the timestamp identical, so the version cannot rest on it alone");
+ }
+
+ [Test]
+ public void OverRows_distinguishes_two_pages_of_one_set()
+ {
+ var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ var firstPage = Page(("a", at, "Unresolved"));
+ var secondPage = Page(("b", at, "Unresolved"));
+
+ Assert.That(secondPage.Matches(firstPage), Is.False,
+ "the two pages render different rows, so a client holding one must not be told the other is current");
+ }
+
+ [Test]
+ public void OverRows_holds_while_the_page_and_the_total_hold()
+ {
+ var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ Assert.That(Page(("a", at, "Unresolved")).Matches(Page(("a", at, "Unresolved"))), Is.True);
+ }
+
+ [Test]
+ public void OverRows_moves_when_only_the_total_moves()
+ {
+ var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
+ var row = ("a", at, "Unresolved");
+
+ Assert.That(DataVersion.OverRows([("total", 9L)], [row], Fields)
+ .Matches(DataVersion.OverRows([("total", 2L)], [row], Fields)), Is.False,
+ "Total-Count is part of the response, so a client holding the old one must not be told it is current");
+ }
+
+ [Test]
+ public void OverRows_distinguishes_rows_that_bare_concatenation_would_collide()
+ {
+ var left = DataVersion.OverRows([("total", 1L)], [("ab", "c")], row => [row.Item1, row.Item2]);
+ var right = DataVersion.OverRows([("total", 1L)], [("a", "bc")], row => [row.Item1, row.Item2]);
+
+ Assert.That(right.Matches(left), Is.False,
+ "fields inside a row are length prefixed, so no value can pose as a different split of the same text");
+ }
+
+ [Test]
+ public void OverRows_refuses_a_missing_row_source()
+ {
+ Assert.Throws(() => DataVersion.OverRows([("total", 0L)], null, _ => []));
+ }
+
+ [Test]
+ public void OverRows_refuses_a_missing_field_selector()
+ {
+ Assert.Throws(() => DataVersion.OverRows([("total", 0L)], [], null));
+ }
+
+ static DataVersion Page(params (string Id, DateTime At, string Status)[] rows) =>
+ DataVersion.OverRows([("total", 2L)], rows, Fields);
+
+ static object[] Fields((string Id, DateTime At, string Status) row) => [row.Id, row.At, row.Status];
+
+ [Test]
+ public void Combine_does_not_depend_on_the_order_instances_answered_in()
+ {
+ var a = DataVersion.FromToken("a");
+ var b = DataVersion.FromToken("b");
+
+ Assert.That(DataVersion.Combine([("one", a), ("two", b)])
+ .Matches(DataVersion.Combine([("two", b), ("one", a)])), Is.True);
+ }
+
+ [Test]
+ public void Combine_moves_when_two_instances_swap_which_version_they_report()
+ {
+ var a = DataVersion.FromToken("a");
+ var b = DataVersion.FromToken("b");
+
+ Assert.That(DataVersion.Combine([("one", a), ("two", b)])
+ .Matches(DataVersion.Combine([("one", b), ("two", a)])), Is.False,
+ "both instances changed, so a composite that only looked at the set of validators would answer 304 over stale data");
+ }
+
+ [Test]
+ public void Combine_differs_from_every_instance_version_it_covers()
+ {
+ var a = DataVersion.FromToken("a");
+ var b = DataVersion.FromToken("b");
+
+ var combined = DataVersion.Combine([("one", a), ("two", b)]);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(combined.Matches(a), Is.False);
+ Assert.That(combined.Matches(b), Is.False);
+ }
+ }
+
+ [Test]
+ public void Combine_is_absent_when_any_instance_has_no_version()
+ {
+ var combined = DataVersion.Combine([("one", DataVersion.FromToken("a")), ("two", DataVersion.None)]);
+
+ Assert.That(combined.HasValue, Is.False,
+ "a composite that ignored an instance would stop reporting that instance's changes");
+ }
+
+ [Test]
+ public void Combine_of_nothing_is_absent()
+ {
+ Assert.That(DataVersion.Combine([]).HasValue, Is.False);
+ }
+
+ [Test]
+ public void Compose_distinguishes_a_term_value_that_carries_the_delimiters()
+ {
+ var forged = DataVersion.Compose(("one", "a|two:1:b"));
+ var genuine = DataVersion.Compose(("one", "a"), ("two", "b"));
+
+ Assert.That(forged.Matches(genuine), Is.False,
+ "a value able to pose as a longer term list would let a peer pin the composite version");
+ }
+
+ [Test]
+ public void Compose_refuses_a_term_whose_text_is_not_derived_from_its_content()
+ {
+ Assert.That(() => DataVersion.Compose(("rows", new object())), Throws.ArgumentException,
+ "a type name is a constant, so the version would never move and clients would cache forever");
+ }
+
+ [TestCase("\"abc\"", TestName = "FromClient_reads_a_quoted_validator")]
+ [TestCase("W/\"abc\"", TestName = "FromClient_reads_a_weak_validator")]
+ [TestCase("abc", TestName = "FromClient_reads_an_unquoted_validator_from_an_older_instance")]
+ public void FromClient_yields_the_bare_validator(string headerValue)
+ {
+ Assert.That(DataVersion.FromClient(headerValue).ToString(), Is.EqualTo("abc"));
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("\"\"")]
+ public void FromClient_treats_a_blank_validator_as_absent(string headerValue)
+ {
+ Assert.That(DataVersion.FromClient(headerValue).HasValue, Is.False,
+ "a caller holding nothing must be a cache miss, never a match");
+ }
+
+ [Test]
+ public void FromClient_leaves_a_malformed_validator_alone_rather_than_truncating_it()
+ {
+ // Stripping every quote would truncate this into something that might match by accident.
+ Assert.That(DataVersion.FromClient("\"abc").ToString(), Is.EqualTo("\"abc"));
+ }
+
+}
diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs
index 8a2c1e945a..3c804c7f70 100644
--- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs
+++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs
@@ -9,6 +9,7 @@ namespace ServiceControl.UnitTests.Infrastructure.WebApi;
using Microsoft.AspNetCore.Routing;
using NUnit.Framework;
using ServiceControl.Infrastructure.WebApi;
+using ServiceControl.Persistence.Infrastructure;
[TestFixture]
public class ConditionalGetTests
@@ -18,7 +19,7 @@ public void Repeating_a_request_with_the_etag_just_issued_is_not_modified()
{
var httpContext = new DefaultHttpContext();
- httpContext.Response.WithEtag("4611686018427387904");
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag;
@@ -36,7 +37,7 @@ public void A_different_etag_still_returns_the_payload()
{
var httpContext = new DefaultHttpContext();
- httpContext.Response.WithEtag("4611686018427387904");
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
httpContext.Request.Headers.IfNoneMatch = "\"something-else\"";
var context = ResultExecuting(httpContext);
@@ -51,7 +52,7 @@ public void The_emitted_etag_is_a_well_formed_entity_tag()
{
var httpContext = new DefaultHttpContext();
- httpContext.Response.WithEtag("4611686018427387904");
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
// RFC 9110 requires an entity-tag to be a quoted string. GetTypedHeaders parses through
// EntityTagHeaderValue and yields null for anything else.
@@ -60,35 +61,88 @@ public void The_emitted_etag_is_a_well_formed_entity_tag()
}
[Test]
- public void A_deterministic_etag_is_a_well_formed_entity_tag()
+ public void An_absent_data_version_emits_no_header()
{
var httpContext = new DefaultHttpContext();
- httpContext.Response.WithDeterministicEtag("any-non-empty-payload-signature");
+ httpContext.Response.WithEtag(DataVersion.None);
- Assert.That(httpContext.Response.GetTypedHeaders().ETag, Is.Not.Null);
+ Assert.That(httpContext.Response.Headers.ContainsKey("ETag"), Is.False,
+ "an empty entity-tag is well formed, so it would match itself and answer 304 for unrelated payloads");
}
[Test]
- public void The_emitted_etag_quotes_the_value_without_altering_it()
+ public void A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it()
{
var httpContext = new DefaultHttpContext();
+ var version = DataVersion.FromToken("4611686018427387904");
- httpContext.Response.WithEtag("4611686018427387904");
+ httpContext.Response.WithQueryStatsAndPagingInfo(
+ new QueryStatsInfo(version, totalCount: 1),
+ new PagingInfo());
- Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"4611686018427387904\""));
+ Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString()));
}
- [TestCase(null)]
- [TestCase("")]
- public void A_call_site_with_nothing_to_validate_emits_no_etag_header(string value)
+ [Test]
+ public void Every_emitted_etag_is_marked_weak()
{
var httpContext = new DefaultHttpContext();
- httpContext.Response.WithEtag(value);
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
- Assert.That(httpContext.Response.Headers.ContainsKey("ETag"), Is.False,
- "an empty entity-tag is well formed, so it would match itself and answer 304 for unrelated payloads");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("W/\"4611686018427387904\""));
+ Assert.That(httpContext.Response.GetTypedHeaders().ETag.IsWeak, Is.True);
+ }
+ }
+
+ [Test]
+ public void An_unmarked_validator_from_an_older_client_still_matches()
+ {
+ var httpContext = new DefaultHttpContext();
+
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
+ httpContext.Request.Headers.IfNoneMatch = "\"4611686018427387904\"";
+
+ var context = ResultExecuting(httpContext);
+
+ new NotModifiedStatusHttpHandler().OnResultExecuting(context);
+
+ Assert.That(context.Result, Is.InstanceOf(),
+ "weak comparison ignores the W/ prefix, which is what carries a client through the upgrade");
+ }
+
+ [Test]
+ public void A_wildcard_precondition_is_not_modified_when_a_representation_exists()
+ {
+ var httpContext = new DefaultHttpContext();
+
+ httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904"));
+ httpContext.Request.Headers.IfNoneMatch = "*";
+
+ var context = ResultExecuting(httpContext);
+
+ new NotModifiedStatusHttpHandler().OnResultExecuting(context);
+
+ Assert.That(context.Result, Is.InstanceOf(),
+ "RFC 9110: * matches whenever a current representation exists");
+ }
+
+ [Test]
+ public void A_wildcard_precondition_is_ignored_when_there_is_no_validator()
+ {
+ var httpContext = new DefaultHttpContext();
+
+ httpContext.Request.Headers.IfNoneMatch = "*";
+
+ var context = ResultExecuting(httpContext);
+
+ new NotModifiedStatusHttpHandler().OnResultExecuting(context);
+
+ Assert.That(context.Result, Is.InstanceOf(),
+ "an endpoint that publishes no validator has nothing for a client to have cached");
}
static ResultExecutingContext ResultExecuting(HttpContext httpContext) =>
diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs
new file mode 100644
index 0000000000..f3bf580901
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs
@@ -0,0 +1,82 @@
+namespace ServiceControl.UnitTests.Operations
+{
+ using System;
+ using System.Collections.Generic;
+ using NUnit.Framework;
+ using ServiceControl.Infrastructure.WebApi;
+ using ServiceControl.Persistence.Infrastructure;
+ using ServiceControl.Persistence.MessageRedirects;
+
+ [TestFixture]
+ public class MessageRedirectVersionTests
+ {
+ [Test]
+ public void From_address_changed_should_change_version()
+ {
+ var knownVersion = VersionOf(Redirects(Redirect(from: "old@machine")));
+
+ var moved = VersionOf(Redirects(Redirect(from: "new@machine")));
+
+ Assert.That(moved.Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void To_address_changed_should_change_version()
+ {
+ var redirect = Redirect(to: "old@machine");
+ var data = Redirects(redirect);
+
+ var knownVersion = VersionOf(data);
+
+ redirect.ToPhysicalAddress = "new@machine";
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Last_modified_changed_should_change_version()
+ {
+ var redirect = Redirect();
+ var data = Redirects(redirect);
+
+ var knownVersion = VersionOf(data);
+
+ redirect.LastModified = redirect.LastModified.AddTicks(1);
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Changing_item_count_should_change_version()
+ {
+ var emptyVersion = VersionOf(Redirects());
+
+ var oneRedirect = Redirects(Redirect());
+
+ Assert.That(VersionOf(oneRedirect).Matches(emptyVersion), Is.False,
+ "an empty list is a representation like any other, so this compares two real versions rather than a version against nothing");
+ }
+
+ [Test]
+ public void An_empty_list_still_reports_a_version()
+ {
+ var version = VersionOf(Redirects());
+
+ Assert.That(version.HasValue, Is.True,
+ "a client watching a list that stays empty must be able to revalidate it rather than refetch the emptiness");
+ }
+
+ static DataVersion VersionOf(IReadOnlyList redirects) =>
+ ResponseVersions.VersionOf(redirects, redirects.Count);
+
+ static IReadOnlyList Redirects(params MessageRedirect[] redirects) => redirects;
+
+ static MessageRedirect Redirect(string from = "sales@machine", string to = "sales@other") =>
+ new()
+ {
+ FromPhysicalAddress = from,
+ ToPhysicalAddress = to,
+ LastModified = new DateTime(2026, 8, 19, 10, 0, 0, DateTimeKind.Utc)
+ };
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupEtagHelperTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupEtagHelperTests.cs
deleted file mode 100644
index d0cc1cb0fd..0000000000
--- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupEtagHelperTests.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-namespace ServiceControl.UnitTests.Operations
-{
- using System;
- using NUnit.Framework;
- using ServiceControl.Recoverability;
-
- [TestFixture]
- public class RetryGroupEtagHelperTests
- {
- [Test]
- public void Id_changed_should_change_etag()
- {
- var group = new GroupOperation { Id = "old" };
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.Id = "new";
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void Count_changed_should_change_etag()
- {
- var group = new GroupOperation { Count = 1 };
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.Count = 2;
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void RetryStatus_changed_should_change_etag()
- {
- var group = new GroupOperation { OperationStatus = RetryState.Waiting.ToString() };
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.OperationStatus = RetryState.Preparing.ToString();
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void RetryProgress_changed_should_change_etag()
- {
- var group = new GroupOperation();
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.OperationProgress = 0.01;
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void RetryStartTime_changed_should_change_etag()
- {
- var group = new GroupOperation();
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.OperationStartTime = DateTime.UtcNow;
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void RetryCompletionTime_changed_should_change_etag()
- {
- var group = new GroupOperation();
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.OperationCompletionTime = DateTime.UtcNow;
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void NeedUserAcknowledgement_changed_should_change_etag()
- {
- var group = new GroupOperation();
- var data = new[] { group };
-
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- group.NeedUserAcknowledgement = true;
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
-
- [Test]
- public void Changing_item_count_should_change_etag()
- {
- var data = new GroupOperation[0];
- var knownEtag = EtagHelper.CalculateEtag(data);
-
- var group = new GroupOperation();
- data = new[] { group };
-
- var newEtag = EtagHelper.CalculateEtag(data);
-
- Assert.That(newEtag, Is.Not.EqualTo(knownEtag));
- }
- }
-}
\ No newline at end of file
diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs
new file mode 100644
index 0000000000..4ff7b4961d
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs
@@ -0,0 +1,265 @@
+namespace ServiceControl.UnitTests.Operations
+{
+ using System;
+ using System.Collections.Generic;
+ using NUnit.Framework;
+ using ServiceControl.Infrastructure.WebApi;
+ using ServiceControl.Persistence.Infrastructure;
+ using ServiceControl.Recoverability;
+
+ [TestFixture]
+ public class RetryGroupVersionTests
+ {
+ [Test]
+ public void Id_changed_should_change_version()
+ {
+ var group = new GroupOperation { Id = "old" };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Id = "new";
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Count_changed_should_change_version()
+ {
+ var group = new GroupOperation { Count = 1 };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Count = 2;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void RetryStatus_changed_should_change_version()
+ {
+ var group = new GroupOperation { OperationStatus = RetryState.Waiting.ToString() };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationStatus = RetryState.Preparing.ToString();
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void RetryProgress_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationProgress = 0.01;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void RetryStartTime_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationStartTime = DateTime.UtcNow;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void RetryCompletionTime_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationCompletionTime = DateTime.UtcNow;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void NeedUserAcknowledgement_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.NeedUserAcknowledgement = true;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Comment_changed_should_change_version()
+ {
+ var group = new GroupOperation { Comment = "before" };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Comment = "after";
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Title_changed_should_change_version()
+ {
+ var group = new GroupOperation { Title = "before" };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Title = "after";
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Type_changed_should_change_version()
+ {
+ var group = new GroupOperation { Type = "before" };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Type = "after";
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void First_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.First = DateTime.UtcNow;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Last_changed_should_change_version()
+ {
+ var group = new GroupOperation();
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.Last = DateTime.UtcNow;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void OperationFailed_changed_should_change_version()
+ {
+ var group = new GroupOperation { OperationFailed = false };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationFailed = true;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void OperationMessagesCompletedCount_changed_should_change_version()
+ {
+ var group = new GroupOperation { OperationMessagesCompletedCount = 1 };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationMessagesCompletedCount = 2;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void OperationRemainingCount_changed_should_change_version()
+ {
+ var group = new GroupOperation { OperationRemainingCount = 2 };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationRemainingCount = 1;
+
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void A_message_completing_moves_the_version_even_when_the_rounded_progress_does_not()
+ {
+ // Progress is rounded to two decimals, so on a retry this big one message does not move it.
+ var group = new GroupOperation
+ {
+ Id = "retry-1",
+ OperationStatus = "Forwarding",
+ OperationProgress = 0.2,
+ OperationMessagesCompletedCount = 10_000,
+ OperationRemainingCount = 40_000
+ };
+ var data = new[] { group };
+
+ var knownVersion = VersionOf(data);
+
+ group.OperationMessagesCompletedCount = 10_001;
+ group.OperationRemainingCount = 39_999;
+
+ Assert.That(group.OperationProgress, Is.EqualTo(0.2), "the premise: the rounded percentage has not moved");
+ Assert.That(VersionOf(data).Matches(knownVersion), Is.False);
+ }
+
+ [Test]
+ public void Changing_item_count_should_change_version()
+ {
+ var emptyVersion = VersionOf(Array.Empty());
+
+ var oneGroup = new[] { new GroupOperation() };
+
+ Assert.That(VersionOf(oneGroup).Matches(emptyVersion), Is.False,
+ "an empty list is a representation like any other, so this compares two real versions rather than a version against nothing");
+ }
+
+ [Test]
+ public void A_title_carrying_a_delimiter_cannot_impersonate_the_next_field()
+ {
+ var carrying = VersionOf([new GroupOperation { Title = "Shipping.Exception", Type = string.Empty }]);
+ var split = VersionOf([new GroupOperation { Title = "Shipping", Type = "Exception" }]);
+
+ Assert.That(carrying.Matches(split), Is.False,
+ "two groups a client can tell apart must not share a validator");
+ }
+
+ [Test]
+ public void Two_groups_cannot_digest_as_one_carrying_a_delimiter()
+ {
+ var two = VersionOf([new GroupOperation { Id = "a" }, new GroupOperation { Id = "b" }]);
+ var oneForging = VersionOf([new GroupOperation { Id = "a|row1:1:b" }]);
+
+ Assert.That(oneForging.Matches(two), Is.False,
+ "a row able to pose as a longer row list would let user text pin the validator");
+ }
+
+ static DataVersion VersionOf(IReadOnlyList groups) =>
+ ResponseVersions.VersionOf(groups, groups.Count);
+ }
+}
diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
index 7f0c779e38..8fc1cf9225 100644
--- a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
+++ b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
@@ -39,7 +39,7 @@ QueryResult> GetPage(IEnumerable source, strin
return new QueryResult>(
pageOfResults,
- new QueryStatsInfo(etag, allResults.Count, isStale: false))
+ new QueryStatsInfo(DataVersion.FromToken(etag), allResults.Count))
{
InstanceId = instanceId
};
diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessagesView_ScatterGather_DataFromBothInstances.cs b/src/ServiceControl.UnitTests/ScatterGather/MessagesView_ScatterGather_DataFromBothInstances.cs
index 7a2dc205a9..69ac74e3b5 100644
--- a/src/ServiceControl.UnitTests/ScatterGather/MessagesView_ScatterGather_DataFromBothInstances.cs
+++ b/src/ServiceControl.UnitTests/ScatterGather/MessagesView_ScatterGather_DataFromBothInstances.cs
@@ -24,10 +24,13 @@ public void HasResults()
[Test]
public void ResultingETagIsDifferentFromBothInstanceSpecificETags()
{
- var resultingEtag = Results.QueryStats.ETag;
+ var resultingVersion = Results.QueryStats.Version;
- Assert.That(resultingEtag, Is.Not.EqualTo(LocalETag), "Resulting etag should not equal local etag");
- Assert.That(resultingEtag, Is.Not.EqualTo(RemoteETag), "Resulting etag should not equal remote etag");
+ Assert.Multiple(() =>
+ {
+ Assert.That(resultingVersion.Matches(DataVersion.FromToken(LocalETag)), Is.False, "Resulting version should not equal local version");
+ Assert.That(resultingVersion.Matches(DataVersion.FromToken(RemoteETag)), Is.False, "Resulting version should not equal remote version");
+ });
}
[Test]
diff --git a/src/ServiceControl.UnitTests/ScatterGather/RemoteInstanceEtagTests.cs b/src/ServiceControl.UnitTests/ScatterGather/RemoteInstanceEtagTests.cs
index e66b2e551e..eee9ae65db 100644
--- a/src/ServiceControl.UnitTests/ScatterGather/RemoteInstanceEtagTests.cs
+++ b/src/ServiceControl.UnitTests/ScatterGather/RemoteInstanceEtagTests.cs
@@ -3,10 +3,12 @@ namespace ServiceControl.UnitTests.ScatterGather
using System.Net.Http;
using CompositeViews.Messages;
using NUnit.Framework;
+ using ServiceControl.Persistence.Infrastructure;
[TestFixture]
public class RemoteInstanceEtagTests
{
+ [TestCase("W/\"4611686018427387904\"", TestName = "A_remote_etag_is_read_when_the_instance_marks_it_weak")]
[TestCase("\"4611686018427387904\"", TestName = "A_remote_etag_is_read_when_the_instance_quotes_it")]
[TestCase("4611686018427387904", TestName = "A_remote_etag_is_read_when_the_instance_predates_the_conditional_get_fix")]
public void A_remote_etag_is_read(string asSentByTheRemoteInstance)
@@ -14,8 +16,8 @@ public void A_remote_etag_is_read(string asSentByTheRemoteInstance)
var response = new HttpResponseMessage();
response.Headers.TryAddWithoutValidation("ETag", asSentByTheRemoteInstance);
- Assert.That(ScatterGatherApiBase.ReadEtag(response.Headers), Is.EqualTo("4611686018427387904"),
- "a rolling upgrade runs both shapes side by side, so both have to be understood");
+ Assert.That(ScatterGatherApiBase.ReadEtag(response.Headers).ToString(), Is.EqualTo("4611686018427387904"),
+ "a rolling upgrade runs all three shapes side by side, so all three have to be understood");
}
[Test]
@@ -23,7 +25,7 @@ public void An_instance_that_sends_no_etag_contributes_nothing()
{
var response = new HttpResponseMessage();
- Assert.That(ScatterGatherApiBase.ReadEtag(response.Headers), Is.Null);
+ Assert.That(ScatterGatherApiBase.ReadEtag(response.Headers).HasValue, Is.False);
}
}
}
diff --git a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs
new file mode 100644
index 0000000000..22eebc1e3f
--- /dev/null
+++ b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs
@@ -0,0 +1,104 @@
+namespace ServiceControl.UnitTests.ScatterGather
+{
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using CompositeViews.Messages;
+ using Microsoft.Extensions.Logging.Abstractions;
+ using NUnit.Framework;
+ using Persistence.Infrastructure;
+ using ServiceBus.Management.Infrastructure.Settings;
+
+ [TestFixture]
+ class ScatterGatherVersionTests
+ {
+ [Test]
+ public void A_composite_is_absent_when_an_instance_reports_no_version()
+ {
+ var api = new LocalAndRemoteApi();
+
+ var everyone = api.AggregateResults(Context(), [Page("local", "a"), Page("remote", "b")]);
+ var oneSilent = api.AggregateResults(Context(), [Page("local", "a"), Page("remote", null)]);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(everyone.QueryStats.Version.HasValue, Is.True, "every instance answered, so the composite covers them all");
+ Assert.That(oneSilent.QueryStats.Version.HasValue, Is.False,
+ "a composite that ignored an instance would stop reporting that instance's changes");
+ }
+ }
+
+ [Test]
+ public void A_composite_moves_when_one_instance_moves()
+ {
+ var api = new LocalAndRemoteApi();
+
+ var before = api.AggregateResults(Context(), [Page("local", "a"), Page("remote", "b")]);
+ var after = api.AggregateResults(Context(), [Page("local", "a"), Page("remote", "c")]);
+
+ Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False,
+ "one instance reported new data, so the composite cannot stay put");
+ }
+
+ [Test]
+ public void A_remote_only_api_reports_the_version_of_the_instances_that_have_the_data()
+ {
+ var api = new RemoteOnlyApi(new Settings());
+
+ var composite = api.AggregateResults(Context(), [NoLocalData(), Page("remote", "b")]);
+
+ Assert.That(composite.QueryStats.Version.HasValue, Is.True,
+ "the remote answered with a version, so discarding it would leave the response with no ETag at all");
+ }
+
+ [Test]
+ public void A_remote_only_api_still_covers_a_remote_configured_with_its_own_instance_id()
+ {
+ var settings = new Settings();
+ var api = new RemoteOnlyApi(settings);
+
+ var composite = api.AggregateResults(Context(), [NoLocalData(), Page(settings.InstanceId, "b")]);
+
+ Assert.That(composite.QueryStats.Version.HasValue, Is.True,
+ "dropping a remote because it answers to the local instance id would promise coverage the composite does not have");
+ }
+
+ [Test]
+ public void A_remote_only_api_reports_no_version_when_no_remote_answered()
+ {
+ var api = new RemoteOnlyApi(new Settings());
+
+ var composite = api.AggregateResults(Context(), [NoLocalData()]);
+
+ Assert.That(composite.QueryStats.Version.HasValue, Is.False,
+ "nothing reported a version, so there is nothing to promise a caller");
+ }
+
+ static ScatterGatherApiMessageViewContext Context() => new(new PagingInfo(), new SortInfo());
+
+ static QueryResult> Page(string instanceId, string validator) =>
+ new([new MessagesView { MessageId = instanceId }], new QueryStatsInfo(DataVersion.FromToken(validator), 1))
+ {
+ InstanceId = instanceId
+ };
+
+ // What ScatterGatherRemoteOnly.LocalQuery returns: no rows and no version.
+ static QueryResult> NoLocalData() =>
+ new(null, QueryStatsInfo.Zero) { IsLocalInstance = true };
+
+ class LocalAndRemoteApi() : ScatterGatherApiMessageView