NXT
Runbooks

Invalidate College Cache

Operational runbook

Cache layers and invalidation triggers

CacheTable / componentPer-row keyInvalidation trigger
RFS verdictsrfsVerdicts(userId, unitId, profileVersion)RFS engine logic change, factor copy change, scoring weight change
AI reasoningcollegeReasoning(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-cachescorecard-fos-v1 name + {unitId}FoS fields change, TTL: 24h
ActionCache — IPEDS@convex-dev/action-cacheipeds-v1 name + {unitId}IPEDS mapper change, TTL: 7d
ActionCache — EADA (athletics)@convex-dev/action-cacheathletics-v1 name + {unitId}EADA mapper/classifier change, TTL: 30d
ActionCache — EPA (walkability)@convex-dev/action-cachewalkability-v1 name + {unitId}EPA dataset version change, TTL: 180d
ActionCache — hero image@convex-dev/action-cacheheroImage-v1 name + school nameCampus photo refresh, TTL: 90d
colleges table content hashcolleges._contentHashunitIdField 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) — see capitalizeRfsFactors below as the canonical example
  • A college field used as RFS input changes definition (e.g. admitRate source 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 ReasoningCollegeContext in features/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:scorecardBackfill

colleges 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 dashboard

The 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) return guard skips rows already fixed; re-runs are safe
  • BatchedbatchSize: 200 stays 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).

  1. Bump the cache name version in actions.ts (e.g. "scorecard-v1""scorecard-v2")
  2. Deploy
  3. 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:

  1. Root cause: lib/rfsEngine.ts emitted factor strings with a lowercase first letter before a copy fix in commit 4d81803
  2. What's cached: rfsVerdicts.factors[] — an array of human-readable strings stored at verdict-computation time
  3. Fix approach: migration over rfsVerdicts, field-level patch, idempotent guard
  4. Scope: purely a display fix — no scoring logic changed, no profileVersion bump needed
  5. 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.

On this page