Skip to content

ADR: Same performance, proven

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.

/// <summary>
/// One whole run: make sure each store has a signed-in account, take the
/// paired rounds, measure the round trip to each store, and sum it up.
/// Clamps the rounds to between one and <see cref="MaxRounds"/>.
/// </summary>
/// <param name="rounds">How many paired rounds to take, clamped to between one and <see cref="MaxRounds"/>.</param>
/// <param name="cancellation">The host's stopping token, which the starter lent this run. Not
/// the token of the request that asked: that request is answered the moment the run starts and
/// the run goes on for half a minute after it.</param>
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();

    // One account per store for the life of the process: registered and
    // timed on the first run, signed into on every run after that. A run
    // that finds its account gone (the store was reset under it) registers
    // a fresh one, which is the one case that spends a registration.
    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++)
    {
        // Alternate which store goes first, so whichever effect order has
        // (a warm cache, a busy second) lands on both sides equally.
        var order = round % 2 == 0 ? stores : stores.Reverse().ToArray();
        foreach (var store in order)
        {
            await RoundAsync(client, store, cancellation);
        }
    }

    // The round trip to each store on its own: a statement that does no
    // work on the relational side, a point read of one document on the
    // document side. Five each, the median kept.
    foreach (var store in stores)
    {
        store.HopMs = await ProofRequests.HopAsync(store.Backend, cancellation);
    }

    return ProofResult.Of(started, clock.GetUtcNow(), rounds, stores);
}

/// <summary>
/// One round on one store, the way a visitor goes: sign in, open the
/// listing, open a vehicle, read the filter values, bid, raise the bid,
/// and reset. A round with no vehicle worth bidding on stops after the
/// listing rather than bidding on one that would end or sell mid-run.
/// </summary>
/// <param name="client">The loopback client every request in the round goes out on.</param>
/// <param name="store">The store's side of the run.</param>
/// <param name="cancellation">The host's stopping token, lent by the run.</param>
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);
}
/// <summary>
/// Whether the proof's remembered account still signs in on this store.
/// Not timed and not sampled: it is a check that the account survived,
/// and the round's own sign-in is the measurement.
/// </summary>
/// <param name="client">The loopback client the check goes out on.</param>
/// <param name="store">The store's side of the run.</param>
/// <param name="email">The address the proof's account on this store was registered with.</param>
/// <param name="cancellation">The host's stopping token, lent by the run.</param>
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.

/// <summary>
/// One request to this container, on one store, with the clock around the
/// whole exchange and the two rings read for what the request caused: how
/// many statements or operations, and what the document store charged.
/// </summary>
/// <param name="client">The loopback client the request goes out on.</param>
/// <param name="store">The store's side of the run, which the sample is added to.</param>
/// <param name="path">The key of the path being measured, such as sign_in or listing.</param>
/// <param name="method">The HTTP method.</param>
/// <param name="url">The address on this container.</param>
/// <param name="cancellation">The host's stopping token, lent by the run: a container being replaced stops measuring itself.</param>
/// <param name="body">What to send, or null for a request with none.</param>
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);
    // The body is part of what a visitor waits for.
    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;
}

/// <summary>The session cookie a sign-in or registration set, as name=value ready to send back, or null when it set none.</summary>
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;
}

/// <summary>
/// A live vehicle with ten minutes left and room under buy-now for a bid and a raise. Reads the
/// ten minutes off the handed clock, because what counts as live is a question about now.
/// </summary>
/// <param name="listing">The listing page's answer.</param>
/// <param name="cancellation">The host's stopping token, lent by the run.</param>
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;
}

/// <summary>The lowest bid the vehicle page will accept, read from its min_next_bid, or null when the page did not answer.</summary>
/// <param name="vehicle">The vehicle page's answer.</param>
/// <param name="cancellation">The host's stopping token, lent by the run.</param>
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;
}

/// <summary>The next acceptable bid after an accepted one, from the vehicle the bid answered with.</summary>
/// <param name="bid">The bid write's answer.</param>
/// <param name="cancellation">The host's stopping token, lent by the run.</param>
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;
}

/// <summary>The median of five round trips to the store, doing as little work as a round trip can.</summary>
/// <param name="backend">The store to reach, asked through its own diagnostics.</param>
/// <param name="cancellation">The host's stopping token, lent by the run; five round trips is five chances to stop.</param>
public static async Task<long?> HopAsync(Backend backend, CancellationToken cancellation)
{
    var samples = new List<long>();
    for (int i = 0; i < 5; i++)
    {
        // Asked of the store through its diagnostics, which each adapter answers its own way.
        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.

/// <summary>
/// How much the two stores are allowed to differ and still be called the
/// same: 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.
/// </summary>
public static bool Same(long difference, long slower) =>
    Math.Abs(difference) <= Math.Max(15, slower * 0.15);

/// <summary>
/// A finished run summed up: for registration and every path, a cell per
/// store, the median of the paired differences (round by round, second
/// store minus first), the same with each side's round trips taken off,
/// and the row's verdict; then the sentence over all the rows.
/// </summary>
/// <param name="started">The moment the run began, read from the runner's clock.</param>
/// <param name="finished">The moment it ended, read from the same clock rather than here.</param>
/// <param name="rounds">How many paired rounds it took.</param>
/// <param name="stores">Each store's side of the run, with every sample it took.</param>
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));
}

/// <summary>One path on one store from its samples: failures counted apart, medians and averages over the requests that answered below 400.</summary>
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);
}

/// <summary>
/// The row's phrase: not measured, the same, or which store leads and by
/// how much, with a note when the lead is all round trip, meaning that
/// taking one round trip per operation off each side leaves them the same.
/// </summary>
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;
}

/// <summary>The one sentence over every row: how many paths came out the same, how many differ only by the round trip, and whether any differ by more.</summary>
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:

Path Azure SQL Database Azure Cosmos DB Difference Without the round trips Verdict
Register (one sample) 233 ms, 3 statements 133 ms, 4 operations, 13.04 RU -100 ms +9 ms all of it the round trip
Sign in 122 ms, 1 statement 82 ms, 2 operations, 2 RU -39 ms -4 ms all of it the round trip
Listing page 51 ms 56 ms -6 ms -6 ms the same
Vehicle page 1 ms 1 ms 0 ms 0 ms the same
Filter values 12 ms 29 ms +7 ms +7 ms the same
Bid write 84 ms, 2 statements 10 ms, 2 operations, 6.52 RU -75 ms -1 ms all of it the round trip
Bid raise 85 ms, 2 statements 9 ms, 2 operations, 11.29 RU -77 ms -3 ms all of it the round trip
Reset 44 ms, 1 statement 10 ms, 2 operations, 7.78 RU -35 ms 0 ms all of it the round trip

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:

The proof card on the live site: the sentence, the round trips, and the eight rows with both stores, the difference, the difference without the round trips, and a verdict on each

The proof card on the second container, whose default store is Cosmos DB: the same sentence and the same shape on every row

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.

Files

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.

Addendum, 2026-09-13: the sign-in button goes somewhere

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.

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

People who have visited

unread

30 days agotoday

Scanners, my own tools' and my own reads left out.

  •    
  •    
  •    

Tech stack

Read from the build, never typed by hand

What TheYard runs on

What we use, where it runs, the version as built, and the file that proves it.

  • .NET 1014 projects
  • React 19.3TypeScript 7.0
  • Azure6 Bicep files
  • 9GitHub Actions workflows
LayerWhat we useWhere it runsVersionProof
Backendthe API
Backendthe API.NET and ASP.NET CoreApp Service, the API container10.0TheYard.Api.csproj
Entity Framework Core (SQL Server)App Service, talking to Azure SQL10.0.12TheYard.Infrastructure.csproj
Identity with JWT bearer authApp Service, the API container10.0.12TheYard.Api.csproj
OpenAPI with Scalar API docsApp Service, /api docs2.17.13TheYard.Api.csproj
Frontendthe page
Frontendthe pageReactYour browser19.3package.json
TypeScriptYour browser7.0package.json
ViteBuild step, in the container image8.3package.json
Server-side rendering on NodeApp Service, the renderer container24render/Dockerfile
Datatwo stores, one port
Datatwo stores, one portAzure SQL DatabaseAzure, managed SQLEF Core 10.0.12TheYard.Infrastructure.csproj
Azure Cosmos DBAzure, NoSQLSDK 3.63.2TheYard.Infrastructure.Cosmos.csproj
Azureinfrastructure as code
Azureinfrastructure as codeApp Service and Container Registry (Linux container)Azure App Service and ACRlive resourceappservice.bicep
Front Door (written, switched off)Off: the subscription refuses Front Door, so the Netlify edge stands in front todayoffmain.bicep
Application Insights with OpenTelemetryAzure Monitor1.6.0TheYard.Api.csproj
Communication Services emailAzure Communication Services1.1.0TheYard.Api.csproj
Azure Functions, Flex Consumption (clock, snapshot, tracker merger, Sheet mirror, ATS sweep, daily card)func-jobhunt-ss, Flex Consumption.NET 10 isolatedJobHunt.Functions.csproj
Bicep for every resource it deploysDeployed from the repository6 Bicep filesinfra/
Edge
EdgeNetlify edge in front of the siteNetlify edge networklive resourcenetlify.toml
Quality gatesevery push
Quality gatesevery pushxUnitGitHub Actions runners2.9.3TheYard.Tests.csproj
NetArchTest architecture rulesGitHub Actions runners1.4.5TheYard.Tests.csproj
VitestGitHub Actions runners5.0package.json
Playwright with axe accessibility checksGitHub Actions runners, against the built site1.63package.json
oxlint and PrettierGitHub Actions runners1.86 and 3.9package.json
Delivery and AIhow it ships
Delivery and AIhow it shipsGitHub Actions (azure-read, blog-post, ci, deploy-function, deploy-render, deploy, edge-branch, gate, nightly)GitHub9 workflowsworkflows/
Built with Claude Code under the test gateDeveloper machine, then the gateevery pushgate.yml
Claude Code on GitHub Actions runs the job hunt's nightly lanesGitHub Actions runners, on the Azure share4 AM and every 4 hnightly.yml

Generated, not typed. A build step reads the project files, package.json, the Dockerfiles, the Bicep and the workflows; a test fails CI when this table drifts from the repository.

How it is built

How it is run

Who and why