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.
| 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<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?
There was a problem hiding this comment.
The only places that its used is in RetryDocumentManager.cs, which sits downstream. Its only that one place that needs it though so I've taken IsStale out of the QueryStatsInfo class (which is used almost everywhere) and created an OrphanedBatches object to use instead only in the Retry logic
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