Invalidate College Cache
Operational runbook
Cache layers and invalidation triggers
| Cache | Table / component | Per-row key | Invalidation trigger |
|---|---|---|---|
| RFS verdicts | rfsVerdicts | (userId, unitId, profileVersion) | RFS engine logic change, factor copy change, scoring weight change |
| AI reasoning | collegeReasoning | (userId, unitId, profileVersion, categoryVersion, contentVersion) | OpenAI prompt change, buildReasoningPrompt change, college data mapper change, primaryCategory flip, contentVersion hash-input change |
| ActionCache — Scorecard | @convex-dev/action-cache (internal component) | scorecard-v1 name + {unitId} | Field list change in INSTITUTION_FIELDS, mapper logic change, TTL: 24h |
| ActionCache — FoS | @convex-dev/action-cache | scorecard-fos-v1 name + {unitId} | FoS fields change, TTL: 24h |
| ActionCache — IPEDS | @convex-dev/action-cache | ipeds-v1 name + {unitId} | IPEDS mapper change, TTL: 7d |
| ActionCache — EADA (athletics) | @convex-dev/action-cache | athletics-v1 name + {unitId} | EADA mapper/classifier change, TTL: 30d |
| ActionCache — EPA (walkability) | @convex-dev/action-cache | walkability-v1 name + {unitId} | EPA dataset version change, TTL: 180d |
| ActionCache — hero image | @convex-dev/action-cache | heroImage-v1 name + school name | Campus photo refresh, TTL: 90d |
colleges table content hash | colleges._contentHash | unitId | Field added/removed from mapInstitutionDoc return object |
When to invalidate
RFS verdicts (rfsVerdicts)
Invalidate when any of these change:
packages/backend/convex/lib/rfsEngine.ts— scoring algorithm, weight constants, admit-rate bucketing- Factor string copy (the human-readable factor lines stored in
rfsVerdicts.factors) — seecapitalizeRfsFactorsbelow as the canonical example - A college field used as RFS input changes definition (e.g.
admitRatesource changes)
Verdicts are keyed on profileVersion — a profileVersion bump on the user's profile row causes the verdict to be treated as stale on next read, triggering re-derivation. Bumping profileVersion is the correct approach for user-side changes; a migration is required for system-side RFS logic changes.
AI reasoning (collegeReasoning)
Invalidate when any of these change:
packages/backend/convex/lib/openai.ts:buildReasoningPrompt— prompt template, field inclusion, tone changes- College fields fed into
ReasoningCollegeContextinfeatures/colleges/actions.ts:generateUserReasoning - OpenAI model version bump
Same profileVersion mechanism applies — bump profileVersion to force re-generation on next detail-screen open.
ActionCache entries (Scorecard / EADA / EPA / IPEDS)
The ActionCache is a Convex component (@convex-dev/action-cache). Each cache is named (e.g. "scorecard-v1"). To force full invalidation, increment the version suffix in the name and deploy:
// packages/backend/convex/features/colleges/actions.ts
const scorecardCache = new ActionCache(components.actionCache, {
action: internal.features.colleges.actions.scorecardFetchRaw,
name: "scorecard-v2", // bumped from v1
ttl: 24 * 60 * 60 * 1000,
});After deploying the name change, all subsequent fetches miss the cache and pull fresh data from the upstream API. The old entries under scorecard-v1 are orphaned (they expire naturally; there is no bulk-delete API for ActionCache).
After a cache-name bump, run the corresponding backfill to re-populate proactively rather than waiting for on-demand cache misses:
npx convex run features/colleges/actions:scorecardBackfillcolleges table (content hash)
The upsert mutation in features/colleges/internal.ts uses a contentHash() function (djb2-style hash over stable-sorted JSON) to skip writes when the doc is unchanged. When you add or remove a field from mapInstitutionDoc's return object, the hash changes for every school — the next Scorecard backfill will re-write all rows.
No manual invalidation is needed here. Just run the Scorecard backfill after the mapper change ships.
How to invalidate
Pattern A: profileVersion bump (user-scoped caches)
Use when: RFS engine change, prompt change, or any change that should force all users to get fresh verdicts / reasoning on next open.
Write a migration in packages/backend/convex/migrations.ts following the bumpProfileVersionForRfsFinancial pattern:
export const bumpProfileVersionForYourChange = migrations.define({
table: "profiles",
batchSize: 50,
migrateOne: async (ctx, doc) => {
const row = doc as { _id: Id<"profiles">; profileVersion?: number };
const next = (row.profileVersion ?? 0) + 1;
await ctx.db.patch("profiles", row._id, {
profileVersion: next,
updatedAt: Date.now(),
});
},
});Run:
npx convex run migrations:run '{"fn":"migrations:bumpProfileVersionForYourChange"}'After the migration, every user's profileVersion is incremented. On the next detail-screen open, generateUserReasoning and the RFS forCollege query will see a version mismatch and re-derive. No rows are deleted; stale rows are overwritten on next access.
Pattern B: direct table wipe (nuclear, use sparingly)
Use when: you need to force re-derivation of all RFS verdicts or all AI reasoning immediately, not lazily on next access.
# Wipe all RFS verdicts (will be re-derived on next per-user detail open)
# No dedicated action yet — add one following the wipeAll pattern in internal.ts
# or delete directly via Convex dashboard → Tables → rfsVerdicts → Clear table
# Wipe all college reasoning
# Same — add an internalMutation or use the dashboardThe wipeAll internalMutation in features/colleges/internal.ts is the reference implementation for bulk table wipes. It takes ≤20,000 rows in one pass — for larger tables add cursor-based pagination.
Pattern C: field copy fix via migration (canonical example)
The capitalizeRfsFactors migration in packages/backend/convex/migrations.ts is the canonical example of a targeted field-level cache fix:
export const capitalizeRfsFactors = migrations.define({
table: "rfsVerdicts",
batchSize: 200,
migrateOne: async (ctx, doc) => {
const factors = (doc as { factors?: string[] }).factors;
if (!Array.isArray(factors) || factors.length === 0) return;
let touched = false;
const next = factors.map((f) => {
if (typeof f !== "string" || f.length === 0) return f;
const first = f.charAt(0);
if (first >= "a" && first <= "z") {
touched = true;
return first.toUpperCase() + f.slice(1);
}
return f;
});
if (!touched) return;
await ctx.db.patch("rfsVerdicts", doc._id, { factors: next });
},
});Run on each environment:
# Dev
npx convex run migrations:run '{"fn":"migrations:capitalizeRfsFactors"}'
# Production
npx convex run --prod migrations:run '{"fn":"migrations:capitalizeRfsFactors"}'Key properties of this pattern:
- Idempotent — the
if (!touched) returnguard skips rows already fixed; re-runs are safe - Batched —
batchSize: 200stays well within Convex's per-mutation row budget - Scoped — touches only the affected field, not the whole row
- Self-deleting — add a "safe to delete after" comment once confirmed no-op on all envs
Pattern D: ActionCache version bump (upstream API or mapper change)
Use when: you've changed how a field is fetched or mapped from an upstream API (Scorecard, EADA, EPA, IPEDS).
- Bump the cache name version in
actions.ts(e.g."scorecard-v1"→"scorecard-v2") - Deploy
- Run the corresponding backfill action to pre-populate the new cache entries
The old cache entries under the prior version name are orphaned and eventually expire (TTLs range from 24h to 180d depending on the source).
Reference: capitalizeRfsFactors as canonical migration
This migration (committed 2026-05-16) is the clearest recent example of the full invalidation workflow for a stored-cache field fix:
- Root cause:
lib/rfsEngine.tsemitted factor strings with a lowercase first letter before a copy fix in commit 4d81803 - What's cached:
rfsVerdicts.factors[]— an array of human-readable strings stored at verdict-computation time - Fix approach: migration over
rfsVerdicts, field-level patch, idempotent guard - Scope: purely a display fix — no scoring logic changed, no
profileVersionbump needed - Lifecycle: run on dev (confirmed done 2026-05-16) → run on prod after deploy → delete migration in follow-up commit once dry-run reports zero modifications
Use this migration as the template for any future "fix how a cached string field looks" change.