chore(followups): reviewer-recommended tests, comments, and hardening from the remediation reviews

The remediation reviews approved every task but left a tail of small notes.
This lands the gateway-side half of them.

Hardening (behavior changes, all narrow):

- BuildFilteredWriteBulkCommand's unreachable default case failed OPEN: a
  fifth bulk-write kind added upstream without a filter case here would have
  shipped the DENIED entries to the worker while reporting them denied to the
  caller. It now throws UnreachableException.
- SqliteCanonicalAuditStore.ListRecentAsync no longer throws on a row it
  cannot date. The retention sweep deliberately preserves such rows (SQLite's
  datetime() yields NULL, so the DELETE never matches), which guaranteed the
  dashboard's recent-audit view would meet one eventually and lose the whole
  page to it. The row is now reported at DateTimeOffset.MinValue with every
  other column intact, behind an optional logger.
- The audit drain loop's finally now completes the channel writer alongside
  detaching the drain, so a producer that raced past the attached check takes
  the write-through branch instead of stranding its event in a buffer nobody
  reads until shutdown. TryComplete is idempotent, so StopAsync is unaffected.

Tests:

- MapCommandReply ownership (Assert.Same on the inner reply), mirroring the
  existing MapEvent ownership test.
- Redactor key-id length boundary at exactly 64 and 65 characters, pinning
  which way it fails. Nothing validates key-id length at creation, so
  docs/Diagnostics.md's "which no issued key id does" is now stated as the
  heuristic it is.
- ApiKeyFailureLimiter.Reset with a PartitionResolution whose partition was
  evicted between the Check and the Reset: inert, and clears nobody else's
  block.
- Constraint-cache concurrency stress: the cap is enforced by the inserting
  thread, so overshoot must be transient and proportional to the in-flight
  inserters, and the cache must settle at or under the cap.
- ListRecentAsync against a raw-SQL undateable row.

Comment/doc accuracy:

- EventsHubViewerRegistry.ReleaseConnection records that it relies on
  SignalR's default sequential per-connection dispatch
  (MaximumParallelInvocationsPerClient = 1).
- A PERF(followup) note on Invoke's double session resolve and why removing it
  needs a SessionManager overload.
- SessionEventDistributor: the volatile-field comment named the pump as the
  lock-free reader, but the pump's single capture point is inside _replayLock;
  the genuinely lock-free reader is SubscriberCount. OnSubscriberOverflow's
  "cannot be observed here" now excepts the DisposeAsync abandon path. The
  churn test names its ConcurrentDictionary bucket-order assumption and that a
  violation surfaces as a read timeout, not a silent pass.
- The two "restores the sequential drain's behavior" claims (SessionManager,
  docs/Sessions.md) were wrong: the sequential drain leaked too, because
  KillWorkerAsync's entry ThrowIfCancellationRequested aborted the whole loop
  on the first session for zero kills. Reworded to "fixes a leak the
  sequential drain also had", with the sweep-bound/shutdown-unbound
  ParallelOptions asymmetry explained.
- ISessionManager.ShutdownAsync's token doc: it degrades the drain to a kill
  sweep rather than cancelling it, with the bounded overrun stated.
  SessionShutdownHostedService.StopAsync records that its cancellation-logging
  branch is now unreachable.
This commit is contained in:
Joseph Doherty
2026-08-15 17:54:31 -04:00
parent 7755745f2f
commit dc2df628e3
19 changed files with 293 additions and 25 deletions
@@ -281,6 +281,55 @@ public sealed class CachingApiKeyVerifierTests
Assert.Same(reparsed, MapConstraints(firstJson));
}
/// <summary>
/// Stress: the constraint cache's bound is enforced by the inserting thread itself
/// (<c>GetOrAdd</c>, then enqueue, then evict), so concurrent inserters can each land an
/// entry before any of them reaches the eviction step. That overshoot is real but must be
/// transient and proportional to the in-flight inserters — not unbounded growth — and once
/// the churn stops the cache must be back at or under the cap. Hammered on both the
/// converging path (every iteration maps the same blob, so all but one <c>GetOrAdd</c>
/// loses) and the growth path (a distinct blob per iteration, which is what forces
/// eviction).
/// </summary>
[Fact]
public void ToGatewayIdentity_ConcurrentBlobs_OvershootIsTransientAndCacheSettlesUnderCap()
{
const int cap = GatewayApiKeyIdentityMapper.MaxCachedConstraintBlobs;
// Overshoot is bounded by how many inserters can sit between their GetOrAdd and their own
// EvictIfOverCapacity, so scale the allowance with the available parallelism rather than
// pinning a magic number. Generous on purpose: the assertion under test is "bounded", not
// "bounded by exactly this".
int transientAllowance = (Environment.ProcessorCount * 8) + 64;
string sharedJson = ConstraintsJson("Area_StressShared");
int peak = 0;
Parallel.For(0, (cap * 2) + 64, index =>
{
MapConstraints(sharedJson);
MapConstraints(ConstraintsJson($"Area_Stress_{index}"));
int size = GatewayApiKeyIdentityMapper.CurrentCacheSize;
int seen = Volatile.Read(ref peak);
while (size > seen && Interlocked.CompareExchange(ref peak, size, seen) != seen)
{
seen = Volatile.Read(ref peak);
}
});
Assert.True(
peak <= cap + transientAllowance,
$"cache peaked at {peak} entries, past the {cap} cap plus the {transientAllowance} transient allowance");
// The cache is process-wide static and other test classes in this assembly map identities
// too, so poll rather than asserting on the instant the loop returns.
Assert.True(
SpinWait.SpinUntil(
() => GatewayApiKeyIdentityMapper.CurrentCacheSize <= cap,
TimeSpan.FromSeconds(5)),
$"cache settled at {GatewayApiKeyIdentityMapper.CurrentCacheSize} entries, past the {cap} cap");
}
private static ApiKeyConstraints MapConstraints(string constraintsJson) =>
GatewayApiKeyIdentityMapper.ToGatewayIdentity(new LibApiKeyIdentity(
KeyId: "operator01",