fix(auth): single-flight API-key verification misses; prune key-id index on eviction

CachingApiKeyVerifier's key-id index was only pruned by Invalidate, so it
grew with every token ever verified. A post-eviction callback now drops the
slot (under the same lock as Set + index, so a late callback cannot
unindex a re-cached entry). Concurrent misses for the same token and key
generation now share one inner verification, so a TTL expiry under load no
longer stampedes SQLite; keying the flight on the generation keeps the
revoke guarantee for joiners.
This commit is contained in:
Joseph Doherty
2026-10-22 06:33:18 -04:00
parent 68d6b5fd6f
commit ed3a2c9c8b
3 changed files with 295 additions and 16 deletions
+10
View File
@@ -113,6 +113,16 @@ library:
library returns carries no expiry timestamp, so the cache cannot cap an entry at the
key's expiry (capping it needs the donor library to surface expiry on the
verification identity). The default 15 s TTL bounds both windows.
Misses are **single-flight**: concurrent misses for the same token share one inner
verification, so a TTL expiry under load costs one store read instead of one per
in-flight RPC. The flight is keyed on the token hash *and* the key's generation, so
a miss that starts after an `Invalidate` never joins a verification that started
before it and cannot receive the pre-revoke identity. The shared inner call is not
bound to any one caller's cancellation token; a caller that gives up stops waiting
without cancelling the others. The per-key index `Invalidate` uses to find every
cached secret variant is pruned by a post-eviction callback when the cache drops an
entry on its own (TTL expiry, compaction), so it tracks the live cache instead of
growing with every token ever verified.
- **`CoalescingMarkApiKeyStore`** wraps the library `IApiKeyStore` and forwards at
most one `MarkUsed` write per key per
`MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` (default 60 s), so even under a