ADR: Same performance, proven
- Status
- accepted
- Date
- 2026-09-09
A card on the Admin tab runs the same requests
a visitor makes against both stores, inside one container, in paired rounds,
and says on each path whether the two stores answer in the same time, and
when they do not, whether the difference is the stores or their distance.
Parent: ADR: One container, both stores.
In plain words
A card on the Admin tab sends the same requests a visitor makes to both databases, SQL and Cosmos DB, from inside the same server, and shows whether they answer in the same time. Running both from one place takes the network out of the comparison, so only the database differs.
What that is worth: a claim about speed is backed by a measurement anyone can run again, and the organization can choose a database on evidence instead of opinion.
Context
Steve asked for proof that SQL and Cosmos DB perform
the same. The measurement record (ADR: Measuring both stores) had
already compared the two containers from a laptop in Missouri, twenty
paired rounds, and found the pages that never touch a store equal to within
a few milliseconds and every write path faster on Cosmos DB by about eighty.
It also said why: the Cosmos DB account is in the container's region and the
SQL server is one region away, because that region refused to create one.
A number measured from a laptop across two containers is a number with two
networks in it. To prove the stores the same, the proof has to take the
networks out: the same process, the same request, the same request ring, and
only the store different. That is what one container running both stores
makes possible, and this record is the measurement that runs there.
Decision
The container measures itself. POST /api/admin/proof starts a run in
the background; GET /api/admin/proof reads the run in progress or the last
result. A run signs into one account of its own per store (registered the
first time this process runs the proof and kept; the addendum on the accounts
says why), then for each of eight rounds, alternating which store goes first, sends the requests a
visitor makes to this container's own address with the store named in a
header: sign in, the listing page, a vehicle page, the filter values, a bid,
a raise, and a reset. Every request is timed around the whole exchange,
body included, and the two rings are read for what it caused: how many
statements or operations, and what the document store charged.
public async Task<ProofResult> RunAsync(int rounds, CancellationToken cancellation)
{
rounds = Math.Clamp(rounds, 1, MaxRounds);
if (backends.All.Count < 2)
{
return ProofResult.Failed("this container runs one store, and a proof needs two", backends, clock.GetUtcNow());
}
var started = clock.GetUtcNow();
var stores = backends.All.Select(backend => new StoreRun(backend)).ToArray();
using var client = clients.Create();
foreach (var store in stores)
{
if (_accounts.TryGetValue(store.Backend.Key, out var account) && await SignsInAsync(client, store, account.Email, cancellation))
{
store.Email = account.Email;
store.Samples.Add(account.Registration);
continue;
}
string email = $"proof-{Guid.NewGuid():N}@example.com";
using var registered = await requests.Timed(client, store, "register", HttpMethod.Post, "/api/auth/register",
cancellation, new { email, password = _password });
if (!registered.IsSuccessStatusCode)
{
return ProofResult.Failed($"registration on {store.Backend.Name} answered {(int)registered.StatusCode}", backends, clock.GetUtcNow());
}
store.Cookie = ProofRequests.SessionCookie(registered);
store.Email = email;
_accounts[store.Backend.Key] = new ProofAccount(email, store.Samples.Last(sample => sample.Path == "register"));
}
for (int round = 0; round < rounds; round++)
{
var order = round % 2 == 0 ? stores : stores.Reverse().ToArray();
foreach (var store in order)
{
await RoundAsync(client, store, cancellation);
}
}
foreach (var store in stores)
{
store.HopMs = await ProofRequests.HopAsync(store.Backend, cancellation);
}
return ProofResult.Of(started, clock.GetUtcNow(), rounds, stores);
}
private async Task RoundAsync(Outbound client, StoreRun store, CancellationToken cancellation)
{
using var signedIn = await requests.Timed(client, store, "sign_in", HttpMethod.Post, "/api/auth/login",
cancellation, new { email = store.Email, password = _password });
if (signedIn.IsSuccessStatusCode)
{
store.Cookie = ProofRequests.SessionCookie(signedIn) ?? store.Cookie;
}
using var listing = await requests.Timed(client, store, "listing", HttpMethod.Get, "/api/vehicles?status=live&sort=most-bids&limit=25", cancellation);
string? vehicleId = await requests.ChooseVehicleAsync(listing, cancellation);
if (vehicleId is null)
{
return;
}
using var vehicle = await requests.Timed(client, store, "vehicle", HttpMethod.Get, $"/api/vehicles/{vehicleId}", cancellation);
int? minimum = await ProofRequests.MinimumAsync(vehicle, cancellation);
using var facets = await requests.Timed(client, store, "facets", HttpMethod.Get, "/api/facets", cancellation);
if (minimum is { } first)
{
using var bid = await requests.Timed(client, store, "bid", HttpMethod.Post, $"/api/vehicles/{vehicleId}/bids",
cancellation, new { amount = first });
int? next = await ProofRequests.NextAsync(bid, cancellation);
if (next is { } second)
{
using var raise = await requests.Timed(client, store, "raise", HttpMethod.Post, $"/api/vehicles/{vehicleId}/bids",
cancellation, new { amount = second });
}
}
using var reset = await requests.Timed(client, store, "reset", HttpMethod.Delete, "/api/bids", cancellation);
}
private async Task<bool> SignsInAsync(Outbound client, StoreRun store, string email, CancellationToken cancellation)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login");
request.Headers.Add(Backends.HeaderName, store.Backend.Key);
request.Content = JsonContent.Create(new { email, password = _password });
using var response = await client.SendAsync(request, cancellation);
if (!response.IsSuccessStatusCode)
{
return false;
}
store.Cookie = ProofRequests.SessionCookie(response);
return true;
}
Live from api/TheYard.Api/Shared/Proof/ProofRunner.Rounds.cs, region rounds, read from this build at 8003063.
The round trip to each store is measured on its own. After the rounds,
five round trips to each store doing as little as a round trip can: a
SELECT 1 on the relational side, a point read of one document on the
document side, the median kept. That number is what lets the card say
whether a difference is the store or the distance to it.
public async Task<HttpResponseMessage> Timed(Outbound client, ProofRunner.StoreRun store, string path, HttpMethod method, string url, CancellationToken cancellation, object? body = null)
{
using var request = new HttpRequestMessage(method, url);
request.Headers.Add(Backends.HeaderName, store.Backend.Key);
if (store.Cookie is not null)
{
request.Headers.Add("Cookie", store.Cookie);
}
if (body is not null)
{
request.Content = JsonContent.Create(body);
}
var at = clock.GetUtcNow();
long start = Stopwatch.GetTimestamp();
var response = await client.SendAsync(request, cancellation);
await response.Content.LoadIntoBufferAsync(cancellation);
long elapsedMs = (long)Stopwatch.GetElapsedTime(start).TotalMilliseconds;
int statements = store.Backend.LogsStatements ? sqlLog.Snapshot().Count(statement => statement.At >= at) : 0;
var operations = store.Backend.LogsOperations
? storeLog.Snapshot().Where(operation => operation.At >= at).ToArray()
: [];
store.Samples.Add(new ProofRunner.Sample(path, elapsedMs, (int)response.StatusCode, statements + operations.Length, operations.Sum(operation => operation.RequestCharge)));
return response;
}
public static string? SessionCookie(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var cookies))
{
return null;
}
foreach (string cookie in cookies)
{
if (cookie.StartsWith(TokenIssuer.CookieName + "=", StringComparison.Ordinal))
{
return cookie.Split(';', 2)[0];
}
}
return null;
}
public async Task<string?> ChooseVehicleAsync(HttpResponseMessage listing, CancellationToken cancellation)
{
if (!listing.IsSuccessStatusCode)
{
return null;
}
var body = await listing.Content.ReadFromJsonAsync<JsonElement>(cancellation);
long now = clock.GetUtcNow().ToUnixTimeMilliseconds();
foreach (var vehicle in body.GetProperty("vehicles").EnumerateArray())
{
long endsAt = vehicle.GetProperty("auction_ends_at").GetInt64();
int price = vehicle.TryGetProperty("current_bid", out var current) && current.ValueKind == JsonValueKind.Number
? current.GetInt32()
: vehicle.GetProperty("starting_bid").GetInt32();
bool underBuyNow = !vehicle.TryGetProperty("buy_now_price", out var buyNow)
|| buyNow.ValueKind != JsonValueKind.Number
|| price + 2_000 < buyNow.GetInt32();
if (endsAt - now > 10 * 60_000 && underBuyNow)
{
return vehicle.GetProperty("id").GetString();
}
}
return null;
}
public static async Task<int?> MinimumAsync(HttpResponseMessage vehicle, CancellationToken cancellation)
{
if (!vehicle.IsSuccessStatusCode)
{
return null;
}
var body = await vehicle.Content.ReadFromJsonAsync<JsonElement>(cancellation);
return body.TryGetProperty("min_next_bid", out var minimum) ? minimum.GetInt32() : null;
}
public static async Task<int?> NextAsync(HttpResponseMessage bid, CancellationToken cancellation)
{
if (!bid.IsSuccessStatusCode)
{
return null;
}
var body = await bid.Content.ReadFromJsonAsync<JsonElement>(cancellation);
if (body.TryGetProperty("kind", out var kind) && kind.GetString() != "accepted")
{
return null;
}
return body.TryGetProperty("vehicle", out var vehicle) && vehicle.TryGetProperty("min_next_bid", out var minimum)
? minimum.GetInt32()
: null;
}
public static async Task<long?> HopAsync(Backend backend, CancellationToken cancellation)
{
var samples = new List<long>();
for (int i = 0; i < 5; i++)
{
if (await backend.Diagnostics.RoundTripMillisecondsAsync(cancellation) is not { } milliseconds)
{
return null;
}
samples.Add((long)milliseconds);
}
return Percentiles.Of(samples, 50);
}
Live from api/TheYard.Api/Shared/Proof/ProofRequests.cs, region timed, read from this build at 8003063.
The verdict is arithmetic, not adjectives. Per path: the median of each
store's samples, and the median of the paired differences, round by round,
so a slow second on both sides cancels. Two stores are "the same" on a path
when that difference is within fifteen milliseconds or fifteen per cent of
the slower one, whichever is more; fifteen milliseconds is about the spread
between two rounds of the same request on the same store. When they are
not the same, the card names the leader and by how much, and then takes one
round trip per operation off each side: if what is left is within the
tolerance, the card says the difference is all round trip.
public static bool Same(long difference, long slower) =>
Math.Abs(difference) <= Math.Max(15, slower * 0.15);
public static ProofResult Of(DateTimeOffset started, DateTimeOffset finished, int rounds, IReadOnlyList<ProofRunner.StoreRun> stores)
{
var first = stores[0];
var second = stores[1];
var rows = new List<ProofRow>();
foreach (var (path, label) in ProofRunner.Paths.Prepend(("register", "Register")))
{
var cells = stores.Select(store => Cell(store, path)).ToArray();
var a = first.Samples.Where(s => s.Path == path && s.Status < 400).Select(s => s.Ms).ToArray();
var b = second.Samples.Where(s => s.Path == path && s.Status < 400).Select(s => s.Ms).ToArray();
int pairs = Math.Min(a.Length, b.Length);
long? difference = pairs == 0 ? null : Percentiles.Of(Enumerable.Range(0, pairs).Select(i => b[i] - a[i]).ToArray(), 50);
long? withoutHops = difference is null
? null
: difference - (long)Math.Round(
(cells[1].OperationsPerRequest * (second.HopMs ?? 0)) - (cells[0].OperationsPerRequest * (first.HopMs ?? 0)));
rows.Add(new ProofRow(path, label, cells, difference, withoutHops, Verdict(difference, withoutHops, cells, first.Backend.Name, second.Backend.Name)));
}
return new ProofResult(
"done",
null,
started,
finished,
rounds,
stores.Select(store => new ProofStore(store.Backend.Key, store.Backend.Name, store.HopMs)).ToArray(),
rows,
SentenceFor(rows, stores));
}
private static ProofCell Cell(ProofRunner.StoreRun store, string path)
{
var all = store.Samples.Where(s => s.Path == path).ToArray();
var ok = all.Where(s => s.Status < 400).ToArray();
long[] ms = ok.Select(s => s.Ms).ToArray();
return new ProofCell(
store.Backend.Name,
ok.Length,
Percentiles.Of(ms, 50),
Percentiles.Of(ms, 95),
ok.Length == 0 ? 0 : Math.Round(ok.Average(s => s.Operations), 1),
!store.Backend.LogsOperations || ok.Length == 0 ? null : Math.Round(ok.Average(s => s.RequestUnits), 2),
all.Length - ok.Length);
}
private static string Verdict(long? difference, long? withoutHops, ProofCell[] cells, string firstName, string secondName)
{
if (difference is null)
{
return "not measured";
}
long slower = Math.Max(cells[0].P50Ms, cells[1].P50Ms);
if (Same(difference.Value, slower))
{
return "the same";
}
string leader = difference < 0 ? secondName : firstName;
string sentence = $"{leader} leads by {Math.Abs(difference.Value)} ms";
if (withoutHops is { } adjusted && Same(adjusted, slower))
{
sentence += ", all of it the round trip to the store";
}
return sentence;
}
private static string SentenceFor(IReadOnlyList<ProofRow> rows, IReadOnlyList<ProofRunner.StoreRun> stores)
{
int same = rows.Count(row => row.Verdict == "the same");
int measured = rows.Count(row => row.MedianDifferenceMs is not null);
int hops = rows.Count(row => row.Verdict.EndsWith("the round trip to the store", StringComparison.Ordinal));
string first = stores[0].Backend.Name;
string second = stores[1].Backend.Name;
string trips = string.Join(" and ", stores.Select(store => $"{store.HopMs?.ToString() ?? "?"} ms to {store.Backend.Name}"));
if (measured == 0)
{
return "Nothing could be measured; the rows say why.";
}
if (same == measured)
{
return $"On every path measured, {first} and {second} answer in the same time, within the tolerance the record states. One round trip to the store is {trips}.";
}
if (same + hops == measured)
{
return $"On {same} of {measured} paths the two stores answer in the same time. On the other {hops} the difference is the round trip to the store, {trips}, and taking one round trip per operation off each side leaves them the same.";
}
return $"On {same} of {measured} paths the two stores answer in the same time; {hops} more differ by exactly the round trip to the store ({trips}); the rest differ by more than that, and the rows say by how much.";
}
Live from api/TheYard.Api/Shared/Proof/ProofResult.cs, region verdict, read from this build at 8003063.
The card says the whole thing in one sentence, computed from the rows:
how many paths are the same, how many differ by exactly the round trip, and
how many differ by more, with the round trips stated.
A run is rationed, and starting one takes a signed-in visitor. Reading
the result is as public as the rest of the Admin tab. Starting a run writes
sixteen bids, so since 1.0.0.109 it takes a signed-in visitor, the rule every
other write on this site follows (ADR: The one write a stranger can make,
addendum); as first shipped it was anonymous, which with two fresh accounts
per run made a loop of one start a minute exactly the hour's allowance of
registrations. A second start while one is running, or within a minute of the
last, answers 409 and the card shows the result it has.
What it measures, and what it does not
It measures the visitor's path: HTTP in, JSON out, with authentication,
validation, the domain rules, the store and the serialisation all inside the
clock, the way a visitor's request has all of them inside it. It does not
measure the visitor's own network, which the measurement record does, and it
does not measure the stores under load, which nothing here does: one request
at a time, in order, is the shape of a demo with one visitor and it is the
shape this proof is honest about.
The two rings the proof reads for statements, operations and request charge
are the container's, so a visitor bidding during a run adds their statements
to a sample's count on whichever store served them. The times are the
proof's own requests and unaffected; the operation counts, and with them the
"without the round trips" column, can carry a visitor's work for the few
seconds a run takes. Reading the card a second time settles it.
The relational round trip is a whole statement, not the wire alone, so the
"without the round trips" column on a path with several statements takes off
a little more than the wire on that side. The direction of the error is
towards the relational store, which is the store the distance handicaps, so
the correction can only understate that store's case, never overstate it.
The numbers
Run on both containers on 1.0.0.94 within a quarter of an hour of each coming
up, eight paired rounds each time, read back from the live sites. The
sentence both cards arrived at, word for word (the second container's round
trip to Cosmos DB came out at 1 ms rather than 2):
On 3 of 8 paths the two stores answer in the same time. On the other 5 the
difference is the round trip to the store, 39 ms to Azure SQL Database and
2 ms to Azure Cosmos DB, and taking one round trip per operation off each
side leaves them the same.
That is the proof, and it is the honest one: the two stores answer in the
same time once the distance to each is taken out, and the distance is real.
The relational server is one region away from the container and every
statement crosses that gap; the document account is in the container's own
region. On this workload a bid is two statements or two operations, so the
relational side pays the gap twice and the card shows exactly that.
The rows from the live site's container. Times are the median over eight
samples unless marked; the difference is the median of the paired
differences, negative when Cosmos DB was faster, which is why it is not
always the difference of the two medians; the last column takes one round
trip per statement or operation off each side:
The second container, whose default is the document store, produced the same
shape to within a few milliseconds on every row: register 197 against 94 with
10 ms left after the round trips, sign in 117 against 77 with 1 ms left, bid
write 85 against 11 with 2 ms left, bid raise 86 against 10 with 1 ms left,
reset 44 against 10 with 3 ms left, and the three pages that never touch a
store the same on both. Two containers, two runs each, one answer.
The card on the live site after that run, and the same card on the second
container:


Two things the run taught that the card alone would not.
The first run after a deploy is not the second. On both containers the
first run since boot put Register at 578 ms against 194 on the live site and
483 against 163 on the second, with 275 and 207 ms left after the round
trips, and the verdict said so. The second run a few minutes later put the
same row at 233 against 133 and 197 against 94, with 9 and 10 ms left. The
card registers one account per store per run, so that row has one sample and
carries whatever a process does only once: the first pass through a code
path on both sides, and on the relational side the compiling EF Core does the
first time it meets a query shape and a command shape. This record does not
prove the cause, only that a second run removes it, and it keeps both numbers
because the first is the one a visitor gets right after a deploy.
The relational store's free offer sleeps. The first health check after a
quiet stretch took 25,882 ms on the live site and a direct read of the origin
timed out, because Azure SQL Database's free offer pauses the database when
nobody has used it for a while and resumes it on the next connection. The
script that started these runs wakes both stores with a health check first
and starts a run only when every store answers in under a second and a half,
so no row here is a store waking up; a visitor who arrives after a pause pays
it once, on the first request that reaches the database, and the site serves
the catalogue from memory while it waits. The document store has no such
state on the free tier.
Consequences
- The proof is a card, so it runs on the deployed container and not on a
laptop, and the numbers it shows are the numbers a visitor to that
container gets, minus their own network.
- Two accounts per process, named
proof-...@example.com, one per store,
made on the first run after a roll and reused after it, so a container
adds two accounts to each store per deploy rather than two per run; they
live in the store until the next reset of that store's test data.
- The interview sentence is the card's sentence, and the numbers behind it
are on the page it is said from.
Where it sits
The proof sits in the host Api (ProofRunner and ProofResult) because it drives the app over HTTP the same way a visitor does; its one reach inward is the round trip that calls YardDatabase.PingAsync in Infrastructure and the probe in Infrastructure.Cosmos. Single responsibility, a class having one reason to change, shapes the split: ProofClients decides where the requests go, ProofRunner times them, and ProofResult holds the verdict arithmetic that ProofTests checks with no store at all. It cost two kept accounts per store per process, and it measures one request at a time, so it says nothing about load. A question about capacity under many visitors would call for a separate load harness run from outside the container.
Addendum, 2026-09-09: the proof's accounts, and who may start it
A reader with no context (the lane's review of 2026-09-09, three readers,
one an architect) put it plainly: POST /api/admin/proof was anonymous, a
run registered two accounts, and with a one-minute cooldown a loop of one
request a minute spent exactly the 120 registrations an hour that ADR: The
one write a stranger can make set as the site's ceiling, so a stranger could
stop real visitors registering by asking the site to prove itself. Two
changes, both in 1.0.0.109. Starting a run takes a signed-in visitor now,
which is the rule every other write here follows; reading stays public, and
the card says so and disables its button signed out rather than failing
after the click. And the proof's accounts are made once per process and
kept: the first run after a roll registers one per store with a password
that is random per process and lives nowhere but in memory, and every later
run signs into them, carrying the first run's registration sample into its
result so the Register row still shows what registering cost. A run that
finds its account gone, because the store's test data was reset under it,
registers a fresh one, which is the one case that spends a registration.
ProofTests holds both: a stranger's start is 401 and nothing runs, and a
second run registers nothing.
Steve reported from his phone that the sign-in button on the comparison did
not work. It did not: signed out, the button read "Sign in to run the proof" and sat
disabled, on the reasoning that a write the visitor cannot make should not
be a button that fails after the click. That reasoning held for the failure
and missed the person: a control that reads like a call to action and does
nothing is a broken control to the one tapping it, and on a phone there is
no hover state to say otherwise. Now the same button, signed out, opens the
account view, which is where the site signs a visitor in; signed in, it
starts the run as before. The browser test holds both halves: the click goes
to ?view=account, and after a sign-in the label offers the run.
Addendum, 2026-10-03: the relational round trip
The relational side's SELECT 1 is now YardDatabase.PingAsync in the relational adapter, so the proof runner in the host asks the adapter for a round trip instead of running a statement itself. The document side does two point reads and halves the time, which the paragraph above calls one point read; the arithmetic it reports is the same.
Addendum, 2026-10-19: the listing after the store did the work
From 1.0.3.178 a listing is a query against the store rather than a walk down a list each process held (ADR: The store does the work). Read on 1.0.3.179, 32 listing pages per site at offsets 0 to 3,100, timed by the server: 19 ms at the median on Azure SQL Database, where it had read 28 from memory, and 387 ms on Azure Cosmos DB, where it had read 43. The difference between the two is not the engines either: Cosmos DB's OFFSET charges for every document it skips, 16 RU for the first page and 1,665 for an offset of 40,000, and its account is the one a region away from the plan. The proof's own rounds were not run again for this addendum, because a run needs a signed-in visitor; the Listing page row in the table above moves on the next run, and the page this record feeds says so.