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
@@ -246,6 +246,13 @@ public sealed class AuditDrainService(
finally
{
writer.DetachDrain();
// Detaching alone leaves a racer that already passed the attached check enqueueing into
// a channel this loop will never read again — those events would sit in the buffer until
// StopAsync's final drain. Completing the writer as well makes that racer's TryWrite
// return false, which is the write-through branch, so the event reaches the store now.
// TryComplete is idempotent, so StopAsync's own CompleteWriting stays safe either way.
writer.CompleteWriting();
}
}
@@ -87,8 +87,11 @@ public sealed class ChannelAuditWriter : IAuditWriter
public void DetachDrain() => Volatile.Write(ref _drainAttached, 0);
/// <summary>
/// Enqueues a canonical audit event for the drain to persist. Never blocks, never throws,
/// and never touches the store on the caller's thread while a drain is attached.
/// Enqueues a canonical audit event for the drain to persist. Never blocks and never throws.
/// It also never touches the store on the caller's thread while a drain is attached — with one
/// exception: once the channel has been completed (shutdown, or a drain loop that died), the
/// enqueue fails and this falls through to the synchronous write, which is what keeps the event
/// rather than stranding it in a buffer nobody reads.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
/// <param name="cancellationToken">Token honoured only by the direct write-through path.</param>
@@ -31,7 +31,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// retries.
/// </para>
/// </remarks>
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) : IAuditEventSink
/// <param name="connectionFactory">Factory for connections to the shared auth database file.</param>
/// <param name="logger">
/// Optional logger for row-level read diagnostics. Optional because the store is also constructed
/// directly by the <c>apikey</c> CLI path and by DI-free tests, which have no logger to hand.
/// </param>
public sealed class SqliteCanonicalAuditStore(
AuthSqliteConnectionFactory connectionFactory,
ILogger<SqliteCanonicalAuditStore>? logger = null) : IAuditEventSink
{
private const string CreateTableSql =
"""
@@ -208,7 +215,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
events.Add(new AuditEvent
{
EventId = Guid.Parse(reader.GetString(0)),
OccurredAtUtc = ParseUtc(reader.GetString(1)),
OccurredAtUtc = ParseUtcOrMinValue(reader.GetString(1), reader.GetString(0)),
Actor = reader.GetString(2),
Action = reader.GetString(3),
Outcome = Enum.Parse<AuditOutcome>(reader.GetString(4)),
@@ -239,6 +246,26 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
Volatile.Write(ref _tableEnsured, 1);
}
private static DateTimeOffset ParseUtc(string value) =>
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
// Reading is defensive where writing is not: an insert always round-trips "O", but the table is
// append-only shared state that an operator (or a future migration) can put an unparseable
// timestamp into, and the retention sweep deliberately keeps such a row — SQLite's datetime()
// yields NULL for it, so the DELETE's comparison is never true. A throwing Parse here would let
// that single row take out the dashboard's whole recent-audit view. MinValue instead sorts the
// row to the far past and keeps every other column readable, which is what an operator looking
// at the view actually needs.
private DateTimeOffset ParseUtcOrMinValue(string value, string eventId)
{
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset parsed))
{
return parsed;
}
// Debug, not warning: the row is still returned and the timestamp text itself is not logged
// (audit rows are not secrets, but the value is attacker-influenceable in the worst case).
logger?.LogDebug(
"Audit event {EventId} has an unparseable occurred_at_utc; reporting it as DateTimeOffset.MinValue.",
eventId);
return DateTimeOffset.MinValue;
}
}
@@ -97,8 +97,13 @@ public static class AuthStoreServiceCollectionExtensions
sp.GetService<TimeProvider>() ?? TimeProvider.System));
DecorateVerifierWithCache(services, security);
// GetService, not GetRequiredService, for the same reason the writer registration below
// gives: the DI-only unit tests build a bare ServiceCollection with no AddLogging(). The
// store's logger is optional and only carries row-level read diagnostics.
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
new SqliteCanonicalAuditStore(
sp.GetRequiredService<AuthSqliteConnectionFactory>(),
sp.GetService<ILogger<SqliteCanonicalAuditStore>>()));
services.AddSingleton<IAuditEventSink>(sp => sp.GetRequiredService<SqliteCanonicalAuditStore>());
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
// DI-only auth/CLI/dashboard unit tests build a bare ServiceCollection without AddLogging().