Skip to content

Refactor and improve data versioning / etags - #5794

Open
warwickschroeder wants to merge 38 commits into
masterfrom
warwick/data-ver
Open

Refactor and improve data versioning / etags#5794
warwickschroeder wants to merge 38 commits into
masterfrom
warwick/data-ver

Conversation

@warwickschroeder

@warwickschroeder warwickschroeder commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 Modified with 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 a StringBuilder. 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.md

What changed

Shared

  • One type for a version. DataVersion composes 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. Replaces EtagHelper and WithDeterministicEtag, which hashed on the way out so the store never recognised its own version coming back. Rule and factory guide in docs/data-versioning-design.md.
  • Archived groups and retry history return a version. Both returned bare lists, so the controller invented one. Retry history's named only the historic request ids, so acknowledging an operation changed the body without moving the validator and a dismissed operation came back.
  • Correct conditional GET. If-None-Match now uses RFC 9110 weak comparison rather than EntityTagHeaderValue.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. The 304 decision reads the action result's status code, not Response.StatusCode, which is not set yet at that point. A FileStreamResult replaced by a 304 is registered for disposal.
  • Multi-instance results combine properly. DataVersion.Combine over 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.
  • Endpoints with no honest version say so. The known-endpoints list is built in memory with no store version behind it, so it now states that rather than emitting an empty validator that can never match.

RavenDB

  • Paged and filtered reads name their query. The version was the index etag alone, so page 2 could carry page 1's validator. It now covers the index etag, the total, the page, sort and filters, and the id of every row rendered.
  • Counts name their filters too. GetGroupErrorsCount returned the bare index etag, so the unresolved count and the archived count of one group shared a validator.
  • The overload that omitted the terms is gone. ToPagedQueryStatsInfo and QueryResultConvert now require an id selector and the query terms, so a new call site cannot quietly skip them.

EF Core

  • Paged and filtered reads name their query, through the same shared QueryNarrowing.Terms as Raven. Applied in the custom check, queue address, failure group and failed message query helpers.
  • Custom checks had no validator at all. The store returned an empty string, so ServicePulse re-downloaded the list on every poll and the acceptance test covering this was excluded on both EF backends.
  • Message bodies revalidate. The validator was the message id, on the assumption a body never changes. Ingestion upserts the existing row, so a replaced body kept the old validator. It now covers the id and the row's last-modified stamp.
  • Retry history rows are ordered. Rows are named by position, so an unordered query versioned row order rather than data: historic operations now order by completion time, unacknowledged ones by key.

Not about data versioning

  • Custom checks dropped their originating endpoint. The projection never selected the endpoint name, host or host id, so ServicePulse showed a check with no endpoint against it. EF only, found while verifying the above.
  • Custom checks reported the page size as the total count. The paging links were wrong on every page. Same query, same fix.
  • QueryStatsInfo.Fresh. EF reads cannot be stale, and the alternative was repeating isStale: false at every new call site.
  • GetAuditCountsForEndpointApi moved to ScatterGatherRemoteOnly. 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.
  • Ingestion and clock test helpers moved to the shared test project. The new store tests need to ingest data and control time on every backend, not just one.
  • Etag acceptance test re-enabled on SQL Server and PostgreSQL. It was excluded with a note deferring it to a separate PR. This is that PR.

Outside the error instance

  • Audit RavenDB persister. All five paged message queries returned the bare index etag, so two pages, two searches or two endpoints shared a validator. /api/messages combines the primary's validator with audit's, so fixing only the primary would leave that endpoint wrong. Audit keeps its own string ETag type; only the composition changed, mirroring the primary's formatting including ticks-precision timestamps.

Test coverage

  • The version type. Every factory, collisions, precision, missing instances.
  • HTTP. Weak comparison, wildcards, malformed and unknown validators, non-success responses, stream disposal.
  • Stores. Per-endpoint version tests, plus a conformance suite proving two queries never share a version, on RavenDB, SQL Server and PostgreSQL. Half its cases return nothing, where only the query terms separate them.
  • Message bodies. Moves when the body is replaced, holds when it is not.
  • Audit. The same conformance shape over pages, searches and endpoints.
  • End to end. Etag acceptance tests on all three backends.

…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.
@warwickschroeder
warwickschroeder marked this pull request as ready for review August 20, 2026 14:18
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@warwickschroeder warwickschroeder Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can look into doing that so it is 100% immutable. Then I can remove the additional field.

@johnsimons johnsimons left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/data-versioning-design.md Outdated
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would Raven ever return a null for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ServiceControl.Persistence.EFCore/Implementation/EventLogDataStore.cs Outdated
Comment thread src/ServiceControl.Persistence.EFCore/Implementation/RetryBatchStore.cs Outdated
Comment thread src/ServiceControl.Persistence.EFCore/Infrastructure/CustomCheckQueries.cs Outdated
Comment thread src/ServiceControl.Persistence.EFCore/Infrastructure/QueueAddressQueries.cs Outdated
Comment thread src/ServiceControl.Persistence.EFCore/Infrastructure/RetryHistoryQueries.cs Outdated
Aggregate(results);

/// <summary>
/// For an API whose own instance holds none of the data. Its local result carries no version, and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/ServiceControl.Persistence.RavenDB/RavenCustomCheckDataStore.cs Outdated
Comment thread src/ServiceControl.Persistence.RavenDB/RavenQueryStatisticsExtensions.cs Outdated
Comment thread src/ServiceControl.Persistence.Tests/VersionAssert.cs Outdated
Comment thread src/ServiceControl.Persistence.Tests/VersionAssert.cs Outdated
Comment thread src/ServiceControl.Persistence/Infrastructure/QueryNarrowing.cs Outdated
return new QueryResult<IList<MessagesView>>(
pageOfResults,
new QueryStatsInfo(etag, allResults.Count, isStale: false))
QueryStatsInfo.Fresh(DataVersion.FromToken(etag), allResults.Count))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants