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.
public interface IVehicleSource
{
Task<IReadOnlyList<Vehicle>> LoadAsync(CancellationToken cancellation);
}
public interface IPhotoManifestSource
{
Task<IReadOnlyList<PhotoEntry>> LoadAsync(CancellationToken cancellation);
}
public interface IBidStore
{
Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation);
Task<IReadOnlyList<StoredStanding>> LoadStandingsAsync(CancellationToken cancellation);
Task<StoredStanding?> ReadStandingAsync(string vehicleId, CancellationToken cancellation);
Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation);
Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation);
Task ClearAsync(string userId, CancellationToken cancellation);
}
public interface IVehicleCatalogue
{
int Target { get; }
CatalogueFillPace Pace { get; }
Task<int> FilledAsync(CancellationToken cancellation);
Task FillAsync(IReadOnlyList<CatalogueEntry> batch, CancellationToken cancellation);
Task<SegmentCounts> CountAsync(CatalogueFilter filter, CancellationToken cancellation);
Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take, CancellationToken cancellation);
Task<IReadOnlyList<CatalogueEntry>> FindAsync(IReadOnlyCollection<string> ids, CancellationToken cancellation);
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:
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);
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);
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:
public BidLedger(IBidStore store)
{
_store = store;
_standing = new ConcurrentDictionary<string, VehicleStanding>(StringComparer.Ordinal);
_byUser = new ConcurrentDictionary<string, ConcurrentDictionary<string, BidState>>(StringComparer.Ordinal);
}
private readonly object _loadGate = new();
private Task? _loaded;
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;
}
return started || loading.IsCompleted ? loading : loading.WaitAsync(cancellation);
}
private async Task LoadFromStoreAsync(CancellationToken cancellation) =>
Replay(await _store.LoadStandingsAsync(cancellation), await _store.LoadAsync(cancellation));
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);
}
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:
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;
}
return started || warming.IsCompleted ? warming : warming.WaitAsync(cancellation);
}
public Task WarmAsync(CancellationToken cancellation) => WarmTask(cancellation);
public bool IsWarm => _warm is { IsCompletedSuccessfully: true };
public Task? Filling => _source.Filling;
public CatalogueReport Report() => _source.Report();
private async Task<Warmed> BuildAsync(CancellationToken cancellation)
{
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:
[Theory]
[InlineData(typeof(IVehicleSource))]
[InlineData(typeof(IPhotoManifestSource))]
[InlineData(typeof(IBidStore))]
[InlineData(typeof(IVehicleCatalogue))]
public void Every_port_member_returns_a_task(Type port)
{
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:
[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).
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).
public static class Warmth
{
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);
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.
_ = Task.Run(async () =>
{
try
{
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.
public interface IVehicleSource
{
Task<IReadOnlyList<Vehicle>> LoadAsync(CancellationToken cancellation);
}
public interface IPhotoManifestSource
{
Task<IReadOnlyList<PhotoEntry>> LoadAsync(CancellationToken cancellation);
}
public interface IBidStore
{
Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation);
Task<IReadOnlyList<StoredStanding>> LoadStandingsAsync(CancellationToken cancellation);
Task<StoredStanding?> ReadStandingAsync(string vehicleId, CancellationToken cancellation);
Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation);
Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation);
Task ClearAsync(string userId, CancellationToken cancellation);
}
public interface IVehicleCatalogue
{
int Target { get; }
CatalogueFillPace Pace { get; }
Task<int> FilledAsync(CancellationToken cancellation);
Task FillAsync(IReadOnlyList<CatalogueEntry> batch, CancellationToken cancellation);
Task<SegmentCounts> CountAsync(CatalogueFilter filter, CancellationToken cancellation);
Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take, CancellationToken cancellation);
Task<IReadOnlyList<CatalogueEntry>> FindAsync(IReadOnlyCollection<string> ids, CancellationToken cancellation);
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.
[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");
}
[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.