perf(task-board): cache the assembled PR card, and never wait on GitHub for what the DB has - #6966
Merged
Merged
Conversation
…ub for what the DB has Prod, last hour: 922 misses to 81 hits on pr_read_cache. A miss blocks on GitHub, which is the ~2s on opening a task — including on a refresh well inside the 30-minute window, where it should have been a hit. Two fixes. **Cache the card, not the reads.** The read cache stores raw GitHub payloads, and `get_comments` on a busy PR runs past the 512KB value cap; the put was rejected, the rejection swallowed, and that PR then missed on EVERY read forever — silently, and indistinguishably from a cold cache. A card is a few hundred bytes, so it always stores, and one KV get replaces the four-to-six this made per PR. `maxStaleMs` is a day, not half an hour: past it the card blocks again, and a card rendered once should never make someone wait on GitHub twice. **Never block on a cold card.** The database already holds the repo, the number and the link. `fetchOrPlaceholder` returns those immediately and runs the GitHub read in the background, so the enrichment lands in KV for the next poll instead of holding the response. The reconciles are skipped on the placeholder: all-null means "we have not asked", not "open, unmerged, no checks", and acting on it would hand a card to the reviewer on the strength of a database row. While a card is unenriched the dialog polls every 2s instead of 60s. Both caches now run on one generalized `JetStreamKVPrCache` (namespace + key, separate buckets, a `cache` label on the metric), and a rejected put is counted as `store_rejected` instead of vanishing — the counter that would have shown this in an hour rather than a week. Cards are invalidated org-wide on a merge.
pedrofrxncx
force-pushed
the
perf/pr-card-level-cache
branch
from
September 4, 2026 00:11
4cbf5ba to
1b7a444
Compare
pedrofrxncx
enabled auto-merge (squash)
September 4, 2026 00:13
decocms Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
PR: #6966 perf(task-board): cache the assembled PR card, and never wait on GitHub for what the DB has Bump type: patch - decocms (apps/api/package.json): 4.328.5 -> 4.328.6 - @decocms/native (apps/native/package.json): 4.328.5 -> 4.328.6 Deploy-Scope: both
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased on
origin/main.The measurement
pr_read_cache_fetches_totalin prod, last hour:A miss blocks on four GitHub round-trips. That is the ~2s on opening a task — including on a page refresh well inside the 30-minute window, where it should have been a hit.
Fix 1 — cache the card, not the raw reads
The read cache stores raw GitHub payloads, and
get_commentson a busy PR runs past the 512KBmaxValueSize. The put is rejected, the rejection was swallowed by a barecatch {}, and that PR then misses on every read forever — silently, and indistinguishably from a cold cache. The busier the PR, the more certain it never caches.A
TaskBoardItemPr[]is a few hundred bytes, so it always stores, and one KV get replaces the four-to-six this made per PR.maxStaleMsis a day, not half an hour: past it the card blocks again, and a card rendered once should never make someone wait on GitHub twice. Stale is still refreshed on every poll, so this trades nothing but the worst case.Fix 2 — never block for what the database already has
The repo, the number and the link are in
task_board_item_prsthe whole time; only the title, checks and preview need GitHub.fetchOrPlaceholderreturns the DB row immediately on a cold card and runs the GitHub read in the background, so the enrichment lands in KV for the next poll instead of holding the response.Two details that matter:
prReadyForReviewwould read it as a candidate and hand the card to the reviewer on the strength of a database row. They run on the next poll, against real data.While any card is unenriched the dialog polls every 2s instead of 60s, so the fill-in is a moment rather than a minute.
Make the failure visible
Both caches now run on one generalized
JetStreamKVPrCache(namespace + key, separate buckets, acachelabel on the metric), and a rejected put incrementsstore_rejectedinstead of vanishing. That counter is what would have surfaced fix 1 in an hour rather than a week.placeholderis a new outcome too, so the cold-card rate is visible.The read cache stays — it still serves the archive/review sweeps, which read many distinct PRs and benefit from payload-level entries.
Testing
pr-cache.test.ts(renamed frompr-read-cache.test.ts; the file no longer only caches reads) — 9 tests:fetchOrPlaceholder: returns the placeholder without waiting then serves the real value; a stale value stayslive; past max-stale it falls back rather than blockingbun run --cwd=apps/api check,bun run lint,knipclean. Pre-existing onmainand unrelated:apps/web checkhas a duplicate-prosemirror-version error, and the 10*.integration.test.tsintask-board/need Postgres.What to watch after deploy
sum by (cache, outcome) (increase(pr_read_cache_fetches_total[1h])). Expectcache="cards"to be overwhelmingly hit/stale, withplaceholderonly on genuinely cold cards. A non-zerostore_rejectedoncardsmeans a check-run summary is blowing the cap and should be truncated before storing.Follow-up, not in scope
The card cache is
storage: Memory, so a NATS restart empties it and every card goes through the placeholder path once. Persisting the last-known card on thetask_board_item_prsrow would survive that — a migration plus a write path.