Skip to content

ADR: The ports learn to wait

ADR: The ports learn to wait

Status
accepted
Date
2026-09-08

The first decision of the Cosmos DB work that reaches the Application layer: every member of the three ports in Ports.cs now returns a Task, BidService holds a semaphore where it held a lock, and the host warms the catalogue and the bids before it serves. Parent: ADR: A second store on Cosmos DB, and what it costs.

In plain words

The parts of the code that read and write data (the ports) now wait for the database without holding a thread (every port returns a Task). The new store has no way to answer without waiting, and on a one-CPU container a blocked thread stalls every request behind it.

What that is worth: a developer can add another store later without a slow database freezing the site, and the organization gets a site where a slow store slows bidding and nothing else.

Context

IVehicleSource.Load(), IPhotoManifestSource.Load() and the three members of IBidStore were synchronous, and they were synchronous for a reason that was true when they were written: the first store was a pair of JSON files, and reading a file is a synchronous thing to do. The second store, SQLite and then Azure SQL Database through EF Core, has a synchronous driver, so the ports kept their shape and EfBidStore.Save called SaveChanges on a request thread.

The third store has no synchronous driver. The Cosmos DB SDK is asynchronous throughout, and the EF Core provider for it throws on ToList and SaveChanges. So a Cosmos adapter behind a synchronous port has exactly one way to exist: call the asynchronous method and block until it finishes.

Why blocking was not taken

Blocking on a task in ASP.NET Core does not deadlock, because there is no synchronization context to deadlock on. It is still the wrong answer here, and the reason is arithmetic about the container rather than a rule about style.

The live container has one vCPU, so the thread pool starts with one worker thread. A bid that blocks that thread while the store answers, five to ten milliseconds in region, holds every other request behind it until the pool notices it is starved and injects another thread, which it does roughly twice a second. Under a burst of bids the site would stall in half-second steps, and the stall would be invisible in any measurement taken one request at a time. PlaceBid also held a lock across the store call, so the bids behind the first one would be blocked threads waiting on a blocked thread. On a lane whose subject is performance, that is the defect the interview would be about.

Decision

Every port member returns a Task. LoadAsync, SaveAsync, ClearAsync. No cancellation tokens, deliberately: a bid write that is halfway through the store-then-memory sequence must finish, and the two loads run once at startup.

// The three seams the layers meet at. Application declares what it needs and
// never learns where the data lives; Infrastructure implements these against
// EF Core and SQLite, against Cosmos DB, the tests against in-memory arrays,
// and the 100,000-record scale-up is a decorator over IVehicleSource that
// nothing above it can see.
//
// Every member returns a Task, because the document store has no synchronous
// driver, and a port that held a request thread while a cloud store answered
// would stall a one-vCPU container (ADR: The ports learn to wait). Every member
// also takes a CancellationToken, because a read the visitor walked away from is
// work the site pays for twice, and a path that drops the token cannot be made to
// stop later without changing every caller (the same record, the addendum on the
// token). Taking it is not the same as cancelling: a bid write that has entered
// the gate runs to its end, and the two startup loads take the host's stopping
// token rather than any request's.
/// <summary>Port: where the vehicle dataset comes from.</summary>
public interface IVehicleSource
{
    Task<IReadOnlyList<Vehicle>> LoadAsync(CancellationToken cancellation);
}

/// <summary>Port: where the photo manifest comes from.</summary>
public interface IPhotoManifestSource
{
    Task<IReadOnlyList<PhotoEntry>> LoadAsync(CancellationToken cancellation);
}

/// <summary>
/// Port: where everybody's bids are kept, and the one place each vehicle's
/// standing is decided. Two sites run one image over the same store, so the
/// standing (price, count, high bidder, sold) is a row or document the store
/// owns, written with a compare-and-set on its version. Memory is a cache of
/// it, and a cache can be stale; the store cannot be argued with
/// (ADR: Three readers with no memory of the project, item 5).
/// A bid belongs to a user (ADR: Accounts and per-user bids), so a buyer's row
/// is keyed by the pair and the standing by the vehicle.
/// </summary>
public interface IBidStore
{
    /// <summary>Every buyer's row, for the replay at startup.</summary>
    Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation);

    /// <summary>Every vehicle's stored standing, for the replay at startup.</summary>
    Task<IReadOnlyList<StoredStanding>> LoadStandingsAsync(CancellationToken cancellation);

    /// <summary>One vehicle's stored standing and its version, or null when the store holds none for it.</summary>
    Task<StoredStanding?> ReadStandingAsync(string vehicleId, CancellationToken cancellation);

    /// <summary>
    /// Every buyer's row on one vehicle. Read when the store holds no standing
    /// for it, which is a vehicle bid on before standings existed or one a
    /// reset cleared, so the standing is worked out from the rows rather than
    /// taken to be the opening ask.
    /// </summary>
    Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation);

    /// <summary>
    /// Writes the buyer's row and the vehicle's new standing, but only if the
    /// standing still carries <paramref name="expectedVersion"/>; null expects
    /// there to be no standing yet. False means another writer moved it first,
    /// and nothing of this bid was kept: the caller reads again and runs the
    /// rules again.
    /// </summary>
    Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation);

    /// <summary>
    /// Forget one person's bids, and the standing of every vehicle they bid on,
    /// so the next read works each one out again from whoever is left. Not
    /// everybody's: this is what the reset button on a page a stranger can also
    /// be looking at is allowed to do (ADR: Reset is one person's start-over).
    /// </summary>
    Task ClearAsync(string userId, CancellationToken cancellation);
}

/// <summary>
/// Port: the catalogue as the store holds it, all hundred thousand vehicles with
/// the slot each id's hash gives it, and the store doing the filtering, the
/// ordering and the paging (ADR: The store does the work). Application plans a
/// listing out of these primitives, the three schedule segments, the room's and
/// the bids' overlay, the page, and the adapters only translate a filter and an
/// order into their own query: EF Core over the Catalogue table, the SDK over the
/// catalogue container. Every read is bounded by the page it asks for; nothing
/// here hands back the whole catalogue.
/// </summary>
public interface IVehicleCatalogue
{
    /// <summary>How many vehicles this catalogue is the first of: the expansion's target count.</summary>
    int Target { get; }

    /// <summary>How the store wants to be filled: a batch at a time, a pause between, and up to how many it fills at warm-up.</summary>
    CatalogueFillPace Pace { get; }

    /// <summary>How many of the first <see cref="Target"/> vehicles the store holds with their slot, read as one count.</summary>
    Task<int> FilledAsync(CancellationToken cancellation);

    /// <summary>Writes one batch of the expansion, each vehicle at its place; a vehicle already there is left as it is or rewritten the same.</summary>
    Task FillAsync(IReadOnlyList<CatalogueEntry> batch, CancellationToken cancellation);

    /// <summary>How many vehicles match, split by the three schedule segments, in one read where the store allows it.</summary>
    Task<SegmentCounts> CountAsync(CatalogueFilter filter, CancellationToken cancellation);

    /// <summary>One page of the matches in one order, skipped and taken in the store's own query.</summary>
    Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take, CancellationToken cancellation);

    /// <summary>The vehicles with these ids, by point read where the address names the partition; an id the catalogue lacks is simply absent.</summary>
    Task<IReadOnlyList<CatalogueEntry>> FindAsync(IReadOnlyCollection<string> ids, CancellationToken cancellation);

    /// <summary>The distinct values behind the filters, read once at warm-up.</summary>
    Task<InventoryFacets> FacetsAsync(CancellationToken cancellation);
}

Live from api/TheYard.Application/Shared/Auction/Ports.cs, region ports, read from this build at 8003063.

BidService holds a semaphore, one at a time, across the await. A lock cannot be held across an await, and the store is awaited inside the critical section on purpose: the store is written first and memory second, so a store that throws leaves nothing behind (ADR: The relational store). Same guarantee, same shape, one bidder at a time:

/// <summary>
/// Places one bid for one buyer. The rules run against the store's standing
/// for the vehicle, read under the gate, and the bid is kept only if the
/// store still holds that same standing when it is written. If the other
/// site moved it in between, the standing is read again and the rules run
/// again, so a bid that no longer clears the minimum is refused with the
/// sentence it would have got a moment later.
///
/// <para>The caller's cancellation is honoured up to the gate and not past it. Waiting
/// a turn is the one part of a bid a caller who has gone can be let out of for nothing;
/// once the turn is theirs the store is written and memory follows, and a sequence
/// abandoned between those two would leave the store holding a standing this process
/// does not know about. So the body runs under no token, deliberately, which is the
/// promise the ports record made and this rung kept (ADR: The ports learn to wait, the
/// addendum on the token).</para>
/// </summary>
public async Task<BidOutcome> PlaceBidAsync(Vehicle vehicle, int amount, AuctionClock clock, string userId, CancellationToken cancellation)
{
    await LoadAsync(cancellation);
    await _gate.WaitAsync(cancellation);
    try
    {
        for (int attempt = 1; attempt <= Attempts; attempt++)
        {
            var (held, version) = await CurrentAsync(vehicle.Id, CancellationToken.None);
            var merged = held is null ? vehicle : vehicle.RaisedTo(held.Amount, held.BidCount);
            // Sold is read from the same standing the write will compare
            // against, so two buyers, on one site or two, cannot both find
            // it false.
            var outcome = BidRules.ResolveBid(merged, amount, clock, held?.SoldBuyNow ?? false);
            if (outcome.Kind == BidOutcomeKind.Rejected)
            {
                return outcome;
            }

            var state = new BidState(
                outcome.Amount,
                merged.BidCount + 1,
                WonBuyNow: outcome.Kind == BidOutcomeKind.Won,
                AtMs: clock.NowMs);
            var next = StandingFold.Next(held, userId, state);
            // The store first, then memory. A store that throws leaves the
            // dictionaries as they were, so a failed write means the bid did
            // not happen anywhere, which is the answer the caller already has.
            if (await _store.TrySaveAsync(userId, vehicle.Id, state, next, version, CancellationToken.None))
            {
                _ledger.Record(userId, vehicle.Id, state, next);
                return outcome;
            }
        }
        return BidOutcome.Rejected(MovedReason);
    }
    finally
    {
        _gate.Release();
    }
}

Live from api/TheYard.Application/Shared/Auction/BidBook.Bidding.cs, region place, read from this build at 8003063.

The constructor stopped reading the store. A constructor cannot wait, and the store now has to be waited for. LoadAsync replays the store once, whoever asks first; the host asks at startup and the writing methods ask again, which costs nothing once the load is done and means a service nobody warmed loads itself on its first bid:

/// <summary>
/// Read once, through <see cref="LoadAsync"/>. Every read after that one is
/// a dictionary: Apply runs over a hundred thousand vehicles on a listing
/// request, and a per-row query would end the feature rather than persist
/// it (ADR: The relational store).
///
/// The constructor does not read the store, because a constructor cannot
/// wait and the store has to be waited for (ADR: The ports learn to
/// wait). The host calls LoadAsync once at startup; BidBook's writing methods
/// call it too, so a book nobody warmed loads itself on its first bid.
/// </summary>
/// <param name="store">Where the bids are kept.</param>
public BidLedger(IBidStore store)
{
    _store = store;
    _standing = new ConcurrentDictionary<string, VehicleStanding>(StringComparer.Ordinal);
    _byUser = new ConcurrentDictionary<string, ConcurrentDictionary<string, BidState>>(StringComparer.Ordinal);
}

/// <summary>Held only while deciding whether a load has to start, so two first callers share one replay.</summary>
private readonly object _loadGate = new();

/// <summary>The replay in flight or finished, or null before anybody has asked for one.</summary>
private Task? _loaded;

/// <summary>
/// Replay the store into both indexes, once, whoever asks first. A replay
/// that failed is not kept: the next caller starts another, so a store
/// that was down at startup is read the first time it is up rather than
/// never (ADR: The ports learn to wait, addendum). Replaying twice is safe,
/// because it writes the store's facts over themselves.
///
/// <para>One replay is shared, so cancelling has to mean two different
/// things and does: the caller who STARTS the replay lends it their token,
/// and every caller who arrives while it is in flight stops only their own
/// wait. A visitor who closed a tab therefore never abandons the load that
/// every other request is queued behind; they get their thread back and the
/// replay finishes for the callers still there. In a running container the
/// starter is the host at startup, so the token the store sees is the
/// host's stopping token; a replay that is cancelled is not kept, and the
/// next caller starts a fresh one, which is the same self-healing rule a
/// failed replay already had (ADR: The ports learn to wait, the addendum on
/// the token).</para>
/// </summary>
/// <param name="cancellation">Lent to the store when this caller starts the replay; otherwise it ends this caller's wait alone.</param>
public Task LoadAsync(CancellationToken cancellation)
{
    Task loading;
    bool started = false;
    lock (_loadGate)
    {
        if (_loaded is null || _loaded.IsFaulted || _loaded.IsCanceled)
        {
            _loaded = LoadFromStoreAsync(cancellation);
            started = true;
        }
        loading = _loaded;
    }
    // The starter already owns the token the replay is running under, so waiting on it
    // again through WaitAsync would only wrap the same cancellation twice.
    return started || loading.IsCompleted ? loading : loading.WaitAsync(cancellation);
}

/// <summary>
/// The store's standings first, then every buyer's row. A vehicle with rows
/// and no standing is one bid on before standings were kept, or one a reset
/// cleared, so its standing is worked out from the rows. A standing whose
/// high bidder has no row is the document store's two writes interrupted
/// between them; the standing is the authority, so the holder's row is put
/// back in memory from it rather than the price being lowered to match.
/// </summary>
/// <param name="cancellation">The starting caller's, which in a running container is the host's stopping token.</param>
private async Task LoadFromStoreAsync(CancellationToken cancellation) =>
    Replay(await _store.LoadStandingsAsync(cancellation), await _store.LoadAsync(cancellation));

/// <summary>
/// Throws the cache away and reads it again from the store. The other site's
/// bids reach this process's listing here, at startup, and on the next write
/// to the same vehicle; the rules never wait for it, because they read the
/// store themselves.
/// </summary>
/// <remarks>Called by <see cref="BidBook.RefreshAsync"/> under the book's gate, so no write lands half way through.</remarks>
/// <param name="cancellation">The caller's cancellation, carried to the store: this read is one caller's and nobody shares it.</param>
internal async Task RefreshAsync(CancellationToken cancellation)
{
    var standings = await _store.LoadStandingsAsync(cancellation);
    var bids = await _store.LoadAsync(cancellation);
    _standing.Clear();
    _byUser.Clear();
    Replay(standings, bids);
}

/// <summary>
/// What the store holds, into both indexes. Two repairs on the way: a
/// vehicle with rows and no standing gets one worked out from its rows, and
/// a standing whose holder has no row gets the holder's row back from the
/// standing, because the standing is the authority.
/// </summary>
private void Replay(IReadOnlyList<StoredStanding> standings, IReadOnlyList<StoredBid> bids)
{
    foreach (var stored in standings)
    {
        _standing[stored.VehicleId] = stored.Standing;
    }

    foreach (var group in bids.GroupBy(bid => bid.VehicleId, StringComparer.Ordinal))
    {
        foreach (var bid in group)
        {
            Remember(bid.UserId, bid.VehicleId, bid.State);
        }
        if (!_standing.ContainsKey(group.Key) && StandingFold.Derive(group) is { } derived)
        {
            _standing[group.Key] = derived;
        }
    }

    foreach (var stored in standings)
    {
        var held = stored.Standing;
        if (!_byUser.TryGetValue(held.HighBidderId, out var mine) || !mine.ContainsKey(stored.VehicleId))
        {
            Remember(held.HighBidderId, stored.VehicleId, new BidState(held.Amount, held.BidCount, held.SoldBuyNow, held.AtMs));
        }
    }
}

Live from api/TheYard.Application/Shared/Auction/BidLedger.cs, region store, read from this build at 8003063.

InventoryService warms once and reads a finished task after that. The Lazy that held the catalogue now holds the task that loads it. WarmAsync starts the load; every synchronous accessor reads the task's result, and after the host has awaited WarmAsync that read is a field access on a task that finished at startup. A caller that skips the warm-up, which is what a unit test over an in-memory source does, blocks on a task the memory source has already completed, a wait of no time. The only way to block a thread here for real is to skip the warm-up against a store that goes over the network, and the host does not:

// One warm-up, shared by every caller, started by whoever asks first; a warm-up that failed is
// not kept, so the next caller starts a fresh one (ADR: The ports learn to wait). What it reads
// is small: the photo manifest, the facets, and one count of what the store holds.
private readonly object _warmGate = new();
private Task<Warmed>? _warm;

private sealed record Warmed(IReadOnlyDictionary<string, IReadOnlyList<PhotoEntry>> Pools, InventoryFacets Facets);

private Task<Warmed> WarmTask(CancellationToken cancellation)
{
    Task<Warmed> warming;
    bool started = false;
    lock (_warmGate)
    {
        if (_warm is null || _warm.IsFaulted || _warm.IsCanceled)
        {
            _warm = BuildAsync(cancellation);
            started = true;
        }
        warming = _warm;
    }
    // The starter is already running under this token; everybody else stops waiting
    // without stopping the warm-up the rest of the process is queued behind.
    return started || warming.IsCompleted ? warming : warming.WaitAsync(cancellation);
}

/// <summary>
/// Read what the catalogue needs before the first visitor does: the photo pools, the
/// facets, and how full the store is.
///
/// <para>One warm-up is shared, so the caller who starts it lends it their token and
/// every later caller stops only their own wait (ADR: The ports learn to wait, the
/// addendum on the token). The starter in a running container is the hosted service that
/// warms beside the listening server, so what the store and the background fill see is
/// the host's stopping token: a container being shut down stops filling, which until this
/// rung it did not.</para>
/// </summary>
/// <param name="cancellation">Lent to the warm-up when this caller starts it; otherwise it ends this caller's wait alone.</param>
public Task WarmAsync(CancellationToken cancellation) => WarmTask(cancellation);

/// <summary>Whether the warm-up has finished, which the host asserts before serving.</summary>
public bool IsWarm => _warm is { IsCompletedSuccessfully: true };

/// <summary>The background fill, while one runs; finished or null otherwise. A test awaits it.</summary>
public Task? Filling => _source.Filling;

/// <summary>Where the catalogue is answered from right now.</summary>
public CatalogueReport Report() => _source.Report();

private async Task<Warmed> BuildAsync(CancellationToken cancellation)
{
    // Pools keyed by lowercase style, because PhotoGallery looks them up lowercased, so a
    // capitalized style in the manifest must not silently miss.
    var pools = (await _manifestSource.LoadAsync(cancellation))
        .GroupBy(photo => photo.Style.ToLowerInvariant())
        .ToDictionary(group => group.Key, group => (IReadOnlyList<PhotoEntry>)[.. group]);

    var facets = await (await _source.ChooseAsync(cancellation)).FacetsAsync(cancellation);
    return new Warmed(pools, facets);
}

Live from api/TheYard.Application/Shared/Auction/Stock.Warm.cs, region warm, read from this build at 8003063.

The host's side of that bargain is two lines, before the pipeline is built:

await app.Services.GetRequiredService<InventoryService>().WarmAsync();
await app.Services.GetRequiredService<BidService>().LoadAsync();

The relational adapters became honest about what they always were. EF Core has had ToListAsync, FindAsync, SaveChangesAsync and ExecuteDeleteAsync all along; EfSources.cs uses them now, and YardDatabase.Prepare and YardSeed.EnsureSeeded are PrepareAsync and EnsureSeededAsync. The JSON readers use File.ReadAllTextAsync. Nothing about what any of them does changed.

What the tests hold

The shape, by reflection, so that a synchronous member added next year fails here rather than on a one-vCPU container:

/// <summary>
/// Every member of every port returns a Task. The first store was a file and
/// the ports were synchronous because reading a file is; the third store has
/// no synchronous driver at all, and a port that blocks a request thread while
/// a cloud store answers is a performance defect on a one-vCPU container. A
/// synchronous member added later fails here rather than in production.
/// </summary>
[Theory]
[InlineData(typeof(IVehicleSource))]
[InlineData(typeof(IPhotoManifestSource))]
[InlineData(typeof(IBidStore))]
[InlineData(typeof(IVehicleCatalogue))]
public void Every_port_member_returns_a_task(Type port)
{
    // A property is a fact about the adapter fixed when it was built (the catalogue's size and
    // how it wants to be filled), never a read of the store, so only the methods are held.
    var methods = port.GetMethods(BindingFlags.Public | BindingFlags.Instance).Where(method => !method.IsSpecialName).ToArray();
    Assert.NotEmpty(methods);
    foreach (var method in methods)
    {
        Assert.True(
            typeof(Task).IsAssignableFrom(method.ReturnType),
            $"{port.Name}.{method.Name} returns {method.ReturnType.Name}, and every port member has to be awaitable");
    }
}

Live from api/TheYard.Tests/PortsTests.cs, region port-surface, read from this build at 8003063.

And the gate, with fifty bids at the minimum arriving together through a store that takes a moment to answer. Exactly one is accepted and the store is written exactly once; without the gate two bidders read the same price, both pass the rules, and the lower one lands second:

/// <summary>
/// The semaphore that replaced the lock still admits one bidder at a time.
/// Fifty bids at the minimum arrive together on one vehicle through a store
/// that takes a moment to answer; with the gate, exactly one of them is
/// accepted at each price and the rest are refused as too low, so the count
/// of accepted bids equals the count of distinct amounts that landed. Without
/// it, two bidders read the same standing price, both pass the rules, and the
/// lower one lands second.
/// </summary>
[Fact]
public async Task The_gate_admits_one_bidder_at_a_time_while_the_store_is_answering()
{
    var clock = TestData.ClockAt(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.FromHours(-4)));
    var vehicle = LiveVehicleFor(clock);
    var store = new SlowStore();
    var service = new BidBook(store);
    int minimum = BidRules.MinNextBid(vehicle);

    var outcomes = await Task.WhenAll(Enumerable.Range(0, 50).Select(i =>
        service.PlaceBidAsync(vehicle, minimum, clock, $"buyer-{i}", CancellationToken.None)));

    int accepted = outcomes.Count(o => o.Kind == BidOutcomeKind.Accepted);
    Assert.Equal(1, accepted);
    Assert.Equal(1, store.Writes);
    Assert.Equal(49, outcomes.Count(o => o.Kind == BidOutcomeKind.Rejected));
}

private sealed class SlowStore : IBidStore
{
    private readonly MemoryBidStore _inner = new();

    public int Writes;

    public Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation) => _inner.LoadAsync(CancellationToken.None);

    public Task<IReadOnlyList<StoredStanding>> LoadStandingsAsync(CancellationToken cancellation) => _inner.LoadStandingsAsync(CancellationToken.None);

    public Task<StoredStanding?> ReadStandingAsync(string vehicleId, CancellationToken cancellation) => _inner.ReadStandingAsync(vehicleId, CancellationToken.None);

    public Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation) => _inner.BidsOnAsync(vehicleId, CancellationToken.None);

    public async Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation)
    {
        await Task.Delay(5);
        bool saved = await _inner.TrySaveAsync(userId, vehicleId, state, standing, expectedVersion, CancellationToken.None);
        if (saved)
        {
            Interlocked.Increment(ref Writes);
        }
        return saved;
    }

    public Task ClearAsync(string userId, CancellationToken cancellation) => _inner.ClearAsync(userId, CancellationToken.None);
}

Live from api/TheYard.Tests/PortsTests.cs, region gate, read from this build at 8003063.

The rest of the suite followed the ports mechanically: the in-memory fakes in four test files return Task.FromResult, the store tests await their saves, and the bidding tests await their bids. 325 tests, all green, after the change.

Consequences

  • A request thread is never blocked on a store. The bid endpoint, the reset endpoint and the two startup loads are asynchronous end to end.
  • The critical section in BidService now includes the store's answer, which it always did in practice; the difference is that the thread waiting its turn is not a thread.
  • IBidStore.SaveAsync is called inside the gate, so a slow store slows bidding rather than the site, which is the right thing for it to slow.
  • Every adapter, present and future, has to be asynchronous. That is the point.
  • RunChecks on the health endpoint is still synchronous; the Cosmos probe it gains is the one place a health check awaits a store, and the health record says how.

Where it sits

This decision starts in Application, where every member of IVehicleSource, IPhotoManifestSource and IBidStore in Ports.cs now returns a Task and BidService guards its writes with a SemaphoreSlim, then spreads outward to the async adapters in Infrastructure and to the host, which awaits WarmAsync and LoadAsync from Composition/Startup.cs before serving. The ports had been shaped by the first store, a file read that never waits; a network store behind them would have blocked the only thread on a one-vCPU container. The change cost every adapter, fake and test a mechanical rewrite, and the shared warm-up task later needed fixing so a failed load could be retried. A store that only ever read local files on a many-core host would not have needed this, though any network store makes it the right shape.

Addendum, 2026-10-20 (1.0.3.188): the warm starts from a hosted service

The listen-first warm, which the paragraph above places in Composition/Startup.cs, runs from Hosted/CatalogueWarm.cs from 1.0.3.188: a hosted service that starts the load when the host starts and returns at once, so the server still listens before the catalogue is in. The rule is unchanged; only its file moved (ADR: The onion cut into vertical slices, rule 2).

Files

Addendum, 2026-09-09: a failed warm is tried again

The Lazy<Task> that shared one load between every caller kept a faulted task as faithfully as a finished one, so a store that was unreachable for one second at startup would have answered every request until the next roll with that second's exception, while the host's log said the store's first visitor would try again (a review with no context read both and put them side by side). InventoryService.WarmAsync and BidService.LoadAsync now keep the task themselves: a load in flight or finished is shared as before, and a load that failed is replaced by the next caller's, under a lock so the start stays single. Replaying the bid store twice is safe because Record keeps the higher standing. Two tests hold it, one per service, each with a source that is down for its first call and up for its second. The live blocks above show the code as it is.

Addendum, 2026-09-09: the one path the claim did not cover

"A request thread is never blocked on a store" was true of the host this record describes, which warmed its one store before it served. Since one container runs both stores (ADR: One container, both stores) there is a second store, warmed in the background on a deployed group and not at all in a test application, and a request that reached it before the warm finished read InventoryService's synchronous accessor and blocked its thread on the load, for the seconds after a roll on the live site and on every first request to the other store under test. A review with no context read the accessor and this record together and asked. The pipeline now awaits the request's store's warm before any endpoint runs, which after the first time is an await on a task that finished at startup; the accessor is unchanged and is reached only warm (ADR: Three readers with no memory of the project).

/// <summary>
/// The request's store, warm before any endpoint reads it.
///
/// <para>The host starts the default store's catalogue loading beside the
/// server and warms the others in the background on a deployed group, so a
/// request that arrives before a store's load has finished, any request in the
/// first minutes after a roll, a visitor following the toggle, or any request
/// to the other store in a test application where the background warm is off,
/// finds a catalogue still loading. <c>Stock</c>'s synchronous accessors then blocked the
/// request thread on the load, on a container with one vCPU and therefore one
/// thread pool worker to start with: the exact shape ADR: The ports learn to
/// wait argued against (ADR: Three readers with no memory of the project).</para>
///
/// <para>So the pipeline awaits the warm, once per request, before the
/// endpoint runs. After the first time it is an await on a task that finished
/// at startup, which is no wait at all. The bid replay is awaited by the
/// writing methods already and read by the reads as whatever has loaded, so
/// it is not gated here; a listing during the seconds a store's bids replay
/// shows the dataset's prices until the replay lands.</para>
/// </summary>
public static class Warmth
{
    /// <summary>Warms the backend's catalogue if it is not warm; a warm one is a task already finished.</summary>
    /// <param name="backend">The store this request is on.</param>
    /// <param name="cancellation">
    /// The request's. One warm-up is shared, so this ends the request's wait and not the
    /// warm-up: a visitor who closed a tab during the seconds after a roll must not abandon the
    /// load every other request is queued behind (ADR: The ports learn to wait, the addendum on
    /// the token).
    /// </param>
    public static Task EnsureAsync(Backend backend, CancellationToken cancellation) =>
        backend.Inventory.IsWarm ? Task.CompletedTask : backend.Inventory.WarmAsync(cancellation);
}

Live from api/TheYard.Api/Shared/Stores/Stores.cs, region warm-before-reading, read from this build at 8003063.

[Fact]
public async Task A_request_on_a_cold_store_waits_for_the_catalogue_without_blocking()
{
    var source = new GatedVehicles();
    var backend = ColdBackend(source);

    var waiting = Warmth.EnsureAsync(backend, CancellationToken.None);

    // Still loading: the task is pending and the store says so. A version
    // that blocked would never have returned this task at all.
    Assert.False(waiting.IsCompleted);
    Assert.False(backend.Inventory.IsWarm);

    source.Release();
    await waiting;

    Assert.True(backend.Inventory.IsWarm);
    Assert.Equal(0, backend.Inventory.Report().HeldInMemory);
}

[Fact]
public async Task A_warm_store_is_waited_on_for_no_time_at_all()
{
    var source = new GatedVehicles();
    var backend = ColdBackend(source);
    source.Release();
    await backend.Inventory.WarmAsync(CancellationToken.None);

    var again = Warmth.EnsureAsync(backend, CancellationToken.None);

    Assert.True(again.IsCompletedSuccessfully);
    Assert.Equal(1, source.Loads);
}

Live from api/TheYard.Tests/WarmthTests.cs, region warm-before-reading, read from this build at 8003063.

Addendum, 2026-10-07 (1.0.3.89): the server listens first

The host no longer loads the default store's catalogue before it listens. Measured on the three rolls App Insights held in full (1.0.3.84, 1.0.3.85 and 1.0.3.87), App Service stopped the old container 1 to 3.5 minutes before the new one was listening, because the new one loaded a hundred thousand vehicles first, on the one core both sites share, and for that stretch nothing answered at all. Steve's call on 7 October: answer first, load after.

So the warm runs beside the server. The page's own files and the platform's probes, /healthz and /readyz, answer within seconds of the container starting; every API read waits for the load, and waits without holding a thread, because the pipeline already awaits the request's store before any /api endpoint runs (the addendum above). The bids replay before the catalogue loads, not after: they are a few rows and done in well under the catalogue's time, so a read let through by the warm finds them in place, and nobody sees a sold vehicle offered or their own bids missing after a roll. Readiness now includes the catalogue, so /readyz says 503 until it has loaded, and the deploy's Verify step polls it the way it polls the version. One promise of this record is kept in a different shape: a dataset that cannot be loaded still ends the process, but by logging the failure and stopping the host after it has started, rather than by never starting it.

// The default store's bids and catalogue load beside the server, not before
// it. The server listens at once, so a roll's new container answers the
// platform's probes and serves the page's files in seconds. Every API read
// waits for the catalogue, and waits without holding a thread, because the
// pipeline awaits the warm before any /api endpoint runs (Warmth, Stores.cs).
// /readyz says not ready until the catalogue is in, so the deploy and a
// blue-green swap wait for the same thing a visitor would.
//
// The bids replay first. It is a few rows against a hundred thousand
// vehicles, so it is done long before the catalogue, and an API read let
// through by the catalogue's warm finds every bid already in place: no
// visitor sees a sold vehicle offered, or their own bids missing, in the
// first minutes after a roll.
//
// A dataset that cannot be loaded still ends the process, loudly, rather
// than serving a site with no catalogue: the failure is logged and the host
// is stopped, so the platform restarts it and says so. A host already
// stopping, as a test's host does when it is disposed mid-load, is left to stop.
_ = Task.Run(async () =>
{
    try
    {
        // The host's stopping token, not the start token: these two run beside the
        // listening server and for minutes after StartAsync has returned, and they are
        // the callers that START the shared replay and the shared warm-up, so what they
        // lend is what the store, the background fill and the words backfill all see. A
        // container being replaced now stops loading instead of filling a store it is
        // about to be shut down for (ADR: The ports learn to wait, the addendum on the
        // token).
        var stopping = lifetime.ApplicationStopping;
        await backends.Default.Startup.Time("bids", backends.Default.Bids.LoadAsync, stopping);
        await backends.Default.Startup.Time("catalogue", backends.Default.Inventory.WarmAsync, stopping);
        backends.Default.Startup.Ready();
    }
    catch (Exception ex) when (!lifetime.ApplicationStopping.IsCancellationRequested)
    {
        logger.LogCritical(ex, "The {Store} store could not be loaded; stopping, so the platform restarts the container", backends.Default.Name);
        lifetime.StopApplication();
    }
}, CancellationToken.None);

Live from api/TheYard.Api/Shared/Hosted/CatalogueWarm.cs, region listen-first, read from this build at 8003063.

Addendum, 2026-10-07 (1.0.3.91): what listening first changed, measured

The addendum above blamed the dark stretch on the catalogue loading before the server listened. The roll of 1.0.3.89 says that was most of the wrong answer. A probe on Steve's machine read each site's origin and domain every two seconds through the roll (greenlane-probe-10389.log): the old containers' last answer arrived at 14:51:08 CDT, and nothing answered, /healthz included, until 14:54:15, 186 seconds. /healthz answers the instant the process listens and reads no store, so those three minutes are App Service replacing the container on its one instance: stopping the old one, then pulling and starting the new one. Both domains answered 504 after 28 seconds, repeatedly, through that stretch.

What listening first did change is everything after it. The new version's API answered within seconds of /healthz returning, and the slowest read after that was 2 to 7 seconds with one first read of 29 seconds, against worst reads of 18 to 112 seconds in the first 150 seconds of the three rolls Application Insights held in full. The dark stretch is the platform's, and no change inside the container removes it; two containers overlapping does, which on this plan needs memory it does not have: the plan reads 92 per cent used at the median over ten days.

Addendum, 2026-10-18: the warm-up no longer loads the catalogue

From 1.0.3.178 warming a store reads the photo manifest, the filter values and one count of what the store holds; the hundred thousand vehicles stay in the store and every listing awaits its query (ADR: The store does the work). The shared task, and the rule that a failed warm-up is not kept, are unchanged.

Addendum, 2026-10-29 (1.0.4.116): the ports learn to stop

"No cancellation tokens, deliberately" stopped being true here. The sentence in the Decision block above was written when the ports had two kinds of caller: a bid write that must not be abandoned halfway through the store-then-memory sequence, and two loads that run once at startup. Both of those callers still exist and the reasoning about them was never wrong. What the sentence missed is the caller that arrived afterwards. From the store-does-the-work change the catalogue is a port too, and every listing, every search, every page and every point read is a query a visitor asked for and can walk away from: a closed tab, a reload, a phone that slept, a poll that timed out at three seconds against a store still answering. A read nobody is waiting for is work the site pays for twice, once to run it and once in the thread and the request units it took from the read somebody is waiting for, and on a one-vCPU container that is the same arithmetic this record already did about blocking.

So every port member now takes a CancellationToken, and the shape is held by a test for the same reason the awaitable shape is: a member added next year with no token would otherwise be found on the live site rather than here.

// The three seams the layers meet at. Application declares what it needs and
// never learns where the data lives; Infrastructure implements these against
// EF Core and SQLite, against Cosmos DB, the tests against in-memory arrays,
// and the 100,000-record scale-up is a decorator over IVehicleSource that
// nothing above it can see.
//
// Every member returns a Task, because the document store has no synchronous
// driver, and a port that held a request thread while a cloud store answered
// would stall a one-vCPU container (ADR: The ports learn to wait). Every member
// also takes a CancellationToken, because a read the visitor walked away from is
// work the site pays for twice, and a path that drops the token cannot be made to
// stop later without changing every caller (the same record, the addendum on the
// token). Taking it is not the same as cancelling: a bid write that has entered
// the gate runs to its end, and the two startup loads take the host's stopping
// token rather than any request's.
/// <summary>Port: where the vehicle dataset comes from.</summary>
public interface IVehicleSource
{
    Task<IReadOnlyList<Vehicle>> LoadAsync(CancellationToken cancellation);
}

/// <summary>Port: where the photo manifest comes from.</summary>
public interface IPhotoManifestSource
{
    Task<IReadOnlyList<PhotoEntry>> LoadAsync(CancellationToken cancellation);
}

/// <summary>
/// Port: where everybody's bids are kept, and the one place each vehicle's
/// standing is decided. Two sites run one image over the same store, so the
/// standing (price, count, high bidder, sold) is a row or document the store
/// owns, written with a compare-and-set on its version. Memory is a cache of
/// it, and a cache can be stale; the store cannot be argued with
/// (ADR: Three readers with no memory of the project, item 5).
/// A bid belongs to a user (ADR: Accounts and per-user bids), so a buyer's row
/// is keyed by the pair and the standing by the vehicle.
/// </summary>
public interface IBidStore
{
    /// <summary>Every buyer's row, for the replay at startup.</summary>
    Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation);

    /// <summary>Every vehicle's stored standing, for the replay at startup.</summary>
    Task<IReadOnlyList<StoredStanding>> LoadStandingsAsync(CancellationToken cancellation);

    /// <summary>One vehicle's stored standing and its version, or null when the store holds none for it.</summary>
    Task<StoredStanding?> ReadStandingAsync(string vehicleId, CancellationToken cancellation);

    /// <summary>
    /// Every buyer's row on one vehicle. Read when the store holds no standing
    /// for it, which is a vehicle bid on before standings existed or one a
    /// reset cleared, so the standing is worked out from the rows rather than
    /// taken to be the opening ask.
    /// </summary>
    Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation);

    /// <summary>
    /// Writes the buyer's row and the vehicle's new standing, but only if the
    /// standing still carries <paramref name="expectedVersion"/>; null expects
    /// there to be no standing yet. False means another writer moved it first,
    /// and nothing of this bid was kept: the caller reads again and runs the
    /// rules again.
    /// </summary>
    Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation);

    /// <summary>
    /// Forget one person's bids, and the standing of every vehicle they bid on,
    /// so the next read works each one out again from whoever is left. Not
    /// everybody's: this is what the reset button on a page a stranger can also
    /// be looking at is allowed to do (ADR: Reset is one person's start-over).
    /// </summary>
    Task ClearAsync(string userId, CancellationToken cancellation);
}

/// <summary>
/// Port: the catalogue as the store holds it, all hundred thousand vehicles with
/// the slot each id's hash gives it, and the store doing the filtering, the
/// ordering and the paging (ADR: The store does the work). Application plans a
/// listing out of these primitives, the three schedule segments, the room's and
/// the bids' overlay, the page, and the adapters only translate a filter and an
/// order into their own query: EF Core over the Catalogue table, the SDK over the
/// catalogue container. Every read is bounded by the page it asks for; nothing
/// here hands back the whole catalogue.
/// </summary>
public interface IVehicleCatalogue
{
    /// <summary>How many vehicles this catalogue is the first of: the expansion's target count.</summary>
    int Target { get; }

    /// <summary>How the store wants to be filled: a batch at a time, a pause between, and up to how many it fills at warm-up.</summary>
    CatalogueFillPace Pace { get; }

    /// <summary>How many of the first <see cref="Target"/> vehicles the store holds with their slot, read as one count.</summary>
    Task<int> FilledAsync(CancellationToken cancellation);

    /// <summary>Writes one batch of the expansion, each vehicle at its place; a vehicle already there is left as it is or rewritten the same.</summary>
    Task FillAsync(IReadOnlyList<CatalogueEntry> batch, CancellationToken cancellation);

    /// <summary>How many vehicles match, split by the three schedule segments, in one read where the store allows it.</summary>
    Task<SegmentCounts> CountAsync(CatalogueFilter filter, CancellationToken cancellation);

    /// <summary>One page of the matches in one order, skipped and taken in the store's own query.</summary>
    Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take, CancellationToken cancellation);

    /// <summary>The vehicles with these ids, by point read where the address names the partition; an id the catalogue lacks is simply absent.</summary>
    Task<IReadOnlyList<CatalogueEntry>> FindAsync(IReadOnlyCollection<string> ids, CancellationToken cancellation);

    /// <summary>The distinct values behind the filters, read once at warm-up.</summary>
    Task<InventoryFacets> FacetsAsync(CancellationToken cancellation);
}

Live from api/TheYard.Application/Shared/Auction/Ports.cs, region ports, read from this build at 8003063.

Taking the token is not the same as cancelling. The two promises above are kept exactly as they were, and keeping them is the reason this is an addendum and not a rewrite:

  • The bid write still finishes. BidBook.PlaceBidAsync takes the token and honours it up to the moment it enters the gate. Inside the critical section the store is written and memory follows, and that sequence is run to its end whatever the caller does, because a bid abandoned between the two leaves the store holding a standing memory does not. The token is checked before the wait for the gate and not after it, which is where a caller who has gone can still be let go of for free.
  • The startup loads take the host's token, not a request's. IVehicleSource.LoadAsync and IPhotoManifestSource.LoadAsync are asked once, by the hosted service that warms the catalogue beside the listening server, and what they are handed is the host's stopping token. A container being shut down stops warming; nothing else can stop it.

What a request's token is. Minimal APIs bind CancellationToken to HttpContext.RequestAborted, so an endpoint that declares the parameter is handed the connection's own cancellation and the whole chain under it needs no plumbing of its own. That is the whole mechanism on the read paths: the endpoint declares it, the handler takes it, the port carries it, and the adapter gives it to EF Core or to the Cosmos DB SDK, both of which take one on every call they offer.

The health endpoints went first, because they are the clearest case of the defect and the easiest to overlook: /api/health is polled by the Admin tab, and its database check is two point reads over the network. A poll that gave up left the container reading the store for as long as the store took, and the report's own answer went nowhere. /readyz is polled by the deploy and by the platform every few seconds and does not reach the store at all, which the test beside the new one holds, so threading the token through readiness cannot quietly turn the most-polled address on the site into a network read.

/// <summary>
/// The report's store check is handed a token that can be cancelled, which is
/// the request's. A version that passed <c>CancellationToken.None</c>, or took
/// no token at all, hands down a token whose <c>CanBeCanceled</c> is false, and
/// no amount of timing out upstream would ever stop the read.
/// </summary>
[Fact]
public async Task The_health_report_hands_the_store_probe_a_token_the_request_can_cancel()
{
    var seen = new ProbeRecord();
    using var host = HostWithProbe(seen);

    var answer = await host.CreateClient().GetAsync("/api/health");

    Assert.True(answer.IsSuccessStatusCode, $"/api/health answered {(int)answer.StatusCode}");
    Assert.True(seen.Asked, "the store probe was never run, so this test proves nothing about its token");
    Assert.True(seen.Token.CanBeCanceled, "the probe was handed a token nobody can cancel, so the request's own cancellation was dropped on the way down");
}

/// <summary>
/// Readiness runs only the checks that gate it, and the store is not one of
/// them, so its probe is never reached. Held here beside the report's test so
/// that threading the token through readiness cannot quietly start a network
/// read on the path the deploy and the orchestrator poll every few seconds.
/// </summary>
[Fact]
public async Task Readiness_still_never_asks_the_store()
{
    var seen = new ProbeRecord();
    using var host = HostWithProbe(seen);

    var answer = await host.CreateClient().GetAsync("/readyz");

    Assert.True(answer.IsSuccessStatusCode, $"/readyz answered {(int)answer.StatusCode}");
    Assert.False(seen.Asked, "readiness reached the store, which is the read the fallback exists to avoid");
}

Live from api/TheYard.Tests/HealthCancellationTests.cs, region health-token, read from this build at 8003063.

Where the count sits. This is the first rung of the remediation the numbers record sets out, and async_without_cancellation in .github/quality-ratchet.json falls in this commit by what the rung reached (ADR: The numbers that may only fall). The number is not a proxy for this change; it is the change, which is why it moves in the same commit.

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