Skip to content

ADR: What the store is actually doing

ADR: What the store is actually doing

Status
accepted
Date
2026-09-08

The document store's counterpart of the SQL card on the Admin tab: every operation this container sent to Azure Cosmos DB, with the container, the kind, the partition it was pinned to or the fact that it fanned out, and the request charge beside the milliseconds. Parent: ADR: A second store on Cosmos DB, and what it costs. The record this one sits beside, ADR: What the database is actually doing, has an addendum pointing here.

In plain words

This page adds a live log of every operation the site sends to its document database (Azure Cosmos DB), shown on the Admin tab with what each one cost in request units beside its milliseconds. Like the SQL log beside it, it never shows a value such as an email address.

What that is worth: a developer can see which request caused each operation and whether it touched one partition or all of them, and the organization can show anyone its database costs on a public page with no login.

Context

The Admin tab's most valuable card for a data-access reviewer is the one that shows the raw SQL and what each statement cost in time, with nowhere to put a parameter value (ADR: What the database is actually doing). Cosmos DB has no SQL log to intercept, and the thing worth showing about it is different: a statement's cost on Cosmos DB is a number the service returns with every response, in request units, and whether an operation touched one partition or every partition is the difference the interview question is about.

Decision

A parallel port, not a wider one. ISqlLog and SqlStatement are left as they are. A second port, IStoreLog, records a StoreOperation:

// The document store's counterpart to the SQL log (ADR: What the store is
// actually doing). A parallel port rather than a widening of ISqlLog, because
// the two record different things: a SQL statement has text, parameters and a
// duration; a Cosmos DB operation has a container, a kind, a partition, a
// request charge and a duration, and only sometimes any text at all. One type
// for both would carry nulls on every row, on both sides.
//
// The no-values rule carries over exactly. A parameter here is a
// SqlParameterShape, which has no field for a value, and a partition is a
// description of the partition rather than the key value that named it: the
// key of an account's partition is the account's id, and the key of a claim
// document is an email address.

/// <summary>
/// One operation this application sent to the document store: where, what
/// kind, what it cost in request units and in milliseconds, whether it was
/// pinned to one partition or fanned out, and the HTTP request that caused it.
/// </summary>
/// <param name="At">When the operation ran.</param>
/// <param name="Container">The container the operation went to.</param>
/// <param name="Kind">The kind of operation, one of <see cref="StoreOperationKind"/>.</param>
/// <param name="Text">The query text, or empty when the operation has none.</param>
/// <param name="Parameters">The shape of each query parameter: name, type and size, never the value.</param>
/// <param name="Partition">A description of the partition the operation was pinned to, or that it fanned out; never the key value.</param>
/// <param name="PhysicalPartitions">How many physical partitions the container has for the operation to reach.</param>
/// <param name="RequestCharge">What the operation cost, in request units.</param>
/// <param name="DurationMs">How long the operation took, in milliseconds.</param>
/// <param name="Outcome">The status code, or the failure the operation met.</param>
/// <param name="Request">The HTTP request that caused the operation, like "GET /api/vehicles", or null when none did.</param>
/// <param name="RequestId">The trace identifier of that HTTP request, or null when there was none.</param>
public sealed record StoreOperation(
    DateTimeOffset At,
    string Container,
    string Kind,
    string Text,
    IReadOnlyList<SqlParameterShape> Parameters,
    string Partition,
    int PhysicalPartitions,
    double RequestCharge,
    long DurationMs,
    string Outcome,
    string? Request,
    string? RequestId);

/// <summary>The kinds of operation the store log distinguishes. Strings, because they are read by a page.</summary>
public static class StoreOperationKind
{
    public const string PointRead = "point read";
    public const string PointWrite = "point write";
    public const string PointDelete = "point delete";
    public const string Query = "query";
    public const string Batch = "batch";
    public const string Metadata = "metadata";
}

/// <summary>Port: where recent document store operations are kept for the Admin tab.</summary>
public interface IStoreLog
{
    void Record(StoreOperation operation);
}

/// <summary>The port wired to nothing, for the tests and the relational containers.</summary>
public sealed class NullStoreLog : IStoreLog
{
    public static readonly NullStoreLog Instance = new();

    private NullStoreLog() { }

    public void Record(StoreOperation operation) { }
}

Live from api/TheYard.Application/Shared/Stores/StoreLog.cs, region store-log-port, read from this build at 8003063.

The two record different things. A SQL statement has its text and parameters plus a duration. A store operation has a container, a kind, a partition, a request charge and a duration, and only sometimes any text. One type for both would carry nulls on every row on both sides, and the Admin card would be reading a type to find out which half of it to believe.

The adapter writes the log, not an interceptor. There is no command to intercept: the SDK is called directly (ADR: A second store on Cosmos DB, and what it costs), and every call goes through one wrapper that times it and reads the charge off the response. It records the operation whether the call succeeded or failed:

/// <summary>
/// Run one operation and write its charge and its duration to the store
/// log, whether it succeeded or not. The store log is a public page: this
/// records the kind, the container, the shape of the parameters and the
/// charge, and never an id that could be an address or a value that could
/// be anything (ADR: What the store is actually doing).
/// </summary>
internal async Task<TResponse> Timed<TResponse>(Container container, string kind, string text, IReadOnlyList<SqlParameterShape> parameters, string partitionLabel, int physical, Func<Task<TResponse>> operation, Func<TResponse, Cost> cost)
{
    var clock = Stopwatch.StartNew();
    try
    {
        var response = await operation();
        var paid = cost(response);
        Record(container, kind, text, parameters, partitionLabel, physical, paid.Charge, clock.Elapsed, paid.Status.ToString());
        return response;
    }
    catch (CosmosException ex)
    {
        Record(container, kind, text, parameters, partitionLabel, physical, ex.RequestCharge, clock.Elapsed, "failed: " + (int)ex.StatusCode);
        throw;
    }
    catch (Exception ex)
    {
        // A refused batch throws its own exception rather than the SDK's,
        // and it is recorded here too, so the one operation that failed is
        // never the one operation the page cannot show.
        Record(container, kind, text, parameters, partitionLabel, physical, 0, clock.Elapsed, "failed: " + ex.GetType().Name);
        throw;
    }
}

Live from api/TheYard.Infrastructure.Cosmos/Shared/Stores/CosmosOperationLog.cs, region timed, read from this build at 8003063.

The no-values rule carries over exactly. A parameter is a SqlParameterShape, which has no field for a value. And a partition is described rather than named: "pinned to the buyer", "pinned to the account", "cross-partition". The key of an account's partition is the account's id, and the key of a claim document is an email address, so a log that printed the partition key value would print the thing the SQL card was designed never to print. A point read is logged as ReadItem with a parameter named id of type String and the id's length, which is the same amount of information the SQL card gives about @normalizedEmail.

Every fan-out says how far it fanned. A query with no partition key is recorded as cross-partition with the container's physical partition count beside it, read once at startup from the SDK's feed ranges. At this size that count is one, and the card says one, because "cross-partition, 1 physical partition" is the honest description of a fan-out to a single server and the number will change when the data does (ADR: The partition key).

A query is one line with its pages added up. The SDK answers a query a page at a time and charges per page. The log records one line per query with the charge of every page summed and the page count in the outcome, so a two-page load reads as one operation that cost what it cost, rather than two lines a reader has to add.

Self-observation is filtered the same way. The health check's two point reads every thirty seconds, and the reads this page itself causes, are dropped before they reach the ring, for the reason the SQL ring drops its own: left in, the card would show nothing but the act of reading it.

What it looks like

On the Cosmos container the SQL card is replaced by this one. On the relational container this endpoint answers an empty list and the SQL card stays. One image and one page, and the page shows whichever store it is on:

/** An operation: when, how long, what it cost, who caused it, where it went and what it carried. */
const storeColumns = (window_: CardWindow): Column<StoreOperation>[] => [
  { name: 'At', mono: true, short: true, cell: (operation) => stampFor(window_, operation.at) },
  {
    name: 'Took',
    mono: true,
    num: true,
    cell: (operation) => `${formatNumber(operation.duration_ms)} ms`,
  },
  { name: 'Charge', mono: true, num: true, cell: (operation) => `${operation.request_charge} RU` },
  { name: 'Caused by', mono: true, cell: (operation) => operation.request ?? 'startup' },
  { name: 'Container', mono: true, cell: (operation) => operation.container },
  { name: 'Kind', short: true, cell: (operation) => operation.kind },
  {
    name: 'Partition',
    cell: (operation) =>
      operation.partition +
      (operation.partition.startsWith('cross')
        ? ` (${operation.physical_partitions} physical)`
        : ''),
  },
  {
    name: 'Operation',
    cell: (operation) => (
      <>
        <pre className={styles.sql}>{operation.text}</pre>
        <span className={styles.muted}>{operation.outcome}</span>
      </>
    ),
  },
  { name: 'Parameters', mono: true, cell: (operation) => describeParameters(operation.parameters) },
];

/** The document store's counterpart of the SQL card: every operation with its partition and its charge (ADR: What the store is actually doing). */
function StoreTable({
  log,
  rows,
  window: window_,
  picker,
}: {
  log: StoreLog;
  rows: Fetched<StoreOperation[]>;
  window: CardWindow;
  picker: ReactNode;
}) {
  return (
    <article className={`${styles.wide} op-glass`} data-testid="store-card">
      <h2 className={styles.cardTitle}>What the document store ran</h2>
      <About>
        Every operation this container sent to {log.store}, newest first: the container, whether it
        was a point read, a point write, a query or a batch, whether it was pinned to one partition
        or fanned out across every physical partition, and what it cost in request units beside how
        long it took. Parameters are listed by name, type and size and never by value, and a
        partition is described rather than named, because this page is public and the key of an
        account&rsquo;s partition is the account. Operations caused by this page and by the health
        check are left out. The buffer holds the last 200 in this container&rsquo;s memory and
        empties on every deploy.
      </About>
      {picker}
      {rows === null ? (
        <CardState state="loading" />
      ) : rows === 'failed' ? (
        <p className={styles.muted} data-testid="card-failed">
          Could not read the store log on the last try; the next try is in 30 seconds.
        </p>
      ) : rows.length === 0 ? (
        window_ !== 'now' ? null : (
          <p className={styles.muted}>Nothing recorded yet.</p>
        )
      ) : (
        <DataTable
          label="Operations this application sent to the document store"
          rows={rows.slice(0, window_ === 'now' ? 60 : 200)}
          rowKey={(_operation, index) => index}
          columns={storeColumns(window_)}
        />
      )}
    </article>
  );
}

Live from src/components/admin/StoreCard/StoreCard.tsx, region store-card, read from this build at 8003063.

The cold start is the first thing in the log: four ReadContainer metadata reads, then the count queries that decide whether to seed, then the two cross-partition SELECT * FROM c loads of the catalogue and the bids, each with its charge. After that an idle container records nothing, and a visitor's bid records a point read and a point write pinned to the buyer, about six request units between them.

Here is that card on 1.0.0.92 with one visitor's whole session in it, newest first: a reset (a query pinned to the buyer's partition and a transactional batch of one delete, 7.78 RU together), a raise (a point read and a ReplaceItem with If-Match, 11.29 RU), a first bid (a point read that finds nothing, which costs the same 1 RU as one that does, and a CreateItem), a sign-in (two point reads, 2 RU), and a registration (two creates and the two reads that looked for a claim first, 13.04 RU):

What the document store ran: one session's reset, raise, bid, sign-in and registration as operations, each with its partition, its request charge and its time

The same three minutes on the relational container, from its own card, where the same reset and raise are a DELETE with one parameter and an UPDATE guarded by RowVersion, at 44 and 41 ms against the document store's 6 and 6:

The SQL this application ran: the same session's reset and raise as statements, with their parameters listed by name and type and never by value

The two cards are the same page reading the same ring, and the columns differ because the stores do: the relational card has nothing to say about a partition or a charge, and the document card has no statement to show, only the operation and what it cost.

What the tests hold

The same canary as the SQL card, on both stores: register an address, read what the store ran, assert the users container is in it and the address is not, in the operations and in the log lines. AdminObservabilityTests asks the container which store it is on and reads the matching card, so one test holds the rule against both:

/// <summary>
/// Whichever store this container is on, what it ran. The SQL section on a
/// relational container, the operations section on a document one, so the
/// same test holds the no-values rule against both stores rather than
/// against the one it was written for (ADR: What the store is actually
/// doing).
/// </summary>
private async Task<(bool Cosmos, string Body)> WhatTheStoreRan()
{
    string store = await _client.GetStringAsync("/api/admin/store");
    using var json = JsonDocument.Parse(store);
    bool cosmos = json.RootElement.GetProperty("store").GetString() == "Azure Cosmos DB";
    return cosmos
        ? (true, json.RootElement.GetProperty("operations").GetRawText())
        : (false, await _client.GetStringAsync("/api/admin/sql"));
}

Live from api/TheYard.Tests/AdminObservabilityTests.cs, region what-the-store-ran, read from this build at 8003063.

And against the real account, the store tests assert that every operation carries a charge, that the only cross-partition operations are the loads, and that no operation's text or partition carries the address (ADR: Accounts on a document store).

Consequences

  • A reviewer can watch the operations this container sends to Cosmos DB, see which request caused each one, and see what each cost in request units, on a public page, with no login and no portal.
  • The comparison card has a number to put beside every millisecond on the document side, which is what makes it a comparison rather than two stopwatches.
  • One more fixed-size ring in memory, two hundred operations, emptied on every roll.
  • The relational side did not change. ISqlLog, the interceptor and the SQL card are as they were, and a container on SQL Server never sees the new type.

Addendum, 2026-09-09: one console line per operation, like every statement

Looking at both Admin tabs after the two-sites lane, Steve noticed the Cosmos DB side had no log like the SQL side's, and asked for one, even a log of the API requests. He was reading the last card, the log as the console got it. On the relational side that card shows every statement, because Entity Framework logs each command at Information and the ring captures that one framework category. The document store had its own card above with every operation, its charge and its partition, and nothing in the console log at all, so the third place the page shows a store's traffic showed one store.

From 1.0.0.103 the store writes one line per operation to the application's logger, in the shape Entity Framework gives a statement: what ran, on which container, what it cost in request units, how long it took, the partition described, the outcome and the query with its parameters by name. Never a value, for the reason this record already gives. The logger is attached by the host after the container is built, the way the relational side attaches Entity Framework's, so a startup operation on a developer's machine is silent and everything after the application exists is on the console, in Application Insights, and on the card. A test asks the container for a request served by the document store and reads the line back under the store's own category, and checks the address it looked up is nowhere on the page.

/// <summary>
/// Write one finished operation to the store log and as one console line:
/// the kind, the container, the charge, the time, the partition described,
/// the outcome and the query text, and never a parameter's value.
/// </summary>
internal void Record(Container container, string kind, string text, IReadOnlyList<SqlParameterShape> parameters, string partitionLabel, int physical, double charge, TimeSpan elapsed, string outcome)
{
    // Nothing in here may break the operation it observed
    // (ADR: What the database is actually doing).
    try
    {
        string name = NameOf(container);
        double rounded = Math.Round(charge, 2);
        long ms = (long)elapsed.TotalMilliseconds;
        log.Record(new StoreOperation(
            DateTimeOffset.UtcNow,
            name,
            kind,
            text,
            parameters,
            partitionLabel,
            physical,
            rounded,
            ms,
            outcome,
            CurrentRequest.Describe(),
            CurrentRequest.Identify()));
        // The same operation as one console line, the shape Entity
        // Framework gives a statement: what ran, what it cost, how long,
        // and the query with its parameters by name and never by value.
        Logger.LogInformation(
            "Executed Cosmos DB {Kind} on {Container} ({Charge} RU, {Ms} ms, {Partition}) {Outcome}: {Text}",
            kind, name, rounded, ms, partitionLabel, outcome, text);
    }
    catch
    {
        // Deliberately silent: a failure to record must never become a
        // failure of the operation, the same reason the SQL interceptor gives.
    }
}

Live from api/TheYard.Infrastructure.Cosmos/Shared/Stores/CosmosOperationLog.cs, region record, read from this build at 8003063.

Where it sits

Three rings share this one: Application owns the IStoreLog port and the StoreOperation record in StoreLog.cs, Infrastructure.Cosmos fills it from the one wrapper in CosmosOperationLog.cs that times every SDK call and reads its charge, and the Api host keeps the ring the Admin tab's StoreCard reads from. Interface segregation, small interfaces shaped to what a caller needs, is the plain reason for a parallel port: ISqlLog stayed as it was, so a container on SQL Server never meets a type full of request charges and partitions it has no use for. Dependency inversion follows too, since the adapter writes to an interface Application defined and the host decides where the lines end up. The price is one more fixed ring of two hundred operations in memory and a second card to keep in step with the SQL one, and a system with many store types would probably push this into OpenTelemetry spans instead of a hand-built ring per store.

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