Repository navigation
perf(auth): cache API-key lookups to drop the per-request DB round-tr… - #594
Conversation
|
@CHKM001 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
…ip (Divinity-Labs#495) Authentication runs in an onRequest hook, so every request — including ones served entirely from the Redis price cache — paid a Postgres round-trip before any work began, and the API could not accept traffic at all when the shared connection pool was slow or exhausted. Cache the lookup in-process, keyed by the key hash, for a short TTL (API_KEY_CACHE_TTL, default 30s): - Positive and negative lookups are both cached, so an invalid key can no longer make the API hit Postgres as fast as a client can send requests. - Expiry is re-checked on every cache hit, so the TTL never extends a key past its own expiresAt. - Revocation stays effectively immediate: the revoke route invalidates the entry directly, so it stops working at once on that instance; other instances pick it up within the TTL (the documented worst-case window). - The cache is size-bounded so a flood of distinct invalid keys cannot grow the negative cache without limit. - Hit/miss is exported as api_key_cache_lookups_total so hit rate is observable rather than assumed. Tests cover repeat-request cache reuse, negative-lookup caching, expiry re-check, immediate revocation, the cross-instance TTL window, and the metric.
6ae529e to
a7435e6
Compare
`ingestPool`'s `beforeEach` only calls `vi.clearAllMocks()`, which resets call history but not mock implementations. The "does not throw when upsertPricePoints rejects" test set a persistent `mockRejectedValue`, so when vitest shuffled the `it` order (as it does in CI) that rejection survived into "stores a price point": upsertPricePoints rejected, ingestPool swallowed the error in its catch, and dispatchPriceUpdate was never called — an order-dependent flake that made the suite, and so every PR's CI, intermittently red. - Use `mockRejectedValueOnce` so the rejection cannot outlive its test. - Restore the default resolved implementations in `beforeEach` as a guard. Verified: the previously-failing shuffle seed now passes, plus three further shuffled runs (942 passed, 1 skipped each). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Merged as
That last one is what makes the trade reviewable instead of hidden. And the Merged locally. Four conflicts, all from auth work that landed after your branch was cut. Three things on top. 1. The cached context dropped
|
PR #495 Cache API-key lookups to drop the per-request DB round-trip (#495)
Problem
src/api/auth.tsauthenticates in anonRequesthook, and every request did aprisma.apiKey.findUniquebefore any work began. Two consequences:including requests that would otherwise be served entirely from the Redis
price cache.
retention worker. When Postgres was slow or the pool was exhausted, the API
did not degrade — it stopped, because nothing could authenticate.
Solution
Cache the lookup in-process, keyed by the key hash, for a short TTL
(
API_KEY_CACHE_TTL, default 30s). The hard part is keeping revocationeffectively immediate; this does it with invalidation and a short TTL, and
documents the worst-case window in the code.
negative result, an invalid key is a free way to make the API hit Postgres as
fast as a client can send requests. Now a repeated invalid key is one query,
not one per attempt.
expiresAt, andevaluate()re-tests it against the current time, so thecache TTL can never extend a key past its own expiry regardless of its length.
(
DELETE /admin/keys/:id) callsinvalidateApiKey(hash)after the update, soa key revoked on the instance that handled the revoke stops working at once.
A key revoked on another instance keeps working locally only until its cache
entry expires — a worst case of exactly
API_KEY_CACHE_TTLseconds, whichis stated in the comment at the cache definition.
MAX_CACHE_ENTRIES = 10_000, oldest-evicted),so a flood of distinct invalid keys cannot grow the negative cache without
limit.
api_key_cache_lookups_total{result="hit"|"miss"}is exported; hit rate is
hit / (hit + miss).Why in-process rather than Redis
The whole point is to stop depending on a shared backing store on the request
hot path and to survive when it is unavailable. An in-process map with a short
TTL and direct invalidation gives immediate revocation on the handling instance
and a bounded, documented window elsewhere, with no new dependency on the
request path. The short TTL is the cross-instance backstop.
Changes
src/api/auth.ts— in-process TTL cache keyed by hash, storing positiveand negative results;
evaluate()re-checks expiry on every hit; bounded sizewith oldest-entry eviction; exports
invalidateApiKey(hash)andclearApiKeyCache()(test helper).lookupApiKeyResultnow consults the cacheand records a hit/miss metric on each call.
src/api/admin.ts— the revoke route callsinvalidateApiKey(existing.hash)after marking the key revoked, so revocation is immediate on this instance.
src/metrics.ts— newapi_key_cache_lookups_totalcounter, labeledresult(bounded tohit/miss).src/config.ts—cache.apiKeyTtlfromAPI_KEY_CACHE_TTL(default 30),with a comment spelling out the revocation window.
.env.example— documentsAPI_KEY_CACHE_TTL.src/__tests__/auth.test.ts— clears the cache between tests; adds thecache suite (below).
Acceptance criteria
serves a repeated request for the same key from cache (no Postgres per request)
(3 requests, 1
findUnique).stops a revoked key immediately on the instance that handles the revoke and
stops a revoked key within the cache TTL even without explicit invalidation.
caches negative lookups so invalid keys do not hit Postgres per attempt.
exports cache lookups as a hit/miss metric.
Plus: re-checks expiry on a cache hit, so the TTL never outlives the key.
Testing
npx tsc --noEmit— clean.npx vitest run src/__tests__/auth.test.ts— 28 passed (6 new).Notes / trade-offs
API_KEY_CACHE_TTLto shorten the cross-instancerevocation window, at the cost of more
findUniquecalls.deploy clears it, which only costs a one-time miss per key.
api_key_auth_failures_totalstill increments per failed request (it countsrejections, not DB queries), so failure observability is unchanged.
Closes #495