Skip to Content
Documentation

Cache

Change a cache entry

What a VitNode API cache miss is, how to change a cache entry's schema or version safely across deploys, and what c.get("cache").flush() deletes.

Cache entries are disposable. Unlike a queued task, losing one costs only the time to load it again, so changing an entry never needs a migration. This page explains when an entry misses, how to change its schema or version, and how to clear a plugin's entries. The examples use the year-stats entry from Cache API data.

What counts as a miss

get() returns undefined and remember() runs the loader when:

  • nothing is stored under the key, for example after a restart without Redis (a cold start), or after the TTL ran out,
  • there is no cache store, or Redis fails,
  • the stored value is malformed JSON or the schema rejects it. That value is deleted on the way,
  • the entry's version differs from the one that stored it.

A stored null, false, 0 or "" is a hit.

What is stored is the JSON of the value you passed in, before the schema runs. Keys the schema strips are stored too and removed again on every read, so return only the fields you want in Redis.

Change the schema

ChangeWhat to do
Add an optional field, or remove oneNothing. Old values still parse
Anything old values fail, such as a new required fieldNothing is required: a rejected value is a miss and reloads. Bump version to skip the failed parse
Same shape, different meaning, such as counting only published notesBump version. The schema cannot tell old values apart

Bump the version in the definition:

plugins/site-notes/src/api/cache/year-stats.cache.ts
export const yearStatsCache = defineCacheEntry({
  name: "year-stats",
  version: 1, 
  version: 2, 
  // key, schema and ttlSeconds unchanged
});

The version is part of the key, so every entry written with v1 becomes a miss at once. Old keys are never read again; with Redis they stay until their TTL runs out, and without Redis they are gone with the process. During a rolling deploy, instances on the old and the new version write different keys and never read each other's values.

Renaming an entry works the same way: the old keys are orphaned and expire on their TTL.

Check the result

After the version bump, open http://localhost:3000/api/site-notes/notes/stats/2026 and list the plugin's entries in Redis:

redis-cli --scan --pattern 'vitnode:cache-entry:site-notes:year-stats:*'

A new …:v2:[2026] key appears next to the old …:v1:[2026], which is never read again and expires on its TTL.

Keep a list fresh with a generation

Deleting an entry after a write leaves a gap: a reader that loaded the old rows just before the commit can store them right after the delete, and the stale value stays until its TTL. A generation closes that gap. Keep a random token per resource and cache the value under it:

const generations = c.get("cache").entry(articleCorrectionsGenerationCache, articleId);
const generation =
  (await generations.get()) ?? (await generations.setIfMissing(randomUUID()));

const list = await c
  .get("cache")
  .entry(articleCorrectionsCache, articleId, generation)
  .remember(async () => await loadCorrections(articleId));

// after the write commits:
await generations.set(randomUUID());
  • Read the generation before loading. A late reader then fills only the old generation, which nobody reads again.
  • setIfMissing creates the first token atomically, so a reader that started before the first write cannot overwrite the token the writer set.
  • Use random tokens and let the generation entry outlive the cached values (for example 24 hours against 10 minutes). A lost generation then starts a new token instead of bringing an old value back.
  • Move the generation after the transaction commits, never inside it. If the move fails because Redis is unreachable, the old value stays until its TTL; with no cache store at all, every read goes to the database.

Write a complete operation shows this in the newsroom corrections feature.

Clear every entry of a plugin

c.get("cache").flush() deletes every entry of the plugin that owns the current request, plus keys left under vitnode:cache:{plugin id}: by the removed string-keyed API. Other plugins' entries, core's internal data and unrelated Redis keys are left alone, and VitNode never runs FLUSHALL.

The plugin comes from the request (c.get("plugin")), not from an entry's definition. In your own routes that is your plugin. In a cron job, a queue task, or a listener of another plugin's event it is the plugin that runs it, often @vitnode/core, and with no plugin on the context flush() does nothing. To expire your own data from there, delete the entries you know: c.get("cache").entry(yearStatsCache, year).delete().