From 7bc096d63db6a1347ae13f7a5b43c32744074619 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 7 Aug 2026 13:23:35 +0800 Subject: [PATCH 01/36] Add DataVersion, one representation for a persisted query result version --- .../Infrastructure/DataVersion.cs | 165 +++++++++++++ .../Infrastructure/DataVersionTests.cs | 217 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/ServiceControl.Persistence/Infrastructure/DataVersion.cs create mode 100644 src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs new file mode 100644 index 0000000000..0552c1a3b3 --- /dev/null +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -0,0 +1,165 @@ +namespace ServiceControl.Persistence.Infrastructure +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Linq; + + /// + /// An opaque version of a persisted query result, surfaced to clients as an HTTP entity-tag. + /// + /// means the store has no version to offer. It does not match anything, including itself. + /// is ordinary value equality and stays reflexive, so the struct remains usable as a dictionary key. + /// + /// + /// A struct, so that default is and no field of this type can ever be + /// null. A null would be a second way to say "no version" that never + /// gets to see. operator == is deliberately not defined: the only two questions worth asking + /// are and , and they answer differently. + /// + /// + [DebuggerDisplay("{validator ?? \"None\",nq}")] + public readonly struct DataVersion : IEquatable + { + readonly string validator; + readonly bool strong; + + DataVersion(string validator, bool strong = false) + { + this.validator = validator; + this.strong = strong; + } + + public static readonly DataVersion None = default; + + public bool HasValue => validator is not null; + + /// + /// Whether this version promises byte equivalence, which decides whether it goes on the wire + /// marked weak. Only can promise it. Not part of , + /// because RFC 9110 requires If-None-Match to compare tags without regard to strength. + /// + public bool IsStrong => strong; + + /// + /// A version the backend produced itself. + /// Weak: the backend computed it over a result set, not over the bytes of a representation. + /// + 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 backend token that moves if and only if the bytes of the representation move, so the + /// entity-tag can go out unmarked. Only the caller can know this holds, so only use it where + /// it demonstrably does. + /// + public static DataVersion FromContent(string token) => + string.IsNullOrEmpty(token) ? None : new DataVersion(token, strong: true); + + /// + /// A version derived from aggregates over the query that produced the page. The terms must be + /// a function of every field the response exposes, computed over the same filtered set, or a + /// change to an unnamed field leaves a client holding a page this version claims is current. + /// Always weak: a summary of aggregates cannot promise byte equivalence. + /// + public static DataVersion Compose(params (string Name, object Value)[] terms) => + terms is null || terms.Length == 0 + ? None + : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString()); + + /// + /// One version for a result gathered from several instances. Absent anywhere is absent overall. + /// Always weak, whatever went into it: it goes through . + /// + public static DataVersion Combine(IEnumerable versions) + { + var validators = new List(); + + foreach (var version in versions) + { + if (!version.HasValue) + { + return None; + } + + validators.Add(version.validator); + } + + if (validators.Count == 0) + { + return None; + } + + // Instances answer in no guaranteed order, so the composite has to be order independent. + validators.Sort(StringComparer.Ordinal); + + return Compose([.. validators.Select((v, i) => ($"instance{i.ToString(CultureInfo.InvariantCulture)}", (object)v))]); + } + + /// + /// A validator a client echoed back, in any shape a current or older instance might send. + /// Never trusted for anything but matching. + /// + 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..]; + } + + // Trimming every quote instead would turn a malformed header into a truncated value + // rather than into the cache miss it should be. + if (value.Length > 1 && value[0] == '"' && value[^1] == '"') + { + value = value[1..^1]; + } + + return FromToken(value); + } + + /// + /// Whether a caller holding already holds this version. The only + /// question a store or a conditional-request filter should ask. Ignores , + /// because RFC 9110 requires If-None-Match to use the weak comparison function, and + /// because a version round-tripped through has lost its marking anyway. + /// + public bool Matches(DataVersion other) => + HasValue && other.HasValue && string.Equals(validator, other.validator, StringComparison.Ordinal); + + /// + /// Ordinary value equality, including . Never use it to decide whether + /// something was modified: it is reflexive, so equals . + /// + public bool Equals(DataVersion other) => + strong == other.strong && string.Equals(validator, other.validator, StringComparison.Ordinal); + + public override bool Equals(object obj) => obj is DataVersion other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(validator?.GetHashCode(StringComparison.Ordinal) ?? 0, strong); + + /// The validator without entity-tag quoting, 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 => $"{term.Name}={Format(term.Value)}")); + + static string Format(object value) => value switch + { + null => string.Empty, + DateTime timestamp => timestamp.Ticks.ToString(CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() + }; + } +} diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs new file mode 100644 index 0000000000..b926943fb7 --- /dev/null +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -0,0 +1,217 @@ +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"); + + Assert.Multiple(() => + { + 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 IEquatable is violated and any + // dictionary or Distinct over DataVersion misbehaves. + 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() + { + Assert.Multiple(() => + { + 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 Combine_does_not_depend_on_the_order_instances_answered_in() + { + var a = DataVersion.FromToken("a"); + var b = DataVersion.FromToken("b"); + + Assert.That(DataVersion.Combine([a, b]).Matches(DataVersion.Combine([b, a])), Is.True); + } + + [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([a, b]); + + Assert.Multiple(() => + { + 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([DataVersion.FromToken("a"), 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); + } + + [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() + { + // Trimming every quote would turn a malformed header into a truncated value that could + // accidentally match, rather than into the cache miss it should be. + Assert.That(DataVersion.FromClient("\"abc").ToString(), Is.EqualTo("\"abc")); + } + + [Test] + public void Only_FromContent_promises_byte_equivalence() + { + Assert.Multiple(() => + { + Assert.That(DataVersion.FromContent("cv-1").IsStrong, Is.True); + Assert.That(DataVersion.FromToken("cv-1").IsStrong, Is.False); + Assert.That(DataVersion.FromToken(1L).IsStrong, Is.False); + Assert.That(DataVersion.FromClient("\"cv-1\"").IsStrong, Is.False); + Assert.That(DataVersion.None.IsStrong, Is.False); + }); + } + + [Test] + public void Composing_and_combining_are_never_exact() + { + var exact = DataVersion.FromContent("cv-1"); + + Assert.Multiple(() => + { + Assert.That(DataVersion.Compose(("total", 3L)).IsStrong, Is.False, + "a hash over aggregates cannot promise the bytes are identical"); + Assert.That(DataVersion.Combine([exact, exact]).IsStrong, Is.False, + "a composite across instances is an approximation whatever went into it"); + }); + } + + [Test] + public void Matching_ignores_the_marking() + { + // RFC 9110 requires If-None-Match to use the weak comparison function, under which the + // marking is not part of the test. A client that revalidates gets its version back through + // FromClient, which cannot know the marking, so this is the ordinary case not an edge one. + Assert.That(DataVersion.FromContent("cv-1").Matches(DataVersion.FromClient("W/\"cv-1\"")), Is.True); + } + + [Test] + public void Equality_does_not_ignore_the_marking() + { + Assert.That(DataVersion.FromContent("cv-1").Equals(DataVersion.FromToken("cv-1")), Is.False, + "Equals is ordinary value equality over everything the struct holds, which is why it must never decide not-modified"); + } +} From c40cc2255e4051bf49620b982cb80db58d139747 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 7 Aug 2026 13:47:02 +0800 Subject: [PATCH 02/36] Carry the message body version as a DataVersion --- .../Implementation/BodyStorage/BodyStorage.cs | 6 +++-- .../RavenAttachmentsBodyStorage.cs | 4 +++- .../ReturnToSenderDequeuerTests.cs | 2 +- .../IBodyStorage.cs | 3 ++- .../BodyStorage/MessageBodyResultTests.cs | 3 ++- .../WebApi/ConditionalGetTests.cs | 23 +++++++++++++++++++ .../Messages/GetMessagesController.cs | 2 +- .../WebApi/HttpResponseExtensions.cs | 12 ++++++++++ 8 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs index d974cbb51f..9f9428f111 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs @@ -8,6 +8,8 @@ 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; +using ServiceControl.Persistence.Infrastructure; /// /// Resolves a message body from wherever it was stored. @@ -46,7 +48,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, DataVersion.FromToken(uniqueMessageId))); } if (row.BodyText != null) @@ -62,7 +64,7 @@ public async Task TryFetch(string bodyId, CancellationToken c new MemoryStream(bytes, writable: false), row.BodyContentType ?? "text/plain", bytes.Length, - uniqueMessageId)); + DataVersion.FromToken(uniqueMessageId))); } if (row.BodySize == 0) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs index 69a498fac0..e796395971 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; + using Persistence.Infrastructure; + using Persistence.Infrastructure; using Persistence.RavenDB; using Raven.Client.Documents; using Raven.Client.Documents.Session; @@ -72,7 +74,7 @@ async Task ResultForUniqueId(IAsyncDocumentSession session, s result.Stream, result.Details.ContentType, (int)result.Details.Size, - result.Details.ChangeVector)); + DataVersion.FromToken(result.Details.ChangeVector))); } } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs index c73e7bbeba..d6e0473429 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs @@ -217,7 +217,7 @@ public Task TryFetch(string bodyId, CancellationToken cancell _ => throw new ArgumentOutOfRangeException(nameof(state), state, null) }); - static MessageBodyStreamContent Content(byte[] body) => new(new MemoryStream(body), "text/plain", body.Length, "etag"); + static MessageBodyStreamContent Content(byte[] body) => new(new MemoryStream(body), "text/plain", body.Length, DataVersion.FromToken("etag")); } } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence/IBodyStorage.cs b/src/ServiceControl.Persistence/IBodyStorage.cs index 7d7715437f..187c3e8dc9 100644 --- a/src/ServiceControl.Persistence/IBodyStorage.cs +++ b/src/ServiceControl.Persistence/IBodyStorage.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; + using ServiceControl.Persistence.Infrastructure; public interface IBodyStorage { @@ -47,5 +48,5 @@ public static MessageBodyResult Available(MessageBodyStreamContent content) MessageBodyStreamContent? ContentValue { get; } } - public sealed record MessageBodyStreamContent(Stream Stream, string ContentType, int BodySize, string Etag); + public sealed record MessageBodyStreamContent(Stream Stream, string ContentType, int BodySize, DataVersion Version); } \ No newline at end of file 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/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 8a2c1e945a..2931660221 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 @@ -91,6 +92,28 @@ public void A_call_site_with_nothing_to_validate_emits_no_etag_header(string val "an empty entity-tag is well formed, so it would match itself and answer 304 for unrelated payloads"); } + [Test] + public void A_data_version_emits_the_same_header_the_string_overload_did() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Response.WithEtag(DataVersion.FromToken("A:2-abc")); + + // The marking arrives in a later task. This one only changes who holds the value. + Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"A:2-abc\"")); + } + + [Test] + public void An_absent_data_version_emits_no_header() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Response.WithEtag(DataVersion.None); + + 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"); + } + static ResultExecutingContext ResultExecuting(HttpContext httpContext) => new( new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs index e4de1674d7..1d2b31e954 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs @@ -100,7 +100,7 @@ public async Task Get(string id, [FromQuery(Name = "instance_id") return NoContent(); } - Response.WithEtag(result.Content.Etag); + Response.WithEtag(result.Content.Version); return File(result.Content.Stream, result.Content.ContentType ?? "text/*"); } diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index 226148016a..759564d179 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -27,6 +27,18 @@ public static void WithEtag(this HttpResponse response, StringValues value) response.Headers.ETag = $"\"{validator}\""; } + public static void WithEtag(this HttpResponse response, DataVersion version) + { + if (!version.HasValue) + { + return; + } + + // RFC 9110 requires an entity-tag to be a quoted string. Unquoted, EntityTagHeaderValue + // cannot parse it and NotModifiedStatusHttpHandler never matches a client's If-None-Match. + response.Headers.ETag = $"\"{version}\""; + } + public static void WithQueryStatsInfo(this HttpResponse response, QueryStatsInfo queryStatsInfo) { response.WithTotalCount(queryStatsInfo.TotalCount); From 0e8498621ad5f30bced4cdb074ed59ec57a8f8a4 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 7 Aug 2026 15:14:33 +0800 Subject: [PATCH 03/36] Retype the query result validator as DataVersion --- .../MessageFailures/FailedErrorsController.cs | 3 ++- .../FailedMessageRetriesController.cs | 3 ++- .../Implementation/CustomCheckDataStore.cs | 3 ++- .../Implementation/EventLogDataStore.cs | 10 ++++---- .../Implementation/QueueAddressStore.cs | 6 +++-- .../Implementation/RetryBatchStore.cs | 3 ++- .../FailedMessageQueryFilters.cs | 3 +-- .../Infrastructure/FailureGroupQueries.cs | 2 +- .../EventLogDataStore.cs | 4 +-- .../RavenCustomCheckDataStore.cs | 2 +- .../RavenQueryStatisticsExtensions.cs | 18 ++++++------- .../EFCore/EventLogDataStoreEFTests.cs | 4 +-- .../EFCore/RetentionSweepTests.cs | 4 +-- .../EventLogDataStoreTests.cs | 10 ++++---- .../FailedMessageQueryDataStoreTests.cs | 6 ++--- .../IEventLogDataStore.cs | 13 +++++----- .../Infrastructure/QueryStatsInfo.cs | 12 ++++----- .../WebApi/ConditionalGetTests.cs | 22 ++++------------ .../MessageView_ScatterGatherTest.cs | 2 +- ...iew_ScatterGather_DataFromBothInstances.cs | 9 ++++--- .../ScatterGather/RemoteInstanceEtagTests.cs | 8 +++--- .../Messages/ScatterGatherApi.cs | 23 ++++++----------- .../CustomChecks/Web/CustomCheckController.cs | 2 +- .../EventLog/EventLogApiController.cs | 2 +- .../WebApi/HttpRequestExtensions.cs | 15 ++++------- .../WebApi/HttpResponseExtensions.cs | 25 ++++--------------- .../Api/ArchiveMessagesController.cs | 5 ++-- .../Api/MessageRedirectsController.cs | 4 +-- .../Web/EndpointsMonitoringController.cs | 4 ++- .../API/FailureGroupsController.cs | 6 ++--- 30 files changed, 101 insertions(+), 132 deletions(-) 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.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index af870ac38b..e952e89073 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -73,7 +73,8 @@ public Task>> GetStats(PagingInfo paging, string? Status = c.Status, ReportedAt = c.ReportedAt, FailureReason = c.FailureReason - }).ToList(), new QueryStatsInfo("", page.Count, false)); + // No version: this store has no aggregate that moves when a check's status does. + }).ToList(), new QueryStatsInfo(DataVersion.None, page.Count, false)); }, 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..4a82774d15 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -25,7 +25,7 @@ public Task Add(EventLogItem logItem, CancellationToken cancellationToken = defa }, cancellationToken); public Task>> GetEventLogItems( - PagingInfo pagingInfo, string? knownVersion = null, CancellationToken cancellationToken = default) => + PagingInfo pagingInfo, DataVersion knownVersion = default, CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (dbContext, token) => { var query = dbContext.EventLogItems.AsNoTracking(); @@ -49,7 +49,7 @@ public Task>> GetEventLogItems( // 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) + if (knownVersion.Matches(version)) { return QueryResult>.Unchanged(queryStats); } @@ -76,8 +76,8 @@ public Task>> GetEventLogItems( return new QueryResult>(items, queryStats); }, cancellationToken); - // Synthesised version ID to be used for an ETag. The highest key is the monotonic term: identity + // Synthesised version for an append-only table. 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(); + static DataVersion Version(long total, DateTime? newest, long? highestId) => + DataVersion.Compose(("total", total), ("newest", newest), ("highestId", highestId)); } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs index be2448f662..4083fb960d 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs @@ -20,8 +20,10 @@ 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 version = DataVersion.Compose( + ("addresses", items.Count), + ("physicalAddresses", string.Join(",", items.Select(x => x.PhysicalAddress)))); - return new QueryResult>(items, new QueryStatsInfo(eTag, query.Count(), false)); + return new QueryResult>(items, new QueryStatsInfo(version, query.Count(), false)); }, 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..a9b0cc9d0f 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs @@ -111,7 +111,8 @@ public Task>> GetOrphanedBatches(string retrySessi IList batches = [.. orphaned.Select(batch => batch.ToRetryBatch(messageCounts.GetValueOrDefault(batch.Id)))]; - return new QueryResult>(batches, new QueryStatsInfo(string.Empty, batches.Count, false)); + // No version: orphaned batches are consumed by the retry session, never by a caching client. + return new QueryResult>(batches, new QueryStatsInfo(DataVersion.None, batches.Count, false)); }, cancellationToken); public Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index 7a28dac79e..c4ec1f3bed 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -174,9 +174,8 @@ public static async Task ToQueryStatsInfo(this IQueryable OrderBy(this IQueryable source, System.Linq.Expressions.Expression> keySelector, bool descending) => diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index 0767a7d4b2..906edf7ec2 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -27,6 +27,6 @@ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection group.Last); - return new QueryStatsInfo($"{groups.Count}-{latest.Ticks}", groups.Count, false); + return new QueryStatsInfo(DataVersion.Compose(("groups", groups.Count), ("last", latest)), groups.Count, false); } } diff --git a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs index e02ae48522..b52ae092bc 100644 --- a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs @@ -29,7 +29,7 @@ await session.StoreAsync( } public async Task>> GetEventLogItems( - PagingInfo pagingInfo, string knownVersion = null, CancellationToken cancellationToken = default) + PagingInfo pagingInfo, DataVersion knownVersion = default, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var documents = await session @@ -43,7 +43,7 @@ public async Task>> GetEventLogItems( // The validator comes off the query statistics, so the page cannot be // skipped. Only the projection below is saved. - if (knownVersion is not null && knownVersion == queryStats.ETag) + if (knownVersion.Matches(queryStats.Version)) { return QueryResult>.Unchanged(queryStats); } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs index f4e6e643d7..5ae267faf8 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs @@ -57,7 +57,7 @@ public async Task>> GetStats(PagingInfo paging, s .Paging(paging) .ToListAsync(cancellationToken); - return new QueryResult>(results, new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults, stats.IsStale)); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task DeleteCustomCheck(Guid id, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index fa5746ccfa..b6a548c785 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -1,18 +1,16 @@ -namespace ServiceControl.Persistence +namespace ServiceControl.Persistence { using Raven.Client.Documents.Session; using ServiceControl.Persistence.Infrastructure; static class RavenQueryStatisticsExtensions { - public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) - { - return new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults, stats.IsStale); - } + public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => + new(stats.ResultEtag is { } resultEtag ? DataVersion.FromToken(resultEtag) : DataVersion.None, + stats.TotalResults, + stats.IsStale); - public static QueryStatsInfo ToQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult) - { - return new QueryStatsInfo(queryResult.ResultEtag.ToString(), queryResult.TotalResults, queryResult.IsStale); - } + public static QueryStatsInfo ToQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult) => + new(DataVersion.FromToken(queryResult.ResultEtag), queryResult.TotalResults, queryResult.IsStale); } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EventLogDataStoreEFTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EventLogDataStoreEFTests.cs index 34b98aada7..7516020d17 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/EventLogDataStoreEFTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/EventLogDataStoreEFTests.cs @@ -89,7 +89,7 @@ public async Task Version_changes_when_rows_are_deleted_behind_the_interface() using (Assert.EnterMultipleScope()) { Assert.That(after.QueryStats.TotalCount, Is.EqualTo(2)); - Assert.That(after.QueryStats.ETag, Is.Not.EqualTo(before.QueryStats.ETag), "a delete must invalidate the client's cached page"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "a delete must invalidate the client's cached page"); } } @@ -120,7 +120,7 @@ public async Task Version_changes_when_a_retention_delete_and_a_backdated_insert { Assert.That(after.QueryStats.TotalCount, Is.EqualTo(before.QueryStats.TotalCount), "the setup only bites while the count is unchanged"); Assert.That(after.Results.Max(i => i.RaisedAt), Is.EqualTo(before.Results.Max(i => i.RaisedAt)), "and while the newest RaisedAt is unchanged"); - Assert.That(after.QueryStats.ETag, Is.Not.EqualTo(before.QueryStats.ETag), "the page changed, so the validator must too"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the page changed, so the validator must too"); } } diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index 35aa3d930a..33b3715dca 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -250,7 +250,7 @@ public async Task Sweeping_event_log_items_changes_the_version() await Store(EventLogRow("expired", Now.AddDays(-15))); await Store(EventLogRow("fresh", Now.AddDays(-1))); - var versionBefore = (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.ETag; + var versionBefore = (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.Version; await RunRetentionSweep(); @@ -260,7 +260,7 @@ public async Task Sweeping_event_log_items_changes_the_version() { Assert.That(after.QueryStats.TotalCount, Is.EqualTo(1)); // The count term of the version exists precisely so that retention invalidates client caches. - Assert.That(after.QueryStats.ETag, Is.Not.EqualTo(versionBefore)); + Assert.That(after.QueryStats.Version.Matches(versionBefore), Is.False); } } diff --git a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs index 5b955d05d2..f9f996eb92 100644 --- a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs @@ -216,7 +216,7 @@ public async Task Matching_known_version_still_reports_total_and_version() // The controller sets Total-Count and ETag on the 304, so neither may be dropped // just because the page was not fetched. Assert.That(result.QueryStats.TotalCount, Is.EqualTo(3)); - Assert.That(result.QueryStats.ETag, Is.EqualTo(version)); + Assert.That(result.QueryStats.Version.Matches(version), Is.True); } } @@ -235,7 +235,7 @@ public async Task Stale_known_version_returns_the_page() Assert.That(result.NotModified, Is.False); Assert.That(result.Results, Has.Count.EqualTo(3)); Assert.That(result.QueryStats.TotalCount, Is.EqualTo(3)); - Assert.That(result.QueryStats.ETag, Is.Not.EqualTo(staleVersion)); + Assert.That(result.QueryStats.Version.Matches(staleVersion), Is.False); } } @@ -244,7 +244,7 @@ public async Task Unrecognised_known_version_returns_the_page() { await AddItems(2); - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), "not-a-version-this-store-ever-issued"); + var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), DataVersion.FromToken("not-a-version-this-store-ever-issued")); using (Assert.EnterMultipleScope()) { @@ -253,8 +253,8 @@ public async Task Unrecognised_known_version_returns_the_page() } } - async Task CurrentVersion() => - (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.ETag; + async Task CurrentVersion() => + (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.Version; async Task AddItems(int count) { diff --git a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs index 057b693ff7..b979c8c202 100644 --- a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs @@ -311,7 +311,7 @@ public async Task Reports_stats_matching_the_query() { Assert.That(stats.TotalCount, Is.EqualTo(1)); Assert.That(stats.TotalCount, Is.EqualTo(query.QueryStats.TotalCount)); - Assert.That(stats.ETag, Is.EqualTo(query.QueryStats.ETag)); + Assert.That(stats.Version.Matches(query.QueryStats.Version), Is.True); } } @@ -323,7 +323,7 @@ public async Task Repeats_the_etag_while_nothing_changes() var first = await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null); var second = await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null); - Assert.That(second.ETag, Is.EqualTo(first.ETag)); + Assert.That(second.Version.Matches(first.Version), Is.True); } [Test] @@ -337,7 +337,7 @@ public async Task Changes_the_etag_when_the_set_changes() var after = await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null); - Assert.That(after.ETag, Is.Not.EqualTo(before.ETag)); + Assert.That(after.Version.Matches(before.Version), Is.False); } diff --git a/src/ServiceControl.Persistence/IEventLogDataStore.cs b/src/ServiceControl.Persistence/IEventLogDataStore.cs index b130161cfb..83076faad3 100644 --- a/src/ServiceControl.Persistence/IEventLogDataStore.cs +++ b/src/ServiceControl.Persistence/IEventLogDataStore.cs @@ -28,19 +28,18 @@ public interface IEventLogDataStore /// /// 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 version the caller already holds, or 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. + /// : an opaque cache validator. 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, DataVersion knownVersion = default, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs index 91abfcef4b..f2971572ba 100644 --- a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs +++ b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs @@ -1,21 +1,21 @@ 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, bool isStale, 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, false); } -} \ No newline at end of file +} diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 2931660221..0a32a14a9b 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -19,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; @@ -37,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); @@ -52,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. @@ -65,7 +65,7 @@ public void A_deterministic_etag_is_a_well_formed_entity_tag() { var httpContext = new DefaultHttpContext(); - httpContext.Response.WithDeterministicEtag("any-non-empty-payload-signature"); + httpContext.Response.WithDeterministicEtag(DataVersion.FromToken("any-non-empty-payload-signature")); Assert.That(httpContext.Response.GetTypedHeaders().ETag, Is.Not.Null); } @@ -75,23 +75,11 @@ public void The_emitted_etag_quotes_the_value_without_altering_it() { var httpContext = new DefaultHttpContext(); - httpContext.Response.WithEtag("4611686018427387904"); + httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904")); Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"4611686018427387904\"")); } - [TestCase(null)] - [TestCase("")] - public void A_call_site_with_nothing_to_validate_emits_no_etag_header(string value) - { - var httpContext = new DefaultHttpContext(); - - httpContext.Response.WithEtag(value); - - 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 A_data_version_emits_the_same_header_the_string_overload_did() { diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs index 7f0c779e38..f0ca1a5b51 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, isStale: false)) { 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/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index d0444a2068..4007580a15 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -20,19 +20,12 @@ interface IApi; // Non-generic, so statics live once rather than once per closed generic instantiation. public abstract class ScatterGatherApiBase { - internal static string ReadEtag(HttpResponseHeaders headers) - { - // Read raw rather than through headers.ETag. An instance predating the quoted validator - // sends a bare token, which EntityTagHeaderValue fails to parse and discards silently. - if (!headers.TryGetValues("ETag", out var values)) - { - return null; - } - - var etag = values.FirstOrDefault(); - - return etag?.Length > 1 && etag[0] == '"' && etag[^1] == '"' ? etag[1..^1] : etag; - } + // Read raw rather than through headers.ETag. An instance predating the quoted validator + // sends a bare token, which EntityTagHeaderValue fails to parse and discards silently. + internal static DataVersion ReadEtag(HttpResponseHeaders headers) => + headers.TryGetValues("ETag", out var values) + ? DataVersion.FromClient(values.FirstOrDefault()) + : DataVersion.None; } public record ScatterGatherContext(PagingInfo PagingInfo); @@ -108,7 +101,7 @@ protected virtual QueryStatsInfo AggregateStats(TIn input, IEnumerable x.QueryStats).ToArray(); return new QueryStatsInfo( - string.Concat(infos.OrderBy(x => x.ETag).Select(x => x.ETag)), + DataVersion.Combine(infos.Select(x => x.Version)), infos.Sum(x => x.TotalCount), isStale: infos.Any(x => x.IsStale), infos.Max(x => x.HighestTotalCountOfAllTheInstances) @@ -191,8 +184,6 @@ static async Task> ParseResult(HttpResponseMessage responseMes totalCount = int.Parse(totalCounts.ElementAt(0)); } - // Unquoted, because AggregateStats concatenates it with the other instances' values and - // the result is re-tagged before it goes back on the wire. var etag = ReadEtag(responseMessage.Headers); return new QueryResult(remoteResults, new QueryStatsInfo(etag, totalCount, isStale: false)); diff --git a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs index 295b30ef90..9439a85323 100644 --- a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs +++ b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs @@ -26,7 +26,7 @@ public async Task> CustomChecks([FromQuery] PagingInfo paging var stats = await checksDataStore.GetStats(pagingInfo, status, cancellationToken); Response.WithPagingLinksAndTotalCount(pagingInfo, stats.QueryStats.TotalCount); - Response.WithEtag(stats.QueryStats.ETag); + Response.WithEtag(stats.QueryStats.Version); return stats.Results; } diff --git a/src/ServiceControl/EventLog/EventLogApiController.cs b/src/ServiceControl/EventLog/EventLogApiController.cs index 824c5240bf..914b874ee6 100644 --- a/src/ServiceControl/EventLog/EventLogApiController.cs +++ b/src/ServiceControl/EventLog/EventLogApiController.cs @@ -24,7 +24,7 @@ public async Task>> Items([FromQuery] Pagin var result = await logDataStore.GetEventLogItems(pagingInfo, Request.GetKnownVersion(), cancellationToken); Response.WithPagingLinksAndTotalCount(pagingInfo, result.QueryStats.TotalCount); - Response.WithEtag(result.QueryStats.ETag); + Response.WithEtag(result.QueryStats.Version); if (result.NotModified) { diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs index f4210fde5a..b3e88e09f8 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs @@ -2,25 +2,20 @@ namespace ServiceControl.Infrastructure.WebApi { using System.Linq; using Microsoft.AspNetCore.Http; + using Persistence.Infrastructure; static class HttpRequestExtensions { /// - /// The validator the caller already holds, unquoted so it can be compared against a store's - /// own version, or null if the caller holds none. + /// The version the caller already holds, or if it holds none. /// - /// Only meaningful for an endpoint that publishes its validator through + /// Only meaningful for an endpoint that publishes its version through /// . An endpoint publishing through /// WithDeterministicEtag hashes the validator on the way out, so what a client echoes /// back cannot be compared with anything a store holds and this would never match. /// /// - public static string GetKnownVersion(this HttpRequest request) => - Unquote(request.Headers.IfNoneMatch.FirstOrDefault()); - - // Trimming every quote instead would turn a malformed header - // into a truncated value rather than into the cache miss it should be. - static string Unquote(string etag) => - etag?.Length > 1 && etag[0] == '"' && etag[^1] == '"' ? etag[1..^1] : etag; + public static DataVersion GetKnownVersion(this HttpRequest request) => + DataVersion.FromClient(request.Headers.IfNoneMatch.FirstOrDefault()); } } diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index 759564d179..cd6e482c27 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -13,20 +13,6 @@ static class HttpResponseExtensions { public static void WithTotalCount(this HttpResponse response, long totalCount) => response.WithHeader("Total-Count", totalCount.ToString(CultureInfo.InvariantCulture)); - public static void WithEtag(this HttpResponse response, StringValues value) - { - var validator = value.ToString(); - - if (string.IsNullOrEmpty(validator)) - { - return; - } - - // RFC 9110 requires an entity-tag to be a quoted string. Unquoted, EntityTagHeaderValue - // cannot parse it and NotModifiedStatusHttpHandler never matches a client's If-None-Match. - response.Headers.ETag = $"\"{validator}\""; - } - public static void WithEtag(this HttpResponse response, DataVersion version) { if (!version.HasValue) @@ -42,18 +28,17 @@ public static void WithEtag(this HttpResponse response, DataVersion version) public static void WithQueryStatsInfo(this HttpResponse response, QueryStatsInfo queryStatsInfo) { response.WithTotalCount(queryStatsInfo.TotalCount); - response.WithEtag(queryStatsInfo.ETag); + response.WithEtag(queryStatsInfo.Version); } - public static void WithDeterministicEtag(this HttpResponse response, string data) + public static void WithDeterministicEtag(this HttpResponse response, DataVersion version) { - if (string.IsNullOrEmpty(data)) + if (!version.HasValue) { return; } - var guid = DeterministicGuid.MakeId(data); - response.WithEtag(guid.ToString()); + response.WithEtag(DataVersion.FromToken(DeterministicGuid.MakeId(version.ToString()).ToString())); } static void WithHeader(this HttpResponse response, string header, StringValues value) => response.Headers.Append(header, value); @@ -115,7 +100,7 @@ static void AddLink(ICollection links, int page, string rel, string uriP public static void WithQueryStatsAndPagingInfo(this HttpResponse response, QueryStatsInfo queryStats, PagingInfo pagingInfo) { response.WithPagingLinksAndTotalCount(pagingInfo, queryStats.TotalCount, queryStats.HighestTotalCountOfAllTheInstances); - response.WithDeterministicEtag(queryStats.ETag); + response.WithDeterministicEtag(queryStats.Version); } public static void WithPagingLinksAndTotalCount(this HttpResponse response, diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index 7c5ab51504..6ad88d440c 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -11,6 +11,7 @@ namespace ServiceControl.MessageFailures.Api using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; [ApiController] @@ -50,7 +51,7 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex { var results = await dataStore.GetArchivedGroupsByClassifier(classifier, cancellationToken); - Response.WithDeterministicEtag(EtagHelper.CalculateEtag(results)); + Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(results))); return Ok(results); } @@ -77,7 +78,7 @@ public async Task> GetGroup(string groupId, strin { var result = await dataStore.GetArchivedGroup(groupId, status, modified, cancellationToken); - Response.WithEtag(result.QueryStats.ETag); + Response.WithEtag(result.QueryStats.Version); return result.Results == null ? NotFound() : result.Results; } diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index f3c8e61c0f..75a5e98356 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -171,7 +171,7 @@ public async Task CountRedirects(CancellationToken cancellationToken = default) { var redirects = await store.GetRedirects(cancellationToken); - Response.WithDeterministicEtag(EtagHelper.CalculateEtag(redirects)); + Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(redirects))); Response.WithTotalCount(redirects.Count); } @@ -193,7 +193,7 @@ public async Task> Redirects(string sort, stri r.LastModified )); - Response.WithDeterministicEtag(EtagHelper.CalculateEtag(redirects)); + Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(redirects))); Response.WithPagingLinksAndTotalCount(pagingInfo, redirects.Count); return queryResult; diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 8040bac1c9..337e9de598 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -68,7 +68,9 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf { var knownEndpoints = monitoring.GetKnownEndpoints(); - Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(string.Empty, knownEndpoints.Count, isStale: false), pagingInfo); + // No version: the monitored-endpoints list is assembled in the controller from several sources, + // none of which exposes an aggregate that moves when one of them does. + Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count, isStale: false), pagingInfo); return knownEndpoints; } diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index 15d7db5b03..2687e59a80 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -67,7 +67,7 @@ public async Task GetAllGroups(string classifier = "Exception } var results = await fetcher.GetGroups(classifier, classifierFilter, cancellationToken); - Response.WithDeterministicEtag(EtagHelper.CalculateEtag(results)); + Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(results))); return results; } @@ -100,7 +100,7 @@ public async Task GetRetryHistory(CancellationToken cancellationTo { var retryHistory = await retryStore.GetRetryHistory(cancellationToken); - Response.WithDeterministicEtag(retryHistory.GetHistoryOperationsUniqueIdentifier()); + Response.WithDeterministicEtag(DataVersion.FromToken(retryHistory.GetHistoryOperationsUniqueIdentifier())); return retryHistory; } @@ -112,7 +112,7 @@ public async Task> GetGroup(string groupId, strin { var result = await store.GetUnresolvedGroup(groupId, status, modified, cancellationToken); - Response.WithEtag(result.QueryStats.ETag); + Response.WithEtag(result.QueryStats.Version); return result.Results == null ? NotFound() : result.Results; } From 6e23697bd34c2030976bbc04254bb22855ca8142 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Sat, 8 Aug 2026 14:10:31 +0800 Subject: [PATCH 04/36] Emit the store version verbatim on every endpoint --- .../Implementation/EventLogDataStore.cs | 4 +- .../Implementation/GroupsDataStore.cs | 37 ++- .../Recoverability/GroupsDataStore.cs | 5 +- .../Recoverability/GroupsDataStoreTests.cs | 4 +- .../IEventLogDataStore.cs | 12 +- .../IGroupsDataStore.cs | 2 +- .../Infrastructure/DataVersion.cs | 63 +++-- .../Infrastructure/DataVersionTests.cs | 11 +- .../WebApi/ConditionalGetTests.cs | 26 +- .../RetryGroupEtagHelperTests.cs | 122 --------- .../Recoverability/RetryGroupVersionTests.cs | 239 ++++++++++++++++++ .../Messages/ScatterGatherApi.cs | 4 +- .../WebApi/HttpRequestExtensions.cs | 6 - .../WebApi/HttpResponseExtensions.cs | 16 +- .../Api/ArchiveMessagesController.cs | 7 +- .../Api/MessageRedirectsController.cs | 4 +- .../Web/EndpointsMonitoringController.cs | 4 +- .../Recoverability/API/EtagHelper.cs | 77 ++---- .../API/FailureGroupsController.cs | 4 +- 19 files changed, 372 insertions(+), 275 deletions(-) delete mode 100644 src/ServiceControl.UnitTests/Recoverability/RetryGroupEtagHelperTests.cs create mode 100644 src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs index 4a82774d15..4eee576956 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -76,8 +76,8 @@ public Task>> GetEventLogItems( return new QueryResult>(items, queryStats); }, cancellationToken); - // Synthesised version for an append-only table. The highest key is the monotonic term: identity - // values gap but never repeat, so an insert moves the version whatever its RaisedAt says. + // The table is append-only, so the highest key is enough to spot an insert: identity values can gap + // but never repeat, whatever RaisedAt says. static DataVersion Version(long total, DateTime? newest, long? highestId) => DataVersion.Compose(("total", total), ("newest", newest), ("highestId", highestId)); } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs index 66a80e3eb2..947829a2f5 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs @@ -29,9 +29,18 @@ 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 messages = WithStatus(dbContext, FailedMessageStatus.Archived); + + var views = await MostRecent(groups.AggregateGroups(messages), token); + + return new QueryResult>( + views, + new QueryStatsInfo(await SourceVersion(groups, messages, token), views.Count, false)); + }, 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); @@ -118,6 +127,28 @@ static async Task AttachComments(ServiceControlDbContext dbContext, IList SourceVersion(IQueryable groups, IQueryable messages, CancellationToken cancellationToken) + { + var stats = await (from failureGroup in groups + join message in messages on failureGroup.FailedMessageUniqueId equals message.UniqueMessageId + select message) + .GroupBy(_ => 1) + .Select(aggregate => new + { + Count = aggregate.Count(), + First = aggregate.Min(message => (DateTime?)message.FirstTimeOfFailure), + Last = aggregate.Max(message => (DateTime?)message.LastTimeOfFailure) + }) + .SingleOrDefaultAsync(cancellationToken); + + return DataVersion.Compose( + ("messages", stats?.Count ?? 0), + ("first", stats?.First), + ("last", stats?.Last)); + } + static async Task> MostRecent(IQueryable groups, CancellationToken cancellationToken) => await groups .OrderByDescending(group => group.Last) diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs index a1bbfcea58..d8f97782ad 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs @@ -40,11 +40,12 @@ public async Task> GetUnresolvedGroupsByClassifier(strin return groups; } - public async Task> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default) + public async Task>> GetArchivedGroupsByClassifier(string classifier, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var groups = session .Query() + .Statistics(out var stats) .Where(v => v.Type == classifier); var results = await groups @@ -52,7 +53,7 @@ public async Task> GetArchivedGroupsByClassifier(string .Take(200) // only show 200 groups .ToListAsync(cancellationToken); - return results; + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task> GetUnresolvedGroup(string groupId, string status, string modified, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs index 783fc0ff76..6502efbb4a 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs @@ -88,7 +88,7 @@ await Insert( InGroup(group).ToFailedMessage(), InGroup(group).ToFailedMessage(FailedMessageStatus.Archived)); - var view = (await GroupsStore.GetArchivedGroupsByClassifier(Classifier)).Single(); + var view = (await GroupsStore.GetArchivedGroupsByClassifier(Classifier)).Results.Single(); using (Assert.EnterMultipleScope()) { @@ -295,7 +295,7 @@ public async Task Leaves_archived_groups_without_their_comment() await Insert(InGroup(group).ToFailedMessage(FailedMessageStatus.Archived)); await EditComment(group.Id, "Only shown on the open group"); - var view = (await GroupsStore.GetArchivedGroupsByClassifier(Classifier)).Single(); + var view = (await GroupsStore.GetArchivedGroupsByClassifier(Classifier)).Results.Single(); Assert.That(view.Comment, Is.Null); } diff --git a/src/ServiceControl.Persistence/IEventLogDataStore.cs b/src/ServiceControl.Persistence/IEventLogDataStore.cs index 83076faad3..03d94c90aa 100644 --- a/src/ServiceControl.Persistence/IEventLogDataStore.cs +++ b/src/ServiceControl.Persistence/IEventLogDataStore.cs @@ -28,17 +28,9 @@ public interface IEventLogDataStore /// /// Which page to return. /// - /// The version the caller already holds, or if it holds none. - /// When it matches, the result is and carries no page. + /// What the caller already holds, or . On a match 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. 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, DataVersion knownVersion = default, CancellationToken cancellationToken = default); } 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/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index 0552c1a3b3..6fbadf0dca 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -7,16 +7,16 @@ namespace ServiceControl.Persistence.Infrastructure using System.Linq; /// - /// An opaque version of a persisted query result, surfaced to clients as an HTTP entity-tag. + /// An opaque version of a query result, sent to clients as an HTTP entity-tag. /// - /// means the store has no version to offer. It does not match anything, including itself. - /// is ordinary value equality and stays reflexive, so the struct remains usable as a dictionary key. + /// means there is no version. It matches nothing, not even itself, so two parties + /// that both know nothing can never answer 304. is plain equality and + /// stays reflexive, so the struct still works as a dictionary key. /// /// - /// A struct, so that default is and no field of this type can ever be - /// null. A null would be a second way to say "no version" that never - /// gets to see. operator == is deliberately not defined: the only two questions worth asking - /// are and , and they answer differently. + /// A struct, so default is and no field can be null. A null would be a second + /// way to say "no version" that never sees. operator == is left undefined on + /// purpose: the only two questions worth asking are and . /// /// [DebuggerDisplay("{validator ?? \"None\",nq}")] @@ -36,16 +36,13 @@ namespace ServiceControl.Persistence.Infrastructure public bool HasValue => validator is not null; /// - /// Whether this version promises byte equivalence, which decides whether it goes on the wire - /// marked weak. Only can promise it. Not part of , - /// because RFC 9110 requires If-None-Match to compare tags without regard to strength. + /// Whether this promises the bytes are identical, which decides if it goes out marked weak. Only + /// can promise it. ignores it, because RFC 9110 says + /// If-None-Match compares tags without regard to strength. /// public bool IsStrong => strong; - /// - /// A version the backend produced itself. - /// Weak: the backend computed it over a result set, not over the bytes of a representation. - /// + /// A version the backend made itself. Weak: it covers a result set, not the response bytes. public static DataVersion FromToken(string token) => string.IsNullOrEmpty(token) ? None : new DataVersion(token); @@ -53,18 +50,16 @@ public static DataVersion FromToken(long token) => new(token.ToString(CultureInfo.InvariantCulture)); /// - /// A backend token that moves if and only if the bytes of the representation move, so the - /// entity-tag can go out unmarked. Only the caller can know this holds, so only use it where - /// it demonstrably does. + /// A backend token that moves only when the response bytes move, so the tag goes out unmarked. Only + /// the caller can know that holds, so only use it where it demonstrably does. /// public static DataVersion FromContent(string token) => string.IsNullOrEmpty(token) ? None : new DataVersion(token, strong: true); /// - /// A version derived from aggregates over the query that produced the page. The terms must be - /// a function of every field the response exposes, computed over the same filtered set, or a - /// change to an unnamed field leaves a client holding a page this version claims is current. - /// Always weak: a summary of aggregates cannot promise byte equivalence. + /// A version built from aggregates over the query behind the page. Name every field the response + /// shows, measured over the same filtered set, or a change to an unnamed one leaves a client holding + /// a stale page. Always weak: a summary of aggregates cannot promise the bytes. /// public static DataVersion Compose(params (string Name, object Value)[] terms) => terms is null || terms.Length == 0 @@ -72,8 +67,8 @@ public static DataVersion Compose(params (string Name, object Value)[] terms) => : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString()); /// - /// One version for a result gathered from several instances. Absent anywhere is absent overall. - /// Always weak, whatever went into it: it goes through . + /// One version for a result gathered from several instances. Missing anywhere means missing overall. + /// Always weak, whatever went in, since it goes through . /// public static DataVersion Combine(IEnumerable versions) { @@ -101,8 +96,8 @@ public static DataVersion Combine(IEnumerable versions) } /// - /// A validator a client echoed back, in any shape a current or older instance might send. - /// Never trusted for anything but matching. + /// A validator a client sent back, in any shape an old or current instance might use. Only ever + /// trusted for matching. /// public static DataVersion FromClient(string headerValue) { @@ -118,8 +113,8 @@ public static DataVersion FromClient(string headerValue) value = value[2..]; } - // Trimming every quote instead would turn a malformed header into a truncated value - // rather than into the cache miss it should be. + // 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]; @@ -129,17 +124,17 @@ public static DataVersion FromClient(string headerValue) } /// - /// Whether a caller holding already holds this version. The only - /// question a store or a conditional-request filter should ask. Ignores , - /// because RFC 9110 requires If-None-Match to use the weak comparison function, and - /// because a version round-tripped through has lost its marking anyway. + /// Whether a caller holding already has this version. The only question a + /// store or a conditional-request filter should ask. Ignores : RFC 9110 requires + /// the weak comparison, and a version that came back through has lost its + /// marking anyway. /// public bool Matches(DataVersion other) => HasValue && other.HasValue && string.Equals(validator, other.validator, StringComparison.Ordinal); /// - /// Ordinary value equality, including . Never use it to decide whether - /// something was modified: it is reflexive, so equals . + /// Plain value equality, marking included. Never use it to decide whether something changed: it is + /// reflexive, so equals . /// public bool Equals(DataVersion other) => strong == other.strong && string.Equals(validator, other.validator, StringComparison.Ordinal); @@ -148,7 +143,7 @@ public bool Equals(DataVersion other) => public override int GetHashCode() => HashCode.Combine(validator?.GetHashCode(StringComparison.Ordinal) ?? 0, strong); - /// The validator without entity-tag quoting, or an empty string for . + /// The validator unquoted, or an empty string for . public override string ToString() => validator ?? string.Empty; static string Describe((string Name, object Value)[] terms) => diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index b926943fb7..6509b01ad5 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -29,8 +29,7 @@ public void None_never_matches_a_real_version() [Test] public void Equality_stays_reflexive_so_the_struct_is_safe_in_collections() { - // `Matches` carries the cache rule. `Equals` must not, or IEquatable is violated and any - // dictionary or Distinct over DataVersion misbehaves. + // 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); } @@ -167,8 +166,7 @@ public void FromClient_treats_a_blank_validator_as_absent(string headerValue) [Test] public void FromClient_leaves_a_malformed_validator_alone_rather_than_truncating_it() { - // Trimming every quote would turn a malformed header into a truncated value that could - // accidentally match, rather than into the cache miss it should be. + // Stripping every quote would truncate this into something that might match by accident. Assert.That(DataVersion.FromClient("\"abc").ToString(), Is.EqualTo("\"abc")); } @@ -202,9 +200,8 @@ public void Composing_and_combining_are_never_exact() [Test] public void Matching_ignores_the_marking() { - // RFC 9110 requires If-None-Match to use the weak comparison function, under which the - // marking is not part of the test. A client that revalidates gets its version back through - // FromClient, which cannot know the marking, so this is the ordinary case not an edge one. + // RFC 9110 requires the weak comparison, which ignores the marking. Anything coming back through + // FromClient has lost its marking anyway, so this is the normal case and not an edge one. Assert.That(DataVersion.FromContent("cv-1").Matches(DataVersion.FromClient("W/\"cv-1\"")), Is.True); } diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 0a32a14a9b..5c76b4638c 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -60,16 +60,6 @@ public void The_emitted_etag_is_a_well_formed_entity_tag() "an ETag that cannot be parsed as an entity-tag disables conditional GET without any error"); } - [Test] - public void A_deterministic_etag_is_a_well_formed_entity_tag() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Response.WithDeterministicEtag(DataVersion.FromToken("any-non-empty-payload-signature")); - - Assert.That(httpContext.Response.GetTypedHeaders().ETag, Is.Not.Null); - } - [Test] public void The_emitted_etag_quotes_the_value_without_altering_it() { @@ -87,7 +77,7 @@ public void A_data_version_emits_the_same_header_the_string_overload_did() httpContext.Response.WithEtag(DataVersion.FromToken("A:2-abc")); - // The marking arrives in a later task. This one only changes who holds the value. + // Marking comes later. This change only moves who holds the value. Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"A:2-abc\"")); } @@ -102,6 +92,20 @@ public void An_absent_data_version_emits_no_header() "an empty entity-tag is well formed, so it would match itself and answer 304 for unrelated payloads"); } + [Test] + 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.WithQueryStatsAndPagingInfo( + new QueryStatsInfo(version, totalCount: 1, isStale: false), + new PagingInfo()); + + // A hashed validator matches nothing a store holds, so the endpoint can never skip its query. + Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString())); + } + static ResultExecutingContext ResultExecuting(HttpContext httpContext) => new( new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), 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..030266ad4c --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -0,0 +1,239 @@ +namespace ServiceControl.UnitTests.Operations +{ + using System; + using NUnit.Framework; + 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 = EtagHelper.VersionOf(data); + + group.Id = "new"; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.Count = 2; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.OperationStatus = RetryState.Preparing.ToString(); + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void RetryProgress_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.OperationProgress = 0.01; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void RetryStartTime_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.OperationStartTime = DateTime.UtcNow; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void RetryCompletionTime_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.OperationCompletionTime = DateTime.UtcNow; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void NeedUserAcknowledgement_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.NeedUserAcknowledgement = true; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.Comment = "after"; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.Title = "after"; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.Type = "after"; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void First_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.First = DateTime.UtcNow; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void Last_changed_should_change_version() + { + var group = new GroupOperation(); + var data = new[] { group }; + + var knownVersion = EtagHelper.VersionOf(data); + + group.Last = DateTime.UtcNow; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.OperationFailed = true; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.OperationMessagesCompletedCount = 2; + + Assert.That(EtagHelper.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 = EtagHelper.VersionOf(data); + + group.OperationRemainingCount = 1; + + Assert.That(EtagHelper.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 = EtagHelper.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(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void Changing_item_count_should_change_version() + { + var emptyVersion = EtagHelper.VersionOf(Array.Empty()); + + var oneGroup = new[] { new GroupOperation() }; + + Assert.That(EtagHelper.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"); + } + } +} diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index 4007580a15..171110e7e8 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -20,8 +20,8 @@ interface IApi; // Non-generic, so statics live once rather than once per closed generic instantiation. public abstract class ScatterGatherApiBase { - // Read raw rather than through headers.ETag. An instance predating the quoted validator - // sends a bare token, which EntityTagHeaderValue fails to parse and discards silently. + // Read raw, not via headers.ETag: an older instance sends an unquoted tag that + // EntityTagHeaderValue cannot parse and drops without a word. internal static DataVersion ReadEtag(HttpResponseHeaders headers) => headers.TryGetValues("ETag", out var values) ? DataVersion.FromClient(values.FirstOrDefault()) diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs index b3e88e09f8..dcb4719231 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs @@ -8,12 +8,6 @@ static class HttpRequestExtensions { /// /// The version the caller already holds, or if it holds none. - /// - /// Only meaningful for an endpoint that publishes its version through - /// . An endpoint publishing through - /// WithDeterministicEtag hashes the validator on the way out, so what a client echoes - /// back cannot be compared with anything a store holds and this would never match. - /// /// public static DataVersion GetKnownVersion(this HttpRequest request) => DataVersion.FromClient(request.Headers.IfNoneMatch.FirstOrDefault()); diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index cd6e482c27..de27730190 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -20,8 +20,8 @@ public static void WithEtag(this HttpResponse response, DataVersion version) return; } - // RFC 9110 requires an entity-tag to be a quoted string. Unquoted, EntityTagHeaderValue - // cannot parse it and NotModifiedStatusHttpHandler never matches a client's If-None-Match. + // Quotes are required by RFC 9110. Without them EntityTagHeaderValue cannot parse the tag and + // NotModifiedStatusHttpHandler never matches a client's If-None-Match. response.Headers.ETag = $"\"{version}\""; } @@ -31,16 +31,6 @@ public static void WithQueryStatsInfo(this HttpResponse response, QueryStatsInfo response.WithEtag(queryStatsInfo.Version); } - public static void WithDeterministicEtag(this HttpResponse response, DataVersion version) - { - if (!version.HasValue) - { - return; - } - - response.WithEtag(DataVersion.FromToken(DeterministicGuid.MakeId(version.ToString()).ToString())); - } - static void WithHeader(this HttpResponse response, string header, StringValues value) => response.Headers.Append(header, value); public static void WithPagingLinks(this HttpResponse response, PagingInfo pageInfo, long highestTotalCountOfAllInstances, long totalResults) @@ -100,7 +90,7 @@ static void AddLink(ICollection links, int page, string rel, string uriP public static void WithQueryStatsAndPagingInfo(this HttpResponse response, QueryStatsInfo queryStats, PagingInfo pagingInfo) { response.WithPagingLinksAndTotalCount(pagingInfo, queryStats.TotalCount, queryStats.HighestTotalCountOfAllTheInstances); - response.WithDeterministicEtag(queryStats.Version); + response.WithEtag(queryStats.Version); } public static void WithPagingLinksAndTotalCount(this HttpResponse response, diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index 6ad88d440c..f739638c2b 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -11,7 +11,6 @@ namespace ServiceControl.MessageFailures.Api using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; - using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; [ApiController] @@ -49,11 +48,11 @@ await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.Err [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace", CancellationToken cancellationToken = default) { - var results = await dataStore.GetArchivedGroupsByClassifier(classifier, cancellationToken); + var result = await dataStore.GetArchivedGroupsByClassifier(classifier, cancellationToken); - Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(results))); + Response.WithEtag(result.QueryStats.Version); - return Ok(results); + return Ok(result.Results); } [Authorize(Policy = Permissions.ErrorMessagesArchive)] diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index 75a5e98356..d224519c42 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -171,7 +171,7 @@ public async Task CountRedirects(CancellationToken cancellationToken = default) { var redirects = await store.GetRedirects(cancellationToken); - Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(redirects))); + Response.WithEtag(EtagHelper.VersionOf(redirects)); Response.WithTotalCount(redirects.Count); } @@ -193,7 +193,7 @@ public async Task> Redirects(string sort, stri r.LastModified )); - Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(redirects))); + Response.WithEtag(EtagHelper.VersionOf(redirects)); Response.WithPagingLinksAndTotalCount(pagingInfo, redirects.Count); return queryResult; diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 337e9de598..3681d3db48 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -68,8 +68,8 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf { var knownEndpoints = monitoring.GetKnownEndpoints(); - // No version: the monitored-endpoints list is assembled in the controller from several sources, - // none of which exposes an aggregate that moves when one of them does. + // No version: this list is stitched together here from sources with no shared count or + // timestamp to watch. Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count, isStale: false), pagingInfo); return knownEndpoints; } diff --git a/src/ServiceControl/Recoverability/API/EtagHelper.cs b/src/ServiceControl/Recoverability/API/EtagHelper.cs index addd49b1ea..8b154087a4 100644 --- a/src/ServiceControl/Recoverability/API/EtagHelper.cs +++ b/src/ServiceControl/Recoverability/API/EtagHelper.cs @@ -1,55 +1,32 @@ +using System; using System.Collections.Generic; -using System.Text; +using System.Linq; +using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.MessageRedirects; -using ServiceControl.Recoverability; +/// +/// Versions for responses built in a controller, where there are no rows to count and no timestamp to read. +/// Every property the response shows has to be named below, or a client keeps a page that has since changed. +/// Invariant, or the double renders as 0,01 on some machines. Ticks, to match what DataVersion does. +/// static class EtagHelper { - internal static string CalculateEtag(IReadOnlyList redirects) - { - if (redirects.Count == 0) - { - return string.Empty; - } - - var data = new StringBuilder(); - foreach (var redirect in redirects) - { - data.Append($"{redirect.MessageRedirectId}.{redirect.ToPhysicalAddress}.{redirect.LastModified.Ticks}"); - } - - return data.ToString(); - } - - public static string CalculateEtag(GroupOperation[] groups) - { - if (groups.Length == 0) - { - return string.Empty; - } - - var data = new StringBuilder(); - foreach (var g in groups) - { - data.Append($"{g.Id}.{g.Count}.{g.OperationStatus}.{g.OperationProgress}.{g.OperationStartTime}.{g.OperationCompletionTime}.{g.NeedUserAcknowledgement}.{g.Comment}"); - } - - return data.ToString(); - } - - internal static string CalculateEtag(IList results) - { - if (results.Count == 0) - { - return string.Empty; - } - - var data = new StringBuilder(); - foreach (var g in results) - { - data.Append($"{g.Id}.{g.Count}"); - } - - return data.ToString(); - } -} \ No newline at end of file + /// + /// All properties. OperationProgress arrives already rounded to two decimals, so on a big retry a + /// message completing moves only the two counters. + /// + internal static DataVersion VersionOf(GroupOperation[] groups) => + DataVersion.Compose( + ("groups", groups.Length), + ("state", string.Join("|", groups.Select(group => FormattableString.Invariant( + $"{group.Id}.{group.Title}.{group.Type}.{group.Count}.{group.Comment}.{group.First?.Ticks}.{group.Last?.Ticks}.{group.OperationStatus}.{group.OperationFailed}.{group.OperationProgress}.{group.OperationMessagesCompletedCount}.{group.OperationRemainingCount}.{group.OperationStartTime?.Ticks}.{group.OperationCompletionTime?.Ticks}.{group.NeedUserAcknowledgement}"))))); + + /// + /// FromPhysicalAddress is not named because MessageRedirectId is derived from it. + /// + internal static DataVersion VersionOf(IReadOnlyList redirects) => + DataVersion.Compose( + ("redirects", redirects.Count), + ("state", string.Join("|", redirects.Select(redirect => FormattableString.Invariant( + $"{redirect.MessageRedirectId}.{redirect.ToPhysicalAddress}.{redirect.LastModified.Ticks}"))))); +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index 2687e59a80..ddb72cac5f 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -67,7 +67,7 @@ public async Task GetAllGroups(string classifier = "Exception } var results = await fetcher.GetGroups(classifier, classifierFilter, cancellationToken); - Response.WithDeterministicEtag(DataVersion.FromToken(EtagHelper.CalculateEtag(results))); + Response.WithEtag(EtagHelper.VersionOf(results)); return results; } @@ -100,7 +100,7 @@ public async Task GetRetryHistory(CancellationToken cancellationTo { var retryHistory = await retryStore.GetRetryHistory(cancellationToken); - Response.WithDeterministicEtag(DataVersion.FromToken(retryHistory.GetHistoryOperationsUniqueIdentifier())); + Response.WithEtag(DataVersion.Compose(("operations", retryHistory.GetHistoryOperationsUniqueIdentifier()))); return retryHistory; } From bf13604c29f8084c9a9f69a74ad7614b707eb4b4 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Sat, 8 Aug 2026 14:59:11 +0800 Subject: [PATCH 05/36] Make the ingestion and clock test helpers available to every backend --- .../PersistenceTestsContext.cs | 1 + .../PersistenceTestsContext.cs | 2 + .../PersistenceTestsContext.cs | 6 +++ .../PersistenceTestsContext.cs | 2 + .../BodyStorage/IngestionClockTests.cs | 31 +++++++++++++++ .../EFCore/ErrorIngestionTestBase.cs | 31 +-------------- .../IPersistenceTestsContext.cs | 6 +++ .../IngestionTestBase.cs | 39 +++++++++++++++++++ .../FailedMessageQueryAfterIngestionTests.cs | 22 +++-------- .../PersistenceTestBase.cs | 2 + 10 files changed, 96 insertions(+), 46 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs create mode 100644 src/ServiceControl.Persistence.Tests/IngestionTestBase.cs diff --git a/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs index e830dd8bbd..3f4a82dd7a 100644 --- a/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs @@ -11,6 +11,7 @@ public class PersistenceTestsContext : IPersistenceTestsContext public PersistenceSettings PersistenceSettings { get; private set; } public string GenerateFailedMessageRecordId(string messageId) => throw new System.NotImplementedException(); public Task InsertFailedMessages(params FailedMessage[] messages) => throw new System.NotImplementedException(); + public void AdvanceClock(System.TimeSpan by) => throw new System.NotImplementedException(); public Task Setup(IHostApplicationBuilder hostBuilder) { diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs index 30bc5ae750..9fccdd71d6 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs @@ -21,6 +21,8 @@ public partial class PersistenceTestsContext : IPersistenceTestsContext string databaseName; string bodyStoragePath; + public void AdvanceClock(TimeSpan by) => FakeTime.Advance(by); + public async Task Setup(IHostApplicationBuilder hostBuilder) { databaseName = $"sc_test_{Guid.NewGuid():n}"; diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs index 13ae79e717..b973581ef9 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs @@ -81,6 +81,12 @@ public async Task InsertFailedMessages(params FailedMessage[] messages) public IRavenSessionProvider SessionProvider { get; private set; } + // Nothing to do: Raven versions come from document and index etags, which move on every write, so + // no test needs to push its clock. There is no hook for the server clock anyway. + public void AdvanceClock(TimeSpan by) + { + } + public Task CompleteDatabaseOperation() { DocumentStore.WaitForIndexing(); diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs index 5ebccb8177..e1ba34cf34 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs @@ -20,6 +20,8 @@ public partial class PersistenceTestsContext : IPersistenceTestsContext string databaseName; string bodyStoragePath; + public void AdvanceClock(TimeSpan by) => FakeTime.Advance(by); + public async Task Setup(IHostApplicationBuilder hostBuilder) { databaseName = $"sc_test_{Guid.NewGuid():n}"; diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs new file mode 100644 index 0000000000..691713c14e --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs @@ -0,0 +1,31 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; + +[TestFixture] +class IngestionClockTests : IngestionTestBase +{ + [Test] + public async Task A_re_ingested_message_moves_the_version() + { + var failure = new IngestedFailure(); + + await Ingest(failure); + await CompleteDatabaseOperation(); + + var before = (await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null)).Version; + + AdvanceClock(TimeSpan.FromMinutes(5)); + + await Ingest(failure.NextAttempt(failure.AttemptedAt.AddMinutes(5))); + await CompleteDatabaseOperation(); + + var after = (await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null)).Version; + + // The EF clock is frozen, so without AdvanceClock a second attempt at the same message leaves + // both the count and LastModified alone and the version cannot move. + Assert.That(after.Matches(before), Is.False); + } +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs b/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs index e269c94761..24eaa2b615 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs @@ -14,7 +14,7 @@ namespace ServiceControl.Persistence.Tests; using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.UnitOfWork; -abstract class ErrorIngestionTestBase : PersistenceTestBase +abstract class ErrorIngestionTestBase : IngestionTestBase { protected ErrorIngestionTestBase() => RegisterServices = services => services.AddSingleton(RecordedBodies); @@ -23,35 +23,6 @@ protected ErrorIngestionTestBase() => protected EFPersisterSettings EFSettings => (EFPersisterSettings)PersistenceSettings; - protected void AdvanceClock(TimeSpan by) => PersistenceTestsContext.FakeTime.Advance(by); - - protected async Task InBatch(Func record) - { - await using var unitOfWork = await UnitOfWorkFactory.StartNew(); - - await record(unitOfWork); - - await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken); - } - - protected Task Ingest(params IngestedFailure[] failures) => - InBatch(async unitOfWork => - { - foreach (var failure in failures) - { - await unitOfWork.Recoverability.RecordFailedProcessingAttempt(failure.Context, failure.ProcessingAttempt, failure.Groups); - } - }); - - protected Task ConfirmRetry(params string[] uniqueMessageIds) => - InBatch(async unitOfWork => - { - foreach (var uniqueMessageId in uniqueMessageIds) - { - await unitOfWork.Recoverability.RecordSuccessfulRetry(uniqueMessageId); - } - }); - protected async Task GetFailedMessage(Guid uniqueMessageId) { var row = await Query(dbContext => dbContext.FailedMessages.AsNoTracking().SingleOrDefaultAsync(m => m.UniqueMessageId == uniqueMessageId)); diff --git a/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs index 4128de9df9..886c86a0b4 100644 --- a/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs @@ -1,6 +1,7 @@ #nullable enable namespace ServiceControl.Persistence.Tests; +using System; using System.Threading.Tasks; using MessageFailures; using Microsoft.Extensions.Hosting; @@ -15,6 +16,11 @@ public interface IPersistenceTestsContext Task CompleteDatabaseOperation(); + /// + /// Move the clock the persister stamps its own timestamps from + /// + void AdvanceClock(TimeSpan by); + PersistenceSettings PersistenceSettings { get; } string GenerateFailedMessageRecordId(string messageId); diff --git a/src/ServiceControl.Persistence.Tests/IngestionTestBase.cs b/src/ServiceControl.Persistence.Tests/IngestionTestBase.cs new file mode 100644 index 0000000000..611381c938 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/IngestionTestBase.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.UnitOfWork; + +/// +/// Writes failures through the real ingestion path. +/// +abstract class IngestionTestBase : PersistenceTestBase +{ + protected async Task InBatch(Func record) + { + await using var unitOfWork = await UnitOfWorkFactory.StartNew(); + + await record(unitOfWork); + + await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken); + } + + protected Task Ingest(params IngestedFailure[] failures) => + InBatch(async unitOfWork => + { + foreach (var failure in failures) + { + await unitOfWork.Recoverability.RecordFailedProcessingAttempt(failure.Context, failure.ProcessingAttempt, failure.Groups); + } + }); + + protected Task ConfirmRetry(params string[] uniqueMessageIds) => + InBatch(async unitOfWork => + { + foreach (var uniqueMessageId in uniqueMessageIds) + { + await unitOfWork.Recoverability.RecordSuccessfulRetry(uniqueMessageId); + } + }); +} diff --git a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryAfterIngestionTests.cs b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryAfterIngestionTests.cs index 44779e7deb..1b2ac294f4 100644 --- a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryAfterIngestionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryAfterIngestionTests.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.Tests; using ServiceControl.MessageFailures; using ServiceControl.Persistence.Infrastructure; -class FailedMessageQueryAfterIngestionTests : PersistenceTestBase +class FailedMessageQueryAfterIngestionTests : IngestionTestBase { [Test] public async Task Ingested_failure_is_returned_by_the_query_store() @@ -18,6 +18,7 @@ public async Task Ingested_failure_is_returned_by_the_query_store() }; await Ingest(failure); + await CompleteDatabaseOperation(); var result = await FailedMessageQueryStore.GetFailedMessages(null, null, null, new PagingInfo(), new SortInfo()); @@ -47,6 +48,7 @@ public async Task Ingestion_stores_the_failing_endpoint_address() var failure = new IngestedFailure { FailingEndpointAddress = "Sales@MACHINE" }; await Ingest(failure); + await CompleteDatabaseOperation(); var view = await FailedMessageQueryStore.GetLatestFailedMessageView(failure.UniqueMessageIdString); @@ -60,6 +62,7 @@ public async Task Ingested_failure_can_be_filtered_by_its_failing_endpoint_addre var other = new IngestedFailure { FailingEndpointAddress = "Billing@MACHINE" }; await Ingest(matching, other); + await CompleteDatabaseOperation(); var result = await FailedMessageQueryStore.GetFailedMessages(null, null, "Sales@MACHINE", new PagingInfo(), new SortInfo()); @@ -74,6 +77,7 @@ public async Task Ingested_failure_is_returned_by_id() var failure = new IngestedFailure(); await Ingest(failure); + await CompleteDatabaseOperation(); var message = await FailedMessageQueryStore.GetFailedMessage(failure.UniqueMessageIdString); @@ -97,6 +101,7 @@ public async Task Repeated_failures_are_counted_as_attempts() await Ingest(failure); await Ingest(secondAttempt); + await CompleteDatabaseOperation(); var view = await FailedMessageQueryStore.GetLatestFailedMessageView(failure.UniqueMessageIdString); var message = await FailedMessageQueryStore.GetFailedMessage(failure.UniqueMessageIdString); @@ -107,19 +112,4 @@ public async Task Repeated_failures_are_counted_as_attempts() Assert.That(message.ProcessingAttempts, Has.Count.EqualTo(2)); } } - - async Task Ingest(params IngestedFailure[] failures) - { - await using (var unitOfWork = await UnitOfWorkFactory.StartNew()) - { - foreach (var failure in failures) - { - await unitOfWork.Recoverability.RecordFailedProcessingAttempt(failure.Context, failure.ProcessingAttempt, failure.Groups); - } - - await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken); - } - - await CompleteDatabaseOperation(); - } } diff --git a/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs b/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs index d32c6db6a2..bd36998a56 100644 --- a/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs +++ b/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs @@ -83,6 +83,8 @@ protected async Task SeedFailedMessage(FailedMessage failedMessag return failedMessage; } + protected void AdvanceClock(TimeSpan by) => PersistenceTestsContext.AdvanceClock(by); + protected static async Task WaitUntil(Func> conditionChecker, string condition, TimeSpan timeout = default) { timeout = timeout == default ? TimeSpan.FromSeconds(10) : timeout; From d788c9a1e91539e00d7e60248cf8767d6bac0b7e Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 18 Aug 2026 10:53:09 +0800 Subject: [PATCH 06/36] Fix merge issues --- .../Implementation/BodyStorage/BodyStorage.cs | 1 - .../Implementation/CustomCheckDataStore.cs | 2 +- .../RavenAttachmentsBodyStorage.cs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs index 9f9428f111..bf5123f32d 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs @@ -9,7 +9,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage; using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.Infrastructure; -using ServiceControl.Persistence.Infrastructure; /// /// Resolves a message body from wherever it was stored. diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index e952e89073..697badd4e5 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -65,6 +65,7 @@ public Task>> GetStats(PagingInfo paging, string? .Take(paging.PageSize) .ToListAsync(token); + // No version: this store has no aggregate that moves when a check's status does. return new QueryResult>(page.Select(c => new CustomCheck { Id = c.Id.ToString(), @@ -73,7 +74,6 @@ public Task>> GetStats(PagingInfo paging, string? Status = c.Status, ReportedAt = c.ReportedAt, FailureReason = c.FailureReason - // No version: this store has no aggregate that moves when a check's status does. }).ToList(), new QueryStatsInfo(DataVersion.None, page.Count, false)); }, cancellationToken); diff --git a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs index e796395971..98772b9496 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using Persistence.Infrastructure; - using Persistence.Infrastructure; using Persistence.RavenDB; using Raven.Client.Documents; using Raven.Client.Documents.Session; From a8bc1fcd219b4f528b9a2da18e785afa3c2d451c Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 18 Aug 2026 15:39:25 +0800 Subject: [PATCH 07/36] Move the message body version when the stored body is replaced --- .../Implementation/BodyStorage/BodyStorage.cs | 15 ++- .../RavenAttachmentsBodyStorage.cs | 3 +- .../BodyStorage/BodyVersionTests.cs | 99 +++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs index bf5123f32d..e15fab509c 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs @@ -29,9 +29,14 @@ 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); @@ -47,7 +52,7 @@ public async Task TryFetch(string bodyId, CancellationToken c return MessageBodyResult.Empty(); } - return MessageBodyResult.Available(new MessageBodyStreamContent(external.Stream, external.ContentType, external.BodySize, DataVersion.FromToken(uniqueMessageId))); + return MessageBodyResult.Available(new MessageBodyStreamContent(external.Stream, external.ContentType, external.BodySize, version)); } if (row.BodyText != null) @@ -63,7 +68,7 @@ public async Task TryFetch(string bodyId, CancellationToken c new MemoryStream(bytes, writable: false), row.BodyContentType ?? "text/plain", bytes.Length, - DataVersion.FromToken(uniqueMessageId))); + version)); } if (row.BodySize == 0) @@ -99,7 +104,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); @@ -110,5 +116,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.RavenDB/RavenAttachmentsBodyStorage.cs b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs index 98772b9496..7b96afeed3 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs @@ -73,7 +73,8 @@ async Task ResultForUniqueId(IAsyncDocumentSession session, s result.Stream, result.Details.ContentType, (int)result.Details.Size, - DataVersion.FromToken(result.Details.ChangeVector))); + // The change vector moves whenever the stored bytes do. + DataVersion.FromContent(result.Details.ChangeVector))); } } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs new file mode 100644 index 0000000000..6a8a75e576 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs @@ -0,0 +1,99 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Operations.BodyStorage; +using ServiceControl.Persistence.Infrastructure; + +[TestFixture] +class BodyVersionTests : IngestionTestBase +{ + static readonly DateTime FirstAttempt = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Version_changes_when_a_later_attempt_carries_a_different_body() + { + var first = Failure("the original body", FirstAttempt); + + await Ingest(first); + await CompleteDatabaseOperation(); + + var (originalBody, before) = await Fetch(first.UniqueMessageIdString); + + // The same message fails again with a different body. Ingestion updates the existing row + // rather than adding one, so the message id is unchanged and cannot serve as a version. + AdvanceClock(TimeSpan.FromHours(8)); + await Ingest(Failure("a completely different body", FirstAttempt.AddHours(8), first)); + await CompleteDatabaseOperation(); + + var (replacedBody, after) = await Fetch(first.UniqueMessageIdString); + + Assert.Multiple(() => + { + Assert.That(originalBody, Is.EqualTo("the original body")); + Assert.That(replacedBody, Is.EqualTo("a completely different body"), "the stored body was replaced"); + Assert.That(after.Matches(before), Is.False, + "the body changed, so a client holding the old version must be sent the new body rather than a 304"); + }); + } + + [Test] + public async Task Version_is_stable_while_the_body_is_not_rewritten() + { + var failure = Failure("the original body", FirstAttempt); + + await Ingest(failure); + await CompleteDatabaseOperation(); + + var (_, first) = await Fetch(failure.UniqueMessageIdString); + var (_, second) = await Fetch(failure.UniqueMessageIdString); + + Assert.That(second.Matches(first), Is.True, + "two reads with no write between them must let a client revalidate successfully"); + } + + [Test] + public async Task A_message_with_no_body_reads_back_as_empty_on_every_backend() + { + var failure = Failure(string.Empty, FirstAttempt); + + await Ingest(failure); + await CompleteDatabaseOperation(); + + var result = await BodyStorage.TryFetch(failure.UniqueMessageIdString); + + // Empty carries no content and so no version, whatever this task does. What is worth pinning + // is that all persistence seams agree it is Empty rather than NotFound or Unavailable, which is + // what decides whether the caller gets a no-body response or a 404. + Assert.That(result.State, Is.EqualTo(MessageBodyState.Empty)); + } + + async Task<(string Body, DataVersion Version)> Fetch(string bodyId) + { + var result = await BodyStorage.TryFetch(bodyId); + + Assert.That(result.State, Is.EqualTo(MessageBodyState.Available)); + + await using var stream = result.Content.Stream; + using var reader = new StreamReader(stream, Encoding.UTF8); + + return (await reader.ReadToEndAsync(), result.Content.Version); + } + + static IngestedFailure Failure(string body, DateTime attemptedAt, IngestedFailure sameMessageAs = null) + { + var identity = sameMessageAs ?? new IngestedFailure(); + + return new IngestedFailure + { + MessageId = identity.MessageId, + EndpointName = identity.EndpointName, + Body = Encoding.UTF8.GetBytes(body), + AttemptedAt = attemptedAt, + TimeOfFailure = attemptedAt + }; + } +} From 8e32ddb921dba738128cf1591a3a3d6ed0af7b7c Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 18 Aug 2026 16:33:13 +0800 Subject: [PATCH 08/36] Mark derived validators weak and compare them per RFC 9110 --- .../WebApi/ConditionalGetTests.cs | 120 +++++++++++++++--- .../WebApi/HttpResponseExtensions.cs | 5 +- .../WebApi/NotModifiedStatusHttpHandler.cs | 17 ++- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 5c76b4638c..7669e25c00 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -61,49 +61,135 @@ public void The_emitted_etag_is_a_well_formed_entity_tag() } [Test] - public void The_emitted_etag_quotes_the_value_without_altering_it() + public void An_absent_data_version_emits_no_header() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Response.WithEtag(DataVersion.None); + + 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 A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it() + { + var httpContext = new DefaultHttpContext(); + var version = DataVersion.FromToken("4611686018427387904"); + + httpContext.Response.WithQueryStatsAndPagingInfo( + new QueryStatsInfo(version, totalCount: 1, isStale: false), + new PagingInfo()); + + // A hashed validator matches nothing a store holds, so the endpoint can never skip its query. + Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString())); + } + + [Test] + public void An_aggregate_derived_etag_is_marked_weak() { var httpContext = new DefaultHttpContext(); httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904")); - Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"4611686018427387904\"")); + Assert.Multiple(() => + { + Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("W/\"4611686018427387904\"")); + Assert.That(httpContext.Response.GetTypedHeaders().ETag.IsWeak, Is.True); + }); } [Test] - public void A_data_version_emits_the_same_header_the_string_overload_did() + public void An_exact_etag_goes_out_unmarked() { var httpContext = new DefaultHttpContext(); - httpContext.Response.WithEtag(DataVersion.FromToken("A:2-abc")); + httpContext.Response.WithEtag(DataVersion.FromContent("A:2-abc")); - // Marking comes later. This change only moves who holds the value. - Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"A:2-abc\"")); + Assert.Multiple(() => + { + Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"A:2-abc\"")); + Assert.That(httpContext.Response.GetTypedHeaders().ETag.IsWeak, Is.False); + }); } [Test] - public void An_absent_data_version_emits_no_header() + public void A_client_holding_a_weak_tag_matches_an_exact_response_tag() { var httpContext = new DefaultHttpContext(); - httpContext.Response.WithEtag(DataVersion.None); + httpContext.Response.WithEtag(DataVersion.FromContent("A:2-abc")); + httpContext.Request.Headers.IfNoneMatch = "W/\"A:2-abc\""; - 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"); + var context = ResultExecuting(httpContext); + + new NotModifiedStatusHttpHandler().OnResultExecuting(context); + + Assert.That(context.Result, Is.InstanceOf(), + "weak comparison ignores strength on both sides, which is what lets an exact and a weak tag over the same value match"); } [Test] - public void A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it() + public void A_weak_validator_matches_under_the_comparison_If_None_Match_requires() { var httpContext = new DefaultHttpContext(); - var version = DataVersion.FromToken("4611686018427387904"); - httpContext.Response.WithQueryStatsAndPagingInfo( - new QueryStatsInfo(version, totalCount: 1, isStale: false), - new PagingInfo()); + httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904")); + httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; - // A hashed validator matches nothing a store holds, so the endpoint can never skip its query. - Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString())); + var context = ResultExecuting(httpContext); + + new NotModifiedStatusHttpHandler().OnResultExecuting(context); + + Assert.That(context.Result, Is.InstanceOf(), + "RFC 9110 requires If-None-Match to use the weak comparison function, so a weak tag must match a weak tag"); + } + + [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/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index de27730190..72623d87b9 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -21,8 +21,9 @@ public static void WithEtag(this HttpResponse response, DataVersion version) } // Quotes are required by RFC 9110. Without them EntityTagHeaderValue cannot parse the tag and - // NotModifiedStatusHttpHandler never matches a client's If-None-Match. - response.Headers.ETag = $"\"{version}\""; + // NotModifiedStatusHttpHandler never matches a client's If-None-Match. Weak unless the + // producing mechanism moves with the stored bytes. + response.Headers.ETag = version.IsStrong ? $"\"{version}\"" : $"W/\"{version}\""; } public static void WithQueryStatsInfo(this HttpResponse response, QueryStatsInfo queryStatsInfo) diff --git a/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs b/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs index 6554f17844..aa6fac69f1 100644 --- a/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs +++ b/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Infrastructure.WebApi { using System; + using System.Linq; using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Headers; @@ -9,8 +10,20 @@ class NotModifiedStatusHttpHandler : IResultFilter { - static bool IfNoneMatch(RequestHeaders requestHeaders, ResponseHeaders responseHeaders) => - responseHeaders.ETag != null && requestHeaders.IfNoneMatch.Contains(responseHeaders.ETag); + static bool IfNoneMatch(RequestHeaders requestHeaders, ResponseHeaders responseHeaders) + { + var current = responseHeaders.ETag; + + if (current is null) + { + return false; + } + + // EntityTagHeaderValue.Equals compares strength as well as tag, and its own documentation + // says not to use it for this. RFC 9110 requires If-None-Match to use weak comparison. + return requestHeaders.IfNoneMatch.Any(candidate => + candidate.Tag.Equals("*", StringComparison.Ordinal) || candidate.Compare(current, useStrongComparison: false)); + } static bool IfNotModifiedSince(DateTimeOffset? ifModifiedSince, DateTimeOffset? lastModified) => lastModified <= ifModifiedSince; From fc2b93c33e1711c89d5a500d10e1cfeb6a9b985c Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 09:28:52 +0800 Subject: [PATCH 09/36] Derive projected versions from the rows they report on --- .../Implementation/QueueAddressStore.cs | 9 +- .../Infrastructure/FailureGroupQueries.cs | 13 +- .../QueueAddressVersionTests.cs | 113 +++++++++++++++ .../FailureGroupVersionTests.cs | 136 ++++++++++++++++++ 4 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs index 4083fb960d..88f85fe708 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs @@ -20,10 +20,13 @@ public Task>> GetAddresses(PagingInfo pagingInfo }); var items = await query.Skip(pagingInfo.Offset).Take(pagingInfo.PageSize).ToListAsync(token); + var addressCount = await query.CountAsync(token); + + // Both fields of every row the body shows, plus the total, gives a reliable data version. var version = DataVersion.Compose( - ("addresses", items.Count), - ("physicalAddresses", string.Join(",", items.Select(x => x.PhysicalAddress)))); + ("addresses", addressCount), + ("page", string.Join("|", items.Select(address => $"{address.PhysicalAddress}={address.FailedMessageCount}")))); - return new QueryResult>(items, new QueryStatsInfo(version, query.Count(), false)); + return new QueryResult>(items, new QueryStatsInfo(version, addressCount, false)); }, cancellationToken); } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index 906edf7ec2..6acecd2cd1 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -23,10 +23,11 @@ into aggregate 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(DataVersion.Compose(("groups", groups.Count), ("last", latest)), groups.Count, false); - } + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => + new(DataVersion.Compose( + ("groups", groups.Count), + ("state", string.Join("|", groups.Select(group => FormattableString.Invariant( + $"{group.Id}.{group.Title}.{group.Type}.{group.Count}.{group.Comment}.{group.First.Ticks}.{group.Last.Ticks}"))))), + groups.Count, + false); } diff --git a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs new file mode 100644 index 0000000000..336f50bd3f --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs @@ -0,0 +1,113 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.Infrastructure; + +[TestFixture] +class QueueAddressVersionTests : IngestionTestBase +{ + [Test] + public async Task Version_changes_when_a_queue_gains_a_failure_and_the_address_set_does_not() + { + await Ingest(Failure("SomeEndpoint@machine1")); + await CompleteDatabaseOperation(); + + var before = await QueueAddressStore.GetAddresses(new PagingInfo()); + + // A second, different message failing on the SAME queue. The address set is unchanged, so a + // validator built from the addresses alone cannot see this, and the client keeps a stale count. + await Ingest(Failure("SomeEndpoint@machine1")); + await CompleteDatabaseOperation(); + + var after = await QueueAddressStore.GetAddresses(new PagingInfo()); + + Assert.Multiple(() => + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); + Assert.That(after.Results[0].FailedMessageCount, Is.EqualTo(2), "and the body now reports two failures"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too, or a revalidating client is served a stale count"); + }); + } + + [Test] + public async Task Version_changes_when_a_message_moves_to_a_different_queue() + { + var first = Failure("SomeEndpoint@machine1"); + + await Ingest(first); + await CompleteDatabaseOperation(); + + var before = await QueueAddressStore.GetAddresses(new PagingInfo()); + + AdvanceClock(TimeSpan.FromHours(1)); + await Ingest(MovedTo("OtherEndpoint@machine2", first)); + await CompleteDatabaseOperation(); + + var after = await QueueAddressStore.GetAddresses(new PagingInfo()); + + Assert.Multiple(() => + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); + Assert.That(after.Results[0].PhysicalAddress, Is.EqualTo("OtherEndpoint@machine2"), "and it is the new one"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body reports a different address under the same count, so the validator cannot stay put"); + }); + } + + [Test] + public async Task Version_changes_when_a_new_address_appears() + { + await Ingest(Failure("SomeEndpoint@machine1")); + await CompleteDatabaseOperation(); + + var before = await QueueAddressStore.GetAddresses(new PagingInfo()); + + await Ingest(Failure("OtherEndpoint@machine2")); + await CompleteDatabaseOperation(); + + var after = await QueueAddressStore.GetAddresses(new PagingInfo()); + + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + await Ingest(Failure("SomeEndpoint@machine1")); + await CompleteDatabaseOperation(); + + var first = await QueueAddressStore.GetAddresses(new PagingInfo()); + var second = await QueueAddressStore.GetAddresses(new PagingInfo()); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task An_empty_store_still_reports_a_version() + { + var result = await QueueAddressStore.GetAddresses(new PagingInfo()); + + Assert.Multiple(() => + { + Assert.That(result.Results, Is.Empty); + Assert.That(result.QueryStats.Version.HasValue, Is.True, + "an empty list is a representation like any other and has to be cacheable"); + }); + } + + static IngestedFailure Failure(string failingEndpointAddress) => + new() { FailingEndpointAddress = failingEndpointAddress }; + + static IngestedFailure MovedTo(string failingEndpointAddress, IngestedFailure original) => + new() + { + MessageId = original.MessageId, + EndpointName = original.EndpointName, + FailingEndpointAddress = failingEndpointAddress, + AttemptedAt = original.AttemptedAt.AddHours(1), + TimeOfFailure = original.TimeOfFailure.AddHours(1) + }; +} diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs new file mode 100644 index 0000000000..43348340c0 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs @@ -0,0 +1,136 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.MessageFailures; + +[TestFixture] +class FailureGroupVersionTests : PersistenceTestBase +{ + const string Classifier = "Exception Type and Stack Trace"; + + static readonly DateTime Oldest = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + static readonly DateTime Middle = new(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); + static readonly DateTime Newest = new(2026, 8, 1, 17, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Version_changes_when_a_group_loses_a_message_that_is_neither_its_oldest_nor_its_newest() + { + var group = NewGroup(); + var middle = InGroup(group, Middle); + + await Insert(InGroup(group, Oldest), middle, InGroup(group, Newest)); + + var before = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + // Archiving takes the message out of the unresolved group without removing the group, and + // the one chosen is neither the earliest nor the latest, so First and Last both stay put. + // Count is the only thing that moves, and Count is what the body reports. + await FailedMessageLifecycleStore.MarkAsArchived(middle.UniqueMessageIdString); + await CompleteDatabaseOperation(); + + var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + Assert.Multiple(() => + { + Assert.That(before.Results.Count, Is.EqualTo(3), "three messages to start with"); + Assert.That(after.Results.Count, Is.EqualTo(2), "and the body now reports two"); + Assert.That(after.Results.First, Is.EqualTo(before.Results.First), "the earliest failure is unchanged"); + Assert.That(after.Results.Last, Is.EqualTo(before.Results.Last), "and so is the latest"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too, or a revalidating client is served a stale count"); + }); + } + + [Test] + public async Task Version_changes_when_a_group_gains_a_message() + { + var group = NewGroup(); + + await Insert(InGroup(group, Oldest)); + + var before = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + await Insert(InGroup(group, Newest)); + + var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + + [Test] + public async Task Version_changes_when_the_span_of_a_group_moves_but_its_count_does_not() + { + var group = NewGroup(); + var oldest = InGroup(group, Oldest); + + await Insert(oldest, InGroup(group, Middle), InGroup(group, Newest)); + + var before = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + // The count lands exactly where it started, so this is the case that proves First and Last + // are named. A version built from the count alone passes every other case in this fixture. + await FailedMessageLifecycleStore.MarkAsArchived(oldest.UniqueMessageIdString); + await Insert(InGroup(group, Newest.AddHours(4))); + + var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + Assert.Multiple(() => + { + Assert.That(after.Results.Count, Is.EqualTo(before.Results.Count), "still three messages"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "a different span of failures is being reported under the same count"); + }); + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + var group = NewGroup(); + + await Insert(InGroup(group, Oldest)); + + var first = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + var second = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task A_group_that_does_not_exist_still_reports_a_version() + { + var result = await GroupsStore.GetUnresolvedGroup("no-such-group", null, null); + + Assert.Multiple(() => + { + Assert.That(result.Results, Is.Null); + Assert.That(result.QueryStats.Version.HasValue, Is.True); + }); + } + + static FailedMessage.FailureGroup NewGroup() => + new() { Id = Guid.NewGuid().ToString(), Title = "OrderPlaced", Type = Classifier }; + + static IngestedFailure InGroup(FailedMessage.FailureGroup group, DateTime failedAt) => + new() + { + Groups = [group], + AttemptedAt = failedAt, + TimeOfFailure = failedAt, + TimeSent = failedAt.AddMinutes(-1) + }; + + async Task Insert(params IngestedFailure[] failures) + { + var messages = Array.ConvertAll(failures, failure => failure.ToFailedMessage()); + + foreach (var message in messages) + { + message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); + } + + await PersistenceTestsContext.InsertFailedMessages(messages); + await CompleteDatabaseOperation(); + } +} From 5a0fa1d074b0ea49ee5680a6acf5dd5dfb2e10f9 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 10:43:48 +0800 Subject: [PATCH 10/36] - Cover a paged endpoint and the empty case in the conditional GET acceptance test. - Add tests for CustomCheck data versioning --- ...eControl.AcceptanceTests.PostgreSql.csproj | 3 - ...ceControl.AcceptanceTests.SqlServer.csproj | 3 - ...hen_a_request_is_repeated_with_its_etag.cs | 19 +--- .../Implementation/CustomCheckDataStore.cs | 31 ++++--- .../CustomCheckVersionTests.cs | 92 +++++++++++++++++++ .../QueueAddressVersionTests.cs | 1 - 6 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs 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.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..5b1a6bc276 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 @@ -6,14 +6,14 @@ namespace ServiceControl.AcceptanceTests.WebApi 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 +21,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++) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index 697badd4e5..5905542543 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -59,22 +59,31 @@ public Task>> GetStats(PagingInfo paging, string? _ => query }; - var page = await query + var checks = await query .OrderBy(c => c.ReportedAt) .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 + }) .ToListAsync(token); - // No version: this store has no aggregate that moves when a check's status does. - 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(DataVersion.None, page.Count, false)); + var totalCount = await query.CountAsync(token); + + // Every field of every check the body shows, along with the total count provides a reliable + // data version. + var version = DataVersion.Compose( + ("checks", totalCount), + ("page", string.Join("|", checks.Select(check => FormattableString.Invariant( + $"{check.Id}.{check.CustomCheckId}.{check.Category}.{check.Status}.{check.ReportedAt.Ticks}.{check.FailureReason}"))))); + + return new QueryResult>(checks, new QueryStatsInfo(version, totalCount, false)); }, 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.Tests/CustomCheckVersionTests.cs b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs new file mode 100644 index 0000000000..87e8dd1792 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs @@ -0,0 +1,92 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Contracts.CustomChecks; +using NUnit.Framework; +using ServiceControl.Operations; +using ServiceControl.Persistence.Infrastructure; + +[TestFixture] +class CustomCheckVersionTests : PersistenceTestBase +{ + static readonly DateTime ReportedAt = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Version_changes_when_a_check_starts_failing() + { + await Report("Disk space", hasFailed: false); + + var before = await CustomChecks.GetStats(new PagingInfo()); + + await Report("Disk space", hasFailed: true); + + var after = await CustomChecks.GetStats(new PagingInfo()); + + Assert.Multiple(() => + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one check"); + Assert.That(after.Results[0].Status, Is.EqualTo(Status.Fail), "and the body now reports it failing"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too, or a revalidating client is shown a stale status"); + }); + } + + [Test] + public async Task Version_changes_when_a_new_check_appears() + { + await Report("Disk space", hasFailed: false); + + var before = await CustomChecks.GetStats(new PagingInfo()); + + await Report("Queue length", hasFailed: false); + + var after = await CustomChecks.GetStats(new PagingInfo()); + + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + await Report("Disk space", hasFailed: false); + + var first = await CustomChecks.GetStats(new PagingInfo()); + var second = await CustomChecks.GetStats(new PagingInfo()); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task An_empty_store_still_reports_a_version() + { + var result = await CustomChecks.GetStats(new PagingInfo()); + + Assert.Multiple(() => + { + Assert.That(result.Results, Is.Empty); + Assert.That(result.QueryStats.Version.HasValue, Is.True, + "an empty list is a representation like any other and has to be cacheable"); + }); + } + + async Task Report(string customCheckId, bool hasFailed) + { + await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail + { + Category = "test-category", + CustomCheckId = customCheckId, + HasFailed = hasFailed, + FailureReason = hasFailed ? "Testing" : null, + ReportedAt = ReportedAt, + OriginatingEndpoint = new EndpointDetails + { + Host = "localhost", + HostId = Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"), + Name = "test-host" + } + }); + + await CompleteDatabaseOperation(); + } +} diff --git a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs index 336f50bd3f..22c99d8f92 100644 --- a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs @@ -42,7 +42,6 @@ public async Task Version_changes_when_a_message_moves_to_a_different_queue() var before = await QueueAddressStore.GetAddresses(new PagingInfo()); - AdvanceClock(TimeSpan.FromHours(1)); await Ingest(MovedTo("OtherEndpoint@machine2", first)); await CompleteDatabaseOperation(); From 869a04b9251e9446c230541daf0e7633fe0c4f0b Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 12:18:54 +0800 Subject: [PATCH 11/36] - Fix Groups data versioning - Add tests for archived groups data versioning --- .../Implementation/GroupsDataStore.cs | 33 +---- .../EFCore/RetentionSweepTests.cs | 21 +++ .../ArchivedGroupVersionTests.cs | 135 ++++++++++++++++++ .../Api/ArchiveMessagesController.cs | 1 - 4 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs index 947829a2f5..b11014ab4c 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); @@ -33,13 +33,10 @@ public Task>> GetArchivedGroupsByClassifier( ExecuteWithDbContext(async (dbContext, token) => { var groups = ByClassifier(dbContext, classifier); - var messages = WithStatus(dbContext, FailedMessageStatus.Archived); - var views = await MostRecent(groups.AggregateGroups(messages), token); + var views = await MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)), token); - return new QueryResult>( - views, - new QueryStatsInfo(await SourceVersion(groups, messages, token), views.Count, false)); + return new QueryResult>(views, views.ToQueryStatsInfo()); }, cancellationToken); public Task> GetUnresolvedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default) => @@ -127,29 +124,7 @@ static async Task AttachComments(ServiceControlDbContext dbContext, IList SourceVersion(IQueryable groups, IQueryable messages, CancellationToken cancellationToken) - { - var stats = await (from failureGroup in groups - join message in messages on failureGroup.FailedMessageUniqueId equals message.UniqueMessageId - select message) - .GroupBy(_ => 1) - .Select(aggregate => new - { - Count = aggregate.Count(), - First = aggregate.Min(message => (DateTime?)message.FirstTimeOfFailure), - Last = aggregate.Max(message => (DateTime?)message.LastTimeOfFailure) - }) - .SingleOrDefaultAsync(cancellationToken); - - return DataVersion.Compose( - ("messages", stats?.Count ?? 0), - ("first", stats?.First), - ("last", stats?.Last)); - } - - static async Task> 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.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index 33b3715dca..f4ada80119 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -264,6 +264,27 @@ public async Task Sweeping_event_log_items_changes_the_version() } } + [Test] + public async Task Sweeping_failed_messages_changes_the_version() + { + await SeedFailedMessage(FailedMessageStatus.Archived, Now.AddDays(-31)); + await SeedFailedMessage(FailedMessageStatus.Archived, Now.AddDays(-1)); + + var versionBefore = (await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null)).Version; + + await RunRetentionSweep(); + + var after = await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.TotalCount, Is.EqualTo(1)); + // A sweep is the only thing that takes a row away without touching the newest LastModified, + // so nothing else keeps the count term of this version honest. + Assert.That(after.Version.Matches(versionBefore), Is.False); + } + } + static EventLogItemEntity EventLogRow(string marker, DateTime raisedAt) => new() { Description = marker, diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs new file mode 100644 index 0000000000..db2485e4ac --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs @@ -0,0 +1,135 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.MessageFailures; + +[TestFixture] +class ArchivedGroupVersionTests : PersistenceTestBase +{ + const string Classifier = "Exception Type and Stack Trace"; + + static readonly DateTime Oldest = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + static readonly DateTime Middle = new(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); + static readonly DateTime Newest = new(2026, 8, 1, 17, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Version_changes_when_group_counts_move_but_the_total_and_the_span_hold() + { + var stays = NewGroup("Shipping"); + var goes = NewGroup("Billing"); + + var oldest = InGroup(stays, Oldest); + var newest = InGroup(stays, Newest); + var middle = InGroup(goes, Middle); + + await Insert(oldest, newest, middle); + await Archive(oldest, newest, middle); + + var before = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + // One message leaves the archived set and another joins it in the same span, so the total + // stays at three and neither the earliest nor the latest failure moves. Only the per group + // counts change, and those are what the body reports. + var replacement = InGroup(stays, Middle); + await Insert(replacement); + await Archive(replacement); + _ = await FailedMessageLifecycleStore.UnArchiveMessages([middle.UniqueMessageIdString]); + await CompleteDatabaseOperation(); + + var after = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.Results, Has.Count.EqualTo(2), "two archived groups to start with"); + Assert.That(after.Results, Has.Count.EqualTo(1), "and one afterwards, so the body definitely changed"); + Assert.That(after.Results.Single().Count, Is.EqualTo(3), "carrying all three archived messages"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too, or a revalidating client keeps a group that is gone"); + } + } + + [Test] + public async Task Version_changes_when_a_group_gains_a_message() + { + var group = NewGroup("Shipping"); + var first = InGroup(group, Oldest); + + await Insert(first); + await Archive(first); + + var before = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + var second = InGroup(group, Newest); + await Insert(second); + await Archive(second); + + var after = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + var group = NewGroup("Shipping"); + var failure = InGroup(group, Oldest); + + await Insert(failure); + await Archive(failure); + + var first = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + var second = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task A_classifier_with_nothing_archived_still_reports_a_version() + { + var result = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results, Is.Empty); + Assert.That(result.QueryStats.Version.HasValue, Is.True); + } + } + + static FailedMessage.FailureGroup NewGroup(string title) => + new() { Id = Guid.NewGuid().ToString(), Title = title, Type = Classifier }; + + static IngestedFailure InGroup(FailedMessage.FailureGroup group, DateTime failedAt) => + new() + { + Groups = [group], + AttemptedAt = failedAt, + TimeOfFailure = failedAt, + TimeSent = failedAt.AddMinutes(-1) + }; + + async Task Archive(params IngestedFailure[] failures) + { + foreach (var failure in failures) + { + await FailedMessageLifecycleStore.MarkAsArchived(failure.UniqueMessageIdString); + } + + await CompleteDatabaseOperation(); + } + + async Task Insert(params IngestedFailure[] failures) + { + var messages = Array.ConvertAll(failures, failure => failure.ToFailedMessage()); + + foreach (var message in messages) + { + message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); + } + + await PersistenceTestsContext.InsertFailedMessages(messages); + await CompleteDatabaseOperation(); + } +} diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index f739638c2b..965bd9a8f2 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -1,6 +1,5 @@ namespace ServiceControl.MessageFailures.Api { - using System; using System.Linq; using System.Threading; using System.Threading.Tasks; From 8686d3fc6ba569cc2fbe2fdded2e73d24ca4f345 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 13:50:51 +0800 Subject: [PATCH 12/36] Clarify some comments --- .../Infrastructure/FailedMessageQueryFilters.cs | 1 + .../Infrastructure/DataVersion.cs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index c4ec1f3bed..0b13ebcaad 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -175,6 +175,7 @@ public static async Task ToQueryStatsInfo(this IQueryable string.IsNullOrEmpty(token) ? None : new DataVersion(token, strong: true); /// - /// A version built from aggregates over the query behind the page. Name every field the response - /// shows, measured over the same filtered set, or a change to an unnamed one leaves a client holding - /// a stale page. Always weak: a summary of aggregates cannot promise the bytes. + /// 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. Always weak: a summary cannot promise the bytes. /// public static DataVersion Compose(params (string Name, object Value)[] terms) => terms is null || terms.Length == 0 From 3b9c32daf84dac1884e76376950889a57361c3e2 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 15:22:18 +0800 Subject: [PATCH 13/36] Add data version tests for messages view --- .../IngestedFailure.cs | 1 + .../MessagesViewVersionTests.cs | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs diff --git a/src/ServiceControl.Persistence.Tests/IngestedFailure.cs b/src/ServiceControl.Persistence.Tests/IngestedFailure.cs index 2de41ceb33..9f17cf7e6d 100644 --- a/src/ServiceControl.Persistence.Tests/IngestedFailure.cs +++ b/src/ServiceControl.Persistence.Tests/IngestedFailure.cs @@ -87,6 +87,7 @@ Dictionary BuildHeaders() MessageMetadata = new Dictionary { ["MessageId"] = MessageId, + ["MessageIntent"] = MessageIntent, ["MessageType"] = MessageType, ["TimeSent"] = TimeSent, ["ConversationId"] = ConversationId, diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs new file mode 100644 index 0000000000..9712fc47a3 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -0,0 +1,120 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.CompositeViews.Messages; +using ServiceControl.Operations; +using ServiceControl.Persistence.Infrastructure; + +[TestFixture] +class MessagesViewVersionTests : IngestionTestBase +{ + [Test] + public async Task Version_changes_when_a_message_is_added() + { + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var before = await AllMessages(); + + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var after = await AllMessages(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.Results, Has.Count.EqualTo(2), "the body now reports two messages"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too"); + } + } + + [Test] + public async Task Version_changes_when_a_message_is_re_ingested_and_the_count_does_not_move() + { + var failure = new IngestedFailure(); + + await Ingest(failure); + await CompleteDatabaseOperation(); + + var before = await AllMessages(); + + AdvanceClock(TimeSpan.FromMinutes(5)); + + await Ingest(failure.NextAttempt(failure.AttemptedAt.AddMinutes(5))); + await CompleteDatabaseOperation(); + + var after = await AllMessages(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one message"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the message the body reports has a later attempt on it, so the validator cannot stay put"); + } + } + + [Test] + public async Task Version_changes_when_the_endpoint_being_queried_gains_a_message() + { + await Ingest(ReceivedBy("Sales")); + await Ingest(ReceivedBy("Shipping")); + await CompleteDatabaseOperation(); + + var before = await MessagesFor("Sales"); + + await Ingest(ReceivedBy("Sales")); + await CompleteDatabaseOperation(); + + var after = await MessagesFor("Sales"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.Results, Has.Count.EqualTo(1), "the Shipping message is not on this page"); + Assert.That(after.Results, Has.Count.EqualTo(2), "and the body now reports two"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too"); + } + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var first = await AllMessages(); + var second = await AllMessages(); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task An_empty_store_still_reports_a_version() + { + var result = await AllMessages(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results, Is.Empty); + Assert.That(result.QueryStats.Version.HasValue, Is.True, + "an empty list is a representation like any other and has to be cacheable"); + } + } + + static IngestedFailure ReceivedBy(string endpointName) => + new() + { + EndpointName = endpointName, + ReceivingEndpoint = new EndpointDetails { Name = endpointName, Host = "ReceiverHost", HostId = Guid.NewGuid() } + }; + + Task>> AllMessages() => + MessagesViewStore.GetAllMessages(new PagingInfo(), new SortInfo(), includeSystemMessages: true); + + Task>> MessagesFor(string endpointName) => + MessagesViewStore.GetAllMessagesForEndpoint(endpointName, new PagingInfo(), new SortInfo(), includeSystemMessages: true); +} From 5e3f0329de54b377056c29a2077228930a80dd85 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 15:36:52 +0800 Subject: [PATCH 14/36] Add data version tests for message redirects --- .../MessageRedirectVersionTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs new file mode 100644 index 0000000000..28c598fe0c --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs @@ -0,0 +1,77 @@ +namespace ServiceControl.UnitTests.Operations +{ + using System; + using System.Collections.Generic; + using NUnit.Framework; + using ServiceControl.Persistence.MessageRedirects; + + [TestFixture] + public class MessageRedirectVersionTests + { + [Test] + public void From_address_changed_should_change_version() + { + var knownVersion = EtagHelper.VersionOf(Redirects(Redirect(from: "old@machine"))); + + var moved = EtagHelper.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 = EtagHelper.VersionOf(data); + + redirect.ToPhysicalAddress = "new@machine"; + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void Last_modified_changed_should_change_version() + { + var redirect = Redirect(); + var data = Redirects(redirect); + + var knownVersion = EtagHelper.VersionOf(data); + + redirect.LastModified = redirect.LastModified.AddTicks(1); + + Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + } + + [Test] + public void Changing_item_count_should_change_version() + { + var emptyVersion = EtagHelper.VersionOf(Redirects()); + + var oneRedirect = Redirects(Redirect()); + + Assert.That(EtagHelper.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 = EtagHelper.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 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) + }; + } +} From c47aa89bd15390baef430bf87b59164e76134f51 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 16:54:43 +0800 Subject: [PATCH 15/36] The old validator named only the historic request ids, so acknowledging an operation changed the body without moving it and clients kept an operation they had dismissed. Live on both persisters. Each backend now versions its own way, as the other stores do: EF composes every field of both collections, RavenDB uses the document change vector. Also extracts the inline composers from QueueAddressStore and CustomCheckDataStore, and renames EtagHelper to ResponseVersions. --- .../Implementation/CustomCheckDataStore.cs | 10 +-- .../Implementation/QueueAddressStore.cs | 8 +- .../Implementation/RetryHistoryDataStore.cs | 8 +- .../Infrastructure/CustomCheckQueries.cs | 19 ++++ .../Infrastructure/QueueAddressQueries.cs | 17 ++++ .../Infrastructure/RetryHistoryQueries.cs | 27 ++++++ .../Recoverability/RetryHistoryDataStore.cs | 14 ++- .../EFCore/RetryHistoryDataStoreTests.cs | 25 +++--- .../RetryHistoryVersionTests.cs | 87 +++++++++++++++++++ .../IRetryHistoryDataStore.cs | 3 +- .../RetryHistory.cs | 5 -- .../MessageRedirectVersionTests.cs | 18 ++-- .../Recoverability/RetryGroupVersionTests.cs | 68 +++++++-------- .../Api/MessageRedirectsController.cs | 4 +- .../API/FailureGroupsController.cs | 6 +- .../Recoverability/API/GroupFetcher.cs | 2 +- .../{EtagHelper.cs => ResponseVersions.cs} | 13 +-- 17 files changed, 237 insertions(+), 97 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs rename src/ServiceControl/Recoverability/API/{EtagHelper.cs => ResponseVersions.cs} (64%) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index 5905542543..7cb9729feb 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Contracts.CustomChecks; +using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.Infrastructure; public class CustomCheckDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), ICustomChecksDataStore @@ -76,14 +77,7 @@ public Task>> GetStats(PagingInfo paging, string? var totalCount = await query.CountAsync(token); - // Every field of every check the body shows, along with the total count provides a reliable - // data version. - var version = DataVersion.Compose( - ("checks", totalCount), - ("page", string.Join("|", checks.Select(check => FormattableString.Invariant( - $"{check.Id}.{check.CustomCheckId}.{check.Category}.{check.Status}.{check.ReportedAt.Ticks}.{check.FailureReason}"))))); - - return new QueryResult>(checks, new QueryStatsInfo(version, totalCount, false)); + 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/QueueAddressStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs index 88f85fe708..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 @@ -22,11 +23,6 @@ public Task>> GetAddresses(PagingInfo pagingInfo var items = await query.Skip(pagingInfo.Offset).Take(pagingInfo.PageSize).ToListAsync(token); var addressCount = await query.CountAsync(token); - // Both fields of every row the body shows, plus the total, gives a reliable data version. - var version = DataVersion.Compose( - ("addresses", addressCount), - ("page", string.Join("|", items.Select(address => $"{address.PhysicalAddress}={address.FailedMessageCount}")))); - - return new QueryResult>(items, new QueryStatsInfo(version, addressCount, false)); + return new QueryResult>(items, items.ToQueryStatsInfo(addressCount)); }, cancellationToken); } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs index 1b94be8b5a..fed44f33e8 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 @@ -43,11 +45,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/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs new file mode 100644 index 0000000000..40dd6c4dad --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using System; +using ServiceControl.Contracts.CustomChecks; +using ServiceControl.Persistence.Infrastructure; + +static class CustomCheckQueries +{ + /// + /// Every field of every check the body shows, plus the total. + /// + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => + new(DataVersion.Compose( + ("checks", totalCount), + ("page", string.Join("|", page.Select(check => FormattableString.Invariant( + $"{check.Id}.{check.CustomCheckId}.{check.Category}.{check.Status}.{check.ReportedAt.Ticks}.{check.FailureReason}"))))), + totalCount, + false); +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs new file mode 100644 index 0000000000..afe2aeb363 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.Infrastructure; + +static class QueueAddressQueries +{ + /// + /// Both fields of every address the body shows, plus the total behind Total-Count. + /// + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => + new(DataVersion.Compose( + ("addresses", totalCount), + ("page", string.Join("|", page.Select(address => $"{address.PhysicalAddress}={address.FailedMessageCount}")))), + totalCount, + false); +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs new file mode 100644 index 0000000000..9e0e3d4b6d --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using System; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +static class RetryHistoryQueries +{ + /// + /// Every field of every operation in both collections, plus the total count of + /// + public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => + new(DataVersion.Compose( + ("historic", history.HistoricOperations.Count), + ("historicState", string.Join("|", history.HistoricOperations.Select(operation => FormattableString.Invariant( + $"{operation.RequestId}.{operation.RetryType}.{operation.StartTime.Ticks}.{operation.CompletionTime.Ticks}.{operation.Originator}.{operation.Failed}.{operation.NumberOfMessagesProcessed}")))), + ("unacknowledged", history.UnacknowledgedOperations.Count), + // Sorted because these rows are read without an ORDER BY, so the order they arrive in is + // not a property of the data and must not move the version. + ("unacknowledgedState", string.Join("|", history.UnacknowledgedOperations + .OrderBy(operation => operation.RequestId, StringComparer.Ordinal) + .ThenBy(operation => operation.RetryType) + .Select(operation => FormattableString.Invariant( + $"{operation.RequestId}.{operation.RetryType}.{operation.StartTime.Ticks}.{operation.CompletionTime.Ticks}.{operation.Last.Ticks}.{operation.Originator}.{operation.Classifier}.{operation.Failed}.{operation.NumberOfMessagesProcessed}"))))), + history.HistoricOperations.Count, + false); +} diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs index b93e1c8df8..2bda8cc492 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs @@ -3,20 +3,30 @@ using System; using System.Threading; using System.Threading.Tasks; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; class RetryHistoryDataStore(IRavenSessionProvider sessionProvider) : IRetryHistoryDataStore { const string DocumentId = "RetryOperations/History"; - public async Task GetRetryHistory(CancellationToken cancellationToken = default) + // Before the first operation completes there is no document and so no change vector, but an + // empty history still has to be cacheable. + static readonly DataVersion EmptyHistory = DataVersion.FromContent("no-retry-history"); + + public async Task> GetRetryHistory(CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var retryHistory = await session.LoadAsync(DocumentId, cancellationToken); + var version = retryHistory == null + ? EmptyHistory + : DataVersion.FromContent(session.Advanced.GetChangeVectorFor(retryHistory)); + retryHistory ??= new(); - return retryHistory; + return new QueryResult(retryHistory, + new QueryStatsInfo(version, retryHistory.HistoricOperations.Count, false)); } public async Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime, diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs index 410c480412..f7bac8b0a6 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Persistence.Tests; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; -using ServiceControl.Recoverability; class RetryHistoryDataStoreTests : ErrorIngestionTestBase { @@ -15,7 +14,7 @@ class RetryHistoryDataStoreTests : ErrorIngestionTestBase [Test] public async Task Returns_an_empty_history_when_nothing_has_completed() { - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; using (Assert.EnterMultipleScope()) { @@ -30,7 +29,7 @@ public async Task Records_a_completed_operation() await RecordCompleted("group-1", originator: "OrderPlaced failures", classifier: "Exception Type and Stack Trace", failed: true, numberOfMessagesProcessed: 3); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; var historic = history.HistoricOperations.Single(); var unacknowledged = history.UnacknowledgedOperations.Single(); @@ -64,7 +63,7 @@ public async Task Returns_the_newest_operations_first() await RecordCompleted("group-2", completionTime: Noon.AddHours(-1)); await RecordCompleted("group-3", completionTime: Noon.AddHours(1)); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; Assert.That(history.HistoricOperations.Select(operation => operation.RequestId), Is.EqualTo(new[] { "group-3", "group-1", "group-2" })); @@ -78,7 +77,7 @@ public async Task Keeps_only_the_newest_operations_up_to_the_depth() await RecordCompleted($"group-{minute}", completionTime: Noon.AddMinutes(minute), depth: 3); } - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; Assert.That(history.HistoricOperations.Select(operation => operation.RequestId), Is.EqualTo(new[] { "group-4", "group-3", "group-2" })); @@ -91,7 +90,7 @@ public async Task Breaks_ties_on_completion_time_by_the_order_recorded() await RecordCompleted("group-2", completionTime: Noon, depth: 2); await RecordCompleted("group-3", completionTime: Noon, depth: 2); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; Assert.That(history.HistoricOperations.Select(operation => operation.RequestId), Is.EqualTo(new[] { "group-3", "group-2" })); @@ -107,7 +106,7 @@ public async Task Applies_a_reduced_depth_to_operations_already_recorded() await RecordCompleted("group-4", completionTime: Noon.AddMinutes(4), depth: 2); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; Assert.That(history.HistoricOperations.Select(operation => operation.RequestId), Is.EqualTo(new[] { "group-4", "group-3" })); @@ -119,7 +118,7 @@ public async Task Keeps_no_history_when_the_depth_is_zero() await RecordCompleted("group-1"); await RecordCompleted("group-2", depth: 0); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; using (Assert.EnterMultipleScope()) { @@ -134,7 +133,7 @@ public async Task Does_not_wait_for_an_acknowledgement_of_message_retries(RetryT { await RecordCompleted("request-1", retryType); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; using (Assert.EnterMultipleScope()) { @@ -149,7 +148,7 @@ public async Task Replaces_the_pending_acknowledgement_when_a_group_is_retried_a await RecordCompleted("group-1", completionTime: Noon, numberOfMessagesProcessed: 3); await RecordCompleted("group-1", completionTime: Noon.AddHours(1), numberOfMessagesProcessed: 7); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; var unacknowledged = history.UnacknowledgedOperations.Single(); @@ -167,7 +166,7 @@ public async Task Keeps_the_pending_acknowledgements_of_other_retry_types_apart( await RecordCompleted("request-1", RetryType.FailureGroup); await RecordCompleted("request-1", RetryType.AllForEndpoint); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; Assert.That(history.UnacknowledgedOperations.Select(operation => operation.RetryType), Is.EquivalentTo(new[] { RetryType.FailureGroup, RetryType.AllForEndpoint })); @@ -180,7 +179,7 @@ public async Task Acknowledges_a_group_retry() var acknowledged = await RetryHistoryStore.AcknowledgeRetryGroup("group-1"); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; using (Assert.EnterMultipleScope()) { @@ -201,7 +200,7 @@ public async Task Does_not_acknowledge_an_operation_of_another_retry_type() var acknowledged = await RetryHistoryStore.AcknowledgeRetryGroup("SomeEndpoint"); - var history = await RetryHistoryStore.GetRetryHistory(); + var history = (await RetryHistoryStore.GetRetryHistory()).Results; using (Assert.EnterMultipleScope()) { diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs new file mode 100644 index 0000000000..ea5c9df6d9 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs @@ -0,0 +1,87 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; + +[TestFixture] +class RetryHistoryVersionTests : PersistenceTestBase +{ + const int DefaultDepth = 10; + + static readonly DateTime Noon = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Version_changes_when_an_operation_is_acknowledged() + { + await RecordCompleted("group-1"); + await CompleteDatabaseOperation(); + + var before = await RetryHistoryStore.GetRetryHistory(); + + var acknowledged = await RetryHistoryStore.AcknowledgeRetryGroup("group-1"); + await CompleteDatabaseOperation(); + + var after = await RetryHistoryStore.GetRetryHistory(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(acknowledged, Is.True, "the premise: there was something to acknowledge"); + Assert.That(before.Results.UnacknowledgedOperations, Has.Count.EqualTo(1)); + Assert.That(after.Results.UnacknowledgedOperations, Is.Empty, "the body changed"); + Assert.That(after.Results.HistoricOperations, Has.Count.EqualTo(1), "and the historic half did not"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body changed, so the validator must too, or a revalidating client keeps an operation it has dismissed"); + } + } + + [Test] + public async Task Version_changes_when_an_operation_completes() + { + await RecordCompleted("group-1"); + await CompleteDatabaseOperation(); + + var before = await RetryHistoryStore.GetRetryHistory(); + + await RecordCompleted("group-2", completionTime: Noon.AddHours(1)); + await CompleteDatabaseOperation(); + + var after = await RetryHistoryStore.GetRetryHistory(); + + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + + [Test] + public async Task Version_is_stable_while_nothing_changes() + { + await RecordCompleted("group-1"); + await CompleteDatabaseOperation(); + + var first = await RetryHistoryStore.GetRetryHistory(); + var second = await RetryHistoryStore.GetRetryHistory(); + + Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + } + + [Test] + public async Task An_empty_history_still_reports_a_version() + { + var result = await RetryHistoryStore.GetRetryHistory(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results.HistoricOperations, Is.Empty); + Assert.That(result.QueryStats.Version.HasValue, Is.True, + "an empty history is a representation like any other and has to be cacheable"); + } + } + + Task RecordCompleted(string requestId, DateTime? completionTime = null) + { + var completed = completionTime ?? Noon; + + return RetryHistoryStore.RecordRetryOperationCompleted(requestId, RetryType.FailureGroup, + completed.AddMinutes(-5), completed, "OrderPlaced failures", "Exception Type and Stack Trace", + messageFailed: false, numberOfMessagesProcessed: 1, completed.AddMinutes(-1), DefaultDepth); + } +} 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/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/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs index 28c598fe0c..3bc540df52 100644 --- a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs @@ -11,9 +11,9 @@ public class MessageRedirectVersionTests [Test] public void From_address_changed_should_change_version() { - var knownVersion = EtagHelper.VersionOf(Redirects(Redirect(from: "old@machine"))); + var knownVersion = ResponseVersions.VersionOf(Redirects(Redirect(from: "old@machine"))); - var moved = EtagHelper.VersionOf(Redirects(Redirect(from: "new@machine"))); + var moved = ResponseVersions.VersionOf(Redirects(Redirect(from: "new@machine"))); Assert.That(moved.Matches(knownVersion), Is.False); } @@ -24,11 +24,11 @@ public void To_address_changed_should_change_version() var redirect = Redirect(to: "old@machine"); var data = Redirects(redirect); - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); redirect.ToPhysicalAddress = "new@machine"; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -37,28 +37,28 @@ public void Last_modified_changed_should_change_version() var redirect = Redirect(); var data = Redirects(redirect); - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); redirect.LastModified = redirect.LastModified.AddTicks(1); - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] public void Changing_item_count_should_change_version() { - var emptyVersion = EtagHelper.VersionOf(Redirects()); + var emptyVersion = ResponseVersions.VersionOf(Redirects()); var oneRedirect = Redirects(Redirect()); - Assert.That(EtagHelper.VersionOf(oneRedirect).Matches(emptyVersion), Is.False, + Assert.That(ResponseVersions.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 = EtagHelper.VersionOf(Redirects()); + var version = ResponseVersions.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"); diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs index 030266ad4c..0942089443 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -13,11 +13,11 @@ public void Id_changed_should_change_version() var group = new GroupOperation { Id = "old" }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Id = "new"; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -26,11 +26,11 @@ public void Count_changed_should_change_version() var group = new GroupOperation { Count = 1 }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Count = 2; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -39,11 +39,11 @@ public void RetryStatus_changed_should_change_version() var group = new GroupOperation { OperationStatus = RetryState.Waiting.ToString() }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationStatus = RetryState.Preparing.ToString(); - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -52,11 +52,11 @@ public void RetryProgress_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationProgress = 0.01; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -65,11 +65,11 @@ public void RetryStartTime_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationStartTime = DateTime.UtcNow; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -78,11 +78,11 @@ public void RetryCompletionTime_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationCompletionTime = DateTime.UtcNow; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -91,11 +91,11 @@ public void NeedUserAcknowledgement_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.NeedUserAcknowledgement = true; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -104,11 +104,11 @@ public void Comment_changed_should_change_version() var group = new GroupOperation { Comment = "before" }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Comment = "after"; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -117,11 +117,11 @@ public void Title_changed_should_change_version() var group = new GroupOperation { Title = "before" }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Title = "after"; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -130,11 +130,11 @@ public void Type_changed_should_change_version() var group = new GroupOperation { Type = "before" }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Type = "after"; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -143,11 +143,11 @@ public void First_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.First = DateTime.UtcNow; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -156,11 +156,11 @@ public void Last_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.Last = DateTime.UtcNow; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -169,11 +169,11 @@ public void OperationFailed_changed_should_change_version() var group = new GroupOperation { OperationFailed = false }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationFailed = true; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -182,11 +182,11 @@ public void OperationMessagesCompletedCount_changed_should_change_version() var group = new GroupOperation { OperationMessagesCompletedCount = 1 }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationMessagesCompletedCount = 2; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -195,11 +195,11 @@ public void OperationRemainingCount_changed_should_change_version() var group = new GroupOperation { OperationRemainingCount = 2 }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.VersionOf(data); group.OperationRemainingCount = 1; - Assert.That(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -216,23 +216,23 @@ public void A_message_completing_moves_the_version_even_when_the_rounded_progres }; var data = new[] { group }; - var knownVersion = EtagHelper.VersionOf(data); + var knownVersion = ResponseVersions.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(EtagHelper.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); } [Test] public void Changing_item_count_should_change_version() { - var emptyVersion = EtagHelper.VersionOf(Array.Empty()); + var emptyVersion = ResponseVersions.VersionOf(Array.Empty()); var oneGroup = new[] { new GroupOperation() }; - Assert.That(EtagHelper.VersionOf(oneGroup).Matches(emptyVersion), Is.False, + Assert.That(ResponseVersions.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"); } } diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index d224519c42..f506d31526 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -171,7 +171,7 @@ public async Task CountRedirects(CancellationToken cancellationToken = default) { var redirects = await store.GetRedirects(cancellationToken); - Response.WithEtag(EtagHelper.VersionOf(redirects)); + Response.WithEtag(ResponseVersions.VersionOf(redirects)); Response.WithTotalCount(redirects.Count); } @@ -193,7 +193,7 @@ public async Task> Redirects(string sort, stri r.LastModified )); - Response.WithEtag(EtagHelper.VersionOf(redirects)); + Response.WithEtag(ResponseVersions.VersionOf(redirects)); Response.WithPagingLinksAndTotalCount(pagingInfo, redirects.Count); return queryResult; diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index ddb72cac5f..9bc0e1ef73 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -67,7 +67,7 @@ public async Task GetAllGroups(string classifier = "Exception } var results = await fetcher.GetGroups(classifier, classifierFilter, cancellationToken); - Response.WithEtag(EtagHelper.VersionOf(results)); + Response.WithEtag(ResponseVersions.VersionOf(results)); return results; } @@ -100,9 +100,9 @@ public async Task GetRetryHistory(CancellationToken cancellationTo { var retryHistory = await retryStore.GetRetryHistory(cancellationToken); - Response.WithEtag(DataVersion.Compose(("operations", retryHistory.GetHistoryOperationsUniqueIdentifier()))); + Response.WithEtag(retryHistory.QueryStats.Version); - return retryHistory; + return retryHistory.Results; } [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] diff --git a/src/ServiceControl/Recoverability/API/GroupFetcher.cs b/src/ServiceControl/Recoverability/API/GroupFetcher.cs index e72ea92458..949ad59444 100644 --- a/src/ServiceControl/Recoverability/API/GroupFetcher.cs +++ b/src/ServiceControl/Recoverability/API/GroupFetcher.cs @@ -21,7 +21,7 @@ public GroupFetcher(IGroupsDataStore store, IRetryHistoryDataStore retryStore, I public async Task GetGroups(string classifier, string classifierFilter, CancellationToken cancellationToken = default) { var dbGroups = await store.GetUnresolvedGroupsByClassifier(classifier, classifierFilter, cancellationToken); - var retryHistory = await retryStore.GetRetryHistory(cancellationToken); + var retryHistory = (await retryStore.GetRetryHistory(cancellationToken)).Results; var unacknowledgedRetries = retryHistory.GetUnacknowledgedByClassifier(classifier); var openRetryAcknowledgements = MapAcksToOpenGroups(dbGroups, unacknowledgedRetries); diff --git a/src/ServiceControl/Recoverability/API/EtagHelper.cs b/src/ServiceControl/Recoverability/API/ResponseVersions.cs similarity index 64% rename from src/ServiceControl/Recoverability/API/EtagHelper.cs rename to src/ServiceControl/Recoverability/API/ResponseVersions.cs index 8b154087a4..9213024103 100644 --- a/src/ServiceControl/Recoverability/API/EtagHelper.cs +++ b/src/ServiceControl/Recoverability/API/ResponseVersions.cs @@ -5,25 +5,16 @@ using ServiceControl.Persistence.MessageRedirects; /// -/// Versions for responses built in a controller, where there are no rows to count and no timestamp to read. -/// Every property the response shows has to be named below, or a client keeps a page that has since changed. -/// Invariant, or the double renders as 0,01 on some machines. Ticks, to match what DataVersion does. +/// Versions for responses built in a controller. /// -static class EtagHelper +static class ResponseVersions { - /// - /// All properties. OperationProgress arrives already rounded to two decimals, so on a big retry a - /// message completing moves only the two counters. - /// internal static DataVersion VersionOf(GroupOperation[] groups) => DataVersion.Compose( ("groups", groups.Length), ("state", string.Join("|", groups.Select(group => FormattableString.Invariant( $"{group.Id}.{group.Title}.{group.Type}.{group.Count}.{group.Comment}.{group.First?.Ticks}.{group.Last?.Ticks}.{group.OperationStatus}.{group.OperationFailed}.{group.OperationProgress}.{group.OperationMessagesCompletedCount}.{group.OperationRemainingCount}.{group.OperationStartTime?.Ticks}.{group.OperationCompletionTime?.Ticks}.{group.NeedUserAcknowledgement}"))))); - /// - /// FromPhysicalAddress is not named because MessageRedirectId is derived from it. - /// internal static DataVersion VersionOf(IReadOnlyList redirects) => DataVersion.Compose( ("redirects", redirects.Count), From 563836b535b110c293d6bc6f8324fca0de777f3a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 20:32:43 +0800 Subject: [PATCH 16/36] Clean after review --- .../Implementation/BodyStorage/BodyStorage.cs | 2 +- .../Implementation/EventLogDataStore.cs | 4 +- .../Infrastructure/RetryHistoryQueries.cs | 2 +- .../BodyStorage/BodyVersionTests.cs | 1 + .../BodyStorage/IngestionClockTests.cs | 3 +- .../CustomCheckVersionTests.cs | 7 +++- .../MessagesViewVersionTests.cs | 6 ++- .../QueueAddressVersionTests.cs | 8 +++- .../ArchivedGroupVersionTests.cs | 7 +++- .../FailureGroupVersionTests.cs | 8 +++- .../RetryHistoryVersionTests.cs | 7 +++- .../VersionAssert.cs | 32 +++++++++++++++ .../Infrastructure/DataVersion.cs | 40 ++++++++++++------- .../Infrastructure/DataVersionTests.cs | 37 +++++++++++++++-- .../GetAuditCountsForEndpointApi.cs | 3 ++ .../Messages/ScatterGatherApi.cs | 27 +++++++++++-- .../Messages/ScatterGatherRemoteOnly.cs | 4 ++ .../WebApi/NotModifiedStatusHttpHandler.cs | 17 +++++++- .../Web/EndpointsMonitoringController.cs | 3 +- 19 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/VersionAssert.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs index e15fab509c..5fc5d9e9df 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs @@ -31,7 +31,7 @@ public async Task TryFetch(string bodyId, CancellationToken c var uniqueMessageId = row.UniqueMessageId.ToString(); - // Ingestion updates the existing row rather than adding one, so the message id is unchanged + // 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), diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs index 4eee576956..357797c7bb 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -76,8 +76,8 @@ public Task>> GetEventLogItems( return new QueryResult>(items, queryStats); }, cancellationToken); - // The table is append-only, so the highest key is enough to spot an insert: identity values can gap - // but never repeat, whatever RaisedAt says. + // Rows are never rewritten, only inserted or swept, so the count catches a sweep and the highest + // key catches an insert: identity values gap but never repeat, whatever RaisedAt says. static DataVersion Version(long total, DateTime? newest, long? highestId) => DataVersion.Compose(("total", total), ("newest", newest), ("highestId", highestId)); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 9e0e3d4b6d..17aaabdc23 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -7,7 +7,7 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; static class RetryHistoryQueries { /// - /// Every field of every operation in both collections, plus the total count of + /// Every field of every operation in both collections, plus each collection's count /// public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => new(DataVersion.Compose( diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs index 6a8a75e576..7eb4af2426 100644 --- a/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs @@ -35,6 +35,7 @@ public async Task Version_changes_when_a_later_attempt_carries_a_different_body( { Assert.That(originalBody, Is.EqualTo("the original body")); Assert.That(replacedBody, Is.EqualTo("a completely different body"), "the stored body was replaced"); + Assert.That(before.HasValue, Is.True, "there was no version to move"); Assert.That(after.Matches(before), Is.False, "the body changed, so a client holding the old version must be sent the new body rather than a 304"); }); diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs index 691713c14e..2daa79648c 100644 --- a/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs +++ b/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs @@ -26,6 +26,7 @@ public async Task A_re_ingested_message_moves_the_version() // The EF clock is frozen, so without AdvanceClock a second attempt at the same message leaves // both the count and LastModified alone and the version cannot move. - Assert.That(after.Matches(before), Is.False); + VersionAssert.Moved(before, after, + "the stored body changed, so a revalidating client must not be served the old bytes"); } } diff --git a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs index 87e8dd1792..d95276adce 100644 --- a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs @@ -27,6 +27,7 @@ public async Task Version_changes_when_a_check_starts_failing() { Assert.That(after.Results, Has.Count.EqualTo(1), "still one check"); Assert.That(after.Results[0].Status, Is.EqualTo(Status.Fail), "and the body now reports it failing"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is shown a stale status"); }); @@ -43,7 +44,8 @@ public async Task Version_changes_when_a_new_check_appears() var after = await CustomChecks.GetStats(new PagingInfo()); - Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + VersionAssert.Moved(before.QueryStats.Version, after.QueryStats.Version, + "a check appeared, so a revalidating client must not be told its page is current"); } [Test] @@ -54,7 +56,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await CustomChecks.GetStats(new PagingInfo()); var second = await CustomChecks.GetStats(new PagingInfo()); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs index 9712fc47a3..5a19e131d1 100644 --- a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -27,6 +27,7 @@ public async Task Version_changes_when_a_message_is_added() using (Assert.EnterMultipleScope()) { Assert.That(after.Results, Has.Count.EqualTo(2), "the body now reports two messages"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too"); } @@ -52,6 +53,7 @@ public async Task Version_changes_when_a_message_is_re_ingested_and_the_count_do using (Assert.EnterMultipleScope()) { Assert.That(after.Results, Has.Count.EqualTo(1), "still one message"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the message the body reports has a later attempt on it, so the validator cannot stay put"); } @@ -75,6 +77,7 @@ public async Task Version_changes_when_the_endpoint_being_queried_gains_a_messag { Assert.That(before.Results, Has.Count.EqualTo(1), "the Shipping message is not on this page"); Assert.That(after.Results, Has.Count.EqualTo(2), "and the body now reports two"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too"); } @@ -89,7 +92,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await AllMessages(); var second = await AllMessages(); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs index 22c99d8f92..c091367692 100644 --- a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs @@ -27,6 +27,7 @@ public async Task Version_changes_when_a_queue_gains_a_failure_and_the_address_s { Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); Assert.That(after.Results[0].FailedMessageCount, Is.EqualTo(2), "and the body now reports two failures"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is served a stale count"); }); @@ -51,6 +52,7 @@ public async Task Version_changes_when_a_message_moves_to_a_different_queue() { Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); Assert.That(after.Results[0].PhysicalAddress, Is.EqualTo("OtherEndpoint@machine2"), "and it is the new one"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body reports a different address under the same count, so the validator cannot stay put"); }); @@ -69,7 +71,8 @@ public async Task Version_changes_when_a_new_address_appears() var after = await QueueAddressStore.GetAddresses(new PagingInfo()); - Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + VersionAssert.Moved(before.QueryStats.Version, after.QueryStats.Version, + "an address appeared, so a revalidating client must not be told its page is current"); } [Test] @@ -81,7 +84,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await QueueAddressStore.GetAddresses(new PagingInfo()); var second = await QueueAddressStore.GetAddresses(new PagingInfo()); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs index db2485e4ac..a60a4fae52 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs @@ -46,6 +46,7 @@ public async Task Version_changes_when_group_counts_move_but_the_total_and_the_s Assert.That(before.Results, Has.Count.EqualTo(2), "two archived groups to start with"); Assert.That(after.Results, Has.Count.EqualTo(1), "and one afterwards, so the body definitely changed"); Assert.That(after.Results.Single().Count, Is.EqualTo(3), "carrying all three archived messages"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client keeps a group that is gone"); } @@ -68,7 +69,8 @@ public async Task Version_changes_when_a_group_gains_a_message() var after = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); - Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + VersionAssert.Moved(before.QueryStats.Version, after.QueryStats.Version, + "the archived group gained a message, so its validator cannot stay put"); } [Test] @@ -83,7 +85,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); var second = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs index 43348340c0..9251ca0267 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs @@ -38,6 +38,7 @@ public async Task Version_changes_when_a_group_loses_a_message_that_is_neither_i Assert.That(after.Results.Count, Is.EqualTo(2), "and the body now reports two"); Assert.That(after.Results.First, Is.EqualTo(before.Results.First), "the earliest failure is unchanged"); Assert.That(after.Results.Last, Is.EqualTo(before.Results.Last), "and so is the latest"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is served a stale count"); }); @@ -56,7 +57,8 @@ public async Task Version_changes_when_a_group_gains_a_message() var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); - Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + VersionAssert.Moved(before.QueryStats.Version, after.QueryStats.Version, + "the group gained a message, so its validator cannot stay put"); } [Test] @@ -79,6 +81,7 @@ public async Task Version_changes_when_the_span_of_a_group_moves_but_its_count_d Assert.Multiple(() => { Assert.That(after.Results.Count, Is.EqualTo(before.Results.Count), "still three messages"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "a different span of failures is being reported under the same count"); }); @@ -94,7 +97,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); var second = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs index ea5c9df6d9..015038e904 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs @@ -30,6 +30,7 @@ public async Task Version_changes_when_an_operation_is_acknowledged() Assert.That(before.Results.UnacknowledgedOperations, Has.Count.EqualTo(1)); Assert.That(after.Results.UnacknowledgedOperations, Is.Empty, "the body changed"); Assert.That(after.Results.HistoricOperations, Has.Count.EqualTo(1), "and the historic half did not"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client keeps an operation it has dismissed"); } @@ -48,7 +49,8 @@ public async Task Version_changes_when_an_operation_completes() var after = await RetryHistoryStore.GetRetryHistory(); - Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + VersionAssert.Moved(before.QueryStats.Version, after.QueryStats.Version, + "another operation completed, so a revalidating client must not keep the old history"); } [Test] @@ -60,7 +62,8 @@ public async Task Version_is_stable_while_nothing_changes() var first = await RetryHistoryStore.GetRetryHistory(); var second = await RetryHistoryStore.GetRetryHistory(); - Assert.That(second.QueryStats.Version.Matches(first.QueryStats.Version), Is.True); + VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + "nothing changed, so the validator has to stay put or conditional GET never pays off"); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/VersionAssert.cs b/src/ServiceControl.Persistence.Tests/VersionAssert.cs new file mode 100644 index 0000000000..ae999f7b20 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/VersionAssert.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Tests; + +using NUnit.Framework; +using ServiceControl.Persistence.Infrastructure; + +static class VersionAssert +{ + /// + /// The data moved, so the version had to move with it. Checks the earlier version exists first, + /// because is false whenever either side is absent, so a store + /// that stopped producing a version at all would otherwise satisfy the same assertion. + /// + public static void Moved(DataVersion before, DataVersion after, string because) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(before.HasValue, Is.True, "there was no version to move"); + Assert.That(after.HasValue, Is.True, "the version went missing rather than moving"); + Assert.That(after.Matches(before), Is.False, because); + } + } + + /// Nothing changed, so a caller holding the earlier version still holds the current one. + public static void Held(DataVersion first, DataVersion second, string because) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(first.HasValue, Is.True, "there was no version to hold"); + Assert.That(second.Matches(first), Is.True, because); + } + } +} diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index 6de9e61f4e..29c18d7567 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -14,8 +14,8 @@ namespace ServiceControl.Persistence.Infrastructure /// stays reflexive, so the struct still works as a dictionary key. /// /// - /// A struct, so default is and no field can be null. A null would be a second - /// way to say "no version" that never sees. operator == is left undefined on + /// A struct, so default is and no variable of this type can be null. A null + /// reference would be a second way to say "no version" that never sees. operator == is left undefined on /// purpose: the only two questions worth asking are and . /// /// @@ -50,7 +50,7 @@ public static DataVersion FromToken(long token) => new(token.ToString(CultureInfo.InvariantCulture)); /// - /// A backend token that moves only when the response bytes move, so the tag goes out unmarked. Only + /// A backend token that moves whenever the response bytes move, so the tag goes out unmarked. Only /// the caller can know that holds, so only use it where it demonstrably does. /// public static DataVersion FromContent(string token) => @@ -68,31 +68,34 @@ public static DataVersion Compose(params (string Name, object Value)[] terms) => /// /// One version for a result gathered from several instances. Missing anywhere means missing overall. - /// Always weak, whatever went in, since it goes through . + /// Keyed on the instance, so a validator moving from one instance to another still moves the + /// composite. Always weak, whatever went in, since it goes through . /// - public static DataVersion Combine(IEnumerable versions) + public static DataVersion Combine(IEnumerable<(string InstanceId, DataVersion Version)> versions) { - var validators = new List(); + ArgumentNullException.ThrowIfNull(versions); - foreach (var version in versions) + var reported = new List<(string InstanceId, string Validator)>(); + + foreach (var (instanceId, version) in versions) { if (!version.HasValue) { return None; } - validators.Add(version.validator); + reported.Add((instanceId, version.validator)); } - if (validators.Count == 0) + if (reported.Count == 0) { return None; } - // Instances answer in no guaranteed order, so the composite has to be order independent. - validators.Sort(StringComparer.Ordinal); - - return Compose([.. validators.Select((v, i) => ($"instance{i.ToString(CultureInfo.InvariantCulture)}", (object)v))]); + return Compose([.. reported + .OrderBy(entry => entry.InstanceId, StringComparer.Ordinal) + .ThenBy(entry => entry.Validator, StringComparer.Ordinal) + .Select(entry => (entry.InstanceId, (object)entry.Validator))]); } /// @@ -147,14 +150,21 @@ public bool Equals(DataVersion other) => public override string ToString() => validator ?? string.Empty; static string Describe((string Name, object Value)[] terms) => - string.Join("|", terms.Select(term => $"{term.Name}={Format(term.Value)}")); + string.Join("|", terms.Select(term => Encode(term.Name, Format(term.Value)))); + + static string Encode(string name, string value) => + $"{name}:{value.Length.ToString(CultureInfo.InvariantCulture)}:{value}"; static string Format(object value) => value switch { null => string.Empty, + string text => text, DateTime timestamp => timestamp.Ticks.ToString(CultureInfo.InvariantCulture), + DateTimeOffset timestamp => timestamp.UtcTicks.ToString(CultureInfo.InvariantCulture), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() + // 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.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index 6509b01ad5..5a666a0bfe 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -112,7 +112,19 @@ 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([a, b]).Matches(DataVersion.Combine([b, a])), Is.True); + 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] @@ -121,7 +133,7 @@ public void Combine_differs_from_every_instance_version_it_covers() var a = DataVersion.FromToken("a"); var b = DataVersion.FromToken("b"); - var combined = DataVersion.Combine([a, b]); + var combined = DataVersion.Combine([("one", a), ("two", b)]); Assert.Multiple(() => { @@ -133,7 +145,7 @@ public void Combine_differs_from_every_instance_version_it_covers() [Test] public void Combine_is_absent_when_any_instance_has_no_version() { - var combined = DataVersion.Combine([DataVersion.FromToken("a"), DataVersion.None]); + 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"); @@ -145,6 +157,23 @@ 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")] @@ -192,7 +221,7 @@ public void Composing_and_combining_are_never_exact() { Assert.That(DataVersion.Compose(("total", 3L)).IsStrong, Is.False, "a hash over aggregates cannot promise the bytes are identical"); - Assert.That(DataVersion.Combine([exact, exact]).IsStrong, Is.False, + Assert.That(DataVersion.Combine([("one", exact), ("two", exact)]).IsStrong, Is.False, "a composite across instances is an approximation whatever went into it"); }); } diff --git a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs index dbf265b0b6..87802224f1 100644 --- a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs @@ -33,6 +33,9 @@ protected override Task>> LocalQuery(AuditCountsFo // Will never be implemented on the primary instance Task.FromResult(new QueryResult>(Empty, QueryStatsInfo.Zero)); + protected override QueryStatsInfo AggregateStats(AuditCountsForEndpointContext input, IEnumerable>> results, IList processedResults) => + AggregateStatsFromRemotesOnly(results); + protected override IList ProcessResults(AuditCountsForEndpointContext input, QueryResult>[] results) => results.SelectMany(r => r.Results) .GroupBy(r => r.UtcDate) diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index 171110e7e8..af6945dbd6 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -44,6 +44,9 @@ protected ScatterGatherApi(TDataStore store, Settings settings, IHttpClientFacto } protected TDataStore DataStore { get; } + + protected string LocalInstanceId => Settings.InstanceId; + Settings Settings { get; } IHttpClientFactory HttpClientFactory { get; } IHttpContextAccessor HttpContextAccessor { get; } @@ -96,12 +99,30 @@ internal QueryResult AggregateResults(TIn input, QueryResult[] resul protected abstract TOut ProcessResults(TIn input, QueryResult[] results); - protected virtual QueryStatsInfo AggregateStats(TIn input, IEnumerable> results, TOut processedResults) + protected virtual QueryStatsInfo AggregateStats(TIn input, IEnumerable> results, TOut processedResults) => + Aggregate(results); + + /// + /// For an API whose own instance holds none of the data. Its local result carries no version, and + /// reports as soon as one result + /// is missing one, which would leave every response with no ETag at all. + /// + protected QueryStatsInfo AggregateStatsFromRemotesOnly(IEnumerable> results) => + Aggregate(results.Where(result => result.InstanceId != LocalInstanceId)); + + static QueryStatsInfo Aggregate(IEnumerable> results) { - var infos = results.Select(x => x.QueryStats).ToArray(); + var reported = results.ToArray(); + + if (reported.Length == 0) + { + return QueryStatsInfo.Zero; + } + + var infos = reported.Select(x => x.QueryStats).ToArray(); return new QueryStatsInfo( - DataVersion.Combine(infos.Select(x => x.Version)), + DataVersion.Combine(reported.Select(result => (result.InstanceId, result.QueryStats.Version))), infos.Sum(x => x.TotalCount), isStale: infos.Any(x => x.IsStale), infos.Max(x => x.HighestTotalCountOfAllTheInstances) diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs index 379070101b..f867a39694 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs @@ -1,5 +1,6 @@ namespace ServiceControl.CompositeViews.Messages { + using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,9 @@ public abstract class ScatterGatherRemoteOnly(Settings settings, IHtt where TOut : class { protected sealed override Task> LocalQuery(TIn input, CancellationToken cancellationToken = default) => QueryResult.Empty(); + + protected sealed override QueryStatsInfo AggregateStats(TIn input, IEnumerable> results, TOut processedResults) => + AggregateStatsFromRemotesOnly(results); } public sealed class NoOpStore diff --git a/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs b/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs index aa6fac69f1..55cf5c0504 100644 --- a/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs +++ b/src/ServiceControl/Infrastructure/WebApi/NotModifiedStatusHttpHandler.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; + using Microsoft.AspNetCore.Mvc.Infrastructure; class NotModifiedStatusHttpHandler : IResultFilter { @@ -36,8 +37,7 @@ public void OnResultExecuting(ResultExecutingContext context) return; } - var statusCode = context.HttpContext.Response.StatusCode; - if (statusCode is < 200 or > 299) + if (!IsSuccess(context)) { return; } @@ -50,10 +50,23 @@ public void OnResultExecuting(ResultExecutingContext context) if (ifNoneMatch || ifNotModifiedSince) { + // The replaced result never executes, so whatever it owned would never be released. + if (context.Result is FileStreamResult file) + { + context.HttpContext.Response.RegisterForDisposeAsync(file.FileStream); + } + context.Result = new StatusCodeResult((int)HttpStatusCode.NotModified); } } + // Response.StatusCode is still whatever the pipeline defaulted to, because the result that + // would set it has not executed yet. + static bool IsSuccess(ResultExecutingContext context) => + context.Result is IStatusCodeActionResult { StatusCode: { } statusCode } + ? statusCode is >= 200 and <= 299 + : context.HttpContext.Response.StatusCode is >= 200 and <= 299; + public void OnResultExecuted(ResultExecutedContext context) { // NOP diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 3681d3db48..6d2b6dd203 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -68,8 +68,7 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf { var knownEndpoints = monitoring.GetKnownEndpoints(); - // No version: this list is stitched together here from sources with no shared count or - // timestamp to watch. + // No version: this list lives in memory and no store version covers it. Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count, isStale: false), pagingInfo); return knownEndpoints; } From cd3d0b25c2e2c6e40aed9f193a389f35ee1d6e57 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 21:11:51 +0800 Subject: [PATCH 17/36] Fix versioning paged results --- .../FailedMessageQueryResults.cs | 4 +- .../MessagesViewQueryResults.cs | 4 +- .../FailedMessageQueryFilters.cs | 10 ++++ .../ErrorMessagesDataStore.cs | 14 +++--- .../RavenQueryStatisticsExtensions.cs | 14 ++++++ .../FailedMessageQueryDataStoreTests.cs | 21 ++++++++- .../MessagesViewVersionTests.cs | 47 +++++++++++++++++++ .../Infrastructure/DataVersion.cs | 30 +++++++++++- 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs index c349a9a696..bcbdc1c583 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.ToPagedQueryStatsInfo(total)); } } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs index 006a69170f..f83cd2c27e 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.ToPagedQueryStatsInfo(total)); } } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index 0b13ebcaad..db14adfc32 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -176,9 +176,19 @@ public static async Task ToQueryStatsInfo(this IQueryable + /// Versions the rows this page renders, plus the total behind Total-Count. + /// + public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => + new(DataVersion.OverPage([("total", total)], page, row => [row.UniqueMessageId, row.LastModified]), + total, + 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.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index a381261bd6..865a1e1aac 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -53,7 +53,7 @@ public async Task>> GetAllMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task>> GetAllMessagesForEndpoint( @@ -79,7 +79,7 @@ public async Task>> GetAllMessagesForEndpoint( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task>> SearchEndpointMessages( @@ -104,7 +104,7 @@ public async Task>> SearchEndpointMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task>> GetAllMessagesByConversation( @@ -126,7 +126,7 @@ public async Task>> GetAllMessagesByConversation var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task>> GetAllMessagesForSearch( @@ -149,7 +149,7 @@ public async Task>> GetAllMessagesForSearch( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task MarkAsArchived(string failedMessageId, CancellationToken cancellationToken = default) @@ -200,7 +200,7 @@ public async Task>> GetFailedMessages( var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task GetFailedMessagesStats( @@ -247,7 +247,7 @@ public async Task>> GetFailedMessagesByEndp var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); } public async Task> GetFailedMessagesSummary(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index b6a548c785..821283c5bd 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -1,10 +1,24 @@ namespace ServiceControl.Persistence { + using System; + using System.Collections.Generic; using Raven.Client.Documents.Session; using ServiceControl.Persistence.Infrastructure; static class RavenQueryStatisticsExtensions { + /// + /// For a paged query. The index etag covers whether the data moved, and the row ids cover which + /// rows this page renders. The etag alone cannot: it is a function of index and collection state, + /// so every filter, page and sort over one index shares it. + /// + public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id) => + new(stats.ResultEtag is { } resultEtag + ? DataVersion.OverPage([("index", resultEtag), ("total", stats.TotalResults)], page, row => [id(row)]) + : DataVersion.None, + stats.TotalResults, + stats.IsStale); + public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => new(stats.ResultEtag is { } resultEtag ? DataVersion.FromToken(resultEtag) : DataVersion.None, stats.TotalResults, diff --git a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs index b979c8c202..1f5d7b3983 100644 --- a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs @@ -311,7 +311,26 @@ public async Task Reports_stats_matching_the_query() { Assert.That(stats.TotalCount, Is.EqualTo(1)); Assert.That(stats.TotalCount, Is.EqualTo(query.QueryStats.TotalCount)); - Assert.That(stats.Version.Matches(query.QueryStats.Version), Is.True); + Assert.That(stats.Version.HasValue, Is.True, "the count endpoint still has to be cacheable"); + Assert.That(stats.Version.Matches(query.QueryStats.Version), Is.False); + } + } + + [Test] + public async Task Two_filters_that_render_different_rows_do_not_share_a_version() + { + await Insert(new IngestedFailure().ToFailedMessage(), new IngestedFailure().ToFailedMessage(FailedMessageStatus.Archived)); + + var unresolved = await FailedMessageQueryStore.GetFailedMessages("unresolved", null, null, new PagingInfo(), new SortInfo()); + var archived = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(unresolved.Results, Has.Count.EqualTo(1), "one unresolved message"); + Assert.That(archived.Results, Has.Count.EqualTo(1), "and one archived, so the counts cannot tell the two apart"); + Assert.That(Ids(archived), Is.Not.EquivalentTo(Ids(unresolved)), "and the two pages render different rows"); + Assert.That(archived.QueryStats.Version.Matches(unresolved.QueryStats.Version), Is.False, + "two different bodies sharing a validator lets a client that reuses one across views be served the wrong page"); } } diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs index 5a19e131d1..86fe9e782f 100644 --- a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -96,6 +96,53 @@ public async Task Version_is_stable_while_nothing_changes() "nothing changed, so the validator has to stay put or conditional GET never pays off"); } + [Test] + public async Task Two_pages_of_one_set_do_not_share_a_version() + { + for (var i = 0; i < 3; i++) + { + await Ingest(new IngestedFailure()); + } + + await CompleteDatabaseOperation(); + + var firstPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); + var secondPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 2, pageSize: 2), new SortInfo(), includeSystemMessages: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two rows on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + Assert.That(secondPage.QueryStats.Version.Matches(firstPage.QueryStats.Version), Is.False, + "a client following the Link rel=next header while revalidating would otherwise render page one as page two"); + } + } + + [Test] + public async Task A_page_keeps_its_version_when_a_row_it_does_not_show_changes() + { + var shown = new IngestedFailure(); + + await Ingest(shown); + await CompleteDatabaseOperation(); + + var before = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 1), new SortInfo(), includeSystemMessages: true); + + AdvanceClock(TimeSpan.FromMinutes(5)); + + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var after = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 1), new SortInfo(), includeSystemMessages: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one row on the page"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "Total-Count went from one to two, and the body reports it, so the validator has to move"); + } + } + [Test] public async Task An_empty_store_still_reports_a_version() { diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index 29c18d7567..b48ac7f4fe 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -66,6 +66,27 @@ public static DataVersion Compose(params (string Name, object Value)[] terms) => ? None : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString()); + /// + /// A version for one page of a result set. covers what the response says + /// about the whole set, such as the total behind Total-Count, and one term per row covers which rows + /// this page actually renders. + /// + public static DataVersion OverPage((string Name, object Value)[] state, IEnumerable rows, Func fields) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(fields); + + var terms = new List<(string Name, object Value)>(state ?? []); + 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 @@ -152,8 +173,13 @@ public bool Equals(DataVersion other) => 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}:{value.Length.ToString(CultureInfo.InvariantCulture)}:{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 { From da50d719d69cb92d2849959ee91b46a3d47f0bce Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 21:25:30 +0800 Subject: [PATCH 18/36] Fix versioning issue for message view --- .../FailedMessageQueryFilters.cs | 3 +- .../MessagesViewVersionTests.cs | 24 ++++++++++ .../Infrastructure/DataVersionTests.cs | 48 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index db14adfc32..8bc36ba03a 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -185,7 +185,8 @@ public static async Task ToQueryStatsInfo(this IQueryable public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => - new(DataVersion.OverPage([("total", total)], page, row => [row.UniqueMessageId, row.LastModified]), + new(DataVersion.OverPage([("total", total)], page, + row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]), total, false); diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs index 86fe9e782f..2d6bd32612 100644 --- a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -143,6 +143,30 @@ public async Task A_page_keeps_its_version_when_a_row_it_does_not_show_changes() } } + [Test] + public async Task Version_changes_when_a_row_on_the_page_changes_status() + { + var archived = new IngestedFailure(); + + await Ingest(archived, new IngestedFailure()); + await CompleteDatabaseOperation(); + + var before = await AllMessages(); + + await FailedMessageLifecycleStore.MarkAsArchived(archived.UniqueMessageIdString); + await CompleteDatabaseOperation(); + + var after = await AllMessages(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.Results, Has.Count.EqualTo(2), "both messages are still on the page"); + Assert.That(after.QueryStats.TotalCount, Is.EqualTo(before.QueryStats.TotalCount), "and the total has not moved"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body reports the new status, so a revalidating client must not be told its page is current"); + } + } + [Test] public async Task An_empty_store_still_reports_a_version() { diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index 5a666a0bfe..b06b68184c 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -106,6 +106,54 @@ public void Compose_with_no_terms_is_absent() Assert.That(DataVersion.Compose().HasValue, Is.False); } + [Test] + public void OverPage_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 OverPage_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 OverPage_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 OverPage_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.OverPage([("total", 9L)], [row], Fields) + .Matches(DataVersion.OverPage([("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"); + } + + static DataVersion Page(params (string Id, DateTime At, string Status)[] rows) => + DataVersion.OverPage([("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() { From 5af79d14838352dd2545e938980b5c04050f67f2 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 21:38:29 +0800 Subject: [PATCH 19/36] Fix custom checks versioning --- .../Infrastructure/CustomCheckQueries.cs | 5 ++- .../CustomCheckVersionTests.cs | 40 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs index 40dd6c4dad..fe526559ce 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs @@ -7,7 +7,10 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; static class CustomCheckQueries { /// - /// Every field of every check the body shows, plus the total. + /// Every field of every check the body shows, plus the total. OriginatingEndpoint has no term of its + /// own and does not need one: Id is a deterministic hash of the endpoint name, its host id and the + /// check id, so naming Id covers all three, and the host string is written once on insert and never + /// updated. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => new(DataVersion.Compose( diff --git a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs index d95276adce..8c41e6a6fc 100644 --- a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Persistence.Tests; using System; +using System.Linq; using System.Threading.Tasks; using Contracts.CustomChecks; using NUnit.Framework; @@ -73,9 +74,36 @@ public async Task An_empty_store_still_reports_a_version() }); } - async Task Report(string customCheckId, bool hasFailed) + [Test] + public async Task Version_changes_when_the_reporting_endpoint_changes_under_an_unchanged_count() { - await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail + var first = await Report("Disk space", hasFailed: false); + + var before = await CustomChecks.GetStats(new PagingInfo()); + + // OriginatingEndpoint has no version term of its own. It is covered because the row id is a + // deterministic hash of it, so swapping the endpoint swaps the id while everything else, the + // count included, stays exactly where it was. + await CustomChecks.DeleteCustomCheck(first); + await Report("Disk space", hasFailed: false, endpointName: "other-host"); + + var after = await CustomChecks.GetStats(new PagingInfo()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(after.Results, Has.Count.EqualTo(1), "still one check"); + Assert.That(after.QueryStats.TotalCount, Is.EqualTo(before.QueryStats.TotalCount), "and the total has not moved"); + Assert.That(after.Results.Single().Id, Is.Not.EqualTo(before.Results.Single().Id), "but it is a different check"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the body reports a check from a different endpoint, so the validator has to move"); + } + } + + // Returns the row's deterministic id, which the backends could render differently. + async Task Report(string customCheckId, bool hasFailed, string endpointName = "test-host") + { + var detail = new CustomCheckDetail { Category = "test-category", CustomCheckId = customCheckId, @@ -86,10 +114,14 @@ await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail { Host = "localhost", HostId = Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"), - Name = "test-host" + Name = endpointName } - }); + }; + + await CustomChecks.UpdateCustomCheckStatus(detail); await CompleteDatabaseOperation(); + + return detail.GetDeterministicId(); } } From 924cde16bc1829d5c5dcb1bd3e76df634d0e4893 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 22:00:34 +0800 Subject: [PATCH 20/36] Improve and add tests --- ...hen_a_request_is_repeated_with_its_etag.cs | 37 ++++++++ .../Infrastructure/FailureGroupQueries.cs | 4 + .../ArchivedGroupVersionTests.cs | 30 +++--- .../FailureGroupVersionTests.cs | 78 +++++++++++++++ .../WebApi/ConditionalGetTests.cs | 67 +++++++++++++ .../ScatterGatherVersionTests.cs | 94 +++++++++++++++++++ .../WebApi/HttpRequestExtensions.cs | 16 +++- 7 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs 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 5b1a6bc276..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,6 +2,7 @@ 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; @@ -52,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.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index 6acecd2cd1..e73ebf22bd 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -23,6 +23,10 @@ into aggregate Last = aggregate.Max(message => message.LastTimeOfFailure) }; + /// + /// Title and Type cannot move within a row, because AggregateGroups groups by them, so a change to + /// either is a different row rather than a changed one. + /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => new(DataVersion.Compose( ("groups", groups.Count), diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs index a60a4fae52..e5db8e389c 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs @@ -18,22 +18,21 @@ class ArchivedGroupVersionTests : PersistenceTestBase [Test] public async Task Version_changes_when_group_counts_move_but_the_total_and_the_span_hold() { - var stays = NewGroup("Shipping"); - var goes = NewGroup("Billing"); + var shipping = NewGroup("Shipping"); + var billing = NewGroup("Billing"); - var oldest = InGroup(stays, Oldest); - var newest = InGroup(stays, Newest); - var middle = InGroup(goes, Middle); + var oldest = InGroup(shipping, Oldest); + var middle = InGroup(shipping, Middle); + var newest = InGroup(billing, Newest); - await Insert(oldest, newest, middle); - await Archive(oldest, newest, middle); + await Insert(oldest, middle, newest); + await Archive(oldest, middle, newest); var before = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); - // One message leaves the archived set and another joins it in the same span, so the total - // stays at three and neither the earliest nor the latest failure moves. Only the per group - // counts change, and those are what the body reports. - var replacement = InGroup(stays, Middle); + // The archived set keeps two groups, three messages, and the same earliest and latest failure. + // All that moves is how the three are split between the groups, from two and one to one and two. + var replacement = InGroup(billing, Middle); await Insert(replacement); await Archive(replacement); _ = await FailedMessageLifecycleStore.UnArchiveMessages([middle.UniqueMessageIdString]); @@ -44,11 +43,14 @@ public async Task Version_changes_when_group_counts_move_but_the_total_and_the_s using (Assert.EnterMultipleScope()) { Assert.That(before.Results, Has.Count.EqualTo(2), "two archived groups to start with"); - Assert.That(after.Results, Has.Count.EqualTo(1), "and one afterwards, so the body definitely changed"); - Assert.That(after.Results.Single().Count, Is.EqualTo(3), "carrying all three archived messages"); + Assert.That(after.Results, Has.Count.EqualTo(2), "and still two afterwards"); + Assert.That(after.Results.Sum(group => group.Count), Is.EqualTo(3), "still three archived messages between them"); + Assert.That(after.Results.Max(group => group.Last), Is.EqualTo(before.Results.Max(group => group.Last)), "and the latest failure has not moved"); + Assert.That(before.Results.Single(group => group.Title == "Shipping").Count, Is.EqualTo(2), "Shipping held two of them"); + Assert.That(after.Results.Single(group => group.Title == "Shipping").Count, Is.EqualTo(1), "and now holds one, so the split between the groups moved"); Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, - "the body changed, so the validator must too, or a revalidating client keeps a group that is gone"); + "the body reports a different count per group, so the validator cannot stay put"); } } diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs index 9251ca0267..855705be58 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.Tests; using System.Threading.Tasks; using NUnit.Framework; using ServiceControl.MessageFailures; +using ServiceControl.Persistence.Infrastructure; [TestFixture] class FailureGroupVersionTests : PersistenceTestBase @@ -101,6 +102,83 @@ public async Task Version_is_stable_while_nothing_changes() "nothing changed, so the validator has to stay put or conditional GET never pays off"); } + [Test] + public async Task The_errors_in_a_group_report_a_version_that_moves_with_them() + { + var group = NewGroup(); + var middle = InGroup(group, Middle); + + await Insert(InGroup(group, Oldest), middle, InGroup(group, Newest)); + + var before = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo()); + + await FailedMessageLifecycleStore.MarkAsArchived(middle.UniqueMessageIdString); + await CompleteDatabaseOperation(); + + var after = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.Results, Has.Count.EqualTo(3), "three errors to start with"); + Assert.That(after.Results, Has.Count.EqualTo(2), "and the body now reports two"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, + "the page lost a row, so the validator cannot stay put"); + } + } + + [Test] + public async Task The_error_count_of_a_group_reports_a_version_that_moves_with_it() + { + var group = NewGroup(); + var middle = InGroup(group, Middle); + + await Insert(InGroup(group, Oldest), middle, InGroup(group, Newest)); + + var before = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); + + await FailedMessageLifecycleStore.MarkAsArchived(middle.UniqueMessageIdString); + await CompleteDatabaseOperation(); + + var after = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.TotalCount, Is.EqualTo(3)); + Assert.That(after.TotalCount, Is.EqualTo(2), "the count the body reports has changed"); + Assert.That(before.Version.HasValue, Is.True, "there was no version to move"); + Assert.That(after.Version.Matches(before.Version), Is.False); + } + } + + [Test] + public async Task An_archived_group_reports_a_version_that_moves_with_it() + { + var group = NewGroup(); + var oldest = InGroup(group, Oldest); + var middle = InGroup(group, Middle); + + await Insert(oldest, middle, InGroup(group, Newest)); + await FailedMessageLifecycleStore.MarkAsArchived(oldest.UniqueMessageIdString); + await FailedMessageLifecycleStore.MarkAsArchived(middle.UniqueMessageIdString); + await CompleteDatabaseOperation(); + + var before = await GroupsStore.GetArchivedGroup(group.Id, null, null); + + _ = await FailedMessageLifecycleStore.UnArchiveMessages([middle.UniqueMessageIdString]); + await CompleteDatabaseOperation(); + + var after = await GroupsStore.GetArchivedGroup(group.Id, null, null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.Results.Count, Is.EqualTo(2), "two archived errors to start with"); + Assert.That(after.Results.Count, Is.EqualTo(1), "and the body now reports one"); + Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); + Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False); + } + } + [Test] public async Task A_group_that_does_not_exist_still_reports_a_version() { diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 7669e25c00..2c760cad8c 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -192,6 +192,73 @@ public void A_wildcard_precondition_is_ignored_when_there_is_no_validator() "an endpoint that publishes no validator has nothing for a client to have cached"); } + [Test] + public void A_caller_holding_one_validator_hands_it_to_the_store() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Request.Headers.IfNoneMatch = "W/\"4611686018427387904\""; + + Assert.That(httpContext.Request.GetKnownVersion().Matches(DataVersion.FromToken("4611686018427387904")), Is.True, + "the store skips its whole query on this, so it has to survive the round trip through the header"); + } + + [Test] + public void A_version_survives_the_round_trip_out_as_a_header_and_back() + { + var issued = DataVersion.FromToken("4611686018427387904"); + + var httpContext = new DefaultHttpContext(); + httpContext.Response.WithEtag(issued); + httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; + + Assert.That(httpContext.Request.GetKnownVersion().Matches(issued), Is.True, + "the store cannot skip work for a version it can no longer recognise coming back"); + } + + [Test] + public void An_exact_version_survives_the_round_trip_too() + { + var issued = DataVersion.FromContent("cv-1"); + + var httpContext = new DefaultHttpContext(); + httpContext.Response.WithEtag(issued); + httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; + + Assert.That(httpContext.Request.GetKnownVersion().Matches(issued), Is.True, + "an unmarked tag goes out without the W/ prefix, so the return path has to cope with both shapes"); + } + + [Test] + public void A_caller_holding_several_validators_hands_the_store_none() + { + var httpContext = new DefaultHttpContext(); + + // RFC 9110 allows a list. Reading the raw header would hand the store the whole list as one + // malformed validator, which matches nothing and silently costs it the short circuit. + httpContext.Request.Headers.IfNoneMatch = "\"first\", \"second\""; + + Assert.That(httpContext.Request.GetKnownVersion().HasValue, Is.False, + "a store can only skip work for a single known version"); + } + + [Test] + public void A_wildcard_precondition_is_not_a_known_version() + { + var httpContext = new DefaultHttpContext(); + + httpContext.Request.Headers.IfNoneMatch = "*"; + + Assert.That(httpContext.Request.GetKnownVersion().HasValue, Is.False, + "the wildcard asks whether any representation exists, which is not a version a store can match"); + } + + [Test] + public void A_caller_holding_nothing_hands_the_store_nothing() + { + Assert.That(new DefaultHttpContext().Request.GetKnownVersion().HasValue, Is.False); + } + static ResultExecutingContext ResultExecuting(HttpContext httpContext) => new( new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), diff --git a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs new file mode 100644 index 0000000000..d14446f9f3 --- /dev/null +++ b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs @@ -0,0 +1,94 @@ +namespace ServiceControl.UnitTests.ScatterGather +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using System.Threading; + 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 settings = new Settings(); + var api = new RemoteOnlyApi(settings); + + var composite = api.AggregateResults(Context(), [NoLocalData(settings.InstanceId), 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_reports_no_version_when_no_remote_answered() + { + var settings = new Settings(); + var api = new RemoteOnlyApi(settings); + + var composite = api.AggregateResults(Context(), [NoLocalData(settings.InstanceId)]); + + 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, isStale: false)) + { + InstanceId = instanceId + }; + + // What ScatterGatherRemoteOnly.LocalQuery returns: no rows and no version. + static QueryResult> NoLocalData(string instanceId) => + new(null, QueryStatsInfo.Zero) { InstanceId = instanceId }; + + class LocalAndRemoteApi() : ScatterGatherApiMessageView( + null, null, null, null, NullLogger.Instance) + { + protected override Task>> LocalQuery(ScatterGatherApiMessageViewContext input, CancellationToken cancellationToken = default) => + throw new System.NotImplementedException(); + } + + class RemoteOnlyApi(Settings settings) : ScatterGatherRemoteOnly>( + settings, null, null, NullLogger.Instance) + { + protected override IList ProcessResults(ScatterGatherApiMessageViewContext input, QueryResult>[] results) => + [.. results.Where(result => result.Results is not null).SelectMany(result => result.Results)]; + } + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs index dcb4719231..482e504750 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs @@ -1,6 +1,6 @@ namespace ServiceControl.Infrastructure.WebApi { - using System.Linq; + using System; using Microsoft.AspNetCore.Http; using Persistence.Infrastructure; @@ -9,7 +9,17 @@ static class HttpRequestExtensions /// /// The version the caller already holds, or if it holds none. /// - public static DataVersion GetKnownVersion(this HttpRequest request) => - DataVersion.FromClient(request.Headers.IfNoneMatch.FirstOrDefault()); + public static DataVersion GetKnownVersion(this HttpRequest request) + { + // Read through typed headers, because If-None-Match is a comma separated list and reading the + // raw header hands the whole list over as one malformed validator. A store can only skip work + // for a single known version, so a caller holding several is treated as holding none, and so is + // the "*" wildcard. + var candidates = request.GetTypedHeaders().IfNoneMatch; + + return candidates is { Count: 1 } && !candidates[0].Tag.Equals("*", StringComparison.Ordinal) + ? DataVersion.FromClient(candidates[0].ToString()) + : DataVersion.None; + } } } From f9c7498a23f0339406c7ec51ef02e9edce11542f Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 22:10:40 +0800 Subject: [PATCH 21/36] Remove the unused strong tag --- .../RavenAttachmentsBodyStorage.cs | 3 +- .../Recoverability/RetryHistoryDataStore.cs | 4 +- .../Infrastructure/DataVersion.cs | 37 ++++------------ .../Infrastructure/DataVersionTests.cs | 41 ++---------------- .../WebApi/ConditionalGetTests.cs | 42 ------------------- .../WebApi/HttpResponseExtensions.cs | 5 +-- 6 files changed, 16 insertions(+), 116 deletions(-) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs index 7b96afeed3..98772b9496 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenAttachmentsBodyStorage.cs @@ -73,8 +73,7 @@ async Task ResultForUniqueId(IAsyncDocumentSession session, s result.Stream, result.Details.ContentType, (int)result.Details.Size, - // The change vector moves whenever the stored bytes do. - DataVersion.FromContent(result.Details.ChangeVector))); + DataVersion.FromToken(result.Details.ChangeVector))); } } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs index 2bda8cc492..2756805ec7 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs @@ -12,7 +12,7 @@ class RetryHistoryDataStore(IRavenSessionProvider sessionProvider) : IRetryHisto // Before the first operation completes there is no document and so no change vector, but an // empty history still has to be cacheable. - static readonly DataVersion EmptyHistory = DataVersion.FromContent("no-retry-history"); + static readonly DataVersion EmptyHistory = DataVersion.FromToken("no-retry-history"); public async Task> GetRetryHistory(CancellationToken cancellationToken = default) { @@ -21,7 +21,7 @@ public async Task> GetRetryHistory(CancellationToken c var version = retryHistory == null ? EmptyHistory - : DataVersion.FromContent(session.Advanced.GetChangeVectorFor(retryHistory)); + : DataVersion.FromToken(session.Advanced.GetChangeVectorFor(retryHistory)); retryHistory ??= new(); diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index b48ac7f4fe..ea94ff734d 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -23,43 +23,24 @@ namespace ServiceControl.Persistence.Infrastructure public readonly struct DataVersion : IEquatable { readonly string validator; - readonly bool strong; - DataVersion(string validator, bool strong = false) - { - this.validator = validator; - this.strong = strong; - } + DataVersion(string validator) => this.validator = validator; public static readonly DataVersion None = default; public bool HasValue => validator is not null; - /// - /// Whether this promises the bytes are identical, which decides if it goes out marked weak. Only - /// can promise it. ignores it, because RFC 9110 says - /// If-None-Match compares tags without regard to strength. - /// - public bool IsStrong => strong; - - /// A version the backend made itself. Weak: it covers a result set, not the response bytes. + /// 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 backend token that moves whenever the response bytes move, so the tag goes out unmarked. Only - /// the caller can know that holds, so only use it where it demonstrably does. - /// - public static DataVersion FromContent(string token) => - string.IsNullOrEmpty(token) ? None : new DataVersion(token, strong: true); - /// /// 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. Always weak: a summary cannot promise the bytes. + /// a stale page. /// public static DataVersion Compose(params (string Name, object Value)[] terms) => terms is null || terms.Length == 0 @@ -90,7 +71,7 @@ public static DataVersion OverPage((string Name, object Value)[] state, IE /// /// 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. Always weak, whatever went in, since it goes through . + /// composite. /// public static DataVersion Combine(IEnumerable<(string InstanceId, DataVersion Version)> versions) { @@ -149,23 +130,21 @@ public static DataVersion FromClient(string headerValue) /// /// Whether a caller holding already has this version. The only question a - /// store or a conditional-request filter should ask. Ignores : RFC 9110 requires - /// the weak comparison, and a version that came back through has lost its - /// marking anyway. + /// store or a conditional-request filter should ask. /// public bool Matches(DataVersion other) => HasValue && other.HasValue && string.Equals(validator, other.validator, StringComparison.Ordinal); /// - /// Plain value equality, marking included. Never use it to decide whether something changed: it is + /// Plain value equality. Never use it to decide whether something changed: it is /// reflexive, so equals . /// public bool Equals(DataVersion other) => - strong == other.strong && string.Equals(validator, other.validator, StringComparison.Ordinal); + string.Equals(validator, other.validator, StringComparison.Ordinal); public override bool Equals(object obj) => obj is DataVersion other && Equals(other); - public override int GetHashCode() => HashCode.Combine(validator?.GetHashCode(StringComparison.Ordinal) ?? 0, strong); + public override int GetHashCode() => validator?.GetHashCode(StringComparison.Ordinal) ?? 0; /// The validator unquoted, or an empty string for . public override string ToString() => validator ?? string.Empty; diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index b06b68184c..624dcee452 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -248,44 +248,9 @@ public void FromClient_leaves_a_malformed_validator_alone_rather_than_truncating } [Test] - public void Only_FromContent_promises_byte_equivalence() + public void Matching_ignores_the_weak_marking_a_client_sends() { - Assert.Multiple(() => - { - Assert.That(DataVersion.FromContent("cv-1").IsStrong, Is.True); - Assert.That(DataVersion.FromToken("cv-1").IsStrong, Is.False); - Assert.That(DataVersion.FromToken(1L).IsStrong, Is.False); - Assert.That(DataVersion.FromClient("\"cv-1\"").IsStrong, Is.False); - Assert.That(DataVersion.None.IsStrong, Is.False); - }); - } - - [Test] - public void Composing_and_combining_are_never_exact() - { - var exact = DataVersion.FromContent("cv-1"); - - Assert.Multiple(() => - { - Assert.That(DataVersion.Compose(("total", 3L)).IsStrong, Is.False, - "a hash over aggregates cannot promise the bytes are identical"); - Assert.That(DataVersion.Combine([("one", exact), ("two", exact)]).IsStrong, Is.False, - "a composite across instances is an approximation whatever went into it"); - }); - } - - [Test] - public void Matching_ignores_the_marking() - { - // RFC 9110 requires the weak comparison, which ignores the marking. Anything coming back through - // FromClient has lost its marking anyway, so this is the normal case and not an edge one. - Assert.That(DataVersion.FromContent("cv-1").Matches(DataVersion.FromClient("W/\"cv-1\"")), Is.True); - } - - [Test] - public void Equality_does_not_ignore_the_marking() - { - Assert.That(DataVersion.FromContent("cv-1").Equals(DataVersion.FromToken("cv-1")), Is.False, - "Equals is ordinary value equality over everything the struct holds, which is why it must never decide not-modified"); + // RFC 9110 requires If-None-Match to use the weak comparison. + Assert.That(DataVersion.FromToken("cv-1").Matches(DataVersion.FromClient("W/\"cv-1\"")), Is.True); } } diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 2c760cad8c..9d211c2a3c 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -99,35 +99,6 @@ public void An_aggregate_derived_etag_is_marked_weak() }); } - [Test] - public void An_exact_etag_goes_out_unmarked() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Response.WithEtag(DataVersion.FromContent("A:2-abc")); - - Assert.Multiple(() => - { - Assert.That(httpContext.Response.Headers.ETag.ToString(), Is.EqualTo("\"A:2-abc\"")); - Assert.That(httpContext.Response.GetTypedHeaders().ETag.IsWeak, Is.False); - }); - } - - [Test] - public void A_client_holding_a_weak_tag_matches_an_exact_response_tag() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Response.WithEtag(DataVersion.FromContent("A:2-abc")); - httpContext.Request.Headers.IfNoneMatch = "W/\"A:2-abc\""; - - var context = ResultExecuting(httpContext); - - new NotModifiedStatusHttpHandler().OnResultExecuting(context); - - Assert.That(context.Result, Is.InstanceOf(), - "weak comparison ignores strength on both sides, which is what lets an exact and a weak tag over the same value match"); - } [Test] public void A_weak_validator_matches_under_the_comparison_If_None_Match_requires() @@ -216,19 +187,6 @@ public void A_version_survives_the_round_trip_out_as_a_header_and_back() "the store cannot skip work for a version it can no longer recognise coming back"); } - [Test] - public void An_exact_version_survives_the_round_trip_too() - { - var issued = DataVersion.FromContent("cv-1"); - - var httpContext = new DefaultHttpContext(); - httpContext.Response.WithEtag(issued); - httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; - - Assert.That(httpContext.Request.GetKnownVersion().Matches(issued), Is.True, - "an unmarked tag goes out without the W/ prefix, so the return path has to cope with both shapes"); - } - [Test] public void A_caller_holding_several_validators_hands_the_store_none() { diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index 72623d87b9..fffd815aa8 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -21,9 +21,8 @@ public static void WithEtag(this HttpResponse response, DataVersion version) } // Quotes are required by RFC 9110. Without them EntityTagHeaderValue cannot parse the tag and - // NotModifiedStatusHttpHandler never matches a client's If-None-Match. Weak unless the - // producing mechanism moves with the stored bytes. - response.Headers.ETag = version.IsStrong ? $"\"{version}\"" : $"W/\"{version}\""; + // NotModifiedStatusHttpHandler never matches a client's If-None-Match. + response.Headers.ETag = $"W/\"{version}\""; } public static void WithQueryStatsInfo(this HttpResponse response, QueryStatsInfo queryStatsInfo) From 233bed7ffc619da154beb01989847db89f542b87 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 22:29:46 +0800 Subject: [PATCH 22/36] Refactor to use shared OverRows function --- .../Infrastructure/CustomCheckQueries.cs | 7 ++-- .../FailedMessageQueryFilters.cs | 2 +- .../Infrastructure/FailureGroupQueries.cs | 6 ++-- .../Infrastructure/QueueAddressQueries.cs | 5 ++- .../Infrastructure/RetryHistoryQueries.cs | 36 ++++++++++++------- .../RavenQueryStatisticsExtensions.cs | 2 +- .../Infrastructure/DataVersion.cs | 14 +++++--- .../Infrastructure/DataVersionTests.cs | 14 ++++---- .../Recoverability/RetryGroupVersionTests.cs | 20 +++++++++++ .../Recoverability/API/ResponseVersions.cs | 17 ++++----- 10 files changed, 75 insertions(+), 48 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs index fe526559ce..ac1a71f09b 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs @@ -1,6 +1,5 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; -using System; using ServiceControl.Contracts.CustomChecks; using ServiceControl.Persistence.Infrastructure; @@ -13,10 +12,8 @@ static class CustomCheckQueries /// updated. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - new(DataVersion.Compose( - ("checks", totalCount), - ("page", string.Join("|", page.Select(check => FormattableString.Invariant( - $"{check.Id}.{check.CustomCheckId}.{check.Category}.{check.Status}.{check.ReportedAt.Ticks}.{check.FailureReason}"))))), + new(DataVersion.OverRows([("checks", totalCount)], page, + check => [check.Id, check.CustomCheckId, check.Category, check.Status, check.ReportedAt, check.FailureReason]), totalCount, false); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index 8bc36ba03a..c713e5f70a 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -185,7 +185,7 @@ public static async Task ToQueryStatsInfo(this IQueryable public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => - new(DataVersion.OverPage([("total", total)], page, + new(DataVersion.OverRows([("total", total)], page, row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]), total, false); diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index e73ebf22bd..4f4d698872 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -28,10 +28,8 @@ into aggregate /// either is a different row rather than a changed one. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => - new(DataVersion.Compose( - ("groups", groups.Count), - ("state", string.Join("|", groups.Select(group => FormattableString.Invariant( - $"{group.Id}.{group.Title}.{group.Type}.{group.Count}.{group.Comment}.{group.First.Ticks}.{group.Last.Ticks}"))))), + new(DataVersion.OverRows([("groups", groups.Count)], groups, + group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last]), groups.Count, false); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs index afe2aeb363..b529bb404e 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs @@ -9,9 +9,8 @@ static class QueueAddressQueries /// Both fields of every address the body shows, plus the total behind Total-Count. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - new(DataVersion.Compose( - ("addresses", totalCount), - ("page", string.Join("|", page.Select(address => $"{address.PhysicalAddress}={address.FailedMessageCount}")))), + new(DataVersion.OverRows([("addresses", totalCount)], page, + address => [address.PhysicalAddress, address.FailedMessageCount]), totalCount, false); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 17aaabdc23..b6c23dc5c4 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -10,18 +10,30 @@ static class RetryHistoryQueries /// Every field of every operation in both collections, plus each collection's count /// public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => - new(DataVersion.Compose( - ("historic", history.HistoricOperations.Count), - ("historicState", string.Join("|", history.HistoricOperations.Select(operation => FormattableString.Invariant( - $"{operation.RequestId}.{operation.RetryType}.{operation.StartTime.Ticks}.{operation.CompletionTime.Ticks}.{operation.Originator}.{operation.Failed}.{operation.NumberOfMessagesProcessed}")))), - ("unacknowledged", history.UnacknowledgedOperations.Count), - // Sorted because these rows are read without an ORDER BY, so the order they arrive in is - // not a property of the data and must not move the version. - ("unacknowledgedState", string.Join("|", history.UnacknowledgedOperations - .OrderBy(operation => operation.RequestId, StringComparer.Ordinal) - .ThenBy(operation => operation.RetryType) - .Select(operation => FormattableString.Invariant( - $"{operation.RequestId}.{operation.RetryType}.{operation.StartTime.Ticks}.{operation.CompletionTime.Ticks}.{operation.Last.Ticks}.{operation.Originator}.{operation.Classifier}.{operation.Failed}.{operation.NumberOfMessagesProcessed}"))))), + new(DataVersion.OverRows( + [("historic", history.HistoricOperations.Count), ("unacknowledged", history.UnacknowledgedOperations.Count)], + Rows(history), + row => row), history.HistoricOperations.Count, false); + + static IEnumerable Rows(RetryHistory history) + { + // The leading marker keeps a historic row from ever digesting the same as an unacknowledged one. + foreach (var operation in history.HistoricOperations) + { + yield return ["historic", operation.RequestId, operation.RetryType, operation.StartTime, operation.CompletionTime, + operation.Originator, operation.Failed, operation.NumberOfMessagesProcessed]; + } + + // Sorted because these rows are read without an ORDER BY, so the order they arrive in is not a + // property of the data and must not move the version. + foreach (var operation in history.UnacknowledgedOperations + .OrderBy(operation => operation.RequestId, StringComparer.Ordinal) + .ThenBy(operation => operation.RetryType)) + { + yield return ["unacknowledged", operation.RequestId, operation.RetryType, operation.StartTime, operation.CompletionTime, + operation.Last, operation.Originator, operation.Classifier, operation.Failed, operation.NumberOfMessagesProcessed]; + } + } } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index 821283c5bd..f0069e2f02 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -14,7 +14,7 @@ static class RavenQueryStatisticsExtensions /// public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id) => new(stats.ResultEtag is { } resultEtag - ? DataVersion.OverPage([("index", resultEtag), ("total", stats.TotalResults)], page, row => [id(row)]) + ? DataVersion.OverRows([("index", resultEtag), ("total", stats.TotalResults)], page, row => [id(row)]) : DataVersion.None, stats.TotalResults, stats.IsStale); diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index ea94ff734d..a6428551be 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -48,16 +48,19 @@ public static DataVersion Compose(params (string Name, object Value)[] terms) => : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString()); /// - /// A version for one page of a result set. covers what the response says - /// about the whole set, such as the total behind Total-Count, and one term per row covers which rows - /// this page actually renders. + /// 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 OverPage((string Name, object Value)[] state, IEnumerable rows, Func fields) + 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)>(state ?? []); + var terms = new List<(string Name, object Value)>(summary ?? []); var row = 0; foreach (var item in rows) @@ -164,6 +167,7 @@ static string Prefixed(string value) => { 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), diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index 624dcee452..e69dc2a218 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -107,7 +107,7 @@ public void Compose_with_no_terms_is_absent() } [Test] - public void OverPage_moves_when_a_row_changes_under_an_unchanged_timestamp() + public void OverRows_moves_when_a_row_changes_under_an_unchanged_timestamp() { var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); @@ -119,7 +119,7 @@ public void OverPage_moves_when_a_row_changes_under_an_unchanged_timestamp() } [Test] - public void OverPage_distinguishes_two_pages_of_one_set() + public void OverRows_distinguishes_two_pages_of_one_set() { var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); @@ -131,7 +131,7 @@ public void OverPage_distinguishes_two_pages_of_one_set() } [Test] - public void OverPage_holds_while_the_page_and_the_total_hold() + public void OverRows_holds_while_the_page_and_the_total_hold() { var at = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); @@ -139,18 +139,18 @@ public void OverPage_holds_while_the_page_and_the_total_hold() } [Test] - public void OverPage_moves_when_only_the_total_moves() + 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.OverPage([("total", 9L)], [row], Fields) - .Matches(DataVersion.OverPage([("total", 2L)], [row], Fields)), Is.False, + 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"); } static DataVersion Page(params (string Id, DateTime At, string Status)[] rows) => - DataVersion.OverPage([("total", 2L)], rows, Fields); + DataVersion.OverRows([("total", 2L)], rows, Fields); static object[] Fields((string Id, DateTime At, string Status) row) => [row.Id, row.At, row.Status]; diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs index 0942089443..d680659a01 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -235,5 +235,25 @@ public void Changing_item_count_should_change_version() Assert.That(ResponseVersions.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 = ResponseVersions.VersionOf([new GroupOperation { Title = "Shipping.Exception", Type = string.Empty }]); + var split = ResponseVersions.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 = ResponseVersions.VersionOf([new GroupOperation { Id = "a" }, new GroupOperation { Id = "b" }]); + var oneForging = ResponseVersions.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"); + } } } diff --git a/src/ServiceControl/Recoverability/API/ResponseVersions.cs b/src/ServiceControl/Recoverability/API/ResponseVersions.cs index 9213024103..1840e7adfc 100644 --- a/src/ServiceControl/Recoverability/API/ResponseVersions.cs +++ b/src/ServiceControl/Recoverability/API/ResponseVersions.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.MessageRedirects; @@ -10,14 +8,13 @@ static class ResponseVersions { internal static DataVersion VersionOf(GroupOperation[] groups) => - DataVersion.Compose( - ("groups", groups.Length), - ("state", string.Join("|", groups.Select(group => FormattableString.Invariant( - $"{group.Id}.{group.Title}.{group.Type}.{group.Count}.{group.Comment}.{group.First?.Ticks}.{group.Last?.Ticks}.{group.OperationStatus}.{group.OperationFailed}.{group.OperationProgress}.{group.OperationMessagesCompletedCount}.{group.OperationRemainingCount}.{group.OperationStartTime?.Ticks}.{group.OperationCompletionTime?.Ticks}.{group.NeedUserAcknowledgement}"))))); + DataVersion.OverRows([("groups", groups.Length)], groups, + group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last, + group.OperationStatus, group.OperationFailed, group.OperationProgress, group.OperationMessagesCompletedCount, + group.OperationRemainingCount, group.OperationStartTime, group.OperationCompletionTime, group.NeedUserAcknowledgement]); + // FromPhysicalAddress needs no field of its own: MessageRedirectId is a deterministic hash of it. internal static DataVersion VersionOf(IReadOnlyList redirects) => - DataVersion.Compose( - ("redirects", redirects.Count), - ("state", string.Join("|", redirects.Select(redirect => FormattableString.Invariant( - $"{redirect.MessageRedirectId}.{redirect.ToPhysicalAddress}.{redirect.LastModified.Ticks}"))))); + DataVersion.OverRows([("redirects", redirects.Count)], redirects, + redirect => [redirect.MessageRedirectId, redirect.ToPhysicalAddress, redirect.LastModified]); } From e24e5b56bf216401527de14e3d3303e081f29bb2 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 22:41:19 +0800 Subject: [PATCH 23/36] Fix ordering for retry history --- .../Implementation/RetryHistoryDataStore.cs | 4 ++++ .../Infrastructure/RetryHistoryQueries.cs | 8 +++----- .../EFCore/RetryHistoryDataStoreTests.cs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs index fed44f33e8..17db0a9281 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryHistoryDataStore.cs @@ -31,6 +31,10 @@ public Task> GetRetryHistory(CancellationToken cancell 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, diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index b6c23dc5c4..033ac0ad68 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -26,11 +26,9 @@ static IEnumerable Rows(RetryHistory history) operation.Originator, operation.Failed, operation.NumberOfMessagesProcessed]; } - // Sorted because these rows are read without an ORDER BY, so the order they arrive in is not a - // property of the data and must not move the version. - foreach (var operation in history.UnacknowledgedOperations - .OrderBy(operation => operation.RequestId, StringComparer.Ordinal) - .ThenBy(operation => operation.RetryType)) + // Rows are named by position, so both collections have to arrive in a deterministic order. Each is + // ordered by its query in RetryHistoryDataStore, historic by completion time and these by their key. + foreach (var operation in history.UnacknowledgedOperations) { yield return ["unacknowledged", operation.RequestId, operation.RetryType, operation.StartTime, operation.CompletionTime, operation.Last, operation.Originator, operation.Classifier, operation.Failed, operation.NumberOfMessagesProcessed]; diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs index f7bac8b0a6..5c7c38e331 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetryHistoryDataStoreTests.cs @@ -209,6 +209,20 @@ public async Task Does_not_acknowledge_an_operation_of_another_retry_type() } } + [Test] + public async Task Unacknowledged_operations_are_read_in_key_order() + { + await RecordCompleted("group-c"); + await RecordCompleted("group-a"); + await RecordCompleted("group-b"); + await CompleteDatabaseOperation(); + + var history = (await RetryHistoryStore.GetRetryHistory()).Results; + + Assert.That(history.UnacknowledgedOperations.Select(operation => operation.RequestId), + Is.EqualTo(new[] { "group-a", "group-b", "group-c" })); + } + Task RecordCompleted(string requestId, RetryType retryType = RetryType.FailureGroup, DateTime? completionTime = null, string originator = "OrderPlaced failures", string classifier = "Exception Type and Stack Trace", bool failed = false, int numberOfMessagesProcessed = 1, int depth = DefaultDepth) From aac3aa490d00ce683d86c39f5c88c48930d11d1f Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 22:51:01 +0800 Subject: [PATCH 24/36] Cleanup --- .../Implementation/BodyStorage/BodyStorage.cs | 4 +--- .../Infrastructure/RetryHistoryQueries.cs | 1 - .../Recoverability/RetryHistoryDataStore.cs | 2 ++ .../Recoverability/MessageRedirectVersionTests.cs | 1 + .../Recoverability/RetryGroupVersionTests.cs | 1 + .../MessageRedirects/Api/MessageRedirectsController.cs | 1 + src/ServiceControl/Recoverability/API/ResponseVersions.cs | 2 ++ 7 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs index 5fc5d9e9df..02c93e381d 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs @@ -29,8 +29,6 @@ public async Task TryFetch(string bodyId, CancellationToken c return MessageBodyResult.NotFound(); } - 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( @@ -39,7 +37,7 @@ public async Task TryFetch(string bodyId, CancellationToken c if (row.BodyStoredExternally) { - var external = await storagePersistence.ReadBody(uniqueMessageId, cancellationToken); + var external = await storagePersistence.ReadBody(row.UniqueMessageId.ToString(), cancellationToken); if (external == null) { diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 033ac0ad68..8d7be7d873 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -1,6 +1,5 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; -using System; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs index 2756805ec7..955dbdb117 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs @@ -19,6 +19,8 @@ public async Task> GetRetryHistory(CancellationToken c using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var retryHistory = await session.LoadAsync(DocumentId, cancellationToken); + // GetChangeVectorFor throws for an entity the session is not tracking, so this relies on the + // session provider's default. Opening this one with NoTracking would turn the endpoint into a 500. var version = retryHistory == null ? EmptyHistory : DataVersion.FromToken(session.Advanced.GetChangeVectorFor(retryHistory)); diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs index 3bc540df52..7fee0c5aae 100644 --- a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs @@ -4,6 +4,7 @@ namespace ServiceControl.UnitTests.Operations using System.Collections.Generic; using NUnit.Framework; using ServiceControl.Persistence.MessageRedirects; + using ServiceControl.Recoverability.API; [TestFixture] public class MessageRedirectVersionTests diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs index d680659a01..50d1a9bff3 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -3,6 +3,7 @@ namespace ServiceControl.UnitTests.Operations using System; using NUnit.Framework; using ServiceControl.Recoverability; + using ServiceControl.Recoverability.API; [TestFixture] public class RetryGroupVersionTests diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index f506d31526..a52ed07595 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -12,6 +12,7 @@ using Infrastructure.DomainEvents; using Infrastructure.WebApi; using MessageFailures.InternalMessages; + using Recoverability.API; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; diff --git a/src/ServiceControl/Recoverability/API/ResponseVersions.cs b/src/ServiceControl/Recoverability/API/ResponseVersions.cs index 1840e7adfc..865918eb80 100644 --- a/src/ServiceControl/Recoverability/API/ResponseVersions.cs +++ b/src/ServiceControl/Recoverability/API/ResponseVersions.cs @@ -1,3 +1,5 @@ +namespace ServiceControl.Recoverability.API; + using System.Collections.Generic; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.MessageRedirects; From 74211ec46af295056018616c5e1a509b336f606d Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 23:03:03 +0800 Subject: [PATCH 25/36] Abstract away the IsStale boolean for EF --- .../Implementation/EventLogDataStore.cs | 2 +- .../Implementation/RetryBatchStore.cs | 2 +- .../Infrastructure/CustomCheckQueries.cs | 5 ++--- .../Infrastructure/FailedMessageQueryFilters.cs | 7 +++---- .../Infrastructure/FailureGroupQueries.cs | 5 ++--- .../Infrastructure/QueueAddressQueries.cs | 5 ++--- .../Infrastructure/RetryHistoryQueries.cs | 5 ++--- .../Recoverability/RetryHistoryDataStore.cs | 2 +- .../Infrastructure/QueryStatsInfo.cs | 9 ++++++++- .../Infrastructure/WebApi/ConditionalGetTests.cs | 2 +- .../ScatterGather/MessageView_ScatterGatherTest.cs | 2 +- .../ScatterGather/ScatterGatherVersionTests.cs | 2 +- .../CompositeViews/Messages/ScatterGatherApi.cs | 2 +- .../Monitoring/Web/EndpointsMonitoringController.cs | 2 +- 14 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs index 357797c7bb..29a49b3ee8 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -44,7 +44,7 @@ public Task>> GetEventLogItems( var total = stats?.Total ?? 0; var version = Version(total, stats?.Newest, stats?.HighestId); - var queryStats = new QueryStatsInfo(version, total, isStale: false); + var queryStats = QueryStatsInfo.Fresh(version, total); // The point of knownVersion. Everything above is index work. // If the caller already has the latest version, skip the rest of the query. diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs index a9b0cc9d0f..bf857ac513 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs @@ -112,7 +112,7 @@ public Task>> GetOrphanedBatches(string retrySessi IList batches = [.. orphaned.Select(batch => batch.ToRetryBatch(messageCounts.GetValueOrDefault(batch.Id)))]; // No version: orphaned batches are consumed by the retry session, never by a caching client. - return new QueryResult>(batches, new QueryStatsInfo(DataVersion.None, batches.Count, false)); + return new QueryResult>(batches, QueryStatsInfo.Fresh(DataVersion.None, batches.Count)); }, cancellationToken); public Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs index ac1a71f09b..01daad17c9 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs @@ -12,8 +12,7 @@ static class CustomCheckQueries /// updated. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - new(DataVersion.OverRows([("checks", totalCount)], page, + QueryStatsInfo.Fresh(DataVersion.OverRows([("checks", totalCount)], page, check => [check.Id, check.CustomCheckId, check.Category, check.Status, check.ReportedAt, check.FailureReason]), - totalCount, - false); + totalCount); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index c713e5f70a..e956e10672 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -178,17 +178,16 @@ public static async Task ToQueryStatsInfo(this IQueryable /// Versions the rows this page renders, plus the total behind Total-Count. /// public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => - new(DataVersion.OverRows([("total", total)], page, + QueryStatsInfo.Fresh(DataVersion.OverRows([("total", total)], page, row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]), - total, - false); + total); static IOrderedQueryable OrderBy(this IQueryable source, System.Linq.Expressions.Expression> keySelector, bool descending) => descending diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index 4f4d698872..b3727c2b55 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -28,8 +28,7 @@ into aggregate /// either is a different row rather than a changed one. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => - new(DataVersion.OverRows([("groups", groups.Count)], groups, + QueryStatsInfo.Fresh(DataVersion.OverRows([("groups", groups.Count)], groups, group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last]), - groups.Count, - false); + groups.Count); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs index b529bb404e..01f0996569 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs @@ -9,8 +9,7 @@ static class QueueAddressQueries /// Both fields of every address the body shows, plus the total behind Total-Count. /// public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - new(DataVersion.OverRows([("addresses", totalCount)], page, + QueryStatsInfo.Fresh(DataVersion.OverRows([("addresses", totalCount)], page, address => [address.PhysicalAddress, address.FailedMessageCount]), - totalCount, - false); + totalCount); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 8d7be7d873..714d332948 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -9,12 +9,11 @@ static class RetryHistoryQueries /// Every field of every operation in both collections, plus each collection's count /// public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => - new(DataVersion.OverRows( + QueryStatsInfo.Fresh(DataVersion.OverRows( [("historic", history.HistoricOperations.Count), ("unacknowledged", history.UnacknowledgedOperations.Count)], Rows(history), row => row), - history.HistoricOperations.Count, - false); + history.HistoricOperations.Count); static IEnumerable Rows(RetryHistory history) { diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs index 955dbdb117..6e54309a05 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs @@ -28,7 +28,7 @@ public async Task> GetRetryHistory(CancellationToken c retryHistory ??= new(); return new QueryResult(retryHistory, - new QueryStatsInfo(version, retryHistory.HistoricOperations.Count, false)); + QueryStatsInfo.Fresh(version, retryHistory.HistoricOperations.Count)); } public async Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime, diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs index f2971572ba..3c9e768aed 100644 --- a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs +++ b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs @@ -16,6 +16,13 @@ public QueryStatsInfo(DataVersion version, long totalCount, bool isStale, long? HighestTotalCountOfAllTheInstances = highestTotalCountOfAllTheInstances ?? totalCount; } - public static readonly QueryStatsInfo Zero = new(DataVersion.None, 0, false); + /// + /// For a result that cannot be stale (when queries can + /// run against an index that has not caught up). + /// + public static QueryStatsInfo Fresh(DataVersion version, long totalCount) => + new(version, totalCount, isStale: false); + + public static readonly QueryStatsInfo Zero = Fresh(DataVersion.None, 0); } } diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 9d211c2a3c..c3937177af 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -78,7 +78,7 @@ public void A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it() var version = DataVersion.FromToken("4611686018427387904"); httpContext.Response.WithQueryStatsAndPagingInfo( - new QueryStatsInfo(version, totalCount: 1, isStale: false), + QueryStatsInfo.Fresh(version, totalCount: 1), new PagingInfo()); // A hashed validator matches nothing a store holds, so the endpoint can never skip its query. diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs index f0ca1a5b51..9946b1a2e7 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(DataVersion.FromToken(etag), allResults.Count, isStale: false)) + QueryStatsInfo.Fresh(DataVersion.FromToken(etag), allResults.Count)) { InstanceId = instanceId }; diff --git a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs index d14446f9f3..607034d622 100644 --- a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs +++ b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs @@ -68,7 +68,7 @@ public void A_remote_only_api_reports_no_version_when_no_remote_answered() 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, isStale: false)) + new([new MessagesView { MessageId = instanceId }], QueryStatsInfo.Fresh(DataVersion.FromToken(validator), 1)) { InstanceId = instanceId }; diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index af6945dbd6..978697ec35 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -207,7 +207,7 @@ static async Task> ParseResult(HttpResponseMessage responseMes var etag = ReadEtag(responseMessage.Headers); - return new QueryResult(remoteResults, new QueryStatsInfo(etag, totalCount, isStale: false)); + return new QueryResult(remoteResults, QueryStatsInfo.Fresh(etag, totalCount)); } readonly ILogger logger; diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 6d2b6dd203..990aa8e022 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -69,7 +69,7 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf var knownEndpoints = monitoring.GetKnownEndpoints(); // No version: this list lives in memory and no store version covers it. - Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count, isStale: false), pagingInfo); + Response.WithQueryStatsAndPagingInfo(QueryStatsInfo.Fresh(DataVersion.None, knownEndpoints.Count), pagingInfo); return knownEndpoints; } From aed683cbcb2d183cffee2cf5a63b814e983d1c7b Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 23:21:09 +0800 Subject: [PATCH 26/36] Use paged data versioning for eventlogs --- .../Implementation/EventLogDataStore.cs | 12 +++---- .../EventLogDataStore.cs | 7 ++-- .../EventLogDataStoreTests.cs | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs index 29a49b3ee8..4d37d2d909 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -43,7 +43,12 @@ public Task>> GetEventLogItems( .FirstOrDefaultAsync(token); var total = stats?.Total ?? 0; - var version = Version(total, stats?.Newest, stats?.HighestId); + var version = DataVersion.Compose( + ("total", total), + ("newest", stats?.Newest), + ("highestId", stats?.HighestId), + ("page", pagingInfo.Page), + ("pageSize", pagingInfo.PageSize)); var queryStats = QueryStatsInfo.Fresh(version, total); // The point of knownVersion. Everything above is index work. @@ -75,9 +80,4 @@ public Task>> GetEventLogItems( return new QueryResult>(items, queryStats); }, cancellationToken); - - // Rows are never rewritten, only inserted or swept, so the count catches a sweep and the highest - // key catches an insert: identity values gap but never repeat, whatever RaisedAt says. - static DataVersion Version(long total, DateTime? newest, long? highestId) => - DataVersion.Compose(("total", total), ("newest", newest), ("highestId", highestId)); } diff --git a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs index b52ae092bc..c3e02c853e 100644 --- a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs @@ -39,10 +39,11 @@ public async Task>> GetEventLogItems( .Paging(pagingInfo) .ToListAsync(cancellationToken); - var queryStats = stats.ToQueryStatsInfo(); + // Names the ids on the page, not just the index etag, or every page of every filter over this + // index shares one validator. + var queryStats = stats.ToPagedQueryStatsInfo(documents, session.Advanced.GetDocumentId); - // The validator comes off the query statistics, so the page cannot be - // skipped. Only the projection below is saved. + // The page cannot be skipped, only the projection below. if (knownVersion.Matches(queryStats.Version)) { return QueryResult>.Unchanged(queryStats); diff --git a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs index f9f996eb92..f570e505a3 100644 --- a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs @@ -220,6 +220,39 @@ public async Task Matching_known_version_still_reports_total_and_version() } } + [Test] + public async Task Two_pages_do_not_share_a_version() + { + await AddItems(3); + + var firstPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 1, pageSize: 2)); + var secondPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 2, pageSize: 2)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two items on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + Assert.That(secondPage.QueryStats.Version.Matches(firstPage.QueryStats.Version), Is.False, + "sharing one would let the store answer page two out of a caller's cached page one"); + } + } + + [Test] + public async Task A_page_is_not_skipped_for_a_version_from_a_different_page() + { + await AddItems(3); + + var firstPageVersion = (await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 1, pageSize: 2))).QueryStats.Version; + + var secondPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 2, pageSize: 2), firstPageVersion); + + using (Assert.EnterMultipleScope()) + { + Assert.That(secondPage.NotModified, Is.False, "the caller holds another page's validator, so this one still has to be fetched"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1)); + } + } + [Test] public async Task Stale_known_version_returns_the_page() { From a103b2ac2022f0e48a999e079922e2337e959ebd Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 19 Aug 2026 23:33:09 +0800 Subject: [PATCH 27/36] add data version design doc --- docs/data-versioning-design.md | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/data-versioning-design.md diff --git a/docs/data-versioning-design.md b/docs/data-versioning-design.md new file mode 100644 index 0000000000..5cee555d98 --- /dev/null +++ b/docs/data-versioning-design.md @@ -0,0 +1,64 @@ +# 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 any field the response body renders can change without the version changing, a client caches that page indefinitely, and nothing reveals it.** No log line, no exception, no failing test. Every design decision below follows from that asymmetry: a version that moves too often costs a redundant download, a version that moves too rarely serves wrong data. + +So the version has to cover the response, not the data. Two requests that render different bodies must not share a validator, which is why paged endpoints name the page and not only the underlying set. + +## 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. + +## Two comparisons, and they are not the same question + +- `Matches(other)` is the cache question, and the only one a store or a conditional-request filter should ask. It requires both sides present. +- `Equals(other)` is ordinary value equality and stays reflexive, so `None.Equals(None)` is true and the struct behaves in a dictionary. + +`operator ==` is deliberately left undefined so that choosing between them is explicit at the call site. + +## 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. + +`GetKnownVersion` reads the caller's validator back through typed headers, not the raw header, because `If-None-Match` is a comma-separated list and the raw header hands the whole list over as one malformed value. A caller holding several validators, or the `*` wildcard, is treated as holding none: a store can only skip work for a single known version. The `304` still comes from the filter either way. + +## Skipping the query + +`GET /api/eventlogitems` is the **only** endpoint that hands the caller's version down to the persister: `IEventLogDataStore.GetEventLogItems` takes a `knownVersion`, and on a match returns `QueryResult.Unchanged` without fetching the page at all. Everywhere else the version is compared after the work is done and only the response body is saved. + +That makes the coverage rule sharper here than anywhere else. A page-blind version does not merely serve a stale page, it means the right page is never queried. The EF store therefore names the page window (`page`, `pageSize`) rather than the rows, which is sound only because a row in that table never changes and the query has a total order, and which keeps the caller's version answerable without fetching anything. + +## 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. From e1e33480194283ba9e3b0c5ac761f6b0e5214c5a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 20 Aug 2026 12:27:56 +0800 Subject: [PATCH 28/36] Changes from review --- .../Implementation/CustomCheckDataStore.cs | 1 + .../FailedMessageQueryFilters.cs | 2 +- .../Infrastructure/RetryHistoryQueries.cs | 2 +- .../BodyStorage/BodyVersionTests.cs | 4 +-- .../CustomCheckVersionTests.cs | 8 ++--- .../QueueAddressVersionTests.cs | 12 +++---- .../FailureGroupVersionTests.cs | 12 +++---- .../Infrastructure/QueryResult.cs | 5 +++ .../Infrastructure/DataVersionTests.cs | 34 +++++++++++++++---- .../WebApi/ConditionalGetTests.cs | 5 ++- .../MessageRedirectVersionTests.cs | 2 +- .../Recoverability/RetryGroupVersionTests.cs | 2 +- .../ScatterGatherVersionTests.cs | 26 +++++++++----- .../GetAuditCountsForEndpointApi.cs | 19 +++-------- .../Messages/ScatterGatherApi.cs | 7 ++-- .../WebApi}/ResponseVersions.cs | 4 +-- .../Api/MessageRedirectsController.cs | 1 - 17 files changed, 85 insertions(+), 61 deletions(-) rename src/ServiceControl/{Recoverability/API => Infrastructure/WebApi}/ResponseVersions.cs (88%) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index 7cb9729feb..1fa3867b3a 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -62,6 +62,7 @@ public Task>> GetStats(PagingInfo paging, string? var checks = await query .OrderBy(c => c.ReportedAt) + .ThenBy(c => c.Id) .Skip(paging.Offset) .Take(paging.PageSize) .Select(c => new CustomCheck diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index e956e10672..1d3d6246ab 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -182,7 +182,7 @@ public static async Task ToQueryStatsInfo(this IQueryable - /// Versions the rows this page renders, plus the total behind Total-Count. + /// Versions the rows this page renders, plus the total behind Total-Count. /// public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => QueryStatsInfo.Fresh(DataVersion.OverRows([("total", total)], page, diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 714d332948..35173174af 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; static class RetryHistoryQueries { /// - /// Every field of every operation in both collections, plus each collection's count + /// Every field of every operation in both collections, plus each collection's count. /// public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => QueryStatsInfo.Fresh(DataVersion.OverRows( diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs index 7eb4af2426..34ab459369 100644 --- a/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/BodyStorage/BodyVersionTests.cs @@ -31,14 +31,14 @@ public async Task Version_changes_when_a_later_attempt_carries_a_different_body( var (replacedBody, after) = await Fetch(first.UniqueMessageIdString); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(originalBody, Is.EqualTo("the original body")); Assert.That(replacedBody, Is.EqualTo("a completely different body"), "the stored body was replaced"); Assert.That(before.HasValue, Is.True, "there was no version to move"); Assert.That(after.Matches(before), Is.False, "the body changed, so a client holding the old version must be sent the new body rather than a 304"); - }); + } } [Test] diff --git a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs index 8c41e6a6fc..bfc5d0a575 100644 --- a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs @@ -24,14 +24,14 @@ public async Task Version_changes_when_a_check_starts_failing() var after = await CustomChecks.GetStats(new PagingInfo()); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(after.Results, Has.Count.EqualTo(1), "still one check"); Assert.That(after.Results[0].Status, Is.EqualTo(Status.Fail), "and the body now reports it failing"); Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is shown a stale status"); - }); + } } [Test] @@ -66,12 +66,12 @@ public async Task An_empty_store_still_reports_a_version() { var result = await CustomChecks.GetStats(new PagingInfo()); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(result.Results, Is.Empty); Assert.That(result.QueryStats.Version.HasValue, Is.True, "an empty list is a representation like any other and has to be cacheable"); - }); + } } [Test] diff --git a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs index c091367692..16d86e8335 100644 --- a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs @@ -23,14 +23,14 @@ public async Task Version_changes_when_a_queue_gains_a_failure_and_the_address_s var after = await QueueAddressStore.GetAddresses(new PagingInfo()); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); Assert.That(after.Results[0].FailedMessageCount, Is.EqualTo(2), "and the body now reports two failures"); Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is served a stale count"); - }); + } } [Test] @@ -48,14 +48,14 @@ public async Task Version_changes_when_a_message_moves_to_a_different_queue() var after = await QueueAddressStore.GetAddresses(new PagingInfo()); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(after.Results, Has.Count.EqualTo(1), "still one address"); Assert.That(after.Results[0].PhysicalAddress, Is.EqualTo("OtherEndpoint@machine2"), "and it is the new one"); Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body reports a different address under the same count, so the validator cannot stay put"); - }); + } } [Test] @@ -93,12 +93,12 @@ public async Task An_empty_store_still_reports_a_version() { var result = await QueueAddressStore.GetAddresses(new PagingInfo()); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(result.Results, Is.Empty); Assert.That(result.QueryStats.Version.HasValue, Is.True, "an empty list is a representation like any other and has to be cacheable"); - }); + } } static IngestedFailure Failure(string failingEndpointAddress) => diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs index 855705be58..a6ed66c66d 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs @@ -33,7 +33,7 @@ public async Task Version_changes_when_a_group_loses_a_message_that_is_neither_i var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(before.Results.Count, Is.EqualTo(3), "three messages to start with"); Assert.That(after.Results.Count, Is.EqualTo(2), "and the body now reports two"); @@ -42,7 +42,7 @@ public async Task Version_changes_when_a_group_loses_a_message_that_is_neither_i Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "the body changed, so the validator must too, or a revalidating client is served a stale count"); - }); + } } [Test] @@ -79,13 +79,13 @@ public async Task Version_changes_when_the_span_of_a_group_moves_but_its_count_d var after = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(after.Results.Count, Is.EqualTo(before.Results.Count), "still three messages"); Assert.That(before.QueryStats.Version.HasValue, Is.True, "there was no version to move"); Assert.That(after.QueryStats.Version.Matches(before.QueryStats.Version), Is.False, "a different span of failures is being reported under the same count"); - }); + } } [Test] @@ -184,11 +184,11 @@ public async Task A_group_that_does_not_exist_still_reports_a_version() { var result = await GroupsStore.GetUnresolvedGroup("no-such-group", null, null); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(result.Results, Is.Null); Assert.That(result.QueryStats.Version.HasValue, Is.True); - }); + } } static FailedMessage.FailureGroup NewGroup() => diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs index be3e6b8377..00d0b07921 100644 --- a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs +++ b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs @@ -9,6 +9,11 @@ public class QueryResult(TOut? results, QueryStatsInfo queryStatsInfo) public string? InstanceId { get; set; } + /// + /// The result the scatter-gather got from its own instance. + /// + public bool IsLocalInstance { get; set; } + public QueryStatsInfo QueryStats { get; } = queryStatsInfo; /// diff --git a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs index e69dc2a218..ace2c6ff01 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -19,11 +19,11 @@ public void None_never_matches_a_real_version() { var real = DataVersion.FromToken("4611686018427387904"); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(DataVersion.None.Matches(real), Is.False); Assert.That(real.Matches(DataVersion.None), Is.False); - }); + } } [Test] @@ -48,11 +48,11 @@ public void A_different_token_does_not_match() [Test] public void An_empty_token_is_absent() { - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(DataVersion.FromToken(null).HasValue, Is.False); Assert.That(DataVersion.FromToken(string.Empty).HasValue, Is.False); - }); + } } [Test] @@ -149,6 +149,28 @@ public void OverRows_moves_when_only_the_total_moves() "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); @@ -183,11 +205,11 @@ public void Combine_differs_from_every_instance_version_it_covers() var combined = DataVersion.Combine([("one", a), ("two", b)]); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(combined.Matches(a), Is.False); Assert.That(combined.Matches(b), Is.False); - }); + } } [Test] diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index c3937177af..e4d0460ff8 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -92,14 +92,13 @@ public void An_aggregate_derived_etag_is_marked_weak() httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904")); - Assert.Multiple(() => + 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 A_weak_validator_matches_under_the_comparison_If_None_Match_requires() { diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs index 7fee0c5aae..74d8b78d92 100644 --- a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs @@ -3,8 +3,8 @@ namespace ServiceControl.UnitTests.Operations using System; using System.Collections.Generic; using NUnit.Framework; + using ServiceControl.Infrastructure.WebApi; using ServiceControl.Persistence.MessageRedirects; - using ServiceControl.Recoverability.API; [TestFixture] public class MessageRedirectVersionTests diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs index 50d1a9bff3..a6afd0d194 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -2,8 +2,8 @@ namespace ServiceControl.UnitTests.Operations { using System; using NUnit.Framework; + using ServiceControl.Infrastructure.WebApi; using ServiceControl.Recoverability; - using ServiceControl.Recoverability.API; [TestFixture] public class RetryGroupVersionTests diff --git a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs index 607034d622..eedb7fbef3 100644 --- a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs +++ b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs @@ -2,8 +2,8 @@ namespace ServiceControl.UnitTests.ScatterGather { using System.Collections.Generic; using System.Linq; - using System.Threading.Tasks; using System.Threading; + using System.Threading.Tasks; using CompositeViews.Messages; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; @@ -44,22 +44,32 @@ public void A_composite_moves_when_one_instance_moves() [Test] public void A_remote_only_api_reports_the_version_of_the_instances_that_have_the_data() { - var settings = new Settings(); - var api = new RemoteOnlyApi(settings); + var api = new RemoteOnlyApi(new Settings()); - var composite = api.AggregateResults(Context(), [NoLocalData(settings.InstanceId), Page("remote", "b")]); + 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_reports_no_version_when_no_remote_answered() + 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(settings.InstanceId)]); + 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"); @@ -74,8 +84,8 @@ static QueryResult> Page(string instanceId, string validator }; // What ScatterGatherRemoteOnly.LocalQuery returns: no rows and no version. - static QueryResult> NoLocalData(string instanceId) => - new(null, QueryStatsInfo.Zero) { InstanceId = instanceId }; + static QueryResult> NoLocalData() => + new(null, QueryStatsInfo.Zero) { IsLocalInstance = true }; class LocalAndRemoteApi() : ScatterGatherApiMessageView( null, null, null, null, NullLogger.Instance) diff --git a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs index 87802224f1..1455b6d8d6 100644 --- a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs @@ -3,13 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; using Api.Contracts; using Messages; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; - using Persistence; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; @@ -19,25 +16,17 @@ // gather approach here. public record AuditCountsForEndpointContext(PagingInfo PagingInfo, string Endpoint) : ScatterGatherContext(PagingInfo); + // The counts only ever live on an audit instance, so this instance has nothing of its own to add. public class GetAuditCountsForEndpointApi( - IMessagesViewDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) - : ScatterGatherApi>(dataStore, settings, httpClientFactory, httpContextAccessor, logger) + : ScatterGatherRemoteOnly>(settings, httpClientFactory, httpContextAccessor, logger) { - static readonly IList Empty = new List(0).AsReadOnly(); - - protected override Task>> LocalQuery(AuditCountsForEndpointContext input, CancellationToken cancellationToken = default) => - // Will never be implemented on the primary instance - Task.FromResult(new QueryResult>(Empty, QueryStatsInfo.Zero)); - - protected override QueryStatsInfo AggregateStats(AuditCountsForEndpointContext input, IEnumerable>> results, IList processedResults) => - AggregateStatsFromRemotesOnly(results); - protected override IList ProcessResults(AuditCountsForEndpointContext input, QueryResult>[] results) => - results.SelectMany(r => r.Results) + results.Where(r => r.Results is not null) + .SelectMany(r => r.Results) .GroupBy(r => r.UtcDate) .Select(g => new AuditCount { diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index 978697ec35..c5bedadfc7 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -45,8 +45,6 @@ protected ScatterGatherApi(TDataStore store, Settings settings, IHttpClientFacto protected TDataStore DataStore { get; } - protected string LocalInstanceId => Settings.InstanceId; - Settings Settings { get; } IHttpClientFactory HttpClientFactory { get; } IHttpContextAccessor HttpContextAccessor { get; } @@ -82,6 +80,7 @@ async Task> LocalCall(TIn input, string instanceId, Cancellati { var result = await LocalQuery(input, cancellationToken); result.InstanceId = instanceId; + result.IsLocalInstance = true; return result; } @@ -107,8 +106,8 @@ protected virtual QueryStatsInfo AggregateStats(TIn input, IEnumerable reports as soon as one result /// is missing one, which would leave every response with no ETag at all. /// - protected QueryStatsInfo AggregateStatsFromRemotesOnly(IEnumerable> results) => - Aggregate(results.Where(result => result.InstanceId != LocalInstanceId)); + protected static QueryStatsInfo AggregateStatsFromRemotesOnly(IEnumerable> results) => + Aggregate(results.Where(result => !result.IsLocalInstance)); static QueryStatsInfo Aggregate(IEnumerable> results) { diff --git a/src/ServiceControl/Recoverability/API/ResponseVersions.cs b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs similarity index 88% rename from src/ServiceControl/Recoverability/API/ResponseVersions.cs rename to src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs index 865918eb80..b3fb4e905d 100644 --- a/src/ServiceControl/Recoverability/API/ResponseVersions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs @@ -1,11 +1,11 @@ -namespace ServiceControl.Recoverability.API; +namespace ServiceControl.Infrastructure.WebApi; using System.Collections.Generic; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.MessageRedirects; /// -/// Versions for responses built in a controller. +/// Versions for responses a controller assembles itself, rather than getting from a store. /// static class ResponseVersions { diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index a52ed07595..f506d31526 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -12,7 +12,6 @@ using Infrastructure.DomainEvents; using Infrastructure.WebApi; using MessageFailures.InternalMessages; - using Recoverability.API; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; From 7f21c131dcd4da2de7d3d1f7118b1e8fc9de49a7 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 20 Aug 2026 12:43:24 +0800 Subject: [PATCH 29/36] Fix after rebase --- .../Infrastructure/RetryHistoryQueries.cs | 2 +- .../Infrastructure/DataVersion.cs | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index 35173174af..bc2357bdd5 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -15,7 +15,7 @@ public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => row => row), history.HistoricOperations.Count); - static IEnumerable Rows(RetryHistory history) + static IEnumerable Rows(RetryHistory history) { // The leading marker keeps a historic row from ever digesting the same as an unacknowledged one. foreach (var operation in history.HistoricOperations) diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index a6428551be..ee6d152ab8 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.Infrastructure using System; using System.Collections.Generic; using System.Diagnostics; + using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; @@ -22,12 +23,13 @@ namespace ServiceControl.Persistence.Infrastructure [DebuggerDisplay("{validator ?? \"None\",nq}")] public readonly struct DataVersion : IEquatable { - readonly string validator; + 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. @@ -42,7 +44,7 @@ public static DataVersion FromToken(long token) => /// 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) => + public static DataVersion Compose(params (string Name, object? Value)[]? terms) => terms is null || terms.Length == 0 ? None : new DataVersion(DeterministicGuid.MakeId(Describe(terms)).ToString()); @@ -55,12 +57,12 @@ public static DataVersion Compose(params (string Name, object Value)[] terms) => /// 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) + 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 terms = new List<(string Name, object? Value)>(summary ?? []); var row = 0; foreach (var item in rows) @@ -145,25 +147,25 @@ public bool Matches(DataVersion other) => 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 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) => + 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) => + 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 + static string Format(object? value) => value switch { null => string.Empty, string text => text, From 53244b541a567a927892a3a9c6ce3cb5b21dd5f9 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 20 Aug 2026 21:37:10 +0800 Subject: [PATCH 30/36] Final review changes --- .../RavenQueryStatisticsExtensions.cs | 63 ++- .../RavenAuditDataStore.cs | 30 +- .../PagedVersionConformanceTests.cs | 129 +++++ .../Implementation/CustomCheckDataStore.cs | 12 +- .../FailedMessageQueryDataStore.cs | 6 +- .../FailedMessageQueryResults.cs | 4 +- .../Implementation/GroupsDataStore.cs | 11 +- .../Implementation/MessagesViewDataStore.cs | 10 +- .../MessagesViewQueryResults.cs | 4 +- .../Implementation/QueueAddressStore.cs | 3 +- .../Infrastructure/CustomCheckQueries.cs | 4 +- .../FailedMessageQueryFilters.cs | 11 +- .../Infrastructure/FailureGroupQueries.cs | 4 +- .../Infrastructure/QueueAddressQueries.cs | 4 +- .../ErrorMessagesDataStore.cs | 18 +- .../Extensions/QueryResultConvert.cs | 16 +- .../QueueAddressStore.cs | 3 +- .../RavenCustomCheckDataStore.cs | 3 +- .../RavenQueryStatisticsExtensions.cs | 24 +- .../Recoverability/GroupsDataStore.cs | 17 +- .../RetryDocumentDataStore.cs | 3 +- .../CustomChecksDataStoreTests.cs | 34 ++ .../PagedVersionConformanceTests.cs | 444 ++++++++++++++++++ .../VersionAssert.cs | 14 + .../Infrastructure/QueryNarrowing.cs | 22 + .../MessageRedirectResponseVersionTests.cs | 90 ++++ .../Infrastructure/WebApi/ResponseVersions.cs | 14 +- .../Api/MessageRedirectsController.cs | 8 +- 28 files changed, 931 insertions(+), 74 deletions(-) create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs create mode 100644 src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs create mode 100644 src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs create mode 100644 src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs index a15ce9da6f..e12efbc286 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs @@ -1,13 +1,70 @@ namespace ServiceControl.Audit.Persistence.RavenDB.Extensions { + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; using Auditing.MessagesView; using Raven.Client.Documents.Session; + using ServiceControl.Audit.Persistence.Infrastructure; static class RavenQueryStatisticsExtensions { - public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) + /// + /// For a paged or filtered query. The index etag says whether the data moved, the row ids say which + /// rows this page renders, and names the question for the case the rows + /// cannot: a page with no rows contributes no row terms, so without it two filters that both match + /// nothing, and a page past the end, all share one value. + /// + public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id, params (string Name, object Value)[] query) { - return new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults); + // No index etag means no version at all, as on the primary side. The rows here carry only ids, + // so the etag is the only term covering a change to a field a row renders; without it a + // validator would stand still while that field moved. + if (stats.ResultEtag is not { } resultEtag) + { + return new QueryStatsInfo(string.Empty, stats.TotalResults); + } + + var terms = new List(query.Length + 2) + { + Term("index", resultEtag), + Term("total", stats.TotalResults) + }; + + terms.AddRange(query.Select(term => Term(term.Name, term.Value))); + + var row = 0; + + foreach (var item in page) + { + terms.Add(Term(string.Concat("row", row++.ToString(CultureInfo.InvariantCulture)), id(item))); + } + + return new QueryStatsInfo(DeterministicGuid.MakeId(string.Join("|", terms)).ToString(), stats.TotalResults); } + + // Length prefixed, so no value can pose as a different set of terms by containing a separator. + static string Term(string name, object value) + { + var text = Format(value); + + return string.Create(CultureInfo.InvariantCulture, $"{name}:{text.Length}:{text}"); + } + + // Mirrors DataVersion.Format on the primary side. Timestamps go in as ticks: their default + // formatting stops at whole seconds, which would collide two ranges a fraction apart. + 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)) + }; } -} \ 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..2ddf231e7a 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs @@ -40,7 +40,8 @@ public async Task>> GetMessages(bool includeSyst .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("includeSystemMessages", includeSystemMessages)))); } public async Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -55,7 +56,8 @@ public async Task>> QueryMessages(string searchP .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("search", searchParam)))); } public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -71,7 +73,8 @@ public async Task>> QueryMessagesByReceivingEndp .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("endpoint", endpoint), ("keyword", keyword)))); } public async Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -87,7 +90,8 @@ public async Task>> QueryMessagesByReceivingEndp .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages)))); } public async Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) @@ -101,9 +105,25 @@ public async Task>> QueryMessagesByConversationI .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, null, ("conversationId", conversationId)))); } + /// + /// The page, ordering and filters a read was narrowed by. Rows name a non-empty page on their own, + /// so these terms are what keep two queries apart when one of them returns nothing. + /// + static (string Name, object Value)[] Narrowing(PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, params (string Name, object Value)[] filters) => + [ + ("page", pagingInfo.Page), + ("pageSize", pagingInfo.PageSize), + ("sort", sortInfo?.Sort), + ("direction", sortInfo?.Direction), + ("from", timeSentRange?.From), + ("to", timeSentRange?.To), + .. filters + ]; + public async Task GetMessageBody(string messageId, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs new file mode 100644 index 0000000000..22780838c0 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs @@ -0,0 +1,129 @@ +namespace ServiceControl.Audit.Persistence.Tests +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Auditing; + using NServiceBus; + using NUnit.Framework; + using ServiceControl.Audit.Infrastructure; + using ServiceControl.Audit.Monitoring; + + [TestFixture] + class PagedVersionConformanceTests : PersistenceTestFixture + { + [Test] + public async Task Two_pages_of_one_set_do_not_share_a_version() + { + await Ingest(MakeMessage(), MakeMessage(), MakeMessage()); + + var firstPage = await DataStore.GetMessages(false, new PagingInfo(page: 1, pageSize: 2), Sort); + var firstPageAgain = await DataStore.GetMessages(false, new PagingInfo(page: 1, pageSize: 2), Sort); + var secondPage = await DataStore.GetMessages(false, new PagingInfo(page: 2, pageSize: 2), Sort); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two rows on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + Assert.That(firstPage.QueryStats.ETag, Is.Not.Empty, "the first page produced no version to compare"); + Assert.That(firstPageAgain.QueryStats.ETag, Is.EqualTo(firstPage.QueryStats.ETag), + "the first page's version moved between two reads of unchanged data, so this test cannot judge anything"); + Assert.That(secondPage.QueryStats.ETag, Is.Not.EqualTo(firstPage.QueryStats.ETag), + "a caller holding page one would be told page two is unchanged and would render page one twice"); + } + } + + [Test] + public async Task Two_searches_do_not_share_a_version() + { + var wanted = Guid.NewGuid().ToString(); + + await Ingest(MakeMessage(conversationId: wanted), MakeMessage(conversationId: wanted), MakeMessage()); + + var matching = await DataStore.QueryMessages(wanted, new PagingInfo(), Sort); + var matchingAgain = await DataStore.QueryMessages(wanted, new PagingInfo(), Sort); + var missing = await DataStore.QueryMessages(Guid.NewGuid().ToString(), new PagingInfo(), Sort); + + using (Assert.EnterMultipleScope()) + { + Assert.That(matching.Results, Is.Not.Empty, "the search that should match found nothing, so the test proves nothing"); + Assert.That(missing.Results, Is.Empty, "and the search for an unused id matched nothing, so the bodies differ"); + Assert.That(matching.QueryStats.ETag, Is.Not.Empty, "the matching search produced no version to compare"); + Assert.That(matchingAgain.QueryStats.ETag, Is.EqualTo(matching.QueryStats.ETag), + "the matching search's version moved between two reads of unchanged data"); + Assert.That(missing.QueryStats.ETag, Is.Not.EqualTo(matching.QueryStats.ETag), + "a caller holding an empty search result would be told a search carrying messages is unchanged"); + } + } + + [Test] + public async Task Two_endpoints_do_not_share_a_version() + { + await Ingest(MakeMessage(processingEndpoint: "Shipping"), MakeMessage(processingEndpoint: "Billing")); + + var shipping = await DataStore.QueryMessagesByReceivingEndpoint(false, "Shipping", new PagingInfo(), Sort); + var shippingAgain = await DataStore.QueryMessagesByReceivingEndpoint(false, "Shipping", new PagingInfo(), Sort); + var billing = await DataStore.QueryMessagesByReceivingEndpoint(false, "Billing", new PagingInfo(), Sort); + + using (Assert.EnterMultipleScope()) + { + Assert.That(shipping.Results, Has.Count.EqualTo(1), "one message for Shipping"); + Assert.That(billing.Results, Has.Count.EqualTo(1), "one for Billing"); + Assert.That(billing.Results[0].MessageId, Is.Not.EqualTo(shipping.Results[0].MessageId), "and they are not the same message"); + Assert.That(shipping.QueryStats.ETag, Is.Not.Empty, "Shipping produced no version to compare"); + Assert.That(shippingAgain.QueryStats.ETag, Is.EqualTo(shipping.QueryStats.ETag), + "Shipping's version moved between two reads of unchanged data"); + Assert.That(billing.QueryStats.ETag, Is.Not.EqualTo(shipping.QueryStats.ETag), + "a caller watching one endpoint's messages would be shown another endpoint's"); + } + } + + static SortInfo Sort => new("Id", "asc"); + + async Task Ingest(params ProcessedMessage[] messages) + { + var unitOfWork = await StartAuditUnitOfWork(messages.Length); + + foreach (var message in messages) + { + await unitOfWork.RecordProcessedMessage(message); + } + + await unitOfWork.DisposeAsync(); + await configuration.CompleteDBOperation(); + } + + static ProcessedMessage MakeMessage(string conversationId = null, string processingEndpoint = null) + { + var messageId = Guid.NewGuid().ToString(); + conversationId ??= Guid.NewGuid().ToString(); + processingEndpoint ??= "SomeEndpoint"; + + var metadata = new Dictionary + { + { "MessageId", messageId }, + { "MessageIntent", MessageIntent.Send }, + { "CriticalTime", TimeSpan.FromSeconds(5) }, + { "ProcessingTime", TimeSpan.FromSeconds(1) }, + { "DeliveryTime", TimeSpan.FromSeconds(4) }, + { "IsSystemMessage", false }, + { "MessageType", "MyMessageType" }, + { "IsRetried", false }, + { "ConversationId", conversationId }, + { "ReceivingEndpoint", new EndpointDetails { Name = processingEndpoint } } + }; + + var headers = new Dictionary + { + { Headers.MessageId, messageId }, + { Headers.ProcessingEndpoint, processingEndpoint }, + { Headers.MessageIntent, MessageIntent.Send.ToString() }, + { Headers.ConversationId, conversationId }, + { Headers.ProcessingStarted, DateTimeOffsetHelper.ToWireFormattedString(DateTimeOffset.UtcNow) }, + { Headers.EnclosedMessageTypes, "MyMessageType" } + }; + + return new ProcessedMessage(headers, metadata); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index 1fa3867b3a..11184ff30e 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -4,6 +4,7 @@ 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; @@ -72,13 +73,20 @@ public Task>> GetStats(PagingInfo paging, string? Category = c.Category, Status = c.Status, ReportedAt = c.ReportedAt, - FailureReason = c.FailureReason + FailureReason = c.FailureReason, + OriginatingEndpoint = new EndpointDetails + { + Name = c.OriginatingEndpointName, + Host = c.OriginatingEndpointHost, + HostId = c.OriginatingEndpointHostId + } }) .ToListAsync(token); var totalCount = await query.CountAsync(token); - return new QueryResult>(checks, checks.ToQueryStatsInfo(totalCount)); + return new QueryResult>(checks, + checks.ToQueryStatsInfo(totalCount, ("status", status), ("page", paging.Page), ("pageSize", paging.PageSize))); }, 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/FailedMessageQueryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs index e74536a194..ff8045bbf7 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs @@ -18,7 +18,7 @@ public Task>> GetFailedMessages(string? sta .FilterByStatus(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) - .ToPagedResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedResult(pagingInfo, sortInfo, [("status", status), ("modified", modified), ("queueAddress", queueAddress)], token), cancellationToken); public Task GetFailedMessagesStats(string? status, string? modified, string? queueAddress, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages @@ -26,7 +26,7 @@ public Task GetFailedMessagesStats(string? status, string? modif .FilterByStatus(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) - .ToQueryStatsInfo(token), cancellationToken); + .ToQueryStatsInfo([("status", status), ("modified", modified), ("queueAddress", queueAddress)], token), cancellationToken); public Task>> GetFailedMessagesByEndpoint(string? status, string endpointName, string? modified, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages @@ -34,7 +34,7 @@ public Task>> GetFailedMessagesByEndpoint(s .Where(message => message.ReceivingEndpointName == endpointName) .FilterByStatus(status) .FilterByLastModifiedRange(modified) - .ToPagedResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedResult(pagingInfo, sortInfo, [("status", status), ("endpointName", endpointName), ("modified", modified)], token), cancellationToken); public Task> GetFailedMessagesSummary(CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (dbContext, token) => diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs index bcbdc1c583..39aba73b84 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; static class FailedMessageQueryResults { - public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) + public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, (string Name, object? Value)[] filters, CancellationToken cancellationToken = default) { var total = await source.LongCountAsync(cancellationToken); @@ -19,6 +19,6 @@ public static async Task>> ToPagedResult(th IList results = [.. entities.Select(entity => entity.ToFailedMessageView())]; - return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total)); + return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total, QueryNarrowing.Terms(pagingInfo, sortInfo, filters))); } } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs index b11014ab4c..a999ddc61b 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs @@ -36,7 +36,7 @@ public Task>> GetArchivedGroupsByClassifier( var views = await MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)), token); - return new QueryResult>(views, views.ToQueryStatsInfo()); + return new QueryResult>(views, views.ToQueryStatsInfo(("classifier", classifier))); }, cancellationToken); public Task> GetUnresolvedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default) => @@ -46,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, [("groupId", groupId), ("status", status), ("modified", modified)], 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) + .ToQueryStatsInfo([("groupId", groupId), ("status", status), ("modified", modified)], token), cancellationToken); public Task EditComment(string groupId, string comment, CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (dbContext, token) => @@ -89,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(("groupId", groupId), ("status", status), ("modified", modified))); } static IQueryable WithStatus(ServiceControlDbContext dbContext, FailedMessageStatus status) => diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs index 2b2d69d2db..681f7c0adb 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs @@ -14,7 +14,7 @@ public Task>> GetAllMessages(PagingInfo pagingIn .AsNoTracking() .IncludeSystemMessagesWhere(includeSystemMessages) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, [("includeSystemMessages", includeSystemMessages), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages @@ -22,25 +22,25 @@ public Task>> GetAllMessagesForEndpoint(string e .Where(message => message.ReceivingEndpointName == endpointName) .IncludeSystemMessagesWhere(includeSystemMessages) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, [("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); // includeSystemMessages is unused here: a conversation is incomplete without the system messages that took part in it. public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages .AsNoTracking() .Where(message => message.ConversationId == conversationId) - .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, [("conversationId", conversationId)], token), cancellationToken); public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => Search(dbContext.FailedMessages.AsNoTracking(), searchTerms) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, [("searchTerms", searchTerms), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => Search(dbContext.FailedMessages.AsNoTracking(), searchKeyword) .Where(message => message.ReceivingEndpointName == endpointName) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, [("endpointName", endpointName), ("searchKeyword", searchKeyword), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); // Neither search hides system messages: a caller who searched // for something specific is not helped by hiding the message that matched it. diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs index f83cd2c27e..ac9cfe8c74 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; static class MessagesViewQueryResults { - public static async Task>> ToPagedMessagesResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) + public static async Task>> ToPagedMessagesResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, (string Name, object? Value)[] filters, CancellationToken cancellationToken = default) { var total = await source.LongCountAsync(cancellationToken); @@ -19,6 +19,6 @@ public static async Task>> ToPagedMessagesResult IList results = [.. entities.Select(entity => entity.ToMessagesView())]; - return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total)); + return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total, QueryNarrowing.Terms(pagingInfo, sortInfo, filters))); } } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs index 74793d79ee..00f32f930d 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs @@ -23,6 +23,7 @@ public Task>> GetAddresses(PagingInfo pagingInfo var items = await query.Skip(pagingInfo.Offset).Take(pagingInfo.PageSize).ToListAsync(token); var addressCount = await query.CountAsync(token); - return new QueryResult>(items, items.ToQueryStatsInfo(addressCount)); + return new QueryResult>(items, + items.ToQueryStatsInfo(addressCount, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); }, cancellationToken); } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs index 01daad17c9..7375555f94 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs @@ -11,8 +11,8 @@ static class CustomCheckQueries /// check id, so naming Id covers all three, and the host string is written once on insert and never /// updated. /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("checks", totalCount)], page, + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => + QueryStatsInfo.Fresh(DataVersion.OverRows([("checks", totalCount), .. query], page, check => [check.Id, check.CustomCheckId, check.Category, check.Status, check.ReportedAt, check.FailureReason]), totalCount); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index 1d3d6246ab..35608663c3 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs @@ -166,7 +166,7 @@ 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) + public static async Task ToQueryStatsInfo(this IQueryable source, (string Name, object? Value)[] query, CancellationToken cancellationToken = default) { var stats = await source .GroupBy(_ => 1) @@ -178,14 +178,15 @@ public static async Task ToQueryStatsInfo(this IQueryable - /// Versions the rows this page renders, plus the total behind Total-Count. + /// Versions the rows this page renders, plus the total behind Total-Count, plus whatever narrowed the + /// query. /// - public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("total", total)], page, + public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total, params (string Name, object? Value)[] query) => + QueryStatsInfo.Fresh(DataVersion.OverRows([("total", total), .. query], page, row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]), total); diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index b3727c2b55..17de48e37b 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -27,8 +27,8 @@ into aggregate /// Title and Type cannot move within a row, because AggregateGroups groups by them, so a change to /// either is a different row rather than a changed one. /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("groups", groups.Count)], groups, + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups, params (string Name, object? Value)[] query) => + QueryStatsInfo.Fresh(DataVersion.OverRows([("groups", groups.Count), .. query], groups, group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last]), groups.Count); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs index 01f0996569..c924b41ba7 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs @@ -8,8 +8,8 @@ static class QueueAddressQueries /// /// Both fields of every address the body shows, plus the total behind Total-Count. /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("addresses", totalCount)], page, + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => + QueryStatsInfo.Fresh(DataVersion.OverRows([("addresses", totalCount), .. query], page, address => [address.PhysicalAddress, address.FailedMessageCount]), totalCount); } diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index 865a1e1aac..45f37391e1 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -53,7 +53,7 @@ public async Task>> GetAllMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("includeSystemMessages", includeSystemMessages)))); } public async Task>> GetAllMessagesForEndpoint( @@ -79,7 +79,7 @@ public async Task>> GetAllMessagesForEndpoint( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages)))); } public async Task>> SearchEndpointMessages( @@ -104,7 +104,7 @@ public async Task>> SearchEndpointMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("endpointName", endpointName), ("searchKeyword", searchKeyword)))); } public async Task>> GetAllMessagesByConversation( @@ -126,7 +126,7 @@ public async Task>> GetAllMessagesByConversation var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("conversationId", conversationId), ("includeSystemMessages", includeSystemMessages)))); } public async Task>> GetAllMessagesForSearch( @@ -149,7 +149,7 @@ public async Task>> GetAllMessagesForSearch( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("searchTerms", searchTerms)))); } public async Task MarkAsArchived(string failedMessageId, CancellationToken cancellationToken = default) @@ -200,7 +200,7 @@ public async Task>> GetFailedMessages( var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("status", status), ("modified", modified), ("queueAddress", queueAddress)))); } public async Task GetFailedMessagesStats( @@ -218,7 +218,7 @@ public async Task GetFailedMessagesStats( .FilterByQueueAddress(queueAddress) .GetQueryResultAsync(cancellationToken); - return stats.ToQueryStatsInfo(); + return stats.ToCountQueryStatsInfo(("status", status), ("modified", modified), ("queueAddress", queueAddress)); } public async Task>> GetFailedMessagesByEndpoint( @@ -247,7 +247,7 @@ public async Task>> GetFailedMessagesByEndp var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id)); + return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("status", status), ("endpointName", endpointName), ("modified", modified)))); } public async Task> GetFailedMessagesSummary(CancellationToken cancellationToken = default) @@ -506,5 +506,7 @@ public async Task GetRetryPendingMessages(DateTime from, DateTime to, } record struct FailedMessageProjection(string UniqueMessageId); + + } } diff --git a/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs b/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs index f9ea464930..e1d467f7bc 100644 --- a/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs +++ b/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs @@ -1,15 +1,19 @@ namespace ServiceControl.Persistence.RavenDB { + using System; using System.Collections.Generic; using Persistence.Infrastructure; using Raven.Client.Documents.Session; static class QueryResultConvert { - public static QueryResult> ToQueryResult(this IList result, QueryStatistics stats) - where T : class - { - return new QueryResult>(result, stats.ToQueryStatsInfo()); - } + /// + /// Takes the row identity and the query terms rather than defaulting to neither, because a caller + /// reaching for a one-argument version of this gets a validator that described only the index, and + /// so will answer "not modified" to every other page and filter over the same one. + /// + public static QueryResult> ToQueryResult(this IList result, QueryStatistics stats, Func id, params (string Name, object Value)[] query) + where T : class => + new(result, stats.ToPagedQueryStatsInfo(result, id, query)); } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs b/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs index 2b06bfa592..499f53a720 100644 --- a/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs @@ -20,7 +20,8 @@ public async Task>> GetAddresses(PagingInfo pagi .Paging(pagingInfo) .ToListAsync(cancellationToken); - var result = new QueryResult>(addresses, stats.ToQueryStatsInfo()); + var result = new QueryResult>(addresses, + stats.ToPagedQueryStatsInfo(addresses, address => address.PhysicalAddress, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); return result; } } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs index 5ae267faf8..f5550cad03 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs @@ -57,7 +57,8 @@ public async Task>> GetStats(PagingInfo paging, s .Paging(paging) .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, check => check.Id, ("status", status), ("page", paging.Page), ("pageSize", paging.PageSize))); } public async Task DeleteCustomCheck(Guid id, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index f0069e2f02..ee7ee59701 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -11,20 +11,26 @@ static class RavenQueryStatisticsExtensions /// For a paged query. The index etag covers whether the data moved, and the row ids cover which /// rows this page renders. The etag alone cannot: it is a function of index and collection state, /// so every filter, page and sort over one index shares it. + /// + /// A page with no rows contributes no row terms at all, so without it a + /// page past the end and any other empty view of the same index share a version. + /// /// - public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id) => + public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id, params (string Name, object Value)[] query) => new(stats.ResultEtag is { } resultEtag - ? DataVersion.OverRows([("index", resultEtag), ("total", stats.TotalResults)], page, row => [id(row)]) + ? DataVersion.OverRows([("index", resultEtag), ("total", stats.TotalResults), .. query], page, row => [id(row)]) : DataVersion.None, stats.TotalResults, stats.IsStale); - public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => - new(stats.ResultEtag is { } resultEtag ? DataVersion.FromToken(resultEtag) : DataVersion.None, - stats.TotalResults, - stats.IsStale); - - public static QueryStatsInfo ToQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult) => - new(DataVersion.FromToken(queryResult.ResultEtag), queryResult.TotalResults, queryResult.IsStale); + /// + /// For a response whose whole content is its count, which has no rows to be named by. + /// is the only thing separating one filter from another here: leave it out + /// and a caller holding the count for one filter is told another filter's count is still current. + /// + public static QueryStatsInfo ToCountQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult, params (string Name, object Value)[] query) => + new(DataVersion.Compose([("index", queryResult.ResultEtag), ("total", queryResult.TotalResults), .. query]), + queryResult.TotalResults, + queryResult.IsStale); } } diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs index d8f97782ad..f7a1928212 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs @@ -53,7 +53,8 @@ public async Task>> GetArchivedGroupsByClass .Take(200) // only show 200 groups .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToQueryStatsInfo()); + return new QueryResult>(results, + stats.ToPagedQueryStatsInfo(results, group => group.Id, ("classifier", classifier))); } public async Task> GetUnresolvedGroup(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -67,7 +68,7 @@ public async Task> GetUnresolvedGroup(string group .FilterByLastModifiedRange(modified) .FirstOrDefaultAsync(cancellationToken); - return new QueryResult(document, stats.ToQueryStatsInfo()); + return new QueryResult(document, OneGroup(stats, document, groupId, status, modified)); } public async Task> GetArchivedGroup(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -81,9 +82,13 @@ public async Task> GetArchivedGroup(string groupId .FilterByLastModifiedRange(modified) .FirstOrDefaultAsync(cancellationToken); - return new QueryResult(document, stats.ToQueryStatsInfo()); + return new QueryResult(document, OneGroup(stats, document, groupId, status, modified)); } + static QueryStatsInfo OneGroup(QueryStatistics stats, FailureGroupView document, string groupId, string status, string modified) => + stats.ToPagedQueryStatsInfo(document is null ? [] : [document], group => group.Id, + ("groupId", groupId), ("status", status), ("modified", modified)); + public async Task>> GetGroupErrors( string groupId, string status, @@ -109,7 +114,9 @@ public async Task>> GetGroupErrors( var results = await query .ToListAsync(cancellationToken); - return results.ToQueryResult(stats); + return results.ToQueryResult(stats, view => view.Id, + ("groupId", groupId), ("status", status), ("modified", modified), + ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize)); } public async Task GetGroupErrorsCount(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -122,7 +129,7 @@ public async Task GetGroupErrorsCount(string groupId, string sta .FilterByLastModifiedRange(modified) .GetQueryResultAsync(cancellationToken); - return queryResult.ToQueryStatsInfo(); + return queryResult.ToCountQueryStatsInfo(("groupId", groupId), ("status", status), ("modified", modified)); } public async Task EditComment(string groupId, string comment, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs index 162565009e..38bcd4bbbe 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs @@ -95,7 +95,8 @@ await session.StoreAsync(new RetryBatch .Statistics(out var stats) .ToListAsync(cancellationToken); - return orphanedBatches.Select(batch => batch.ToContract()).ToList().ToQueryResult(stats); + return orphanedBatches.Select(batch => batch.ToContract()).ToList() + .ToQueryResult(stats, batch => batch.Id, ("retrySessionId", retrySessionId)); } public async Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.Tests/CustomChecksDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/CustomChecksDataStoreTests.cs index 04818e0417..364ed83b41 100644 --- a/src/ServiceControl.Persistence.Tests/CustomChecksDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/CustomChecksDataStoreTests.cs @@ -41,6 +41,40 @@ public async Task CustomChecks_load_from_data_store() } } + [Test] + public async Task Reported_endpoint_survives_a_round_trip() + { + await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail + { + Category = "test-category", + CustomCheckId = "Test-Check", + HasFailed = true, + FailureReason = "Testing", + OriginatingEndpoint = new EndpointDetails + { + Host = "localhost", + HostId = Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"), + Name = "test-host" + }, + }); + + await CompleteDatabaseOperation(); + + var stats = await CustomChecks.GetStats(new PagingInfo()); + + Assert.That(stats.Results, Has.Count.EqualTo(1)); + + var reported = stats.Results[0].OriginatingEndpoint; + + using (Assert.EnterMultipleScope()) + { + Assert.That(reported, Is.Not.Null, "the check came back with no reporting endpoint, so the page that renders its name cannot draw it"); + Assert.That(reported?.Name, Is.EqualTo("test-host")); + Assert.That(reported?.Host, Is.EqualTo("localhost")); + Assert.That(reported?.HostId, Is.EqualTo(Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"))); + } + } + [Test] public async Task Storing_failed_custom_checks_returns_unchanged() { diff --git a/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs b/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs new file mode 100644 index 0000000000..c2dd1e9891 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs @@ -0,0 +1,444 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Contracts.CustomChecks; +using NUnit.Framework; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence.Infrastructure; + +[TestFixture] +class PagedVersionConformanceTests : IngestionTestBase +{ + const string ExceptionClassifier = "Exception Type and Stack Trace"; + const string MessageTypeClassifier = "Message Type"; + + static readonly DateTime Oldest = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + static readonly DateTime Middle = new(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); + static readonly DateTime Newest = new(2026, 8, 1, 17, 0, 0, DateTimeKind.Utc); + static readonly DateTime ReportedAt = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); + + static IEnumerable Scenarios() => + [ + new("custom checks, two pages", + "page one and page two of the custom checks list render different checks", + fixture => fixture.CustomChecksTwoPages()), + new("custom checks, two status filters", + "the failing checks and the passing checks are different lists", + fixture => fixture.CustomChecksTwoStatuses()), + new("queue addresses, two pages", + "page one and page two of the queue address list render different addresses", + fixture => fixture.QueueAddressesTwoPages()), + new("the errors in a group, two pages", + "page one and page two of a group's failures render different messages", + fixture => fixture.GroupErrorsTwoPages()), + new("the error count of a group, two status filters", + "the unresolved count and the archived count are different numbers, and the count is the whole response", + fixture => fixture.GroupErrorCountTwoStatuses()), + new("archived groups, two classifiers", + "grouping the archive by exception type and by message type produces different groups", + fixture => fixture.ArchivedGroupsTwoClassifiers()), + new("the messages view, two pages", + "page one and page two of the messages list render different messages", + fixture => fixture.MessagesViewTwoPages()), + new("the error list, two status filters that both match nothing", + "two filters that happen to be empty today are still two different questions", + fixture => fixture.ErrorsTwoEmptyStatuses()), + + // The cases above compare queries whose rows differ, so the row terms alone tell them apart and + // they would pass even if a store named none of its filters. These compare two queries that both + // return nothing, where there are no rows to name and only the query terms are left to do it. + new("custom checks, two status filters that both match nothing", + "asking for the failing checks and the passing checks of an empty store are still two questions", + fixture => fixture.CustomChecksTwoEmptyStatuses()), + new("custom checks, two pages past the end", + "two pages beyond the last are two questions, and the paging links differ", + fixture => fixture.CustomChecksTwoEmptyPages()), + new("queue addresses, two pages past the end", + "two pages beyond the last are two questions", + fixture => fixture.QueueAddressesTwoEmptyPages()), + new("the errors in a group, two pages past the end", + "two pages beyond the last are two questions", + fixture => fixture.GroupErrorsTwoEmptyPages()), + new("the error count of a group, two status filters that both count nothing", + "two counts that are both zero still answer different questions", + fixture => fixture.GroupErrorCountTwoEmptyStatuses()), + new("archived groups, two classifiers with nothing archived", + "two classifiers that group nothing are still two questions", + fixture => fixture.ArchivedGroupsTwoEmptyClassifiers()), + new("the messages view, two pages past the end", + "two pages beyond the last are two questions", + fixture => fixture.MessagesViewTwoEmptyPages()) + ]; + + [Test] + [TestCaseSource(nameof(Scenarios))] + public async Task Two_queries_of_one_store_do_not_share_a_version(Scenario scenario) + { + var queried = await scenario.Run(this); + + // Proves the first query's own version was standing still across the two reads. Without it a + // shared version below could be waved away as the store legitimately moving between requests. + VersionAssert.Held(queried.First, queried.FirstAgain, + "the first query's version moved between two reads of unchanged data, so this scenario cannot judge anything"); + + VersionAssert.Distinct(queried.First, queried.Second, scenario.Because); + } + + async Task CustomChecksTwoPages() + { + await ReportCheck("Disk space"); + await ReportCheck("Queue length"); + await ReportCheck("Certificate expiry"); + + var firstPage = await CustomChecks.GetStats(new PagingInfo(page: 1, pageSize: 2)); + var firstPageAgain = await CustomChecks.GetStats(new PagingInfo(page: 1, pageSize: 2)); + var secondPage = await CustomChecks.GetStats(new PagingInfo(page: 2, pageSize: 2)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two checks on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + } + + return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); + } + + async Task CustomChecksTwoStatuses() + { + await ReportCheck("Disk space", hasFailed: true); + await ReportCheck("Queue length", hasFailed: false); + + var failing = await CustomChecks.GetStats(new PagingInfo(), "fail"); + var failingAgain = await CustomChecks.GetStats(new PagingInfo(), "fail"); + var passing = await CustomChecks.GetStats(new PagingInfo(), "pass"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(failing.Results, Has.Count.EqualTo(1), "one failing check"); + Assert.That(passing.Results, Has.Count.EqualTo(1), "one passing check"); + Assert.That(passing.Results[0].Id, Is.Not.EqualTo(failing.Results[0].Id), "and they are not the same check"); + } + + return new(failing.QueryStats.Version, failingAgain.QueryStats.Version, passing.QueryStats.Version); + } + + async Task QueueAddressesTwoPages() + { + await Ingest(Failure("Shipping@machine1"), Failure("Billing@machine1"), Failure("Sales@machine1")); + await CompleteDatabaseOperation(); + + var firstPage = await QueueAddressStore.GetAddresses(new PagingInfo(page: 1, pageSize: 2)); + var firstPageAgain = await QueueAddressStore.GetAddresses(new PagingInfo(page: 1, pageSize: 2)); + var secondPage = await QueueAddressStore.GetAddresses(new PagingInfo(page: 2, pageSize: 2)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two addresses on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + } + + return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); + } + + async Task GroupErrorsTwoPages() + { + var group = NewGroup(ExceptionClassifier); + + await Insert(InGroup(group, Oldest), InGroup(group, Middle), InGroup(group, Newest)); + + var firstPage = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 1, pageSize: 2)); + var firstPageAgain = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 1, pageSize: 2)); + var secondPage = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 2, pageSize: 2)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two failures on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + } + + return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); + } + + async Task GroupErrorCountTwoStatuses() + { + var group = NewGroup(ExceptionClassifier); + var toArchive = InGroup(group, Middle); + + await Insert(InGroup(group, Oldest), toArchive, InGroup(group, Newest)); + await Archive(toArchive); + + var unresolved = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); + var unresolvedAgain = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); + var archived = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(unresolved.TotalCount, Is.EqualTo(2), "two of the three are still unresolved"); + Assert.That(archived.TotalCount, Is.EqualTo(1), "and one is archived, so the two responses carry different counts"); + } + + return new(unresolved.Version, unresolvedAgain.Version, archived.Version); + } + + async Task ArchivedGroupsTwoClassifiers() + { + var byException = NewGroup(ExceptionClassifier); + var byMessageType = NewGroup(MessageTypeClassifier); + + // One failure filed under both classifiers, so each classifier has exactly one group to + // report and the two groups differ in their id and their type. + var failure = new IngestedFailure + { + Groups = [byException, byMessageType], + AttemptedAt = Middle, + TimeOfFailure = Middle, + TimeSent = Middle.AddMinutes(-1) + }; + + await Insert(failure); + await Archive(failure); + + var exceptionType = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); + var exceptionTypeAgain = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); + var messageType = await GroupsStore.GetArchivedGroupsByClassifier(MessageTypeClassifier); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exceptionType.Results, Has.Count.EqualTo(1), "one archived group by exception type"); + Assert.That(messageType.Results, Has.Count.EqualTo(1), "one archived group by message type"); + Assert.That(messageType.Results[0].Id, Is.Not.EqualTo(exceptionType.Results[0].Id), "and they are not the same group"); + } + + return new(exceptionType.QueryStats.Version, exceptionTypeAgain.QueryStats.Version, messageType.QueryStats.Version); + } + + async Task CustomChecksTwoEmptyStatuses() + { + var failing = await CustomChecks.GetStats(new PagingInfo(), "fail"); + var failingAgain = await CustomChecks.GetStats(new PagingInfo(), "fail"); + var passing = await CustomChecks.GetStats(new PagingInfo(), "pass"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(failing.Results, Is.Empty, "no failing checks"); + Assert.That(passing.Results, Is.Empty, "and no passing ones, so neither has rows to be named by"); + } + + return new(failing.QueryStats.Version, failingAgain.QueryStats.Version, passing.QueryStats.Version); + } + + async Task CustomChecksTwoEmptyPages() + { + await ReportCheck("Disk space"); + + var third = await CustomChecks.GetStats(new PagingInfo(page: 3, pageSize: 1)); + var thirdAgain = await CustomChecks.GetStats(new PagingInfo(page: 3, pageSize: 1)); + var fourth = await CustomChecks.GetStats(new PagingInfo(page: 4, pageSize: 1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(third.Results, Is.Empty, "page three is past the only check"); + Assert.That(fourth.Results, Is.Empty, "and so is page four"); + } + + return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); + } + + async Task QueueAddressesTwoEmptyPages() + { + await Ingest(Failure("Shipping@machine1")); + await CompleteDatabaseOperation(); + + var third = await QueueAddressStore.GetAddresses(new PagingInfo(page: 3, pageSize: 1)); + var thirdAgain = await QueueAddressStore.GetAddresses(new PagingInfo(page: 3, pageSize: 1)); + var fourth = await QueueAddressStore.GetAddresses(new PagingInfo(page: 4, pageSize: 1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(third.Results, Is.Empty, "page three is past the only address"); + Assert.That(fourth.Results, Is.Empty, "and so is page four"); + } + + return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); + } + + async Task GroupErrorsTwoEmptyPages() + { + var group = NewGroup(ExceptionClassifier); + + await Insert(InGroup(group, Oldest)); + + var third = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 3, pageSize: 1)); + var thirdAgain = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 3, pageSize: 1)); + var fourth = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 4, pageSize: 1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(third.Results, Is.Empty, "page three is past the only failure"); + Assert.That(fourth.Results, Is.Empty, "and so is page four"); + } + + return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); + } + + async Task GroupErrorCountTwoEmptyStatuses() + { + var group = NewGroup(ExceptionClassifier); + + await Insert(InGroup(group, Oldest)); + + var archived = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); + var archivedAgain = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); + var retryIssued = await GroupsStore.GetGroupErrorsCount(group.Id, "retryIssued", null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(archived.TotalCount, Is.Zero, "nothing in the group is archived"); + Assert.That(retryIssued.TotalCount, Is.Zero, "and nothing has a retry issued, so both counts are the same number"); + } + + return new(archived.Version, archivedAgain.Version, retryIssued.Version); + } + + async Task ArchivedGroupsTwoEmptyClassifiers() + { + // One unarchived failure, so the index is not empty, filed under neither classifier being asked for. + await Insert(InGroup(NewGroup("Endpoint Address"), Middle)); + + var byException = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); + var byExceptionAgain = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); + var byMessageType = await GroupsStore.GetArchivedGroupsByClassifier(MessageTypeClassifier); + + using (Assert.EnterMultipleScope()) + { + Assert.That(byException.Results, Is.Empty, "nothing archived under exception type"); + Assert.That(byMessageType.Results, Is.Empty, "nor under message type, so neither has rows to be named by"); + } + + return new(byException.QueryStats.Version, byExceptionAgain.QueryStats.Version, byMessageType.QueryStats.Version); + } + + async Task MessagesViewTwoEmptyPages() + { + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var third = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 3, pageSize: 1), new SortInfo(), includeSystemMessages: true); + var thirdAgain = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 3, pageSize: 1), new SortInfo(), includeSystemMessages: true); + var fourth = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 4, pageSize: 1), new SortInfo(), includeSystemMessages: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(third.Results, Is.Empty, "page three is past the only message"); + Assert.That(fourth.Results, Is.Empty, "and so is page four"); + } + + return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); + } + + async Task ErrorsTwoEmptyStatuses() + { + // One unresolved failure, so the index is not empty, and two filters that select none of it. + // The rows are what usually tell one query from another, and neither of these has any. + await Ingest(new IngestedFailure()); + await CompleteDatabaseOperation(); + + var archived = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); + var archivedAgain = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); + var retryIssued = await FailedMessageQueryStore.GetFailedMessages("retryIssued", null, null, new PagingInfo(), new SortInfo()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(archived.Results, Is.Empty, "nothing is archived"); + Assert.That(retryIssued.Results, Is.Empty, "and nothing has a retry issued, so neither has rows to be named by"); + } + + return new(archived.QueryStats.Version, archivedAgain.QueryStats.Version, retryIssued.QueryStats.Version); + } + + async Task MessagesViewTwoPages() + { + await Ingest(new IngestedFailure(), new IngestedFailure(), new IngestedFailure()); + await CompleteDatabaseOperation(); + + var firstPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); + var firstPageAgain = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); + var secondPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 2, pageSize: 2), new SortInfo(), includeSystemMessages: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two messages on the first page"); + Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + } + + return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); + } + + async Task ReportCheck(string customCheckId, bool hasFailed = false) + { + await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail + { + Category = "test-category", + CustomCheckId = customCheckId, + HasFailed = hasFailed, + FailureReason = hasFailed ? "Testing" : null, + ReportedAt = ReportedAt, + OriginatingEndpoint = new EndpointDetails + { + Host = "localhost", + HostId = Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"), + Name = "test-host" + } + }); + + await CompleteDatabaseOperation(); + } + + static IngestedFailure Failure(string failingEndpointAddress) => + new() { FailingEndpointAddress = failingEndpointAddress }; + + static FailedMessage.FailureGroup NewGroup(string classifier) => + new() { Id = Guid.NewGuid().ToString(), Title = "OrderPlaced", Type = classifier }; + + static IngestedFailure InGroup(FailedMessage.FailureGroup group, DateTime failedAt) => + new() + { + Groups = [group], + AttemptedAt = failedAt, + TimeOfFailure = failedAt, + TimeSent = failedAt.AddMinutes(-1) + }; + + async Task Archive(params IngestedFailure[] failures) + { + foreach (var failure in failures) + { + await FailedMessageLifecycleStore.MarkAsArchived(failure.UniqueMessageIdString); + } + + await CompleteDatabaseOperation(); + } + + async Task Insert(params IngestedFailure[] failures) + { + var messages = Array.ConvertAll(failures, failure => failure.ToFailedMessage()); + + foreach (var message in messages) + { + message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); + } + + await PersistenceTestsContext.InsertFailedMessages(messages); + await CompleteDatabaseOperation(); + } + + internal sealed record Scenario(string Name, string Because, Func> Run) + { + public override string ToString() => Name; + } + + internal sealed record Queried(DataVersion First, DataVersion FirstAgain, DataVersion Second); +} diff --git a/src/ServiceControl.Persistence.Tests/VersionAssert.cs b/src/ServiceControl.Persistence.Tests/VersionAssert.cs index ae999f7b20..446a9b9d19 100644 --- a/src/ServiceControl.Persistence.Tests/VersionAssert.cs +++ b/src/ServiceControl.Persistence.Tests/VersionAssert.cs @@ -20,6 +20,20 @@ public static void Moved(DataVersion before, DataVersion after, string because) } } + /// + /// Two different queries, answered at the same instant. Neither describes the other, so a caller + /// holding one must never be told the other is current. + /// + public static void Distinct(DataVersion one, DataVersion other, string because) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(one.HasValue, Is.True, "the first query produced no version to compare"); + Assert.That(other.HasValue, Is.True, "the second query produced no version to compare"); + Assert.That(other.Matches(one), Is.False, because); + } + } + /// Nothing changed, so a caller holding the earlier version still holds the current one. public static void Held(DataVersion first, DataVersion second, string because) { diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs b/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs new file mode 100644 index 0000000000..1be6ffb41e --- /dev/null +++ b/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Infrastructure +{ + /// + /// The page, ordering and filters a read was narrowed by, expressed as version terms. + /// + /// A version over a list normally tells two queries apart by the rows it returns. A query that matches + /// nothing returns no rows and so contributes no terms, which leaves every empty view of the same data + /// sharing one version. + /// + /// + public static class QueryNarrowing + { + public static (string Name, object? Value)[] Terms(PagingInfo pagingInfo, SortInfo? sortInfo, params (string Name, object? Value)[] filters) => + [ + ("page", pagingInfo.Page), + ("pageSize", pagingInfo.PageSize), + ("sort", sortInfo?.Sort), + ("direction", sortInfo?.Direction), + .. filters + ]; + } +} diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs new file mode 100644 index 0000000000..6308891c85 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs @@ -0,0 +1,90 @@ +namespace ServiceControl.UnitTests.Operations +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using NUnit.Framework; + using ServiceControl.MessageRedirects.Api; + using ServiceControl.Persistence.Infrastructure; + using ServiceControl.Persistence.MessageRedirects; + + [TestFixture] + public class MessageRedirectResponseVersionTests + { + [Test] + public async Task Two_pages_of_redirects_do_not_share_a_version() + { + var store = Store("a@machine1", "c@machine3", "e@machine5"); + + var firstPage = await Read(store, new PagingInfo(page: 1, pageSize: 2)); + var firstPageAgain = await Read(store, new PagingInfo(page: 1, pageSize: 2)); + var secondPage = await Read(store, new PagingInfo(page: 2, pageSize: 2)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstPage.Rows, Has.Count.EqualTo(2), "two redirects on the first page"); + Assert.That(secondPage.Rows, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); + Assert.That(firstPage.Etag, Is.Not.Null.And.Not.Empty, "the first page sent no validator"); + Assert.That(firstPageAgain.Etag, Is.EqualTo(firstPage.Etag), + "the first page's validator moved between two reads of unchanged data, so this test cannot judge anything"); + Assert.That(secondPage.Etag, Is.Not.EqualTo(firstPage.Etag), + "a client following the Link rel=next header while revalidating would render page one as page two"); + } + } + + [Test] + public async Task Two_sort_orders_of_redirects_do_not_share_a_version() + { + var store = Store("a@machine1", "c@machine3", "e@machine5"); + + var ascending = await Read(store, new PagingInfo(page: 1, pageSize: 2), sort: "from_physical_address", direction: "asc"); + var descending = await Read(store, new PagingInfo(page: 1, pageSize: 2), sort: "from_physical_address", direction: "desc"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ascending.Rows.First().FromPhysicalAddress, Is.EqualTo("a@machine1"), "ascending starts at the first address"); + Assert.That(descending.Rows.First().FromPhysicalAddress, Is.EqualTo("e@machine5"), "descending starts at the last, so the bodies differ"); + Assert.That(ascending.Etag, Is.Not.Null.And.Not.Empty, "the ascending page sent no validator"); + Assert.That(descending.Etag, Is.Not.EqualTo(ascending.Etag), + "a client that switches sort order while holding a validator is told the reordered page is unchanged"); + } + } + + static async Task<(string Etag, IList Rows)> Read( + IMessageRedirectsDataStore store, PagingInfo pagingInfo, string sort = null, string direction = null) + { + var controller = new MessageRedirectsController(null, store, null) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var rows = await controller.Redirects(sort, direction, pagingInfo); + + return (controller.Response.Headers.ETag.ToString(), rows.ToList()); + } + + static IMessageRedirectsDataStore Store(params string[] fromAddresses) => + new FakeStore([.. fromAddresses.Select((from, index) => new MessageRedirect + { + FromPhysicalAddress = from, + ToPhysicalAddress = $"destination{index}@machine", + LastModified = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc) + })]); + + class FakeStore(IReadOnlyList redirects) : IMessageRedirectsDataStore + { + public Task> GetRedirects(CancellationToken cancellationToken = default) => + Task.FromResult(redirects); + + public Task AddRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task UpdateRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task RemoveRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs index b3fb4e905d..03595190ac 100644 --- a/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs @@ -15,8 +15,16 @@ internal static DataVersion VersionOf(GroupOperation[] groups) => group.OperationStatus, group.OperationFailed, group.OperationProgress, group.OperationMessagesCompletedCount, group.OperationRemainingCount, group.OperationStartTime, group.OperationCompletionTime, group.NeedUserAcknowledgement]); - // FromPhysicalAddress needs no field of its own: MessageRedirectId is a deterministic hash of it. internal static DataVersion VersionOf(IReadOnlyList redirects) => - DataVersion.OverRows([("redirects", redirects.Count)], redirects, - redirect => [redirect.MessageRedirectId, redirect.ToPhysicalAddress, redirect.LastModified]); + DataVersion.OverRows([("redirects", redirects.Count)], redirects, Fields); + + /// + /// One sorted page of redirects, for a response that renders the page while reporting the total behind it. + /// + internal static DataVersion VersionOfPage(IReadOnlyList page, int total, PagingInfo pagingInfo) => + DataVersion.OverRows([("redirects", total), ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize)], page, Fields); + + // FromPhysicalAddress needs no field of its own: MessageRedirectId is a deterministic hash of it. + static object[] Fields(MessageRedirect redirect) => + [redirect.MessageRedirectId, redirect.ToPhysicalAddress, redirect.LastModified]; } diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index f506d31526..39a3f0890c 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -182,9 +182,13 @@ public async Task> Redirects(string sort, stri { var redirects = await store.GetRedirects(cancellationToken); - var queryResult = redirects + // Materialised because the version has to describe the page this response renders. + var page = redirects .Sort(sort, direction) .Paging(pagingInfo) + .ToList(); + + var queryResult = page .Select(r => new RedirectsQueryResult ( r.MessageRedirectId, @@ -193,7 +197,7 @@ public async Task> Redirects(string sort, stri r.LastModified )); - Response.WithEtag(ResponseVersions.VersionOf(redirects)); + Response.WithEtag(ResponseVersions.VersionOfPage(page, redirects.Count, pagingInfo)); Response.WithPagingLinksAndTotalCount(pagingInfo, redirects.Count); return queryResult; From 3d6cc21b5aec501c21c3c40cb3acded1fd980645 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 15:07:53 +0800 Subject: [PATCH 31/36] Clean up the not required knownVersion function on EventLogs --- docs/data-versioning-design.md | 15 --- docs/eventlog-design.md | 2 +- .../Implementation/EventLogDataStore.cs | 37 ++------ .../Infrastructure/EventLogQueries.cs | 18 ++++ .../EventLogDataStore.cs | 8 +- .../EventLogDataStoreTests.cs | 93 ------------------- .../IEventLogDataStore.cs | 6 +- .../Infrastructure/DataVersion.cs | 4 +- .../Infrastructure/QueryResult.cs | 9 -- .../WebApi/ConditionalGetTests.cs | 54 ----------- .../EventLog/EventLogApiController.cs | 9 +- .../WebApi/HttpRequestExtensions.cs | 25 ----- 12 files changed, 30 insertions(+), 250 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs delete mode 100644 src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs diff --git a/docs/data-versioning-design.md b/docs/data-versioning-design.md index 5cee555d98..05ffa0bc53 100644 --- a/docs/data-versioning-design.md +++ b/docs/data-versioning-design.md @@ -36,27 +36,12 @@ Term names and every field inside a row are **length prefixed**. Without that, f 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. -## Two comparisons, and they are not the same question - -- `Matches(other)` is the cache question, and the only one a store or a conditional-request filter should ask. It requires both sides present. -- `Equals(other)` is ordinary value equality and stays reflexive, so `None.Equals(None)` is true and the struct behaves in a dictionary. - -`operator ==` is deliberately left undefined so that choosing between them is explicit at the call site. - ## 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. -`GetKnownVersion` reads the caller's validator back through typed headers, not the raw header, because `If-None-Match` is a comma-separated list and the raw header hands the whole list over as one malformed value. A caller holding several validators, or the `*` wildcard, is treated as holding none: a store can only skip work for a single known version. The `304` still comes from the filter either way. - -## Skipping the query - -`GET /api/eventlogitems` is the **only** endpoint that hands the caller's version down to the persister: `IEventLogDataStore.GetEventLogItems` takes a `knownVersion`, and on a match returns `QueryResult.Unchanged` without fetching the page at all. Everywhere else the version is compared after the work is done and only the response body is saved. - -That makes the coverage rule sharper here than anywhere else. A page-blind version does not merely serve a stale page, it means the right page is never queried. The EF store therefore names the page window (`page`, `pageSize`) rather than the rows, which is sound only because a row in that table never changes and the query has a total order, and which keeps the caller's version answerable without fetching anything. - ## 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. 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.Persistence.EFCore/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs index 4d37d2d909..a09750b05c 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,40 +26,11 @@ public Task Add(EventLogItem logItem, CancellationToken cancellationToken = defa }, cancellationToken); public Task>> GetEventLogItems( - PagingInfo pagingInfo, DataVersion knownVersion = default, 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 = DataVersion.Compose( - ("total", total), - ("newest", stats?.Newest), - ("highestId", stats?.HighestId), - ("page", pagingInfo.Page), - ("pageSize", pagingInfo.PageSize)); - var queryStats = QueryStatsInfo.Fresh(version, total); - - // 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.Matches(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. @@ -78,6 +50,9 @@ public Task>> GetEventLogItems( }) .ToListAsync(token); - return new QueryResult>(items, queryStats); + var total = await query.LongCountAsync(token); + + return new QueryResult>(items, + items.ToQueryStatsInfo(total, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); }, cancellationToken); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs new file mode 100644 index 0000000000..5d4992cbf9 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using ServiceControl.EventLog; +using ServiceControl.Persistence.Infrastructure; + +static class EventLogQueries +{ + /// + /// Every scalar field of every item the body shows, plus the total behind Total-Count so that retention + /// deleting rows off the end of the log still moves the version. RelatedTo has no term of its own and + /// does not need one: an item is inserted once and never updated, so the same Id always renders the same + /// links. + /// + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => + QueryStatsInfo.Fresh(DataVersion.OverRows([("items", totalCount), .. query], page, + item => [item.Id, item.Description, item.Severity, item.RaisedAt, item.Category, item.EventType]), + totalCount); +} diff --git a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs index c3e02c853e..3234e06ec4 100644 --- a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs @@ -29,7 +29,7 @@ await session.StoreAsync( } public async Task>> GetEventLogItems( - PagingInfo pagingInfo, DataVersion knownVersion = default, CancellationToken cancellationToken = default) + PagingInfo pagingInfo, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var documents = await session @@ -43,12 +43,6 @@ public async Task>> GetEventLogItems( // index shares one validator. var queryStats = stats.ToPagedQueryStatsInfo(documents, session.Advanced.GetDocumentId); - // The page cannot be skipped, only the projection below. - if (knownVersion.Matches(queryStats.Version)) - { - return QueryResult>.Unchanged(queryStats); - } - // The id lives in document metadata rather than on the document var items = documents.ConvertAll(document => new EventLogItemView { diff --git a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs index f570e505a3..a7b8b905a5 100644 --- a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs @@ -96,18 +96,6 @@ public async Task Empty_store_returns_no_items() } } - [Test] - public async Task Empty_store_is_a_page_of_nothing_rather_than_not_modified() - { - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo()); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.NotModified, Is.False, "an empty store still has a representation to return"); - Assert.That(result.Results, Is.Not.Null); - } - } - [Test] public async Task Page_size_limits_returned_items_but_not_the_total() { @@ -188,38 +176,6 @@ public async Task Version_is_stable_while_nothing_changes() Assert.That(secondRead, Is.EqualTo(firstRead)); } - [Test] - public async Task Matching_known_version_reports_not_modified() - { - await AddItems(3); - var version = await CurrentVersion(); - - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), version); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.NotModified, Is.True, "a caller already holding the current version must be told so, not handed the page again"); - Assert.That(result.Results, Is.Null, "a not-modified result carries no page"); - } - } - - [Test] - public async Task Matching_known_version_still_reports_total_and_version() - { - await AddItems(3); - var version = await CurrentVersion(); - - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), version); - - using (Assert.EnterMultipleScope()) - { - // The controller sets Total-Count and ETag on the 304, so neither may be dropped - // just because the page was not fetched. - Assert.That(result.QueryStats.TotalCount, Is.EqualTo(3)); - Assert.That(result.QueryStats.Version.Matches(version), Is.True); - } - } - [Test] public async Task Two_pages_do_not_share_a_version() { @@ -237,55 +193,6 @@ public async Task Two_pages_do_not_share_a_version() } } - [Test] - public async Task A_page_is_not_skipped_for_a_version_from_a_different_page() - { - await AddItems(3); - - var firstPageVersion = (await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 1, pageSize: 2))).QueryStats.Version; - - var secondPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 2, pageSize: 2), firstPageVersion); - - using (Assert.EnterMultipleScope()) - { - Assert.That(secondPage.NotModified, Is.False, "the caller holds another page's validator, so this one still has to be fetched"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1)); - } - } - - [Test] - public async Task Stale_known_version_returns_the_page() - { - await AddItems(2); - var staleVersion = await CurrentVersion(); - - await AddItems(1); - - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), staleVersion); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.NotModified, Is.False); - Assert.That(result.Results, Has.Count.EqualTo(3)); - Assert.That(result.QueryStats.TotalCount, Is.EqualTo(3)); - Assert.That(result.QueryStats.Version.Matches(staleVersion), Is.False); - } - } - - [Test] - public async Task Unrecognised_known_version_returns_the_page() - { - await AddItems(2); - - var result = await EventLogDataStore.GetEventLogItems(new PagingInfo(), DataVersion.FromToken("not-a-version-this-store-ever-issued")); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result.NotModified, Is.False, "an unrecognised validator must be treated as a cache miss, never as a match"); - Assert.That(result.Results, Is.Not.Null); - } - } - async Task CurrentVersion() => (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.Version; diff --git a/src/ServiceControl.Persistence/IEventLogDataStore.cs b/src/ServiceControl.Persistence/IEventLogDataStore.cs index 03d94c90aa..d7fa9eb579 100644 --- a/src/ServiceControl.Persistence/IEventLogDataStore.cs +++ b/src/ServiceControl.Persistence/IEventLogDataStore.cs @@ -27,11 +27,7 @@ public interface IEventLogDataStore /// Returns one page of event log items, newest RaisedAt first. /// /// Which page to return. - /// - /// What the caller already holds, or . On a match the result is - /// and carries no page. - /// Task>> GetEventLogItems( - PagingInfo pagingInfo, DataVersion knownVersion = default, CancellationToken cancellationToken = default); + PagingInfo pagingInfo, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index ee6d152ab8..37a109e44a 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -134,8 +134,8 @@ public static DataVersion FromClient(string headerValue) } /// - /// Whether a caller holding already has this version. The only question a - /// store or a conditional-request filter should ask. + /// Whether this and are the same version and both present. Absence never + /// counts as a match, so any comparison involving is false. /// public bool Matches(DataVersion other) => HasValue && other.HasValue && string.Equals(validator, other.validator, StringComparison.Ordinal); diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs index 00d0b07921..519217f1c5 100644 --- a/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs +++ b/src/ServiceControl.Persistence/Infrastructure/QueryResult.cs @@ -16,17 +16,8 @@ public class QueryResult(TOut? results, QueryStatsInfo queryStatsInfo) public QueryStatsInfo QueryStats { get; } = queryStatsInfo; - /// - /// The caller already holds this version, so was never fetched and is - /// null. is still populated. - /// - public bool NotModified { get; private init; } - public static QueryResult Empty() => new(null, QueryStatsInfo.Zero); - public static QueryResult Unchanged(QueryStatsInfo queryStatsInfo) => - new(null, queryStatsInfo) { NotModified = true }; - public static implicit operator Task>(QueryResult instance) => Task.FromResult(instance); } } \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index e4d0460ff8..cb4be99aae 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -162,60 +162,6 @@ public void A_wildcard_precondition_is_ignored_when_there_is_no_validator() "an endpoint that publishes no validator has nothing for a client to have cached"); } - [Test] - public void A_caller_holding_one_validator_hands_it_to_the_store() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Request.Headers.IfNoneMatch = "W/\"4611686018427387904\""; - - Assert.That(httpContext.Request.GetKnownVersion().Matches(DataVersion.FromToken("4611686018427387904")), Is.True, - "the store skips its whole query on this, so it has to survive the round trip through the header"); - } - - [Test] - public void A_version_survives_the_round_trip_out_as_a_header_and_back() - { - var issued = DataVersion.FromToken("4611686018427387904"); - - var httpContext = new DefaultHttpContext(); - httpContext.Response.WithEtag(issued); - httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; - - Assert.That(httpContext.Request.GetKnownVersion().Matches(issued), Is.True, - "the store cannot skip work for a version it can no longer recognise coming back"); - } - - [Test] - public void A_caller_holding_several_validators_hands_the_store_none() - { - var httpContext = new DefaultHttpContext(); - - // RFC 9110 allows a list. Reading the raw header would hand the store the whole list as one - // malformed validator, which matches nothing and silently costs it the short circuit. - httpContext.Request.Headers.IfNoneMatch = "\"first\", \"second\""; - - Assert.That(httpContext.Request.GetKnownVersion().HasValue, Is.False, - "a store can only skip work for a single known version"); - } - - [Test] - public void A_wildcard_precondition_is_not_a_known_version() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Request.Headers.IfNoneMatch = "*"; - - Assert.That(httpContext.Request.GetKnownVersion().HasValue, Is.False, - "the wildcard asks whether any representation exists, which is not a version a store can match"); - } - - [Test] - public void A_caller_holding_nothing_hands_the_store_nothing() - { - Assert.That(new DefaultHttpContext().Request.GetKnownVersion().HasValue, Is.False); - } - static ResultExecutingContext ResultExecuting(HttpContext httpContext) => new( new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), diff --git a/src/ServiceControl/EventLog/EventLogApiController.cs b/src/ServiceControl/EventLog/EventLogApiController.cs index 914b874ee6..4e7f7e4ff7 100644 --- a/src/ServiceControl/EventLog/EventLogApiController.cs +++ b/src/ServiceControl/EventLog/EventLogApiController.cs @@ -1,7 +1,6 @@ namespace ServiceControl.EventLog { using System.Collections.Generic; - using System.Net; using System.Threading; using System.Threading.Tasks; using Infrastructure.Auth; @@ -20,17 +19,11 @@ public class EventLogApiController(IEventLogDataStore logDataStore) : Controller [HttpGet] public async Task>> Items([FromQuery] PagingInfo pagingInfo, CancellationToken cancellationToken = default) { - // Passing knownVersion lets the persister skip work it would otherwise waste - var result = await logDataStore.GetEventLogItems(pagingInfo, Request.GetKnownVersion(), cancellationToken); + var result = await logDataStore.GetEventLogItems(pagingInfo, cancellationToken); Response.WithPagingLinksAndTotalCount(pagingInfo, result.QueryStats.TotalCount); Response.WithEtag(result.QueryStats.Version); - if (result.NotModified) - { - return StatusCode((int)HttpStatusCode.NotModified); - } - return Ok(result.Results); } } diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs deleted file mode 100644 index 482e504750..0000000000 --- a/src/ServiceControl/Infrastructure/WebApi/HttpRequestExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ServiceControl.Infrastructure.WebApi -{ - using System; - using Microsoft.AspNetCore.Http; - using Persistence.Infrastructure; - - static class HttpRequestExtensions - { - /// - /// The version the caller already holds, or if it holds none. - /// - public static DataVersion GetKnownVersion(this HttpRequest request) - { - // Read through typed headers, because If-None-Match is a comma separated list and reading the - // raw header hands the whole list over as one malformed validator. A store can only skip work - // for a single known version, so a caller holding several is treated as holding none, and so is - // the "*" wildcard. - var candidates = request.GetTypedHeaders().IfNoneMatch; - - return candidates is { Count: 1 } && !candidates[0].Tag.Equals("*", StringComparison.Ordinal) - ? DataVersion.FromClient(candidates[0].ToString()) - : DataVersion.None; - } - } -} From 3c625445b9a2fd1d4514df8dafce7a5d54a64d7d Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 20:21:14 +0800 Subject: [PATCH 32/36] Clean not needed PagedQueryResults --- .../RavenQueryStatisticsExtensions.cs | 64 +-- .../RavenAuditDataStore.cs | 32 +- .../PagedVersionConformanceTests.cs | 129 ----- .../Implementation/CustomCheckDataStore.cs | 3 +- .../Implementation/EventLogDataStore.cs | 3 +- .../FailedMessageQueryDataStore.cs | 6 +- .../FailedMessageQueryResults.cs | 4 +- .../Implementation/GroupsDataStore.cs | 8 +- .../Implementation/MessagesViewDataStore.cs | 10 +- .../MessagesViewQueryResults.cs | 4 +- .../Implementation/QueueAddressStore.cs | 3 +- .../Infrastructure/CustomCheckQueries.cs | 18 - .../Infrastructure/EventLogQueries.cs | 18 - .../FailedMessageQueryFilters.cs | 25 - .../Infrastructure/FailureGroupQueries.cs | 10 - .../QueryStatsInfoExtensions.cs | 60 +++ .../Infrastructure/QueueAddressQueries.cs | 15 - .../ErrorMessagesDataStore.cs | 16 +- .../EventLogDataStore.cs | 4 +- .../Extensions/QueryResultConvert.cs | 10 +- .../QueueAddressStore.cs | 3 +- .../RavenCustomCheckDataStore.cs | 3 +- .../RavenQueryStatisticsExtensions.cs | 26 +- .../Recoverability/GroupsDataStore.cs | 17 +- .../RetryDocumentDataStore.cs | 6 +- .../BodyStorage/IngestionClockTests.cs | 32 -- .../EFCore/RetentionSweepTests.cs | 2 - .../EventLogDataStoreTests.cs | 17 - .../FailedMessageQueryDataStoreTests.cs | 19 - .../MessagesViewVersionTests.cs | 24 +- .../PagedVersionConformanceTests.cs | 444 ------------------ .../VersionAssert.cs | 23 +- .../Infrastructure/DataVersion.cs | 25 +- .../Infrastructure/QueryNarrowing.cs | 22 - .../HeaderAssertions.cs | 6 +- .../Infrastructure/DataVersionTests.cs | 6 - .../WebApi/ConditionalGetTests.cs | 19 +- .../MessageRedirectResponseVersionTests.cs | 90 ---- .../MessageRedirectVersionTests.cs | 22 +- .../Recoverability/RetryGroupVersionTests.cs | 81 ++-- .../CustomChecks/Web/CustomCheckController.cs | 3 +- .../EventLog/EventLogApiController.cs | 3 +- .../WebApi/HttpResponseExtensions.cs | 10 +- .../Infrastructure/WebApi/ResponseVersions.cs | 28 +- .../Api/MessageRedirectsController.cs | 23 +- .../API/FailureGroupsController.cs | 2 +- 46 files changed, 211 insertions(+), 1187 deletions(-) delete mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs delete mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs delete mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs delete mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs delete mode 100644 src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs delete mode 100644 src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs delete mode 100644 src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs delete mode 100644 src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs index e12efbc286..ac2429c37d 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/Extensions/RavenQueryStatisticsExtensions.cs @@ -1,70 +1,18 @@ namespace ServiceControl.Audit.Persistence.RavenDB.Extensions { - using System; - using System.Collections.Generic; using System.Globalization; - using System.Linq; using Auditing.MessagesView; using Raven.Client.Documents.Session; - using ServiceControl.Audit.Persistence.Infrastructure; static class RavenQueryStatisticsExtensions { /// - /// For a paged or filtered query. The index etag says whether the data moved, the row ids say which - /// rows this page renders, and names the question for the case the rows - /// cannot: a page with no rows contributes no row terms, so without it two filters that both match - /// nothing, and a page past the end, all share one value. + /// 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 ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id, params (string Name, object Value)[] query) - { - // No index etag means no version at all, as on the primary side. The rows here carry only ids, - // so the etag is the only term covering a change to a field a row renders; without it a - // validator would stand still while that field moved. - if (stats.ResultEtag is not { } resultEtag) - { - return new QueryStatsInfo(string.Empty, stats.TotalResults); - } - - var terms = new List(query.Length + 2) - { - Term("index", resultEtag), - Term("total", stats.TotalResults) - }; - - terms.AddRange(query.Select(term => Term(term.Name, term.Value))); - - var row = 0; - - foreach (var item in page) - { - terms.Add(Term(string.Concat("row", row++.ToString(CultureInfo.InvariantCulture)), id(item))); - } - - return new QueryStatsInfo(DeterministicGuid.MakeId(string.Join("|", terms)).ToString(), stats.TotalResults); - } - - // Length prefixed, so no value can pose as a different set of terms by containing a separator. - static string Term(string name, object value) - { - var text = Format(value); - - return string.Create(CultureInfo.InvariantCulture, $"{name}:{text.Length}:{text}"); - } - - // Mirrors DataVersion.Format on the primary side. Timestamps go in as ticks: their default - // formatting stops at whole seconds, which would collide two ranges a fraction apart. - 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)) - }; + public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => + new(stats.ResultEtag?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, stats.TotalResults); } } diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs index 2ddf231e7a..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) @@ -40,8 +40,7 @@ public async Task>> GetMessages(bool includeSyst .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("includeSystemMessages", includeSystemMessages)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -56,8 +55,7 @@ public async Task>> QueryMessages(string searchP .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("search", searchParam)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -73,8 +71,7 @@ public async Task>> QueryMessagesByReceivingEndp .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("endpoint", endpoint), ("keyword", keyword)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken = default) @@ -90,8 +87,7 @@ public async Task>> QueryMessagesByReceivingEndp .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, timeSentRange, ("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) @@ -105,25 +101,9 @@ public async Task>> QueryMessagesByConversationI .ToMessagesView() .ToListAsync(token: cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, view => view.Id, Narrowing(pagingInfo, sortInfo, null, ("conversationId", conversationId)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } - /// - /// The page, ordering and filters a read was narrowed by. Rows name a non-empty page on their own, - /// so these terms are what keep two queries apart when one of them returns nothing. - /// - static (string Name, object Value)[] Narrowing(PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, params (string Name, object Value)[] filters) => - [ - ("page", pagingInfo.Page), - ("pageSize", pagingInfo.PageSize), - ("sort", sortInfo?.Sort), - ("direction", sortInfo?.Direction), - ("from", timeSentRange?.From), - ("to", timeSentRange?.To), - .. filters - ]; - public async Task GetMessageBody(string messageId, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs deleted file mode 100644 index 22780838c0..0000000000 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/PagedVersionConformanceTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -namespace ServiceControl.Audit.Persistence.Tests -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using Auditing; - using NServiceBus; - using NUnit.Framework; - using ServiceControl.Audit.Infrastructure; - using ServiceControl.Audit.Monitoring; - - [TestFixture] - class PagedVersionConformanceTests : PersistenceTestFixture - { - [Test] - public async Task Two_pages_of_one_set_do_not_share_a_version() - { - await Ingest(MakeMessage(), MakeMessage(), MakeMessage()); - - var firstPage = await DataStore.GetMessages(false, new PagingInfo(page: 1, pageSize: 2), Sort); - var firstPageAgain = await DataStore.GetMessages(false, new PagingInfo(page: 1, pageSize: 2), Sort); - var secondPage = await DataStore.GetMessages(false, new PagingInfo(page: 2, pageSize: 2), Sort); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two rows on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - Assert.That(firstPage.QueryStats.ETag, Is.Not.Empty, "the first page produced no version to compare"); - Assert.That(firstPageAgain.QueryStats.ETag, Is.EqualTo(firstPage.QueryStats.ETag), - "the first page's version moved between two reads of unchanged data, so this test cannot judge anything"); - Assert.That(secondPage.QueryStats.ETag, Is.Not.EqualTo(firstPage.QueryStats.ETag), - "a caller holding page one would be told page two is unchanged and would render page one twice"); - } - } - - [Test] - public async Task Two_searches_do_not_share_a_version() - { - var wanted = Guid.NewGuid().ToString(); - - await Ingest(MakeMessage(conversationId: wanted), MakeMessage(conversationId: wanted), MakeMessage()); - - var matching = await DataStore.QueryMessages(wanted, new PagingInfo(), Sort); - var matchingAgain = await DataStore.QueryMessages(wanted, new PagingInfo(), Sort); - var missing = await DataStore.QueryMessages(Guid.NewGuid().ToString(), new PagingInfo(), Sort); - - using (Assert.EnterMultipleScope()) - { - Assert.That(matching.Results, Is.Not.Empty, "the search that should match found nothing, so the test proves nothing"); - Assert.That(missing.Results, Is.Empty, "and the search for an unused id matched nothing, so the bodies differ"); - Assert.That(matching.QueryStats.ETag, Is.Not.Empty, "the matching search produced no version to compare"); - Assert.That(matchingAgain.QueryStats.ETag, Is.EqualTo(matching.QueryStats.ETag), - "the matching search's version moved between two reads of unchanged data"); - Assert.That(missing.QueryStats.ETag, Is.Not.EqualTo(matching.QueryStats.ETag), - "a caller holding an empty search result would be told a search carrying messages is unchanged"); - } - } - - [Test] - public async Task Two_endpoints_do_not_share_a_version() - { - await Ingest(MakeMessage(processingEndpoint: "Shipping"), MakeMessage(processingEndpoint: "Billing")); - - var shipping = await DataStore.QueryMessagesByReceivingEndpoint(false, "Shipping", new PagingInfo(), Sort); - var shippingAgain = await DataStore.QueryMessagesByReceivingEndpoint(false, "Shipping", new PagingInfo(), Sort); - var billing = await DataStore.QueryMessagesByReceivingEndpoint(false, "Billing", new PagingInfo(), Sort); - - using (Assert.EnterMultipleScope()) - { - Assert.That(shipping.Results, Has.Count.EqualTo(1), "one message for Shipping"); - Assert.That(billing.Results, Has.Count.EqualTo(1), "one for Billing"); - Assert.That(billing.Results[0].MessageId, Is.Not.EqualTo(shipping.Results[0].MessageId), "and they are not the same message"); - Assert.That(shipping.QueryStats.ETag, Is.Not.Empty, "Shipping produced no version to compare"); - Assert.That(shippingAgain.QueryStats.ETag, Is.EqualTo(shipping.QueryStats.ETag), - "Shipping's version moved between two reads of unchanged data"); - Assert.That(billing.QueryStats.ETag, Is.Not.EqualTo(shipping.QueryStats.ETag), - "a caller watching one endpoint's messages would be shown another endpoint's"); - } - } - - static SortInfo Sort => new("Id", "asc"); - - async Task Ingest(params ProcessedMessage[] messages) - { - var unitOfWork = await StartAuditUnitOfWork(messages.Length); - - foreach (var message in messages) - { - await unitOfWork.RecordProcessedMessage(message); - } - - await unitOfWork.DisposeAsync(); - await configuration.CompleteDBOperation(); - } - - static ProcessedMessage MakeMessage(string conversationId = null, string processingEndpoint = null) - { - var messageId = Guid.NewGuid().ToString(); - conversationId ??= Guid.NewGuid().ToString(); - processingEndpoint ??= "SomeEndpoint"; - - var metadata = new Dictionary - { - { "MessageId", messageId }, - { "MessageIntent", MessageIntent.Send }, - { "CriticalTime", TimeSpan.FromSeconds(5) }, - { "ProcessingTime", TimeSpan.FromSeconds(1) }, - { "DeliveryTime", TimeSpan.FromSeconds(4) }, - { "IsSystemMessage", false }, - { "MessageType", "MyMessageType" }, - { "IsRetried", false }, - { "ConversationId", conversationId }, - { "ReceivingEndpoint", new EndpointDetails { Name = processingEndpoint } } - }; - - var headers = new Dictionary - { - { Headers.MessageId, messageId }, - { Headers.ProcessingEndpoint, processingEndpoint }, - { Headers.MessageIntent, MessageIntent.Send.ToString() }, - { Headers.ConversationId, conversationId }, - { Headers.ProcessingStarted, DateTimeOffsetHelper.ToWireFormattedString(DateTimeOffset.UtcNow) }, - { Headers.EnclosedMessageTypes, "MyMessageType" } - }; - - return new ProcessedMessage(headers, metadata); - } - } -} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs index 11184ff30e..997680763f 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/CustomCheckDataStore.cs @@ -85,8 +85,7 @@ public Task>> GetStats(PagingInfo paging, string? var totalCount = await query.CountAsync(token); - return new QueryResult>(checks, - checks.ToQueryStatsInfo(totalCount, ("status", status), ("page", paging.Page), ("pageSize", paging.PageSize))); + 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 a09750b05c..c3d962f8b3 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs @@ -52,7 +52,6 @@ public Task>> GetEventLogItems( var total = await query.LongCountAsync(token); - return new QueryResult>(items, - items.ToQueryStatsInfo(total, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); + 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 ff8045bbf7..d434750028 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs @@ -18,7 +18,7 @@ public Task>> GetFailedMessages(string? sta .FilterByStatus(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) - .ToPagedResult(pagingInfo, sortInfo, [("status", status), ("modified", modified), ("queueAddress", queueAddress)], token), cancellationToken); + .ToPagedResult(pagingInfo, sortInfo, token), cancellationToken); public Task GetFailedMessagesStats(string? status, string? modified, string? queueAddress, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages @@ -26,7 +26,7 @@ public Task GetFailedMessagesStats(string? status, string? modif .FilterByStatus(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) - .ToQueryStatsInfo([("status", status), ("modified", modified), ("queueAddress", queueAddress)], 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 @@ -34,7 +34,7 @@ public Task>> GetFailedMessagesByEndpoint(s .Where(message => message.ReceivingEndpointName == endpointName) .FilterByStatus(status) .FilterByLastModifiedRange(modified) - .ToPagedResult(pagingInfo, sortInfo, [("status", status), ("endpointName", endpointName), ("modified", modified)], token), cancellationToken); + .ToPagedResult(pagingInfo, sortInfo, token), cancellationToken); public Task> GetFailedMessagesSummary(CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (dbContext, token) => diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs index 39aba73b84..2907fbf2ee 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; static class FailedMessageQueryResults { - public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, (string Name, object? Value)[] filters, CancellationToken cancellationToken = default) + public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) { var total = await source.LongCountAsync(cancellationToken); @@ -19,6 +19,6 @@ public static async Task>> ToPagedResult(th IList results = [.. entities.Select(entity => entity.ToFailedMessageView())]; - return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total, QueryNarrowing.Terms(pagingInfo, sortInfo, filters))); + 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 a999ddc61b..e642fa7823 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs @@ -36,7 +36,7 @@ public Task>> GetArchivedGroupsByClassifier( var views = await MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)), token); - return new QueryResult>(views, views.ToQueryStatsInfo(("classifier", classifier))); + return new QueryResult>(views, views.ToQueryStatsInfo()); }, cancellationToken); public Task> GetUnresolvedGroup(string groupId, string? status, string? modified, CancellationToken cancellationToken = default) => @@ -47,11 +47,11 @@ public Task> GetArchivedGroup(string groupId, stri 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, [("groupId", groupId), ("status", status), ("modified", modified)], token), cancellationToken); + .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([("groupId", groupId), ("status", status), ("modified", modified)], token), cancellationToken); + .ToCountQueryStatsInfo("failures", token), cancellationToken); public Task EditComment(string groupId, string comment, CancellationToken cancellationToken = default) => ExecuteWithDbContext(async (dbContext, token) => @@ -92,7 +92,7 @@ static async Task> SingleGroup(ServiceControlDbCon .ToListAsync(cancellationToken); return new QueryResult(groups.FirstOrDefault()!, - groups.ToQueryStatsInfo(("groupId", groupId), ("status", status), ("modified", modified))); + groups.ToQueryStatsInfo()); } static IQueryable WithStatus(ServiceControlDbContext dbContext, FailedMessageStatus status) => diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs index 681f7c0adb..2b2d69d2db 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewDataStore.cs @@ -14,7 +14,7 @@ public Task>> GetAllMessages(PagingInfo pagingIn .AsNoTracking() .IncludeSystemMessagesWhere(includeSystemMessages) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, [("includeSystemMessages", includeSystemMessages), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages @@ -22,25 +22,25 @@ public Task>> GetAllMessagesForEndpoint(string e .Where(message => message.ReceivingEndpointName == endpointName) .IncludeSystemMessagesWhere(includeSystemMessages) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, [("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); // includeSystemMessages is unused here: a conversation is incomplete without the system messages that took part in it. public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => dbContext.FailedMessages .AsNoTracking() .Where(message => message.ConversationId == conversationId) - .ToPagedMessagesResult(pagingInfo, sortInfo, [("conversationId", conversationId)], token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => Search(dbContext.FailedMessages.AsNoTracking(), searchTerms) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, [("searchTerms", searchTerms), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => ExecuteWithDbContext((dbContext, token) => Search(dbContext.FailedMessages.AsNoTracking(), searchKeyword) .Where(message => message.ReceivingEndpointName == endpointName) .FilterBySentTimeRange(timeSentRange) - .ToPagedMessagesResult(pagingInfo, sortInfo, [("endpointName", endpointName), ("searchKeyword", searchKeyword), ("from", timeSentRange?.From), ("to", timeSentRange?.To)], token), cancellationToken); + .ToPagedMessagesResult(pagingInfo, sortInfo, token), cancellationToken); // Neither search hides system messages: a caller who searched // for something specific is not helped by hiding the message that matched it. diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs index ac9cfe8c74..50168b188f 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessagesViewQueryResults.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; static class MessagesViewQueryResults { - public static async Task>> ToPagedMessagesResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, (string Name, object? Value)[] filters, CancellationToken cancellationToken = default) + public static async Task>> ToPagedMessagesResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken = default) { var total = await source.LongCountAsync(cancellationToken); @@ -19,6 +19,6 @@ public static async Task>> ToPagedMessagesResult IList results = [.. entities.Select(entity => entity.ToMessagesView())]; - return new QueryResult>(results, entities.ToPagedQueryStatsInfo(total, QueryNarrowing.Terms(pagingInfo, sortInfo, filters))); + 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 00f32f930d..74793d79ee 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/QueueAddressStore.cs @@ -23,7 +23,6 @@ public Task>> GetAddresses(PagingInfo pagingInfo var items = await query.Skip(pagingInfo.Offset).Take(pagingInfo.PageSize).ToListAsync(token); var addressCount = await query.CountAsync(token); - return new QueryResult>(items, - items.ToQueryStatsInfo(addressCount, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); + return new QueryResult>(items, items.ToQueryStatsInfo(addressCount)); }, cancellationToken); } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs deleted file mode 100644 index 7375555f94..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Infrastructure; - -using ServiceControl.Contracts.CustomChecks; -using ServiceControl.Persistence.Infrastructure; - -static class CustomCheckQueries -{ - /// - /// Every field of every check the body shows, plus the total. OriginatingEndpoint has no term of its - /// own and does not need one: Id is a deterministic hash of the endpoint name, its host id and the - /// check id, so naming Id covers all three, and the host string is written once on insert and never - /// updated. - /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("checks", totalCount), .. query], page, - check => [check.Id, check.CustomCheckId, check.Category, check.Status, check.ReportedAt, check.FailureReason]), - totalCount); -} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs deleted file mode 100644 index 5d4992cbf9..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/EventLogQueries.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Infrastructure; - -using ServiceControl.EventLog; -using ServiceControl.Persistence.Infrastructure; - -static class EventLogQueries -{ - /// - /// Every scalar field of every item the body shows, plus the total behind Total-Count so that retention - /// deleting rows off the end of the log still moves the version. RelatedTo has no term of its own and - /// does not need one: an item is inserted once and never updated, so the same Id always renders the same - /// links. - /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("items", totalCount), .. query], page, - item => [item.Id, item.Description, item.Severity, item.RaisedAt, item.Category, item.EventType]), - totalCount); -} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailedMessageQueryFilters.cs index 35608663c3..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,30 +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, (string Name, object? Value)[] query, 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; - - // Aggregates rather than the rows, which holds only because every write path sets LastModified. - // Only safe for a response that reports the count and nothing else. A paged response has to use - // ToPagedQueryStatsInfo. - return QueryStatsInfo.Fresh(DataVersion.Compose([("failures", count), ("lastModified", stats?.Latest), .. query]), count); - } - - /// - /// Versions the rows this page renders, plus the total behind Total-Count, plus whatever narrowed the - /// query. - /// - public static QueryStatsInfo ToPagedQueryStatsInfo(this IReadOnlyCollection page, long total, params (string Name, object? Value)[] query) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("total", total), .. query], page, - row => [row.UniqueMessageId, row.LastModified, row.Status, row.NumberOfProcessingAttempts]), - total); - 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 17de48e37b..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,13 +21,4 @@ into aggregate First = aggregate.Min(message => message.FirstTimeOfFailure), Last = aggregate.Max(message => message.LastTimeOfFailure) }; - - /// - /// Title and Type cannot move within a row, because AggregateGroups groups by them, so a change to - /// either is a different row rather than a changed one. - /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups, params (string Name, object? Value)[] query) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("groups", groups.Count), .. query], groups, - group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last]), - groups.Count); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs new file mode 100644 index 0000000000..ab46efdd39 --- /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) => + QueryStatsInfo.Fresh( + 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) => + QueryStatsInfo.Fresh( + 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) => + QueryStatsInfo.Fresh( + 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 QueryStatsInfo.Fresh(DataVersion.Compose([(name, count)]), count); + } + + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => + QueryStatsInfo.Fresh( + 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) => + QueryStatsInfo.Fresh( + DataVersion.OverRows( + [("addresses", totalCount)], + items, + address => [address.PhysicalAddress, address.FailedMessageCount]), + totalCount); +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs deleted file mode 100644 index c924b41ba7..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Infrastructure; - -using ServiceControl.MessageFailures; -using ServiceControl.Persistence.Infrastructure; - -static class QueueAddressQueries -{ - /// - /// Both fields of every address the body shows, plus the total behind Total-Count. - /// - public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection page, long totalCount, params (string Name, object? Value)[] query) => - QueryStatsInfo.Fresh(DataVersion.OverRows([("addresses", totalCount), .. query], page, - address => [address.PhysicalAddress, address.FailedMessageCount]), - totalCount); -} diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index 45f37391e1..1ba1c9a5d5 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -53,7 +53,7 @@ public async Task>> GetAllMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("includeSystemMessages", includeSystemMessages)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> GetAllMessagesForEndpoint( @@ -79,7 +79,7 @@ public async Task>> GetAllMessagesForEndpoint( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("endpointName", endpointName), ("includeSystemMessages", includeSystemMessages)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> SearchEndpointMessages( @@ -104,7 +104,7 @@ public async Task>> SearchEndpointMessages( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("endpointName", endpointName), ("searchKeyword", searchKeyword)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> GetAllMessagesByConversation( @@ -126,7 +126,7 @@ public async Task>> GetAllMessagesByConversation var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("conversationId", conversationId), ("includeSystemMessages", includeSystemMessages)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task>> GetAllMessagesForSearch( @@ -149,7 +149,7 @@ public async Task>> GetAllMessagesForSearch( var results = await query.ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("searchTerms", searchTerms)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task MarkAsArchived(string failedMessageId, CancellationToken cancellationToken = default) @@ -200,7 +200,7 @@ public async Task>> GetFailedMessages( var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("status", status), ("modified", modified), ("queueAddress", queueAddress)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task GetFailedMessagesStats( @@ -218,7 +218,7 @@ public async Task GetFailedMessagesStats( .FilterByQueueAddress(queueAddress) .GetQueryResultAsync(cancellationToken); - return stats.ToCountQueryStatsInfo(("status", status), ("modified", modified), ("queueAddress", queueAddress)); + return stats.ToQueryStatsInfo(); } public async Task>> GetFailedMessagesByEndpoint( @@ -247,7 +247,7 @@ public async Task>> GetFailedMessagesByEndp var results = await query .ToListAsync(cancellationToken); - return new QueryResult>(results, stats.ToPagedQueryStatsInfo(results, view => view.Id, QueryNarrowing.Terms(pagingInfo, sortInfo, ("status", status), ("endpointName", endpointName), ("modified", modified)))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task> GetFailedMessagesSummary(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs index 3234e06ec4..da0940a168 100644 --- a/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs @@ -39,9 +39,7 @@ public async Task>> GetEventLogItems( .Paging(pagingInfo) .ToListAsync(cancellationToken); - // Names the ids on the page, not just the index etag, or every page of every filter over this - // index shares one validator. - var queryStats = stats.ToPagedQueryStatsInfo(documents, session.Advanced.GetDocumentId); + var queryStats = stats.ToQueryStatsInfo(); // The id lives in document metadata rather than on the document var items = documents.ConvertAll(document => new EventLogItemView diff --git a/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs b/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs index e1d467f7bc..230bb5297a 100644 --- a/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs +++ b/src/ServiceControl.Persistence.RavenDB/Extensions/QueryResultConvert.cs @@ -1,19 +1,13 @@ namespace ServiceControl.Persistence.RavenDB { - using System; using System.Collections.Generic; using Persistence.Infrastructure; using Raven.Client.Documents.Session; static class QueryResultConvert { - /// - /// Takes the row identity and the query terms rather than defaulting to neither, because a caller - /// reaching for a one-argument version of this gets a validator that described only the index, and - /// so will answer "not modified" to every other page and filter over the same one. - /// - public static QueryResult> ToQueryResult(this IList result, QueryStatistics stats, Func id, params (string Name, object Value)[] query) + public static QueryResult> ToQueryResult(this IList result, QueryStatistics stats) where T : class => - new(result, stats.ToPagedQueryStatsInfo(result, id, query)); + new(result, stats.ToQueryStatsInfo()); } } diff --git a/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs b/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs index 499f53a720..2b06bfa592 100644 --- a/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/QueueAddressStore.cs @@ -20,8 +20,7 @@ public async Task>> GetAddresses(PagingInfo pagi .Paging(pagingInfo) .ToListAsync(cancellationToken); - var result = new QueryResult>(addresses, - stats.ToPagedQueryStatsInfo(addresses, address => address.PhysicalAddress, ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize))); + var result = new QueryResult>(addresses, stats.ToQueryStatsInfo()); return result; } } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs index f5550cad03..5ae267faf8 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs @@ -57,8 +57,7 @@ public async Task>> GetStats(PagingInfo paging, s .Paging(paging) .ToListAsync(cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, check => check.Id, ("status", status), ("page", paging.Page), ("pageSize", paging.PageSize))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task DeleteCustomCheck(Guid id, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index ee7ee59701..bf128d4ee6 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -1,35 +1,17 @@ namespace ServiceControl.Persistence { - using System; - using System.Collections.Generic; using Raven.Client.Documents.Session; using ServiceControl.Persistence.Infrastructure; static class RavenQueryStatisticsExtensions { - /// - /// For a paged query. The index etag covers whether the data moved, and the row ids cover which - /// rows this page renders. The etag alone cannot: it is a function of index and collection state, - /// so every filter, page and sort over one index shares it. - /// - /// A page with no rows contributes no row terms at all, so without it a - /// page past the end and any other empty view of the same index share a version. - /// - /// - public static QueryStatsInfo ToPagedQueryStatsInfo(this QueryStatistics stats, IEnumerable page, Func id, params (string Name, object Value)[] query) => - new(stats.ResultEtag is { } resultEtag - ? DataVersion.OverRows([("index", resultEtag), ("total", stats.TotalResults), .. query], page, row => [id(row)]) - : DataVersion.None, + public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => + new(stats.ResultEtag is { } resultEtag ? DataVersion.FromToken(resultEtag) : DataVersion.None, stats.TotalResults, stats.IsStale); - /// - /// For a response whose whole content is its count, which has no rows to be named by. - /// is the only thing separating one filter from another here: leave it out - /// and a caller holding the count for one filter is told another filter's count is still current. - /// - public static QueryStatsInfo ToCountQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult, params (string Name, object Value)[] query) => - new(DataVersion.Compose([("index", queryResult.ResultEtag), ("total", queryResult.TotalResults), .. query]), + public static QueryStatsInfo ToQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult) => + new(DataVersion.FromToken(queryResult.ResultEtag), queryResult.TotalResults, queryResult.IsStale); } diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs index f7a1928212..d8f97782ad 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs @@ -53,8 +53,7 @@ public async Task>> GetArchivedGroupsByClass .Take(200) // only show 200 groups .ToListAsync(cancellationToken); - return new QueryResult>(results, - stats.ToPagedQueryStatsInfo(results, group => group.Id, ("classifier", classifier))); + return new QueryResult>(results, stats.ToQueryStatsInfo()); } public async Task> GetUnresolvedGroup(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -68,7 +67,7 @@ public async Task> GetUnresolvedGroup(string group .FilterByLastModifiedRange(modified) .FirstOrDefaultAsync(cancellationToken); - return new QueryResult(document, OneGroup(stats, document, groupId, status, modified)); + return new QueryResult(document, stats.ToQueryStatsInfo()); } public async Task> GetArchivedGroup(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -82,13 +81,9 @@ public async Task> GetArchivedGroup(string groupId .FilterByLastModifiedRange(modified) .FirstOrDefaultAsync(cancellationToken); - return new QueryResult(document, OneGroup(stats, document, groupId, status, modified)); + return new QueryResult(document, stats.ToQueryStatsInfo()); } - static QueryStatsInfo OneGroup(QueryStatistics stats, FailureGroupView document, string groupId, string status, string modified) => - stats.ToPagedQueryStatsInfo(document is null ? [] : [document], group => group.Id, - ("groupId", groupId), ("status", status), ("modified", modified)); - public async Task>> GetGroupErrors( string groupId, string status, @@ -114,9 +109,7 @@ public async Task>> GetGroupErrors( var results = await query .ToListAsync(cancellationToken); - return results.ToQueryResult(stats, view => view.Id, - ("groupId", groupId), ("status", status), ("modified", modified), - ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize)); + return results.ToQueryResult(stats); } public async Task GetGroupErrorsCount(string groupId, string status, string modified, CancellationToken cancellationToken = default) @@ -129,7 +122,7 @@ public async Task GetGroupErrorsCount(string groupId, string sta .FilterByLastModifiedRange(modified) .GetQueryResultAsync(cancellationToken); - return queryResult.ToCountQueryStatsInfo(("groupId", groupId), ("status", status), ("modified", modified)); + return queryResult.ToQueryStatsInfo(); } public async Task EditComment(string groupId, string comment, CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs index 38bcd4bbbe..9502389a8c 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs @@ -90,13 +90,13 @@ await session.StoreAsync(new RetryBatch using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var orphanedBatches = await session .Query() - .Where(b => b.Status == RetryBatchStatus.MarkingDocuments && b.RetrySessionId != retrySessionId) .Statistics(out var stats) .ToListAsync(cancellationToken); - return orphanedBatches.Select(batch => batch.ToContract()).ToList() - .ToQueryResult(stats, batch => batch.Id, ("retrySessionId", retrySessionId)); + return orphanedBatches.Select(batch => batch.ToContract()) + .ToList() + .ToQueryResult(stats); } public async Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs b/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs deleted file mode 100644 index 2daa79648c..0000000000 --- a/src/ServiceControl.Persistence.Tests/BodyStorage/IngestionClockTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ServiceControl.Persistence.Tests; - -using System; -using System.Threading.Tasks; -using NUnit.Framework; - -[TestFixture] -class IngestionClockTests : IngestionTestBase -{ - [Test] - public async Task A_re_ingested_message_moves_the_version() - { - var failure = new IngestedFailure(); - - await Ingest(failure); - await CompleteDatabaseOperation(); - - var before = (await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null)).Version; - - AdvanceClock(TimeSpan.FromMinutes(5)); - - await Ingest(failure.NextAttempt(failure.AttemptedAt.AddMinutes(5))); - await CompleteDatabaseOperation(); - - var after = (await FailedMessageQueryStore.GetFailedMessagesStats(null, null, null)).Version; - - // The EF clock is frozen, so without AdvanceClock a second attempt at the same message leaves - // both the count and LastModified alone and the version cannot move. - VersionAssert.Moved(before, after, - "the stored body changed, so a revalidating client must not be served the old bytes"); - } -} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index f4ada80119..6b5cf6e93f 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -279,8 +279,6 @@ public async Task Sweeping_failed_messages_changes_the_version() using (Assert.EnterMultipleScope()) { Assert.That(after.TotalCount, Is.EqualTo(1)); - // A sweep is the only thing that takes a row away without touching the newest LastModified, - // so nothing else keeps the count term of this version honest. Assert.That(after.Version.Matches(versionBefore), Is.False); } } diff --git a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs index a7b8b905a5..02c9481eda 100644 --- a/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EventLogDataStoreTests.cs @@ -176,23 +176,6 @@ public async Task Version_is_stable_while_nothing_changes() Assert.That(secondRead, Is.EqualTo(firstRead)); } - [Test] - public async Task Two_pages_do_not_share_a_version() - { - await AddItems(3); - - var firstPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 1, pageSize: 2)); - var secondPage = await EventLogDataStore.GetEventLogItems(new PagingInfo(page: 2, pageSize: 2)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two items on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - Assert.That(secondPage.QueryStats.Version.Matches(firstPage.QueryStats.Version), Is.False, - "sharing one would let the store answer page two out of a caller's cached page one"); - } - } - async Task CurrentVersion() => (await EventLogDataStore.GetEventLogItems(new PagingInfo())).QueryStats.Version; diff --git a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs index 1f5d7b3983..e03645f4d3 100644 --- a/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessageFailures/FailedMessageQueryDataStoreTests.cs @@ -312,25 +312,6 @@ public async Task Reports_stats_matching_the_query() Assert.That(stats.TotalCount, Is.EqualTo(1)); Assert.That(stats.TotalCount, Is.EqualTo(query.QueryStats.TotalCount)); Assert.That(stats.Version.HasValue, Is.True, "the count endpoint still has to be cacheable"); - Assert.That(stats.Version.Matches(query.QueryStats.Version), Is.False); - } - } - - [Test] - public async Task Two_filters_that_render_different_rows_do_not_share_a_version() - { - await Insert(new IngestedFailure().ToFailedMessage(), new IngestedFailure().ToFailedMessage(FailedMessageStatus.Archived)); - - var unresolved = await FailedMessageQueryStore.GetFailedMessages("unresolved", null, null, new PagingInfo(), new SortInfo()); - var archived = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); - - using (Assert.EnterMultipleScope()) - { - Assert.That(unresolved.Results, Has.Count.EqualTo(1), "one unresolved message"); - Assert.That(archived.Results, Has.Count.EqualTo(1), "and one archived, so the counts cannot tell the two apart"); - Assert.That(Ids(archived), Is.Not.EquivalentTo(Ids(unresolved)), "and the two pages render different rows"); - Assert.That(archived.QueryStats.Version.Matches(unresolved.QueryStats.Version), Is.False, - "two different bodies sharing a validator lets a client that reuses one across views be served the wrong page"); } } diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs index 2d6bd32612..9ee8c0f1bf 100644 --- a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -97,29 +97,7 @@ public async Task Version_is_stable_while_nothing_changes() } [Test] - public async Task Two_pages_of_one_set_do_not_share_a_version() - { - for (var i = 0; i < 3; i++) - { - await Ingest(new IngestedFailure()); - } - - await CompleteDatabaseOperation(); - - var firstPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); - var secondPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 2, pageSize: 2), new SortInfo(), includeSystemMessages: true); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two rows on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - Assert.That(secondPage.QueryStats.Version.Matches(firstPage.QueryStats.Version), Is.False, - "a client following the Link rel=next header while revalidating would otherwise render page one as page two"); - } - } - - [Test] - public async Task A_page_keeps_its_version_when_a_row_it_does_not_show_changes() + public async Task Version_changes_when_the_total_moves_under_an_unchanged_page() { var shown = new IngestedFailure(); diff --git a/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs b/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs deleted file mode 100644 index c2dd1e9891..0000000000 --- a/src/ServiceControl.Persistence.Tests/PagedVersionConformanceTests.cs +++ /dev/null @@ -1,444 +0,0 @@ -namespace ServiceControl.Persistence.Tests; - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Contracts.CustomChecks; -using NUnit.Framework; -using ServiceControl.MessageFailures; -using ServiceControl.Operations; -using ServiceControl.Persistence.Infrastructure; - -[TestFixture] -class PagedVersionConformanceTests : IngestionTestBase -{ - const string ExceptionClassifier = "Exception Type and Stack Trace"; - const string MessageTypeClassifier = "Message Type"; - - static readonly DateTime Oldest = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); - static readonly DateTime Middle = new(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); - static readonly DateTime Newest = new(2026, 8, 1, 17, 0, 0, DateTimeKind.Utc); - static readonly DateTime ReportedAt = new(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc); - - static IEnumerable Scenarios() => - [ - new("custom checks, two pages", - "page one and page two of the custom checks list render different checks", - fixture => fixture.CustomChecksTwoPages()), - new("custom checks, two status filters", - "the failing checks and the passing checks are different lists", - fixture => fixture.CustomChecksTwoStatuses()), - new("queue addresses, two pages", - "page one and page two of the queue address list render different addresses", - fixture => fixture.QueueAddressesTwoPages()), - new("the errors in a group, two pages", - "page one and page two of a group's failures render different messages", - fixture => fixture.GroupErrorsTwoPages()), - new("the error count of a group, two status filters", - "the unresolved count and the archived count are different numbers, and the count is the whole response", - fixture => fixture.GroupErrorCountTwoStatuses()), - new("archived groups, two classifiers", - "grouping the archive by exception type and by message type produces different groups", - fixture => fixture.ArchivedGroupsTwoClassifiers()), - new("the messages view, two pages", - "page one and page two of the messages list render different messages", - fixture => fixture.MessagesViewTwoPages()), - new("the error list, two status filters that both match nothing", - "two filters that happen to be empty today are still two different questions", - fixture => fixture.ErrorsTwoEmptyStatuses()), - - // The cases above compare queries whose rows differ, so the row terms alone tell them apart and - // they would pass even if a store named none of its filters. These compare two queries that both - // return nothing, where there are no rows to name and only the query terms are left to do it. - new("custom checks, two status filters that both match nothing", - "asking for the failing checks and the passing checks of an empty store are still two questions", - fixture => fixture.CustomChecksTwoEmptyStatuses()), - new("custom checks, two pages past the end", - "two pages beyond the last are two questions, and the paging links differ", - fixture => fixture.CustomChecksTwoEmptyPages()), - new("queue addresses, two pages past the end", - "two pages beyond the last are two questions", - fixture => fixture.QueueAddressesTwoEmptyPages()), - new("the errors in a group, two pages past the end", - "two pages beyond the last are two questions", - fixture => fixture.GroupErrorsTwoEmptyPages()), - new("the error count of a group, two status filters that both count nothing", - "two counts that are both zero still answer different questions", - fixture => fixture.GroupErrorCountTwoEmptyStatuses()), - new("archived groups, two classifiers with nothing archived", - "two classifiers that group nothing are still two questions", - fixture => fixture.ArchivedGroupsTwoEmptyClassifiers()), - new("the messages view, two pages past the end", - "two pages beyond the last are two questions", - fixture => fixture.MessagesViewTwoEmptyPages()) - ]; - - [Test] - [TestCaseSource(nameof(Scenarios))] - public async Task Two_queries_of_one_store_do_not_share_a_version(Scenario scenario) - { - var queried = await scenario.Run(this); - - // Proves the first query's own version was standing still across the two reads. Without it a - // shared version below could be waved away as the store legitimately moving between requests. - VersionAssert.Held(queried.First, queried.FirstAgain, - "the first query's version moved between two reads of unchanged data, so this scenario cannot judge anything"); - - VersionAssert.Distinct(queried.First, queried.Second, scenario.Because); - } - - async Task CustomChecksTwoPages() - { - await ReportCheck("Disk space"); - await ReportCheck("Queue length"); - await ReportCheck("Certificate expiry"); - - var firstPage = await CustomChecks.GetStats(new PagingInfo(page: 1, pageSize: 2)); - var firstPageAgain = await CustomChecks.GetStats(new PagingInfo(page: 1, pageSize: 2)); - var secondPage = await CustomChecks.GetStats(new PagingInfo(page: 2, pageSize: 2)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two checks on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - } - - return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); - } - - async Task CustomChecksTwoStatuses() - { - await ReportCheck("Disk space", hasFailed: true); - await ReportCheck("Queue length", hasFailed: false); - - var failing = await CustomChecks.GetStats(new PagingInfo(), "fail"); - var failingAgain = await CustomChecks.GetStats(new PagingInfo(), "fail"); - var passing = await CustomChecks.GetStats(new PagingInfo(), "pass"); - - using (Assert.EnterMultipleScope()) - { - Assert.That(failing.Results, Has.Count.EqualTo(1), "one failing check"); - Assert.That(passing.Results, Has.Count.EqualTo(1), "one passing check"); - Assert.That(passing.Results[0].Id, Is.Not.EqualTo(failing.Results[0].Id), "and they are not the same check"); - } - - return new(failing.QueryStats.Version, failingAgain.QueryStats.Version, passing.QueryStats.Version); - } - - async Task QueueAddressesTwoPages() - { - await Ingest(Failure("Shipping@machine1"), Failure("Billing@machine1"), Failure("Sales@machine1")); - await CompleteDatabaseOperation(); - - var firstPage = await QueueAddressStore.GetAddresses(new PagingInfo(page: 1, pageSize: 2)); - var firstPageAgain = await QueueAddressStore.GetAddresses(new PagingInfo(page: 1, pageSize: 2)); - var secondPage = await QueueAddressStore.GetAddresses(new PagingInfo(page: 2, pageSize: 2)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two addresses on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - } - - return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); - } - - async Task GroupErrorsTwoPages() - { - var group = NewGroup(ExceptionClassifier); - - await Insert(InGroup(group, Oldest), InGroup(group, Middle), InGroup(group, Newest)); - - var firstPage = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 1, pageSize: 2)); - var firstPageAgain = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 1, pageSize: 2)); - var secondPage = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 2, pageSize: 2)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two failures on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - } - - return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); - } - - async Task GroupErrorCountTwoStatuses() - { - var group = NewGroup(ExceptionClassifier); - var toArchive = InGroup(group, Middle); - - await Insert(InGroup(group, Oldest), toArchive, InGroup(group, Newest)); - await Archive(toArchive); - - var unresolved = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); - var unresolvedAgain = await GroupsStore.GetGroupErrorsCount(group.Id, "unresolved", null); - var archived = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); - - using (Assert.EnterMultipleScope()) - { - Assert.That(unresolved.TotalCount, Is.EqualTo(2), "two of the three are still unresolved"); - Assert.That(archived.TotalCount, Is.EqualTo(1), "and one is archived, so the two responses carry different counts"); - } - - return new(unresolved.Version, unresolvedAgain.Version, archived.Version); - } - - async Task ArchivedGroupsTwoClassifiers() - { - var byException = NewGroup(ExceptionClassifier); - var byMessageType = NewGroup(MessageTypeClassifier); - - // One failure filed under both classifiers, so each classifier has exactly one group to - // report and the two groups differ in their id and their type. - var failure = new IngestedFailure - { - Groups = [byException, byMessageType], - AttemptedAt = Middle, - TimeOfFailure = Middle, - TimeSent = Middle.AddMinutes(-1) - }; - - await Insert(failure); - await Archive(failure); - - var exceptionType = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); - var exceptionTypeAgain = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); - var messageType = await GroupsStore.GetArchivedGroupsByClassifier(MessageTypeClassifier); - - using (Assert.EnterMultipleScope()) - { - Assert.That(exceptionType.Results, Has.Count.EqualTo(1), "one archived group by exception type"); - Assert.That(messageType.Results, Has.Count.EqualTo(1), "one archived group by message type"); - Assert.That(messageType.Results[0].Id, Is.Not.EqualTo(exceptionType.Results[0].Id), "and they are not the same group"); - } - - return new(exceptionType.QueryStats.Version, exceptionTypeAgain.QueryStats.Version, messageType.QueryStats.Version); - } - - async Task CustomChecksTwoEmptyStatuses() - { - var failing = await CustomChecks.GetStats(new PagingInfo(), "fail"); - var failingAgain = await CustomChecks.GetStats(new PagingInfo(), "fail"); - var passing = await CustomChecks.GetStats(new PagingInfo(), "pass"); - - using (Assert.EnterMultipleScope()) - { - Assert.That(failing.Results, Is.Empty, "no failing checks"); - Assert.That(passing.Results, Is.Empty, "and no passing ones, so neither has rows to be named by"); - } - - return new(failing.QueryStats.Version, failingAgain.QueryStats.Version, passing.QueryStats.Version); - } - - async Task CustomChecksTwoEmptyPages() - { - await ReportCheck("Disk space"); - - var third = await CustomChecks.GetStats(new PagingInfo(page: 3, pageSize: 1)); - var thirdAgain = await CustomChecks.GetStats(new PagingInfo(page: 3, pageSize: 1)); - var fourth = await CustomChecks.GetStats(new PagingInfo(page: 4, pageSize: 1)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(third.Results, Is.Empty, "page three is past the only check"); - Assert.That(fourth.Results, Is.Empty, "and so is page four"); - } - - return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); - } - - async Task QueueAddressesTwoEmptyPages() - { - await Ingest(Failure("Shipping@machine1")); - await CompleteDatabaseOperation(); - - var third = await QueueAddressStore.GetAddresses(new PagingInfo(page: 3, pageSize: 1)); - var thirdAgain = await QueueAddressStore.GetAddresses(new PagingInfo(page: 3, pageSize: 1)); - var fourth = await QueueAddressStore.GetAddresses(new PagingInfo(page: 4, pageSize: 1)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(third.Results, Is.Empty, "page three is past the only address"); - Assert.That(fourth.Results, Is.Empty, "and so is page four"); - } - - return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); - } - - async Task GroupErrorsTwoEmptyPages() - { - var group = NewGroup(ExceptionClassifier); - - await Insert(InGroup(group, Oldest)); - - var third = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 3, pageSize: 1)); - var thirdAgain = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 3, pageSize: 1)); - var fourth = await GroupsStore.GetGroupErrors(group.Id, "unresolved", null, new SortInfo(), new PagingInfo(page: 4, pageSize: 1)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(third.Results, Is.Empty, "page three is past the only failure"); - Assert.That(fourth.Results, Is.Empty, "and so is page four"); - } - - return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); - } - - async Task GroupErrorCountTwoEmptyStatuses() - { - var group = NewGroup(ExceptionClassifier); - - await Insert(InGroup(group, Oldest)); - - var archived = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); - var archivedAgain = await GroupsStore.GetGroupErrorsCount(group.Id, "archived", null); - var retryIssued = await GroupsStore.GetGroupErrorsCount(group.Id, "retryIssued", null); - - using (Assert.EnterMultipleScope()) - { - Assert.That(archived.TotalCount, Is.Zero, "nothing in the group is archived"); - Assert.That(retryIssued.TotalCount, Is.Zero, "and nothing has a retry issued, so both counts are the same number"); - } - - return new(archived.Version, archivedAgain.Version, retryIssued.Version); - } - - async Task ArchivedGroupsTwoEmptyClassifiers() - { - // One unarchived failure, so the index is not empty, filed under neither classifier being asked for. - await Insert(InGroup(NewGroup("Endpoint Address"), Middle)); - - var byException = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); - var byExceptionAgain = await GroupsStore.GetArchivedGroupsByClassifier(ExceptionClassifier); - var byMessageType = await GroupsStore.GetArchivedGroupsByClassifier(MessageTypeClassifier); - - using (Assert.EnterMultipleScope()) - { - Assert.That(byException.Results, Is.Empty, "nothing archived under exception type"); - Assert.That(byMessageType.Results, Is.Empty, "nor under message type, so neither has rows to be named by"); - } - - return new(byException.QueryStats.Version, byExceptionAgain.QueryStats.Version, byMessageType.QueryStats.Version); - } - - async Task MessagesViewTwoEmptyPages() - { - await Ingest(new IngestedFailure()); - await CompleteDatabaseOperation(); - - var third = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 3, pageSize: 1), new SortInfo(), includeSystemMessages: true); - var thirdAgain = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 3, pageSize: 1), new SortInfo(), includeSystemMessages: true); - var fourth = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 4, pageSize: 1), new SortInfo(), includeSystemMessages: true); - - using (Assert.EnterMultipleScope()) - { - Assert.That(third.Results, Is.Empty, "page three is past the only message"); - Assert.That(fourth.Results, Is.Empty, "and so is page four"); - } - - return new(third.QueryStats.Version, thirdAgain.QueryStats.Version, fourth.QueryStats.Version); - } - - async Task ErrorsTwoEmptyStatuses() - { - // One unresolved failure, so the index is not empty, and two filters that select none of it. - // The rows are what usually tell one query from another, and neither of these has any. - await Ingest(new IngestedFailure()); - await CompleteDatabaseOperation(); - - var archived = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); - var archivedAgain = await FailedMessageQueryStore.GetFailedMessages("archived", null, null, new PagingInfo(), new SortInfo()); - var retryIssued = await FailedMessageQueryStore.GetFailedMessages("retryIssued", null, null, new PagingInfo(), new SortInfo()); - - using (Assert.EnterMultipleScope()) - { - Assert.That(archived.Results, Is.Empty, "nothing is archived"); - Assert.That(retryIssued.Results, Is.Empty, "and nothing has a retry issued, so neither has rows to be named by"); - } - - return new(archived.QueryStats.Version, archivedAgain.QueryStats.Version, retryIssued.QueryStats.Version); - } - - async Task MessagesViewTwoPages() - { - await Ingest(new IngestedFailure(), new IngestedFailure(), new IngestedFailure()); - await CompleteDatabaseOperation(); - - var firstPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); - var firstPageAgain = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 1, pageSize: 2), new SortInfo(), includeSystemMessages: true); - var secondPage = await MessagesViewStore.GetAllMessages(new PagingInfo(page: 2, pageSize: 2), new SortInfo(), includeSystemMessages: true); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Results, Has.Count.EqualTo(2), "two messages on the first page"); - Assert.That(secondPage.Results, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - } - - return new(firstPage.QueryStats.Version, firstPageAgain.QueryStats.Version, secondPage.QueryStats.Version); - } - - async Task ReportCheck(string customCheckId, bool hasFailed = false) - { - await CustomChecks.UpdateCustomCheckStatus(new CustomCheckDetail - { - Category = "test-category", - CustomCheckId = customCheckId, - HasFailed = hasFailed, - FailureReason = hasFailed ? "Testing" : null, - ReportedAt = ReportedAt, - OriginatingEndpoint = new EndpointDetails - { - Host = "localhost", - HostId = Guid.Parse("55D0800D-CC90-47C3-83EB-DDE292140C28"), - Name = "test-host" - } - }); - - await CompleteDatabaseOperation(); - } - - static IngestedFailure Failure(string failingEndpointAddress) => - new() { FailingEndpointAddress = failingEndpointAddress }; - - static FailedMessage.FailureGroup NewGroup(string classifier) => - new() { Id = Guid.NewGuid().ToString(), Title = "OrderPlaced", Type = classifier }; - - static IngestedFailure InGroup(FailedMessage.FailureGroup group, DateTime failedAt) => - new() - { - Groups = [group], - AttemptedAt = failedAt, - TimeOfFailure = failedAt, - TimeSent = failedAt.AddMinutes(-1) - }; - - async Task Archive(params IngestedFailure[] failures) - { - foreach (var failure in failures) - { - await FailedMessageLifecycleStore.MarkAsArchived(failure.UniqueMessageIdString); - } - - await CompleteDatabaseOperation(); - } - - async Task Insert(params IngestedFailure[] failures) - { - var messages = Array.ConvertAll(failures, failure => failure.ToFailedMessage()); - - foreach (var message in messages) - { - message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); - } - - await PersistenceTestsContext.InsertFailedMessages(messages); - await CompleteDatabaseOperation(); - } - - internal sealed record Scenario(string Name, string Because, Func> Run) - { - public override string ToString() => Name; - } - - internal sealed record Queried(DataVersion First, DataVersion FirstAgain, DataVersion Second); -} diff --git a/src/ServiceControl.Persistence.Tests/VersionAssert.cs b/src/ServiceControl.Persistence.Tests/VersionAssert.cs index 446a9b9d19..59c83a8ce5 100644 --- a/src/ServiceControl.Persistence.Tests/VersionAssert.cs +++ b/src/ServiceControl.Persistence.Tests/VersionAssert.cs @@ -5,11 +5,6 @@ namespace ServiceControl.Persistence.Tests; static class VersionAssert { - /// - /// The data moved, so the version had to move with it. Checks the earlier version exists first, - /// because is false whenever either side is absent, so a store - /// that stopped producing a version at all would otherwise satisfy the same assertion. - /// public static void Moved(DataVersion before, DataVersion after, string because) { using (Assert.EnterMultipleScope()) @@ -20,21 +15,6 @@ public static void Moved(DataVersion before, DataVersion after, string because) } } - /// - /// Two different queries, answered at the same instant. Neither describes the other, so a caller - /// holding one must never be told the other is current. - /// - public static void Distinct(DataVersion one, DataVersion other, string because) - { - using (Assert.EnterMultipleScope()) - { - Assert.That(one.HasValue, Is.True, "the first query produced no version to compare"); - Assert.That(other.HasValue, Is.True, "the second query produced no version to compare"); - Assert.That(other.Matches(one), Is.False, because); - } - } - - /// Nothing changed, so a caller holding the earlier version still holds the current one. public static void Held(DataVersion first, DataVersion second, string because) { using (Assert.EnterMultipleScope()) @@ -43,4 +23,7 @@ public static void Held(DataVersion first, DataVersion second, string because) Assert.That(second.Matches(first), Is.True, because); } } + + public static bool Matches(this DataVersion one, DataVersion other) => + one.HasValue && other.HasValue && one.Equals(other); } diff --git a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs index 37a109e44a..85f89b2799 100644 --- a/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs +++ b/src/ServiceControl.Persistence/Infrastructure/DataVersion.cs @@ -9,16 +9,6 @@ namespace ServiceControl.Persistence.Infrastructure /// /// An opaque version of a query result, sent to clients as an HTTP entity-tag. - /// - /// means there is no version. It matches nothing, not even itself, so two parties - /// that both know nothing can never answer 304. is plain equality and - /// stays reflexive, so the struct still works as a dictionary key. - /// - /// - /// A struct, so default is and no variable of this type can be null. A null - /// reference would be a second way to say "no version" that never sees. operator == is left undefined on - /// purpose: the only two questions worth asking are and . - /// /// [DebuggerDisplay("{validator ?? \"None\",nq}")] public readonly struct DataVersion : IEquatable @@ -106,8 +96,8 @@ public static DataVersion Combine(IEnumerable<(string InstanceId, DataVersion Ve } /// - /// A validator a client sent back, in any shape an old or current instance might use. Only ever - /// trusted for matching. + /// 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) { @@ -133,17 +123,6 @@ public static DataVersion FromClient(string headerValue) return FromToken(value); } - /// - /// Whether this and are the same version and both present. Absence never - /// counts as a match, so any comparison involving is false. - /// - public bool Matches(DataVersion other) => - HasValue && other.HasValue && string.Equals(validator, other.validator, StringComparison.Ordinal); - - /// - /// Plain value equality. Never use it to decide whether something changed: it is - /// reflexive, so equals . - /// public bool Equals(DataVersion other) => string.Equals(validator, other.validator, StringComparison.Ordinal); diff --git a/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs b/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs deleted file mode 100644 index 1be6ffb41e..0000000000 --- a/src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ServiceControl.Persistence.Infrastructure -{ - /// - /// The page, ordering and filters a read was narrowed by, expressed as version terms. - /// - /// A version over a list normally tells two queries apart by the rows it returns. A query that matches - /// nothing returns no rows and so contributes no terms, which leaves every empty view of the same data - /// sharing one version. - /// - /// - public static class QueryNarrowing - { - public static (string Name, object? Value)[] Terms(PagingInfo pagingInfo, SortInfo? sortInfo, params (string Name, object? Value)[] filters) => - [ - ("page", pagingInfo.Page), - ("pageSize", pagingInfo.PageSize), - ("sort", sortInfo?.Sort), - ("direction", sortInfo?.Direction), - .. filters - ]; - } -} 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 index ace2c6ff01..b5b47b6050 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/DataVersionTests.cs @@ -269,10 +269,4 @@ public void FromClient_leaves_a_malformed_validator_alone_rather_than_truncating Assert.That(DataVersion.FromClient("\"abc").ToString(), Is.EqualTo("\"abc")); } - [Test] - public void Matching_ignores_the_weak_marking_a_client_sends() - { - // RFC 9110 requires If-None-Match to use the weak comparison. - Assert.That(DataVersion.FromToken("cv-1").Matches(DataVersion.FromClient("W/\"cv-1\"")), Is.True); - } } diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index cb4be99aae..9f4e4455ee 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -81,12 +81,11 @@ public void A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it() QueryStatsInfo.Fresh(version, totalCount: 1), new PagingInfo()); - // A hashed validator matches nothing a store holds, so the endpoint can never skip its query. Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString())); } [Test] - public void An_aggregate_derived_etag_is_marked_weak() + public void Every_emitted_etag_is_marked_weak() { var httpContext = new DefaultHttpContext(); @@ -99,22 +98,6 @@ public void An_aggregate_derived_etag_is_marked_weak() } } - [Test] - public void A_weak_validator_matches_under_the_comparison_If_None_Match_requires() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Response.WithEtag(DataVersion.FromToken("4611686018427387904")); - httpContext.Request.Headers.IfNoneMatch = httpContext.Response.Headers.ETag; - - var context = ResultExecuting(httpContext); - - new NotModifiedStatusHttpHandler().OnResultExecuting(context); - - Assert.That(context.Result, Is.InstanceOf(), - "RFC 9110 requires If-None-Match to use the weak comparison function, so a weak tag must match a weak tag"); - } - [Test] public void An_unmarked_validator_from_an_older_client_still_matches() { diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs deleted file mode 100644 index 6308891c85..0000000000 --- a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectResponseVersionTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace ServiceControl.UnitTests.Operations -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - using NUnit.Framework; - using ServiceControl.MessageRedirects.Api; - using ServiceControl.Persistence.Infrastructure; - using ServiceControl.Persistence.MessageRedirects; - - [TestFixture] - public class MessageRedirectResponseVersionTests - { - [Test] - public async Task Two_pages_of_redirects_do_not_share_a_version() - { - var store = Store("a@machine1", "c@machine3", "e@machine5"); - - var firstPage = await Read(store, new PagingInfo(page: 1, pageSize: 2)); - var firstPageAgain = await Read(store, new PagingInfo(page: 1, pageSize: 2)); - var secondPage = await Read(store, new PagingInfo(page: 2, pageSize: 2)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(firstPage.Rows, Has.Count.EqualTo(2), "two redirects on the first page"); - Assert.That(secondPage.Rows, Has.Count.EqualTo(1), "and the third on the second, so the bodies differ"); - Assert.That(firstPage.Etag, Is.Not.Null.And.Not.Empty, "the first page sent no validator"); - Assert.That(firstPageAgain.Etag, Is.EqualTo(firstPage.Etag), - "the first page's validator moved between two reads of unchanged data, so this test cannot judge anything"); - Assert.That(secondPage.Etag, Is.Not.EqualTo(firstPage.Etag), - "a client following the Link rel=next header while revalidating would render page one as page two"); - } - } - - [Test] - public async Task Two_sort_orders_of_redirects_do_not_share_a_version() - { - var store = Store("a@machine1", "c@machine3", "e@machine5"); - - var ascending = await Read(store, new PagingInfo(page: 1, pageSize: 2), sort: "from_physical_address", direction: "asc"); - var descending = await Read(store, new PagingInfo(page: 1, pageSize: 2), sort: "from_physical_address", direction: "desc"); - - using (Assert.EnterMultipleScope()) - { - Assert.That(ascending.Rows.First().FromPhysicalAddress, Is.EqualTo("a@machine1"), "ascending starts at the first address"); - Assert.That(descending.Rows.First().FromPhysicalAddress, Is.EqualTo("e@machine5"), "descending starts at the last, so the bodies differ"); - Assert.That(ascending.Etag, Is.Not.Null.And.Not.Empty, "the ascending page sent no validator"); - Assert.That(descending.Etag, Is.Not.EqualTo(ascending.Etag), - "a client that switches sort order while holding a validator is told the reordered page is unchanged"); - } - } - - static async Task<(string Etag, IList Rows)> Read( - IMessageRedirectsDataStore store, PagingInfo pagingInfo, string sort = null, string direction = null) - { - var controller = new MessageRedirectsController(null, store, null) - { - ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } - }; - - var rows = await controller.Redirects(sort, direction, pagingInfo); - - return (controller.Response.Headers.ETag.ToString(), rows.ToList()); - } - - static IMessageRedirectsDataStore Store(params string[] fromAddresses) => - new FakeStore([.. fromAddresses.Select((from, index) => new MessageRedirect - { - FromPhysicalAddress = from, - ToPhysicalAddress = $"destination{index}@machine", - LastModified = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc) - })]); - - class FakeStore(IReadOnlyList redirects) : IMessageRedirectsDataStore - { - public Task> GetRedirects(CancellationToken cancellationToken = default) => - Task.FromResult(redirects); - - public Task AddRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task UpdateRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task RemoveRedirect(MessageRedirect redirect, CancellationToken cancellationToken = default) => Task.CompletedTask; - } - } -} diff --git a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs index 74d8b78d92..f3bf580901 100644 --- a/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/MessageRedirectVersionTests.cs @@ -4,6 +4,7 @@ namespace ServiceControl.UnitTests.Operations using System.Collections.Generic; using NUnit.Framework; using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.MessageRedirects; [TestFixture] @@ -12,9 +13,9 @@ public class MessageRedirectVersionTests [Test] public void From_address_changed_should_change_version() { - var knownVersion = ResponseVersions.VersionOf(Redirects(Redirect(from: "old@machine"))); + var knownVersion = VersionOf(Redirects(Redirect(from: "old@machine"))); - var moved = ResponseVersions.VersionOf(Redirects(Redirect(from: "new@machine"))); + var moved = VersionOf(Redirects(Redirect(from: "new@machine"))); Assert.That(moved.Matches(knownVersion), Is.False); } @@ -25,11 +26,11 @@ public void To_address_changed_should_change_version() var redirect = Redirect(to: "old@machine"); var data = Redirects(redirect); - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); redirect.ToPhysicalAddress = "new@machine"; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -38,33 +39,36 @@ public void Last_modified_changed_should_change_version() var redirect = Redirect(); var data = Redirects(redirect); - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); redirect.LastModified = redirect.LastModified.AddTicks(1); - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] public void Changing_item_count_should_change_version() { - var emptyVersion = ResponseVersions.VersionOf(Redirects()); + var emptyVersion = VersionOf(Redirects()); var oneRedirect = Redirects(Redirect()); - Assert.That(ResponseVersions.VersionOf(oneRedirect).Matches(emptyVersion), Is.False, + 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 = ResponseVersions.VersionOf(Redirects()); + 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") => diff --git a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs index a6afd0d194..4ff7b4961d 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RetryGroupVersionTests.cs @@ -1,8 +1,10 @@ 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] @@ -14,11 +16,11 @@ public void Id_changed_should_change_version() var group = new GroupOperation { Id = "old" }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Id = "new"; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -27,11 +29,11 @@ public void Count_changed_should_change_version() var group = new GroupOperation { Count = 1 }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Count = 2; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -40,11 +42,11 @@ public void RetryStatus_changed_should_change_version() var group = new GroupOperation { OperationStatus = RetryState.Waiting.ToString() }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationStatus = RetryState.Preparing.ToString(); - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -53,11 +55,11 @@ public void RetryProgress_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationProgress = 0.01; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -66,11 +68,11 @@ public void RetryStartTime_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationStartTime = DateTime.UtcNow; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -79,11 +81,11 @@ public void RetryCompletionTime_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationCompletionTime = DateTime.UtcNow; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -92,11 +94,11 @@ public void NeedUserAcknowledgement_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.NeedUserAcknowledgement = true; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -105,11 +107,11 @@ public void Comment_changed_should_change_version() var group = new GroupOperation { Comment = "before" }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Comment = "after"; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -118,11 +120,11 @@ public void Title_changed_should_change_version() var group = new GroupOperation { Title = "before" }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Title = "after"; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -131,11 +133,11 @@ public void Type_changed_should_change_version() var group = new GroupOperation { Type = "before" }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Type = "after"; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -144,11 +146,11 @@ public void First_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.First = DateTime.UtcNow; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -157,11 +159,11 @@ public void Last_changed_should_change_version() var group = new GroupOperation(); var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.Last = DateTime.UtcNow; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -170,11 +172,11 @@ public void OperationFailed_changed_should_change_version() var group = new GroupOperation { OperationFailed = false }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationFailed = true; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -183,11 +185,11 @@ public void OperationMessagesCompletedCount_changed_should_change_version() var group = new GroupOperation { OperationMessagesCompletedCount = 1 }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationMessagesCompletedCount = 2; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -196,11 +198,11 @@ public void OperationRemainingCount_changed_should_change_version() var group = new GroupOperation { OperationRemainingCount = 2 }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + var knownVersion = VersionOf(data); group.OperationRemainingCount = 1; - Assert.That(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] @@ -217,31 +219,31 @@ public void A_message_completing_moves_the_version_even_when_the_rounded_progres }; var data = new[] { group }; - var knownVersion = ResponseVersions.VersionOf(data); + 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(ResponseVersions.VersionOf(data).Matches(knownVersion), Is.False); + Assert.That(VersionOf(data).Matches(knownVersion), Is.False); } [Test] public void Changing_item_count_should_change_version() { - var emptyVersion = ResponseVersions.VersionOf(Array.Empty()); + var emptyVersion = VersionOf(Array.Empty()); var oneGroup = new[] { new GroupOperation() }; - Assert.That(ResponseVersions.VersionOf(oneGroup).Matches(emptyVersion), Is.False, + 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 = ResponseVersions.VersionOf([new GroupOperation { Title = "Shipping.Exception", Type = string.Empty }]); - var split = ResponseVersions.VersionOf([new GroupOperation { Title = "Shipping", Type = "Exception" }]); + 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"); @@ -250,11 +252,14 @@ public void A_title_carrying_a_delimiter_cannot_impersonate_the_next_field() [Test] public void Two_groups_cannot_digest_as_one_carrying_a_delimiter() { - var two = ResponseVersions.VersionOf([new GroupOperation { Id = "a" }, new GroupOperation { Id = "b" }]); - var oneForging = ResponseVersions.VersionOf([new GroupOperation { Id = "a|row1:1:b" }]); + 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/CustomChecks/Web/CustomCheckController.cs b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs index 9439a85323..75f221890b 100644 --- a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs +++ b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs @@ -25,8 +25,7 @@ public async Task> CustomChecks([FromQuery] PagingInfo paging { var stats = await checksDataStore.GetStats(pagingInfo, status, cancellationToken); - Response.WithPagingLinksAndTotalCount(pagingInfo, stats.QueryStats.TotalCount); - Response.WithEtag(stats.QueryStats.Version); + Response.WithQueryStatsAndPagingInfo(stats.QueryStats, pagingInfo); return stats.Results; } diff --git a/src/ServiceControl/EventLog/EventLogApiController.cs b/src/ServiceControl/EventLog/EventLogApiController.cs index 4e7f7e4ff7..3d76ca4221 100644 --- a/src/ServiceControl/EventLog/EventLogApiController.cs +++ b/src/ServiceControl/EventLog/EventLogApiController.cs @@ -21,8 +21,7 @@ public async Task>> Items([FromQuery] Pagin { var result = await logDataStore.GetEventLogItems(pagingInfo, cancellationToken); - Response.WithPagingLinksAndTotalCount(pagingInfo, result.QueryStats.TotalCount); - Response.WithEtag(result.QueryStats.Version); + Response.WithQueryStatsAndPagingInfo(result.QueryStats, pagingInfo); return Ok(result.Results); } diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index fffd815aa8..77e67c920e 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -89,15 +89,9 @@ static void AddLink(ICollection links, int page, string rel, string uriP public static void WithQueryStatsAndPagingInfo(this HttpResponse response, QueryStatsInfo queryStats, PagingInfo pagingInfo) { - response.WithPagingLinksAndTotalCount(pagingInfo, queryStats.TotalCount, queryStats.HighestTotalCountOfAllTheInstances); + response.WithTotalCount(queryStats.TotalCount); + response.WithPagingLinks(pagingInfo, queryStats.HighestTotalCountOfAllTheInstances, queryStats.TotalCount); response.WithEtag(queryStats.Version); } - - public static void WithPagingLinksAndTotalCount(this HttpResponse response, - PagingInfo pagingInfo, long totalCount, long highestTotalCountOfAllInstances = 1) - { - response.WithTotalCount(totalCount); - response.WithPagingLinks(pagingInfo, highestTotalCountOfAllInstances, totalCount); - } } } \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs index 03595190ac..3ad86916a9 100644 --- a/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/ResponseVersions.cs @@ -5,26 +5,24 @@ namespace ServiceControl.Infrastructure.WebApi; using ServiceControl.Persistence.MessageRedirects; /// -/// Versions for responses a controller assembles itself, rather than getting from a store. +/// Versions for responses a controller assembles itself, rather than getting from a store. Each takes the +/// total as well as the rows: it carries whatever the response says about the whole set, and it keeps an +/// empty list cacheable, because with no rows and no summary there would be no terms to compose and so no +/// validator at all. /// static class ResponseVersions { - internal static DataVersion VersionOf(GroupOperation[] groups) => - DataVersion.OverRows([("groups", groups.Length)], groups, + internal static DataVersion VersionOf(IReadOnlyList page, int total) => + DataVersion.OverRows( + [("groups", total)], + page, group => [group.Id, group.Title, group.Type, group.Count, group.Comment, group.First, group.Last, group.OperationStatus, group.OperationFailed, group.OperationProgress, group.OperationMessagesCompletedCount, group.OperationRemainingCount, group.OperationStartTime, group.OperationCompletionTime, group.NeedUserAcknowledgement]); - internal static DataVersion VersionOf(IReadOnlyList redirects) => - DataVersion.OverRows([("redirects", redirects.Count)], redirects, Fields); - - /// - /// One sorted page of redirects, for a response that renders the page while reporting the total behind it. - /// - internal static DataVersion VersionOfPage(IReadOnlyList page, int total, PagingInfo pagingInfo) => - DataVersion.OverRows([("redirects", total), ("page", pagingInfo.Page), ("pageSize", pagingInfo.PageSize)], page, Fields); - - // FromPhysicalAddress needs no field of its own: MessageRedirectId is a deterministic hash of it. - static object[] Fields(MessageRedirect redirect) => - [redirect.MessageRedirectId, redirect.ToPhysicalAddress, redirect.LastModified]; + internal static DataVersion VersionOf(IReadOnlyList page, int total) => + DataVersion.OverRows( + [("redirects", total)], + page, + redirect => [redirect.MessageRedirectId, redirect.ToPhysicalAddress, redirect.LastModified]); } diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index 39a3f0890c..fa26ae8e57 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -171,7 +171,7 @@ public async Task CountRedirects(CancellationToken cancellationToken = default) { var redirects = await store.GetRedirects(cancellationToken); - Response.WithEtag(ResponseVersions.VersionOf(redirects)); + Response.WithEtag(ResponseVersions.VersionOf(redirects, redirects.Count)); Response.WithTotalCount(redirects.Count); } @@ -182,25 +182,20 @@ public async Task> Redirects(string sort, stri { var redirects = await store.GetRedirects(cancellationToken); - // Materialised because the version has to describe the page this response renders. var page = redirects .Sort(sort, direction) .Paging(pagingInfo) .ToList(); - var queryResult = page - .Select(r => new RedirectsQueryResult - ( - r.MessageRedirectId, - r.FromPhysicalAddress, - r.ToPhysicalAddress, - r.LastModified - )); + Response.WithQueryStatsAndPagingInfo(QueryStatsInfo.Fresh(ResponseVersions.VersionOf(page, redirects.Count), redirects.Count), pagingInfo); - Response.WithEtag(ResponseVersions.VersionOfPage(page, redirects.Count, pagingInfo)); - Response.WithPagingLinksAndTotalCount(pagingInfo, redirects.Count); - - return queryResult; + return page.Select(r => new RedirectsQueryResult + ( + r.MessageRedirectId, + r.FromPhysicalAddress, + r.ToPhysicalAddress, + r.LastModified + )); } public record RedirectsQueryResult(Guid MessageRedirectId, string FromPhysicalAddress, string ToPhysicalAddress, DateTime LastModified); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index 9bc0e1ef73..93dca14a47 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -67,7 +67,7 @@ public async Task GetAllGroups(string classifier = "Exception } var results = await fetcher.GetGroups(classifier, classifierFilter, cancellationToken); - Response.WithEtag(ResponseVersions.VersionOf(results)); + Response.WithEtag(ResponseVersions.VersionOf(results, results.Length)); return results; } From df91de9e8d3a594f1d4557208394ac447b0b8a51 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 20:28:23 +0800 Subject: [PATCH 33/36] Remove IsStale from shared persistence layer --- .../Implementation/RetryBatchStore.cs | 7 +++---- .../Infrastructure/QueryStatsInfoExtensions.cs | 12 ++++++------ .../Infrastructure/RetryHistoryQueries.cs | 2 +- .../RavenQueryStatisticsExtensions.cs | 6 ++---- .../Recoverability/RetryHistoryDataStore.cs | 2 +- .../RetryDocumentDataStore.cs | 6 ++---- .../EFCore/RetryBatchStoreTests.cs | 4 ++-- src/ServiceControl.Persistence/IRetryBatchStore.cs | 2 +- .../Infrastructure/QueryStatsInfo.cs | 13 ++----------- src/ServiceControl.Persistence/OrphanedBatches.cs | 6 ++++++ .../Infrastructure/WebApi/ConditionalGetTests.cs | 2 +- .../ScatterGather/MessageView_ScatterGatherTest.cs | 2 +- .../ScatterGather/ScatterGatherVersionTests.cs | 2 +- .../CompositeViews/Messages/ScatterGatherApi.cs | 3 +-- .../Api/MessageRedirectsController.cs | 2 +- .../Monitoring/Web/EndpointsMonitoringController.cs | 2 +- .../Recoverability/Retrying/RetryDocumentManager.cs | 8 ++++---- 17 files changed, 36 insertions(+), 45 deletions(-) create mode 100644 src/ServiceControl.Persistence/OrphanedBatches.cs diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs index bf857ac513..bcf7881d6e 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs @@ -99,7 +99,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,10 +109,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)))]; - // No version: orphaned batches are consumed by the retry session, never by a caching client. - return new QueryResult>(batches, QueryStatsInfo.Fresh(DataVersion.None, batches.Count)); + return new OrphanedBatches(batches, MightBeIncomplete: false); }, cancellationToken); public Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs index ab46efdd39..48c9d2dff7 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/QueryStatsInfoExtensions.cs @@ -11,7 +11,7 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; static class QueryStatsInfoExtensions { public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) => - QueryStatsInfo.Fresh( + new QueryStatsInfo( DataVersion.OverRows( [("checks", totalCount)], items, @@ -19,7 +19,7 @@ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) => - QueryStatsInfo.Fresh( + new QueryStatsInfo( DataVersion.OverRows( [("items", totalCount)], items, @@ -27,7 +27,7 @@ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) => - QueryStatsInfo.Fresh( + new QueryStatsInfo( DataVersion.OverRows( [("messages", totalCount)], items, @@ -39,11 +39,11 @@ public static async Task ToCountQueryStatsInfo(this IQu { var count = await source.LongCountAsync(cancellationToken); - return QueryStatsInfo.Fresh(DataVersion.Compose([(name, count)]), count); + return new QueryStatsInfo(DataVersion.Compose([(name, count)]), count); } public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) => - QueryStatsInfo.Fresh( + new QueryStatsInfo( DataVersion.OverRows( [("groups", groups.Count)], groups, @@ -51,7 +51,7 @@ public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection items, long totalCount) => - QueryStatsInfo.Fresh( + new QueryStatsInfo( DataVersion.OverRows( [("addresses", totalCount)], items, diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs index bc2357bdd5..881aa7bd1f 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs @@ -9,7 +9,7 @@ static class RetryHistoryQueries /// Every field of every operation in both collections, plus each collection's count. /// public static QueryStatsInfo ToQueryStatsInfo(this RetryHistory history) => - QueryStatsInfo.Fresh(DataVersion.OverRows( + new QueryStatsInfo(DataVersion.OverRows( [("historic", history.HistoricOperations.Count), ("unacknowledged", history.UnacknowledgedOperations.Count)], Rows(history), row => row), diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs index bf128d4ee6..7e58d3ecdb 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs @@ -7,12 +7,10 @@ static class RavenQueryStatisticsExtensions { public static QueryStatsInfo ToQueryStatsInfo(this QueryStatistics stats) => new(stats.ResultEtag is { } resultEtag ? DataVersion.FromToken(resultEtag) : DataVersion.None, - stats.TotalResults, - stats.IsStale); + stats.TotalResults); public static QueryStatsInfo ToQueryStatsInfo(this Raven.Client.Documents.Queries.QueryResult queryResult) => new(DataVersion.FromToken(queryResult.ResultEtag), - queryResult.TotalResults, - queryResult.IsStale); + queryResult.TotalResults); } } diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs index 6e54309a05..54685d77f7 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/RetryHistoryDataStore.cs @@ -28,7 +28,7 @@ public async Task> GetRetryHistory(CancellationToken c retryHistory ??= new(); return new QueryResult(retryHistory, - QueryStatsInfo.Fresh(version, retryHistory.HistoricOperations.Count)); + new QueryStatsInfo(version, retryHistory.HistoricOperations.Count)); } public async Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime, diff --git a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs index 9502389a8c..3b67232127 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs @@ -85,7 +85,7 @@ await session.StoreAsync(new RetryBatch return batchId; } - public async Task>> GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default) + public async Task GetOrphanedBatches(string retrySessionId, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var orphanedBatches = await session @@ -94,9 +94,7 @@ await session.StoreAsync(new RetryBatch .Statistics(out var stats) .ToListAsync(cancellationToken); - return orphanedBatches.Select(batch => batch.ToContract()) - .ToList() - .ToQueryResult(stats); + return new OrphanedBatches([.. orphanedBatches.Select(batch => batch.ToContract())], stats.IsStale); } public async Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetryBatchStoreTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetryBatchStoreTests.cs index 6ab2896ae8..6c17ca5584 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetryBatchStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetryBatchStoreTests.cs @@ -44,7 +44,7 @@ public async Task Does_not_report_batches_of_the_current_session_as_orphaned() var orphaned = await RetryBatchStore.GetOrphanedBatches(OtherSession); - Assert.That(orphaned.Results, Is.Empty); + Assert.That(orphaned.Batches, Is.Empty); } [Test] @@ -225,7 +225,7 @@ [.. Enumerable.Range(0, messageCount).Select(_ => Guid.NewGuid().ToString())], Noon, classifier: "Message Type"); - async Task> Orphaned() => (await RetryBatchStore.GetOrphanedBatches(OtherSession)).Results; + async Task> Orphaned() => (await RetryBatchStore.GetOrphanedBatches(OtherSession)).Batches; async Task> ClaimedBy(string batchId) { 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/Infrastructure/QueryStatsInfo.cs b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs index 3c9e768aed..0cf462e9ed 100644 --- a/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs +++ b/src/ServiceControl.Persistence/Infrastructure/QueryStatsInfo.cs @@ -5,24 +5,15 @@ public readonly struct QueryStatsInfo public readonly DataVersion Version; public readonly long TotalCount; public readonly long HighestTotalCountOfAllTheInstances; - public readonly bool IsStale; - public QueryStatsInfo(DataVersion version, long totalCount, bool isStale, long? highestTotalCountOfAllTheInstances = null) + public QueryStatsInfo(DataVersion version, long totalCount, long? highestTotalCountOfAllTheInstances = null) { Version = version; TotalCount = totalCount; - IsStale = isStale; HighestTotalCountOfAllTheInstances = highestTotalCountOfAllTheInstances ?? totalCount; } - /// - /// For a result that cannot be stale (when queries can - /// run against an index that has not caught up). - /// - public static QueryStatsInfo Fresh(DataVersion version, long totalCount) => - new(version, totalCount, isStale: false); - - public static readonly QueryStatsInfo Zero = Fresh(DataVersion.None, 0); + public static readonly QueryStatsInfo Zero = new(DataVersion.None, 0); } } diff --git a/src/ServiceControl.Persistence/OrphanedBatches.cs b/src/ServiceControl.Persistence/OrphanedBatches.cs new file mode 100644 index 0000000000..dc8bd1ea6b --- /dev/null +++ b/src/ServiceControl.Persistence/OrphanedBatches.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence +{ + using System.Collections.Generic; + + public record OrphanedBatches(IReadOnlyList Batches, bool MightBeIncomplete); +} diff --git a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs index 9f4e4455ee..3c804c7f70 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/WebApi/ConditionalGetTests.cs @@ -78,7 +78,7 @@ public void A_paged_endpoint_emits_the_store_version_rather_than_a_hash_of_it() var version = DataVersion.FromToken("4611686018427387904"); httpContext.Response.WithQueryStatsAndPagingInfo( - QueryStatsInfo.Fresh(version, totalCount: 1), + new QueryStatsInfo(version, totalCount: 1), new PagingInfo()); Assert.That(httpContext.Response.Headers.ETag.ToString(), Does.Contain(version.ToString())); diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs index 9946b1a2e7..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, - QueryStatsInfo.Fresh(DataVersion.FromToken(etag), allResults.Count)) + new QueryStatsInfo(DataVersion.FromToken(etag), allResults.Count)) { InstanceId = instanceId }; diff --git a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs index eedb7fbef3..22eebc1e3f 100644 --- a/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs +++ b/src/ServiceControl.UnitTests/ScatterGather/ScatterGatherVersionTests.cs @@ -78,7 +78,7 @@ public void A_remote_only_api_reports_no_version_when_no_remote_answered() static ScatterGatherApiMessageViewContext Context() => new(new PagingInfo(), new SortInfo()); static QueryResult> Page(string instanceId, string validator) => - new([new MessagesView { MessageId = instanceId }], QueryStatsInfo.Fresh(DataVersion.FromToken(validator), 1)) + new([new MessagesView { MessageId = instanceId }], new QueryStatsInfo(DataVersion.FromToken(validator), 1)) { InstanceId = instanceId }; diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index c5bedadfc7..9d4b677fc5 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -123,7 +123,6 @@ static QueryStatsInfo Aggregate(IEnumerable> results) return new QueryStatsInfo( DataVersion.Combine(reported.Select(result => (result.InstanceId, result.QueryStats.Version))), infos.Sum(x => x.TotalCount), - isStale: infos.Any(x => x.IsStale), infos.Max(x => x.HighestTotalCountOfAllTheInstances) ); } @@ -206,7 +205,7 @@ static async Task> ParseResult(HttpResponseMessage responseMes var etag = ReadEtag(responseMessage.Headers); - return new QueryResult(remoteResults, QueryStatsInfo.Fresh(etag, totalCount)); + return new QueryResult(remoteResults, new QueryStatsInfo(etag, totalCount)); } readonly ILogger logger; diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index fa26ae8e57..765e8b3d46 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -187,7 +187,7 @@ public async Task> Redirects(string sort, stri .Paging(pagingInfo) .ToList(); - Response.WithQueryStatsAndPagingInfo(QueryStatsInfo.Fresh(ResponseVersions.VersionOf(page, redirects.Count), redirects.Count), pagingInfo); + Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(ResponseVersions.VersionOf(page, redirects.Count), redirects.Count), pagingInfo); return page.Select(r => new RedirectsQueryResult ( diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 990aa8e022..08225581bb 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -69,7 +69,7 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf var knownEndpoints = monitoring.GetKnownEndpoints(); // No version: this list lives in memory and no store version covers it. - Response.WithQueryStatsAndPagingInfo(QueryStatsInfo.Fresh(DataVersion.None, knownEndpoints.Count), pagingInfo); + Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count), pagingInfo); return knownEndpoints; } diff --git a/src/ServiceControl/Recoverability/Retrying/RetryDocumentManager.cs b/src/ServiceControl/Recoverability/Retrying/RetryDocumentManager.cs index 6c67c78268..143412ce3e 100644 --- a/src/ServiceControl/Recoverability/Retrying/RetryDocumentManager.cs +++ b/src/ServiceControl/Recoverability/Retrying/RetryDocumentManager.cs @@ -22,16 +22,16 @@ public async Task AdoptOrphanedBatches(CancellationToken cancellationToken { var orphanedBatches = await store.GetOrphanedBatches(RetrySessionId, cancellationToken); - logger.LogInformation("Found {OrphanedBatchCount} orphaned retry batches from previous sessions", orphanedBatches.Results.Count); + logger.LogInformation("Found {OrphanedBatchCount} orphaned retry batches from previous sessions", orphanedBatches.Batches.Count); // let's leave Task.Run for now due to sync sends - await Task.WhenAll(orphanedBatches.Results.Select(b => Task.Run(async () => + await Task.WhenAll(orphanedBatches.Batches.Select(b => Task.Run(async () => { logger.LogInformation("Adopting retry batch {BatchId} with {BatchMessageCount} messages", b.Id, b.MessageCount); await MoveBatchToStaging(b.Id, cancellationToken); }))); - foreach (var batch in orphanedBatches.Results) + foreach (var batch in orphanedBatches.Batches) { if (batch.RetryType != RetryType.MultipleMessages) { @@ -44,7 +44,7 @@ await Task.WhenAll(orphanedBatches.Results.Select(b => Task.Run(async () => return false; } - return orphanedBatches.QueryStats.IsStale || orphanedBatches.Results.Any(); + return orphanedBatches.MightBeIncomplete || orphanedBatches.Batches.Count > 0; } public virtual Task MoveBatchToStaging(string batchId, CancellationToken cancellationToken = default) => store.MoveBatchToStaging(batchId, cancellationToken); From 523ad9288251df8eafffed293494b1e1315304b2 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 20:48:31 +0800 Subject: [PATCH 34/36] Clean known enpoints --- .../Infrastructure/WebApi/HttpResponseExtensions.cs | 9 +++++++-- .../Monitoring/Web/EndpointsMonitoringController.cs | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs index 77e67c920e..04190dd0e5 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HttpResponseExtensions.cs @@ -89,9 +89,14 @@ static void AddLink(ICollection links, int page, string rel, string uriP public static void WithQueryStatsAndPagingInfo(this HttpResponse response, QueryStatsInfo queryStats, PagingInfo pagingInfo) { - response.WithTotalCount(queryStats.TotalCount); - response.WithPagingLinks(pagingInfo, queryStats.HighestTotalCountOfAllTheInstances, queryStats.TotalCount); + response.WithPagingLinksAndTotalCount(pagingInfo, queryStats.TotalCount, queryStats.HighestTotalCountOfAllTheInstances); response.WithEtag(queryStats.Version); } + + public static void WithPagingLinksAndTotalCount(this HttpResponse response, PagingInfo pagingInfo, long totalCount, long? highestTotalCountOfAllInstances = null) + { + response.WithTotalCount(totalCount); + response.WithPagingLinks(pagingInfo, highestTotalCountOfAllInstances ?? totalCount, totalCount); + } } } \ No newline at end of file diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 08225581bb..0f65691ec4 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -68,8 +68,7 @@ public IList KnownEndpoints([FromQuery] PagingInfo pagingInf { var knownEndpoints = monitoring.GetKnownEndpoints(); - // No version: this list lives in memory and no store version covers it. - Response.WithQueryStatsAndPagingInfo(new QueryStatsInfo(DataVersion.None, knownEndpoints.Count), pagingInfo); + Response.WithPagingLinksAndTotalCount(pagingInfo, knownEndpoints.Count); return knownEndpoints; } From d6febd92e24c4c081cab9644dbff1ec13c96b570 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 21:38:24 +0800 Subject: [PATCH 35/36] Review changes --- .../Implementation/RetryBatchStore.cs | 3 +-- .../CustomCheckVersionTests.cs | 2 +- .../MessagesViewVersionTests.cs | 2 +- .../QueueAddressVersionTests.cs | 2 +- .../Recoverability/ArchivedGroupVersionTests.cs | 2 +- .../Recoverability/FailureGroupVersionTests.cs | 2 +- .../Recoverability/RetryHistoryVersionTests.cs | 2 +- src/ServiceControl.Persistence.Tests/VersionAssert.cs | 2 +- src/ServiceControl.Persistence/OrphanedBatches.cs | 7 ++++++- 9 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs index bcf7881d6e..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 { @@ -111,7 +110,7 @@ public Task GetOrphanedBatches(string retrySessionId, Cancellat IReadOnlyList batches = [.. orphaned.Select(batch => batch.ToRetryBatch(messageCounts.GetValueOrDefault(batch.Id)))]; - return new OrphanedBatches(batches, MightBeIncomplete: false); + return OrphanedBatches.Complete(batches); }, cancellationToken); public Task> GetAvailableBatchGroups(CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs index bfc5d0a575..3654501e64 100644 --- a/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/CustomCheckVersionTests.cs @@ -57,7 +57,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await CustomChecks.GetStats(new PagingInfo()); var second = await CustomChecks.GetStats(new PagingInfo()); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs index 9ee8c0f1bf..cb42a5744a 100644 --- a/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/MessagesViewVersionTests.cs @@ -92,7 +92,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await AllMessages(); var second = await AllMessages(); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs index 16d86e8335..79205d74dc 100644 --- a/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/QueueAddressVersionTests.cs @@ -84,7 +84,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await QueueAddressStore.GetAddresses(new PagingInfo()); var second = await QueueAddressStore.GetAddresses(new PagingInfo()); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs index e5db8e389c..22d2156ce7 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ArchivedGroupVersionTests.cs @@ -87,7 +87,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); var second = await GroupsStore.GetArchivedGroupsByClassifier(Classifier); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs index a6ed66c66d..df857929d3 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/FailureGroupVersionTests.cs @@ -98,7 +98,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); var second = await GroupsStore.GetUnresolvedGroup(group.Id, null, null); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs index 015038e904..2d07d10630 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/RetryHistoryVersionTests.cs @@ -62,7 +62,7 @@ public async Task Version_is_stable_while_nothing_changes() var first = await RetryHistoryStore.GetRetryHistory(); var second = await RetryHistoryStore.GetRetryHistory(); - VersionAssert.Held(first.QueryStats.Version, second.QueryStats.Version, + VersionAssert.Matches(first.QueryStats.Version, second.QueryStats.Version, "nothing changed, so the validator has to stay put or conditional GET never pays off"); } diff --git a/src/ServiceControl.Persistence.Tests/VersionAssert.cs b/src/ServiceControl.Persistence.Tests/VersionAssert.cs index 59c83a8ce5..25edc5bc5c 100644 --- a/src/ServiceControl.Persistence.Tests/VersionAssert.cs +++ b/src/ServiceControl.Persistence.Tests/VersionAssert.cs @@ -15,7 +15,7 @@ public static void Moved(DataVersion before, DataVersion after, string because) } } - public static void Held(DataVersion first, DataVersion second, string because) + public static void Matches(DataVersion first, DataVersion second, string because) { using (Assert.EnterMultipleScope()) { diff --git a/src/ServiceControl.Persistence/OrphanedBatches.cs b/src/ServiceControl.Persistence/OrphanedBatches.cs index dc8bd1ea6b..5ee137fe6f 100644 --- a/src/ServiceControl.Persistence/OrphanedBatches.cs +++ b/src/ServiceControl.Persistence/OrphanedBatches.cs @@ -2,5 +2,10 @@ namespace ServiceControl.Persistence { using System.Collections.Generic; - public record OrphanedBatches(IReadOnlyList Batches, bool MightBeIncomplete); + // 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); + } } From 14998d20b8927c23f4bf4a81cfb6242b940c810f Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 21 Aug 2026 23:21:28 +0800 Subject: [PATCH 36/36] add etags to messages2 --- docs/data-versioning-design.md | 6 ++++-- .../Auditing/MessagesView/GetMessages2Controller.cs | 1 + .../CompositeViews/Messages/GetMessages2Controller.cs | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/data-versioning-design.md b/docs/data-versioning-design.md index 05ffa0bc53..739224ec54 100644 --- a/docs/data-versioning-design.md +++ b/docs/data-versioning-design.md @@ -10,9 +10,11 @@ This is the primary (error) instance only. The audit instance still carries a `s ## The one rule -**If any field the response body renders can change without the version changing, a client caches that page indefinitely, and nothing reveals it.** No log line, no exception, no failing test. Every design decision below follows from that asymmetry: a version that moves too often costs a redundant download, a version that moves too rarely serves wrong data. +**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. -So the version has to cover the response, not the data. Two requests that render different bodies must not share a validator, which is why paged endpoints name the page and not only the underlying set. +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 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/CompositeViews/Messages/GetMessages2Controller.cs b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs index df6dec1155..6c1af26c31 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs @@ -66,6 +66,7 @@ public async Task> Messages( } Response.WithTotalCount(result.QueryStats.TotalCount); + Response.WithEtag(result.QueryStats.Version); return result.Results; }