Flow
The order I would do the work in
Each step uses something the code already has.
1Events that change a key
Revoke, rotate, and expiry.
2Eviction by hash
After the database update, drop the cache entry using the hash stored on the row.
3Other copies of the service
A shorter lifetime, a shared version number, or a broadcast, with the delay written down.
4Usage count
Send only the added amount as one database add, not a full total.
5A warm-key test
Use a key, revoke it, use it again inside the lifetime, and expect a refusal.
Flow
How each change reaches the cache
Each fix uses something the code already holds.
1Revoke
The route holds the row, and the row holds the hash. Drop the entry by hash after the update.
2Rotate
Drop the old key's entry the same way. The new key needs nothing, since only keys that passed get stored.
3Expiry
The copy checks its own expiry date and drops itself.
4Other copies of the service
Each keeps its own cache until it expires, so they need a shorter lifetime, a shared version number, or a broadcast.
A revoked key on my telemetry service would have kept working for a few minutes after the revoke. No alarm went off. I found it by rereading the day's code changes.
One lookup per request, then a shortcut
The service takes in reports from my coding agents, and each one sends a secret key. At first, the server checked every key against the database on every request. That's slow but honest: revoke a key, and the next request is refused.
Later that day a cache went in. I think the agent wrote it, going by the summary documents that came with it. The cache is a map in memory. It's keyed by a hash of the secret, and it holds a copy of the key's database row. A copy is trusted for a few minutes. Only keys that passed get stored.
A copy can tell when its own expiry date has passed. It can't tell when someone revokes the key, because that change only lands in the database. The code does check for "revoked" on the copy, but only good keys get copied, so that check likely never trips.
Why the revoke never reached the cache
The revoke route gets a row ID. It updates the row and stops. There is a helper for dropping a key from the cache, and it's imported but never called. It also wants the original secret, which the server never keeps. The row does hold the hash, so the repair is small. Update the row first, then drop the cache entry by its hash:
row = db.mark_revoked(row_id)
cache.pop(row.fingerprint, None)
Do it in that order. Drop the entry first, and a request in between can put the old row right back.
Rotating a key has the same problem. The old key, maybe the leaked one, keeps working from cache.
The usage count can go down
The cache also batches the usage count, which is a good idea. But each flush writes a full total from an old copy, or from zero. That can replace a bigger number with a smaller one. Sending just the added amount, as a single add in the database, avoids it.
More than one copy of the service
Dropping the entry only fixes the copy of the service that handled the revoke. Any other copy keeps its own cache until it expires. A shorter lifetime, a shared version number, or a broadcast message would close that. I haven't picked one, and the right pick depends on how many copies I end up running.