Skip to content

ADR: Measuring both stores

ADR: Measuring both stores

Status
accepted
Date
2026-09-08

Both containers, the same image, the same region, the same 1 CPU and 1.5 GB, measured in one session from one machine, with the request charge beside every millisecond the document store can put one on. Parent: ADR: A second store on Cosmos DB, and what it costs. The partition key's own numbers are in ADR: The partition key.

In plain words

This page measures the same visitor actions against both databases (the SQL one and the document one, Azure Cosmos DB) in twenty paired rounds from one machine. Pages served from memory came out equal; every action that touches a store was faster on the document side, because that store sits in the container's own region.

What that is worth: a developer gets a method that measures the difference between two stores instead of the network, and the organization gets numbers it can defend, at a cost of 1,096 request units on the free tier.

The method

Not a stopwatch and not a vibe. This repository learned the lesson twice (ADR: The search index; ADR: The SQL Server backend, the addendum on the CI failure that could not explain itself), and the method is the one in SearchIndexBenchmarkTests: paired rounds, both sides inside each round, the order alternated, and the median of the per-round differences reported beside each side's own medians, because sequential timings over a shared network measure the network and pairing measures the difference.

Every round is what a visitor does, in order: register, sign in, load a listing page, open a vehicle, read the filter values, place a bid, raise it, and reset. Twenty rounds, each on a fresh account, against each container's own Azure address rather than the domain, so the edge is not in either column; the edge's own share is measured once, separately, at the end.

The request charges are not measured from the client, because the client cannot see them. They are read from the Cosmos DB container's own Admin metrics after the rounds, which is the container reporting what the store charged it for each route, as the median over the requests it saw (ADR: What the store is actually doing).

The cold starts are the ones each container actually had on the roll that put this version on it, read from each container's own startup timings, which count from the process's own start time (ADR: Backends, side by side). Nothing was restarted to make a number.

"""Measure both stores in one session, the way the search index was measured.

Since 19 October both sites are one web app (ADR: One web app for every .NET process): pass the two public
domains, or the one origin twice, and the origin answers each store by the domain's name; at the bare origin a
side gets the store its X-Yard-Store header names, which this script does not send, so it would read SQL twice.

Paired rounds, both containers inside each round, alternating which goes
first, and the median of the per-round differences alongside each side's own
medians. Sequential timings on a shared network measure the network; pairing
measures the difference (api/TheYard.Tests/SearchIndexBenchmarkTests.cs, and
ADR: Measuring both stores).

Every round is what a visitor does: register, sign in, load a listing page,
open a vehicle, read the filter values, place a bid, raise it, and reset. Each
request is timed from this machine; the Cosmos DB side's request charge per
route is read from that container's own Admin metrics afterwards, because the
container is the only thing that knows what the store charged it.

    python scripts/measure_stores.py --sql http://... --cosmos http://... --rounds 20
"""

from __future__ import annotations

import argparse
import http.cookiejar
import json
import statistics
import sys
import time
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone

ROUTES = [
    "register",
    "sign in",
    "listing page",
    "vehicle page",
    "filter values",
    "bid write",
    "bid raise",
    "reset",
]


class Side:
    """One container: its origin, a cookie jar per round, and the timings it produced."""

    def __init__(self, name: str, origin: str) -> None:
        self.name = name
        self.origin = origin.rstrip("/")
        self.timings: dict[str, list[float]] = {route: [] for route in ROUTES}
        self.opener = None

    def fresh_browser(self) -> None:
        jar = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))

    def call(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict | list | None, float]:
        data = None if body is None else json.dumps(body).encode()
        request = urllib.request.Request(self.origin + path, data=data, method=method)
        if data is not None:
            request.add_header("Content-Type", "application/json")
        started = time.perf_counter()
        try:
            with self.opener.open(request, timeout=60) as response:
                elapsed = (time.perf_counter() - started) * 1000
                raw = response.read()
                status = response.status
        except urllib.error.HTTPError as error:
            elapsed = (time.perf_counter() - started) * 1000
            raw = error.read()
            status = error.code
        parsed = None
        if raw:
            try:
                parsed = json.loads(raw)
            except json.JSONDecodeError:
                parsed = None
        return status, parsed, elapsed

    def timed(self, route: str, method: str, path: str, body: dict | None = None):
        status, parsed, elapsed = self.call(method, path, body)
        self.timings[route].append(elapsed)
        return status, parsed


def one_round(side: Side, round_number: int) -> None:
    side.fresh_browser()
    email = f"measure-{round_number}-{uuid.uuid4().hex}@example.com"

    status, _ = side.timed("register", "POST", "/api/auth/register", {"email": email, "password": "correct horse"})
    assert status == 200, f"{side.name}: register answered {status}"
    status, _ = side.timed("sign in", "POST", "/api/auth/login", {"email": email, "password": "correct horse"})
    assert status == 200, f"{side.name}: sign in answered {status}"

    status, listing = side.timed("listing page", "GET", "/api/vehicles?limit=100&status=live")
    assert status == 200 and listing, f"{side.name}: listing answered {status}"
    # The listing is sorted by ending soonest, so its first rows end within
    # seconds and a bid on one of them arrives at an auction that has ended.
    # Take a live one with at least five minutes left.
    now_ms = int(time.time() * 1000)
    vehicle = next(
        v for v in listing["vehicles"]
        if v["auction_status"] == "live" and v["auction_ends_at"] - now_ms > 5 * 60 * 1000
    )
    vehicle_id = vehicle["id"]

    status, opened = side.timed("vehicle page", "GET", f"/api/vehicles/{vehicle_id}")
    assert status == 200, f"{side.name}: vehicle answered {status}"
    status, _ = side.timed("filter values", "GET", "/api/facets")
    assert status == 200, f"{side.name}: facets answered {status}"

    amount = opened["min_next_bid"]
    for attempt in range(3):
        status, answer = side.timed("bid write", "POST", f"/api/vehicles/{vehicle_id}/bids", {"amount": amount})
        if status == 200:
            break
        # Somebody else moved the price between the read and the bid; read it again.
        side.timings["bid write"].pop()
        _, opened, _ = side.call("GET", f"/api/vehicles/{vehicle_id}")
        amount = opened["min_next_bid"]
    assert status == 200, f"{side.name}: bid answered {status}: {answer}"

    raised = answer["vehicle"]["min_next_bid"]
    status, answer = side.timed("bid raise", "POST", f"/api/vehicles/{vehicle_id}/bids", {"amount": raised})
    assert status == 200, f"{side.name}: raise answered {status}: {answer}"

    status, _ = side.timed("reset", "DELETE", "/api/bids")
    assert status == 204, f"{side.name}: reset answered {status}"


def percentile(values: list[float], p: int) -> float:
    ordered = sorted(values)
    rank = max(0, min(len(ordered) - 1, int(-(-p * len(ordered) // 100)) - 1))
    return ordered[rank]


def metrics(side: Side) -> dict:
    side.fresh_browser()
    status, parsed, _ = side.call("GET", "/api/admin/metrics")
    return parsed if status == 200 and isinstance(parsed, dict) else {}


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--sql", required=True, help="origin of the container on Azure SQL Database")
    parser.add_argument("--cosmos", required=True, help="origin of the container on Azure Cosmos DB")
    parser.add_argument("--rounds", type=int, default=20)
    parser.add_argument("--out", default="measure-stores.json")
    args = parser.parse_args()

    sql = Side("Azure SQL Database", args.sql)
    cosmos = Side("Azure Cosmos DB", args.cosmos)
    before = {"sql": metrics(sql), "cosmos": metrics(cosmos)}

    for round_number in range(args.rounds):
        order = [sql, cosmos] if round_number % 2 == 0 else [cosmos, sql]
        for side in order:
            one_round(side, round_number)
        print(f"round {round_number + 1} of {args.rounds} done", file=sys.stderr)

    after = {"sql": metrics(sql), "cosmos": metrics(cosmos)}

    rows = []
    for route in ROUTES:
        a, b = sql.timings[route], cosmos.timings[route]
        diffs = [y - x for x, y in zip(a, b)]
        rows.append(
            {
                "route": route,
                "sql_p50_ms": round(statistics.median(a), 1),
                "sql_p95_ms": round(percentile(a, 95), 1),
                "cosmos_p50_ms": round(statistics.median(b), 1),
                "cosmos_p95_ms": round(percentile(b, 95), 1),
                "median_difference_ms": round(statistics.median(diffs), 1),
            }
        )

    charges = {entry["route"]: entry for entry in after["cosmos"].get("store_by_route", [])}
    result = {
        "measured_at": datetime.now(timezone.utc).isoformat(),
        "rounds": args.rounds,
        "sql": {"origin": sql.origin, "startup": after["sql"].get("startup")},
        "cosmos": {"origin": cosmos.origin, "startup": after["cosmos"].get("startup"), "store": after["cosmos"].get("store"), "charges_by_route": charges},
        "rows": rows,
        "before": {"sql_window": before["sql"].get("requests", {}).get("window"), "cosmos_window": before["cosmos"].get("requests", {}).get("window")},
    }
    with open(args.out, "w", encoding="utf-8") as handle:
        json.dump(result, handle, indent=2)

    print(f"\n{args.rounds} paired rounds, milliseconds, from {sys.platform}\n")
    print("| what a visitor does | SQL p50 | SQL p95 | Cosmos p50 | Cosmos p95 | median difference (Cosmos minus SQL) |")
    print("| --- | --- | --- | --- | --- | --- |")
    for row in rows:
        print(f"| {row['route']} | {row['sql_p50_ms']} | {row['sql_p95_ms']} | {row['cosmos_p50_ms']} | {row['cosmos_p95_ms']} | {row['median_difference_ms']:+} |")
    print("\nCosmos DB request charge by route (this container's own store log, median per request):")
    for route, entry in sorted(charges.items()):
        print(f"  {route}: {entry['ru_p50']} RU over {entry['operations_per_request']} operations, max {entry['ru_max']} RU, {entry['cross_partition']} cross-partition")
    for name, side in (("sql", sql), ("cosmos", cosmos)):
        startup = after[name].get("startup") or {}
        print(f"\n{side.name} startup: ready {startup.get('ready_ms')} ms, store check {startup.get('schema_ms')} ms, seed {startup.get('seed_ms')} ms / {startup.get('seed_ru')} RU, catalogue {startup.get('catalogue_ms')} ms, bids {startup.get('bids_ms')} ms")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Live from scripts/measure_stores.py, region *, read from this build at 8003063.

The numbers

Measured 2026-09-08 at 16:32 CDT, twenty paired rounds from Steve's machine in Missouri against both containers' Azure addresses in West US 2, both on 1.0.0.91 and the same image, the same 1 CPU and 1.5 GB. Milliseconds are the whole request as the client saw it, so about 120 of every number is the network between Missouri and Washington, and the last column is the one that cancels it: the median of the per-round differences, Cosmos DB minus SQL.

what a visitor does SQL p50 SQL p95 Cosmos p50 Cosmos p95 median difference Cosmos RU per request
register 318.6 378.5 215.6 227.6 -105.2 13.04 (two point reads that miss, two creates)
sign in 249.3 343.2 221.1 349.7 -34.9 2.00 (two point reads)
listing page 164.6 179.7 168.0 271.9 +7.5 0
vehicle page 127.3 144.5 133.1 517.4 +5.2 0
filter values 145.4 160.8 146.0 170.1 -0.6 0
bid write 218.6 223.7 135.0 147.1 -77.6 6.52 (a point read that misses, a create)
bid raise 218.7 227.3 134.5 158.3 -79.9 11.29 (a point read, a replace)
reset 171.8 193.2 137.9 162.1 -33.5 7.78 (a query inside the partition, a batch of one delete)

The request units are exact, read out of the Cosmos DB container's own store log after the rounds and added up per request: every one of the twenty sign-ins cost 2.00 RU, every registration 13.04, every bid write 6.52. The operations behind them, with their in-region latencies, from the same log:

operation, in region RU ms
point read that finds the document 1.00 1 to 3
point read that finds nothing (404) 1.00 1 to 3
create a bid document (about 200 bytes) 5.52 5 to 7
replace a bid document with If-Match 10.29 4
create an account document 5.52 5
query for one buyer's bid ids, inside the partition 2.26 3
transactional batch of one delete 5.52 5

And the cold starts, each container's own, on the roll that put 1.0.0.91 on it:

cold start SQL Cosmos
store check: schema present, or containers present 2,483 ms 2,003 ms
first-boot seed, or the check that it is there 339 ms 450 ms (0 RU: already seeded on the previous roll)
catalogue load, 200 rows or 200 documents, expanded to 100,000 1,654 ms 1,190 ms
bids load 130 ms 17 ms
process start to ready 6,117 ms 4,688 ms
the seed itself, measured on the roll before 200 rows and 50 photos in about a second 1,380 RU in 2,154 ms

Reading them

The paths that never touch a store are equal, to within noise. The listing page, the vehicle page and the filter values differ by seven, five and less than one millisecond over twenty rounds, on requests of 130 to 170 ms. That is parity by construction, which is what the design record promised: those requests are served from the in-memory index on both sides and the store is not on their path.

Every path that touches a store is faster on Cosmos DB, by a margin that is the geography and the driver rather than the engine. A bid write is 135 ms against 219, a registration 216 against 319. The Azure SQL Database is in West US 3 because West US 2 refused to create one (ADR: The SQL Server backend), so every SQL statement pays a hop between regions that the Cosmos DB account, in the container's own region, does not. A bid is two operations on both sides (a read and a write), so the difference is the hop and the driver rather than the shape of the work. The honest reading is that the second container's store is next door and the first's is one region away, and the numbers say what that costs: about 80 ms on a write, about 35 on a sign-in.

The cold starts are a wash and both are dominated by the same thing. Six seconds against four and a half, and in both cases most of it is the store check (the connection being established and the identity token acquired) and the catalogue being expanded to 100,000 in memory, which is the same code on both sides. The Cosmos DB container's check had been 48,793 ms on 1.0.0.90, before the identity library was pinned (ADR: A second store on Cosmos DB, and what it costs, addendum), and that number is the reason cold start is measured rather than assumed.

The whole session cost the document store 1,096 request units. Twenty registrations, twenty sign-ins, forty bids and twenty resets, on the free tier, for $0.00, and it would have been a twenty-seventh of a cent on serverless.

The edge's share

The domain, theyard.stevenstout.biz, is the same SQL container behind Netlify's edge. /api/facets took 677 ms through the edge and 149 ms at the origin in the same minute, which is why the comparison above is at the origins and why a visitor comparing the two tabs by feel is comparing the edge as much as the store. A second visit through the edge is faster once its connection is warm; that number is the first.

What did not carry over

Everything a visitor does, the same tests on both stacks, and the suite booted on either store, pass. The differences that remain are the ones the records name rather than ones a demo finds:

  • The document store carries no images field, because none is ever shown, and a test holds the precondition (ADR: A second store on Cosmos DB, and what it costs).
  • Uniqueness of an address is a claim document rather than an index, and there is no cascade from an account to its bids (ADR: Accounts on a document store).
  • The SQL card is the operations card on the document container, with fields the SQL card cannot have (ADR: What the store is actually doing).
  • The default view's sort, ending soonest, cannot be pushed down to the document store at all, because the schedule is derived from the id and the clock rather than stored. On the parity path that never arises, since the sort runs in memory on both sides; it is written here because the experiment made it visible (ADR: The partition key).

Where it sits

No ring: this is a measurement decision about how scripts/measure_stores.py compares the two stores in paired rounds, so the onion and SOLID have nothing to say about it.

Files

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