feat(updates): colo cache for /updates with targeted worldwide invalidation#2662
feat(updates): colo cache for /updates with targeted worldwide invalidation#2662riderx wants to merge 12 commits into
Conversation
📝 WalkthroughWalkthroughAdds a regional Colo cache for ChangesUpdates Colo Cache
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Database
participant SupabaseTrigger
participant RegionalPlugin
participant ColoCache
Database->>SupabaseTrigger: Notify app_ids after data changes
SupabaseTrigger->>RegionalPlugin: POST /cache_invalidate with chunks
RegionalPlugin->>ColoCache: Bump app cache tokens
ColoCache-->>RegionalPlugin: Return bump count
RegionalPlugin-->>SupabaseTrigger: Return regional success count
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Stale comment
Risk: high. Cursor Bugbot completed with status skipping, so the required automated review did not finish successfully. This /updates colo-cache and worldwide invalidation change exceeds the low-risk approval threshold; human review is required.
Sent by Cursor Approval Agent: Pull Request Approver External
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot completed with status skipping and reported 3 unresolved medium-severity findings on the /updates colo-cache path, and this change exceeds the low-risk approval threshold (hot-path caching, migration, worldwide invalidation). Human review is required; reviewers assigned.
Sent by Cursor Approval Agent: Pull Request Approver External
|
Review round 1 fixed in e95cebf: Sonar duplication gate (7.5% > 3%) — the cached path now shares Cursor — unbounded org fan-out: the trigger notify helper dedupes, caps at 1000 apps and chunks every pg_net body; the fan-out posts ≤100 ids per request per region. Cursor — silent 100-id truncation: the plugin route now rejects oversized lists with an explicit 400 ( Cursor — manifest staleness: manifest entries are now cached under the app's version token (a bump invalidates them together with channel payloads), and new statement-level triggers on Tests grew to 20 (chunking, loud cap, manifest-bump invalidation); full sweep 974 green; migration re-applied+rolled back on local Supabase; typos/lint/typecheck clean. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot did not reach a terminal state within the 8-minute window (still pending on the latest push), and this /updates colo-cache plus worldwide invalidation change exceeds the medium-risk approval threshold. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
|
Security round fixed: Cursor — SECURITY DEFINER function RPC-callable by API roles: The other findings in this round (rollout manifest cache ignoring the token, missing 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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.
Inline comments:
In `@supabase/functions/_backend/private/cache_invalidate.ts`:
- Around line 40-44: Update the loop in the cache invalidation handler to invoke
bumpAppCacheToken concurrently for all appIds, await the resulting promises
together, and count only successful bumps while preserving the existing bumped
total behavior.
In `@supabase/functions/_backend/triggers/cache_invalidate.ts`:
- Around line 17-19: Replace the duplicated FANOUT_CHUNK_SIZE literal in the
cache invalidation trigger with an import of the shared MAX_INVALIDATE_APPS
constant from private/cache_invalidate.ts, and use that imported symbol wherever
the fanout chunk size is needed. Remove the comment-only synchronization
contract and ensure the existing fanout behavior remains unchanged.
- Around line 48-52: Define PLUGIN_INVALIDATE_URLS and CACHE_INVALIDATE_SECRET
in cloudflare_workers/api/wrangler.jsonc using the project’s expected
configuration or secret mechanism, so cache invalidation can run on the API
worker and the checks in the cache invalidation trigger no longer silently skip.
In `@supabase/migrations/20260711100000_updates_cache_invalidation.sql`:
- Around line 1-163: Add Postgres-level integration tests for the migration’s
functions notify_updates_cache_invalidation, invalidate_updates_cache, and
invalidate_updates_cache_manifest, exercising deduplication, chunking/capping,
table-operation branching, and affected app resolution. Verify each trigger on
channels, channel_devices, apps, app_versions, manifest, orgs, and stripe_info
invokes invalidation with the correct app IDs, including statement-level
manifest behavior; use real database SQL rather than the mocked pg.ts unit test.
- Around line 12-51: Lock down the SECURITY DEFINER function
public.notify_updates_cache_invalidation by revoking EXECUTE from PUBLIC and
granting EXECUTE only to the intended internal role(s). Add the privilege
statements after the function definition, preserving access for trusted callers
while preventing arbitrary RPC invocation and pg_net fan-out.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: debd1354-1a2e-4485-bab5-079aa365883b
📒 Files selected for processing (12)
cloudflare_workers/api/index.tscloudflare_workers/plugin/index.tscloudflare_workers/plugin/wrangler.jsoncsupabase/functions/_backend/private/cache_invalidate.tssupabase/functions/_backend/triggers/cache_invalidate.tssupabase/functions/_backend/utils/cloudflare.tssupabase/functions/_backend/utils/pg.tssupabase/functions/_backend/utils/update.tssupabase/functions/_backend/utils/updates_colo_cache.tssupabase/functions/triggers/index.tssupabase/migrations/20260711100000_updates_cache_invalidation.sqltests/updates-colo-cache.unit.test.ts
🔗 Linked repositories identified
CodeRabbit considers these linked repositories for cross-repo context during reviews:
Cap-go/capacitor-updater(manual)
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fe0102d. Configure here.
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping and reported 3 unresolved findings on the latest commit, including a high-severity cache-skew issue. WcaleNieWolny and Dalanir are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
|
Round 3 fixed: Cursor — owner/channel cache skew (High): token reads are now memoized per request, so one update check always reads a single invalidation generation — a bump landing mid-request can't mix stale and fresh payloads in one response. (The second half of that finding — "old entries aren't deleted, delaying invalidation" — is not accurate: a bump changes the token embedded in every payload key, making old entries unreachable immediately; they expire unread.) Cursor — DB errors cached as missing: Cursor — stripe_info deletes skip invalidation: trigger extended to DELETE with Also from CodeRabbit's round: token bumps parallelized, Unit tests now 22 on the cache module; full sweep 976 green; migration re-applied on local Supabase (re-runnable). 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot did not reach a terminal state within the 8-minute window on the latest push, and this /updates colo-cache plus worldwide invalidation change exceeds the medium-risk approval threshold. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: this /updates colo-cache and worldwide invalidation change exceeds the low-risk approval threshold despite Cursor Bugbot passing cleanly on the latest commit. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver External
|
@coderabbitai full review |
✅ Action performedFull review finished. You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 8 minutes. |
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 13f4829. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 13f4829. This /updates colo-cache change exceeds the low-risk approval threshold; Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver External
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_238a5d39-1062-4f86-b7d3-934e404d9532) |
|
Latest round handled in 490978a: Fixed
Not changing, with reasons
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 490978a. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 490978a. This /updates colo-cache change exceeds the low-risk approval threshold; Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver External
…dation Most apps resolve updates identically for every device on the same (platform, defaultChannel): serve them from the colo cache (Cache API, free) with zero database queries, while per-device work (semver compares, rollout decisions, override lookups) stays per-request. - updates_colo_cache: app-owner + channel resolution cached under a per-app version token, TTL backstop 60s; apps with channel_device_count > 0 keep their per-device override lookup on every request, so a customer gaining a FIRST override reacts within one token bump; rollout manifest cached per version id, non-empty results only - targeted invalidation: DB triggers (channels, channel_devices, apps, app_versions, orgs, stripe_info) call triggers/cache_invalidate via pg_net, which fans one POST out to every regional plugin worker — placement-pinned, so each call lands in the colo whose cache serves that region; end-to-end ~1s from commit, TTL as backstop - /cache_invalidate route on plugin workers guarded by a dedicated shared secret; fanout guarded by the existing API secret - gated by UPDATES_CACHE_MODE (default off) per regional env At 83M requests/day this removes ~95%+ of read queries from the replica fleet; enables shrinking it once hit rates are confirmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review round 1 (Sonar duplication gate + Cursor findings): - share requestChannelOverrideLookup and resolveRolloutChannelDataPostgres between the direct and cached read paths (injectable manifest loader) instead of duplicating them in updates_colo_cache - manifest cache is now keyed under the app token, so a token bump also invalidates cached rollout manifests; new statement-level manifest triggers (INSERT/DELETE via transition tables) bump on manifest-only changes without one pg_net call per row - invalidation payloads are bounded end to end: trigger helper dedupes, caps at 1000 apps and chunks pg_net bodies; the fan-out posts one chunk of <=100 per request; the plugin route rejects oversized lists loudly instead of silently truncating Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
notify_updates_cache_invalidation is SECURITY DEFINER and performs privileged fan-out with get_apikey(): revoke default PUBLIC/anon/ authenticated execute so API roles cannot trigger invalidation storms; grant service_role only. Trigger functions revoked the same way for defense in depth. Triggers switched to CREATE OR REPLACE (repo convention, re-runnable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new tests/updates-cache-invalidation.test.ts against local Supabase: row-trigger wiring on all 6 tables, statement-level manifest triggers with transition tables, notify chunking (150 ids = 100+20), 1000 cap, dedupe, empty-input no-op, privilege lockdown, and a functional channels/apps insert asserting pg_net queue rows carry the app_id - parallelize token bumps in the plugin invalidate route (CodeRabbit) - FANOUT_CHUNK_SIZE now imports MAX_INVALIDATE_APPS instead of mirroring it by comment (CodeRabbit) - provision PLUGIN_INVALIDATE_URLS on the api worker prod env with the real regional plugin domains (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pe deletes Review round 3 (Cursor): - per-request token memo (WeakMap on the request): every cached lookup of one update check reads the same invalidation generation, so a token bump landing mid-request can no longer mix stale owner data with fresh channel data in a single response - cachedGetAppOwner uses the new throwing queryAppOwnerPostgres variant: a transient replica/Hyperdrive failure keeps today's null-owner behavior but is never cached as "unknown app" (which would misclassify the app as on-prem for the whole TTL) - stripe_info trigger now covers DELETE (orphaned-row cleanup bumps the cache too), with TG_OP-safe customer_id resolution Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No reason for a dedicated CACHE_INVALIDATE_SECRET: the plugin /cache_invalidate route now uses middlewareAPISecret (timing-safe, same intercommunication secret already provisioned on every worker) and the fan-out forwards the apisecret header it was authenticated with. One less secret to deploy and rotate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce types - unknown apps (Capgo removed, misconfigured open-source installs) are the eternal hot path: cache the negative owner result for UPDATES_CACHE_NEGATIVE_TTL_SECONDS (default 600s) instead of the 60s payload TTL, so they cost at most one database query per colo per 10 minutes; safe because the apps INSERT trigger bumps the token the moment an app is created - add missing devices.country_code to both generated supabase.types.ts (backend + frontend): main's country_code feature (#2646/#2659) landed without the regen, breaking typecheck on every PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…replica-safe negatives Review blockers: - all invalidation triggers are now STATEMENT-level with transition tables: bulk writes (daily channel_devices cleanup deleting thousands of rows, bundle uploads, org backfills) produce one aggregated, deduplicated notification per statement instead of one pg_net call per row — bounded invalidation traffic even with cache mode off - cache keys use a fixed synthetic origin instead of the inbound request URL: devices hitting alias hosts (*.usecapgo.com, updater.* zones, api.capgo.app/updates) now share one entry per colo with the canonical domain, so the fan-out token bump reaches them too - negative (unknown-app) caching escalates instead of starting long: the first sightings of a missing app use the short payload TTL (a brand-new app may not be on the regional replica yet); only after 15 minutes of consecutive absence — beyond any plausible replication lag — does the 600s negative TTL kick in - oversized-invalidation unit test was passing on auth failure (stale header); it now authenticates properly and asserts the size guard - pg-level tests rewritten for the statement triggers, including a bulk test: 500-row channel_devices delete = exactly one queued notification Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a3e0ef7 moved the changelog window from the action input (from_tag:) to an env var (FROM_TAG:) for the Cloudflare Workers AI generator but left the assertion on the old string, breaking the Tinbase DB test job on every branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- deterministic localeCompare comparator for cache-key param ordering - resolveRolloutChannelDataPostgres takes an options object (8 params over the limit) and queryAppOwnerPostgres loses its redundant block - ALTER FUNCTION ... OWNER TO postgres on both invalidation functions so SECURITY DEFINER privileges stay consistent regardless of the migration role (repo convention) - empty-input notify test now observes total endpoint queue growth inside a transaction instead of a marker filter that could never match - fan-out chunk test asserts every region receives the complete deduplicated set, not just the global union Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
490978a to
31bee67
Compare
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_79ef7817-3a54-4bfa-8067-6cd671644d6a) |
There was a problem hiding this comment.
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 31bee67. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
There was a problem hiding this comment.
Stale comment
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 31bee67. This /updates colo-cache change exceeds the low-risk approval threshold; Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver External
Main shipped its own (more thorough) fix for the Workers AI changelog assertions; drop the interim branch-side alignment in favor of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_bbdcc9d8-cad9-4552-807f-9c59e86497d9) |
There was a problem hiding this comment.
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 2d73154. This /updates colo-cache change exceeds the low-risk approval threshold; Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver External
There was a problem hiding this comment.
Risk: high. Not approving: Cursor Bugbot finished with status skipping on the latest push (usage limit reached) and has no clean review for commit 2d73154. Dalanir and WcaleNieWolny are already requested for human review.
Sent by Cursor Approval Agent: Pull Request Approver
|






What
Caches the app-level part of every update check in the colo cache (Cache API — free) and adds a dedicated invalidation fan-out that bumps each regional plugin worker directly, so a database change is reflected worldwide in ~1s. Supersedes the read-replica PRs #2640 / #2619 (closed): at 83M requests/day, any per-touch-billed read path (DO duration, KV, D1) loses to a free cache; this keeps drizzle and today's Hyperdrive/replica origin untouched.
How it works
Read path (
update.ts, gated byUPDATES_CACHE_MODE, default off):cachedGetAppOwner+cachedRequestInfosare drop-in replacements for the existing pg functions. The app-level payloads (app owner + plan validation, channel resolution incl. rollout fields, manifest per version) are cached per(app, platform, defaultChannel, flags)under a per-app version token, TTL 60s backstop.channel_device_count > 0keep their per-device override lookup on every request (per-device query params — Hyperdrive can't cache those today either). Customers with one default channel and no overrides get the zero-query fast path; the counter inside the cached payload flips the override path back on.appsINSERT trigger clears them instantly.Invalidation (the "even faster than TTL" part):
Auth: fan-out uses the existing API secret; the plugin route uses a dedicated
CACHE_INVALIDATE_SECRET. Every failure path is soft — the 60s TTL is the backstop, and with the flag off nothing changes at all.Why this beats the replica attempts
Misses still go through Hyperdrive → existing replicas; with ~95%+ of reads served from cache the replica fleet can shrink afterwards.
Rollout
CACHE_INVALIDATE_SECRET+PLUGIN_INVALIDATE_URLS(CSV of the 9 regional plugin domains) tointernal/cloudflare/.env.prod, deploy secrets to api + plugin workers.UPDATES_CACHE_MODE: "on"per regional plugin env, watch hit rate viaupdates cache hitlogs and replica QPS.Tested
tests/updates-colo-cache.unit.test.ts): gating, owner caching incl. negative results, token-bump invalidation, channel caching with per-device override always uncached, override skip at count 0, null-channel caching, per-platform/channel key separation, rollout decided per device on a cached row, route auth (401/503/400) + token bumps, fan-out per region with secret, soft-skip on missing env, URL parsing.🤖 Generated with Claude Code
Note
High Risk
Changes the high-traffic update-check path and correctness depends on cache + worldwide invalidation; misconfiguration or missed fan-out could serve stale channel/version data until TTL, though the flag defaults off and TTL is the backstop.
Overview
Adds an optional colo-local Cache API layer for the
/updateshot path (UPDATES_CACHE_MODE, default off), with ~60s TTL and per-app version tokens so one bump invalidates all cached variants for that app.When enabled,
update.tsusescachedGetAppOwnerandcachedRequestInfosinstead of direct Postgres reads for app-level data (owner/plan, channel resolution, rollout manifests); per-device override lookups and rollout decisions still run every request.pg.tsis refactored to sharerequestChannelOverrideLookup,queryAppOwnerPostgres, and rollout resolution for the cached and uncached paths.Invalidation: a migration adds statement-level triggers on channels, channel_devices, apps, app_versions, manifest, orgs, and stripe_info →
notify_updates_cache_invalidation(pg_net) →triggers/cache_invalidateon the API worker, which fans out POSTs to every URL inPLUGIN_INVALIDATE_URLS→ regional plugin/cache_invalidatebumps tokens in each colo. Prod API wrangler sets the nine regional plugin domains; each plugin env getsUPDATES_CACHE_MODE: "off"until flipped per region.Unit and Postgres integration tests cover cache semantics, auth, chunking, and bulk trigger aggregation.
Reviewed by Cursor Bugbot for commit 2d73154. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
/updates, gated by deployment configuration, with per-app token invalidation and configurable TTLs./cache_invalidateendpoint.PLUGIN_INVALIDATE_URLSandUPDATES_CACHE_MODE.Bug Fixes
Tests