Fixed KnexKvStore returning null for missing keys#1977
Conversation
Walkthrough
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fedify's KvStore contract requires get() to resolve to undefined when a key does not exist; null is reserved for stored values. Since Fedify introduced persistent negative key caching, its KvKeyCache strictly distinguishes the two: undefined is a cache miss (fetch the key), while null marks the key as known-unavailable and skips fetching entirely. Because KnexKvStore returned null for missing rows, every signing key that had never been cached was misreported as a cached negative entry, so Fedify never fetched the sender's key and inbound HTTP signature verification failed with 401 for any new peer. This only affected deployments using the default MySQL-backed store (self-hosters) - the Redis store used in production follows the contract correctly. Also handle stored JSON null values on read: Fedify persists null to mark failed key lookups, and reading such a row previously threw a TypeError in Object.hasOwn(). Fixes #1961
The expired-entry cleanup in get() deleted by key alone, so between reading an expired row and issuing the delete, a set() on another instance could refresh the same key - and the delete would then remove the brand-new value, making a successful write silently vanish until the next set(). Scoping the delete to rows that are still expired closes the race; the periodic cleanup in set() already guards its delete the same way.
57750c0 to
a649dca
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/knex.kvstore.ts (1)
56-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract
new Date()to a variable for consistency.Using a single
Dateinstance for both therow.expirescheck and the conditional deletion ensures the condition uses the exact same timestamp, avoiding microsecond timing discrepancies between the evaluation of the if-statement and the query execution.♻️ Proposed refactor
- if (row.expires !== null && row.expires <= new Date()) { + const now = new Date(); + if (row.expires !== null && row.expires <= now) { this.logging.debug( `KnexKvStore: Deleting expired key ${key}`, keyInfo, ); // Only delete if still expired - a concurrent set() may have // refreshed the row between our read and this delete await this.knex(this.table) .where(query) - .where('expires', '<=', new Date()) + .where('expires', '<=', now) .del(); return undefined; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/knex.kvstore.ts` around lines 56 - 67, In the expiration handling of the key lookup method, extract a single current timestamp before the row.expires check and reuse that Date value for both the if condition and the conditional delete query. Keep the existing concurrency protection and return behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/knex.kvstore.ts`:
- Around line 56-67: In the expiration handling of the key lookup method,
extract a single current timestamp before the row.expires check and reuse that
Date value for both the if condition and the conditional delete query. Keep the
existing concurrency protection and return behavior unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: feb62b20-59d9-45c3-bcad-2279df088a9a
📒 Files selected for processing (2)
src/knex.kvstore.integration.test.tssrc/knex.kvstore.ts
Evaluating new Date() twice meant the delete could use a slightly later timestamp than the expiry check it guards.
Summary
Fixes #1961 — follows between self-hosted instances failing with
401 Failed to verify the request signature.KnexKvStore.get()returnednullfor missing keys, but Fedify'sKvStorecontract requiresundefinedwhen a key does not exist —nullis a legitimate stored value. Since Fedify introduced persistent negative key caching (v2.1+), itsKvKeyCachestrictly distinguishes the two:undefined→ cache miss → fetch the sender's key and verifynull→ "this key is known to be unavailable" → skip fetching for 10 minutesBecause our MySQL-backed store reported every missing key as
null, every signing key that had never been cached was treated as a cached negative entry, so Fedify never fetched the sender's key and inbound HTTP signature verification failed with 401 for any new peer. This matches the reporter's logs exactly — on a freshly truncatedkey_valuetable, Fedify logsEntry ... found in cache, but it is unavailable, which is only possible if the store misreports misses as negatives:("no fetch failure details are available" because there never was a fetch.)
Verification
Running Fedify's actual
KvKeyCacheagainst both store behaviours:Also fixed
Reading back a stored JSON
null(Fedify persistsnullto mark failed key lookups, with a TTL) previously threwTypeErrorinObject.hasOwn(row.value, ...). It now correctly round-trips asnull.