Refactor and improve data versioning / etags - #5794
Conversation
…ceptance test. - Add tests for CustomCheck data versioning
- Add tests for archived groups data versioning
…ng 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.
31a46fe to
7f21c13
Compare
| 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( |
There was a problem hiding this comment.
@warwickschroeder I haven't done a thorough review yet, but this one seems wrong. We don't need to add another column to this table, as we previously mentioned; bodies are immutable. In other words, the body would only change if a different UniqueMessageId was issued.
There was a problem hiding this comment.
@johnsimons from what I'm reading, it doesnt seem to be immutable. The failed message is set via an upsert, which also includes the body text.
It looks like if an already retried message is edited and retried again, the body would be updated due to it checking for the originals header?
There was a problem hiding this comment.
An edit creates a brand new message.
I am 100% sure that bodies are immutable.
If we are updating the body as part of an upsert, we should not.
There was a problem hiding this comment.
I just reviewed the upsert, I think because we don't know whether it is going to be an insert or update, we still need to send the body regardless, but we could skip updating the body if it is an update.
There was a problem hiding this comment.
Ok, I can look into doing that so it is 100% immutable. Then I can remove the additional field.
johnsimons
left a comment
There was a problem hiding this comment.
I'm halfway through reviewing this PR.
One thing that isn't sitting well with me is that I don't think the data storage layer should need to be aware of ETags.
Imagine an internal service that needs data from two different data storage implementations. That service doesn't care about ETags. The only thing that really cares about them is the HTTP layer.
So in my opinion, the data stores shouldn't have to return QueryResult at all. They should return just the data, and something sitting between the controller/action and the data storage should be responsible for adding the ETag information.
I can understand how this separation can be hard to achieve given RavenDB mixes both worlds.
| 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( |
There was a problem hiding this comment.
An edit creates a brand new message.
I am 100% sure that bodies are immutable.
If we are updating the body as part of an upsert, we should not.
| 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( |
There was a problem hiding this comment.
I just reviewed the upsert, I think because we don't know whether it is going to be an insert or update, we still need to send the body regardless, but we could skip updating the body if it is an update.
| // 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) |
There was a problem hiding this comment.
Would Raven ever return a null for this?
There was a problem hiding this comment.
It is nullable so possibly, and if it is null we should be sending a DataVersion.None rather than a null or empty string. This entire file has been refactored quite a bit to simplify however.
|
|
||
| return new QueryResult<IList<RetryBatch>>(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<IList<RetryBatch>>(batches, QueryStatsInfo.Fresh(DataVersion.None, batches.Count)); |
There was a problem hiding this comment.
This is what we talked about @warwickschroeder, this one is used for internal consumption so, it is not required
There was a problem hiding this comment.
Simplified
| /// check id, so naming Id covers all three, and the host string is written once on insert and never | ||
| /// updated. | ||
| /// </summary> | ||
| public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection<CustomCheck> page, long totalCount, params (string Name, object? Value)[] query) => |
There was a problem hiding this comment.
This is only referenced in one place. Do we really need to have a class on its own?
There was a problem hiding this comment.
Its following the pattern of FailureGroupQueries, but I could consolidate all of these helper functions into a single class?
There was a problem hiding this comment.
I've consolidated all these into an extensions file.
| .FilterByLastModifiedRange(modified) | ||
| .FirstOrDefaultAsync(cancellationToken); | ||
|
|
||
| return new QueryResult<FailureGroupView>(document, stats.ToQueryStatsInfo()); |
There was a problem hiding this comment.
i don't entirely understand why we need to change Raven, given Raven has its own etag buildt into it?
There was a problem hiding this comment.
Reverted to just using ravens ResultEtag
| Aggregate(results); | ||
|
|
||
| /// <summary> | ||
| /// For an API whose own instance holds none of the data. Its local result carries no version, and |
There was a problem hiding this comment.
I'm not clear on why this is treated differently.
If a remote instance has no data and forces it to none why should a local instance be excluded from that?
|
|
||
| return new QueryResult<IList<CustomCheck>>(results, new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults, stats.IsStale)); | ||
| return new QueryResult<IList<CustomCheck>>(results, | ||
| stats.ToPagedQueryStatsInfo(results, check => check.Id, ("status", status), ("page", paging.Page), ("pageSize", paging.PageSize))); |
There was a problem hiding this comment.
Shouldn't this just return stats.ResultsEtag in the version struct?
There was a problem hiding this comment.
Reverted. No longer taking paging into account.
| return new QueryStatsInfo($"{stats.ResultEtag}", stats.TotalResults, stats.IsStale); | ||
| } | ||
| /// <summary> | ||
| /// For a paged query. The index etag covers whether the data moved, and the row ids cover which |
There was a problem hiding this comment.
I'm confused as to the reason this needs to be done, are you saying the etag in raven is based on the whole table?
My understanding is that it was already aggregated for you from the result set it returned
There was a problem hiding this comment.
You are right, I'm going to revert this. I was thinking too much from a "backend" PoW and forgot about what these versions would be used for and who will be using it. An identical etag for page 1 and page 2 in ServicePulse is fine as they have different URLs, and SP doesnt do anything with etags iteself either. This may not be the case for other clients depending on how they consume and use the etag, but we dont have those, so ill simplify.
| } | ||
|
|
||
| /// <summary>Nothing changed, so a caller holding the earlier version still holds the current one.</summary> | ||
| public static void Held(DataVersion first, DataVersion second, string because) |
There was a problem hiding this comment.
Should this be VersionAssert.Matches()? "Held" seems like how this assertion is used rather than what it's asserts
| /// 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. | ||
| /// </summary> | ||
| public static void Distinct(DataVersion one, DataVersion other, string because) |
There was a problem hiding this comment.
This is the same code as Moved with different messages, does it need to be it's own assertion?
| /// The page, ordering and filters a read was narrowed by, expressed as version terms. | ||
| /// <para> | ||
| /// 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 |
There was a problem hiding this comment.
I thought that blank results were meant to return an DataVersion.None anyway so that they weren't flagged as unchanged.
There was a problem hiding this comment.
Removed the entire file. No longer needed, as discussed in another comment
| return new QueryResult<IList<MessagesView>>( | ||
| pageOfResults, | ||
| new QueryStatsInfo(etag, allResults.Count, isStale: false)) | ||
| QueryStatsInfo.Fresh(DataVersion.FromToken(etag), allResults.Count)) |
There was a problem hiding this comment.
Given the "isStale" flag is exposing a ravenDb concept directly could it be replaced by returning ETags correcly? I know that the EF implementation always just returns false.
Or is it used downstream for something else?
Context
An HTTP server can tell a client "nothing has changed since you last asked" instead of resending the whole answer. It does that by stamping each response with a short opaque label, an entity-tag, and the client sends that label back on its next request. If the label still matches, the server answers
304 Not Modifiedwith no body at all.Before this branch, ServiceControl built those labels ad hoc: loose strings threaded through the persistence layer, plus a helper (
EtagHelper) that glued a few fields together with aStringBuilder. Different stores disagreed about what an absent label looked like, and the empty string was used to mean "no label", which is dangerous because the empty string matches itself.Size
76% tests and docs, 24% production code, by lines changed across the whole PR. Even before this PR the test coverage for ETags and data versioning was lacking heavily. By adding these tests, several real defects were descovered and resolved.
Design
See
docs/data-versioning-design.mdWhat changed
Shared
DataVersioncomposes from a backend token, named terms, per-row terms, or several instances combined. Terms are length-prefixed, so user text containing a delimiter cannot make two different results digest identically. ReplacesEtagHelperandWithDeterministicEtag, which hashed on the way out so the store never recognised its own version coming back. Rule and factory guide indocs/data-versioning-design.md.If-None-Matchnow uses RFC 9110 weak comparison rather thanEntityTagHeaderValue.Equals, which compares strength too. The header is read through typed headers, so a comma-separated list is no longer treated as one malformed value, and*is handled. The304decision reads the action result's status code, notResponse.StatusCode, which is not set yet at that point. AFileStreamResultreplaced by a304is registered for disposal.DataVersion.Combineover instance and version pairs replaces sorting and concatenating raw etags, and reports nothing when any instance did not supply one, so a response never claims to cover data it could not version.RavenDB
GetGroupErrorsCountreturned the bare index etag, so the unresolved count and the archived count of one group shared a validator.ToPagedQueryStatsInfoandQueryResultConvertnow require an id selector and the query terms, so a new call site cannot quietly skip them.EF Core
QueryNarrowing.Termsas Raven. Applied in the custom check, queue address, failure group and failed message query helpers.Not about data versioning
QueryStatsInfo.Fresh. EF reads cannot be stale, and the alternative was repeatingisStale: falseat every new call site.GetAuditCountsForEndpointApimoved toScatterGatherRemoteOnly. It had to source its version from remotes only, since its own instance holds none of the data. That removed a never-implemented local query and an unused store dependency.Outside the error instance
/api/messagescombines the primary's validator with audit's, so fixing only the primary would leave that endpoint wrong. Audit keeps its ownstring ETagtype; only the composition changed, mirroring the primary's formatting including ticks-precision timestamps.Test coverage