Caching ist einfach.
Invalidierung kommt mit.
Jede Cache-Bibliothek gibt dir `get` und `set` und überlässt dir das eigentliche Problem: zu wissen, wann der Wert aufgehört hat zu stimmen. Voltro beobachtet die Datenbank ohnehin auf Änderungen — ein gecachtes Query-Ergebnis kann also in dem Moment verworfen werden, in dem seine Tabelle geschrieben wird, über denselben Mechanismus, der Live-Updates in den Browser schiebt.
// Read-through in one call — key, TTL, and the work on a miss
const plan = await ctx.cache.wrap('plan:' + id, '5m', () => fetchPlan(id))
// A cached QUERY needs no key work at all: it knows its table,
// and the change event that drives live subscriptions drops it
// the moment a write lands — on every replica.Was die Schicht abdeckt.
Standardmäßig an, nichts zu provisionieren
Ein Memory-Cache ist immer da — `ctx.cache` funktioniert also ab der ersten Zeile des ersten Handlers in einem frischen Projekt. Nichts lokal zu installieren, nichts für einen Test hochzufahren und kein Zweig im Code für „Cache nicht konfiguriert".
Redis einwechseln, ohne Call-Sites anzufassen
Zeig die Config auf ein Redis-kompatibles Backend, und jeder bestehende Aufruf läuft weiter — dieselbe Schnittstelle, jetzt über Replicas geteilt. Von einem Container auf eine Flotte zu skalieren ist eine Config-Änderung, kein Refactoring aller Stellen, an denen du etwas gecacht hast.
Query-Ergebnisse invalidieren sich selbst
Eine gecachte Query kennt die Tabelle, die sie liest. Landet ein Schreibvorgang, verwirft dasselbe Change-Event, das schon Live-Subscriptions treibt, die veralteten Einträge — eine Mutation auf einer Replica lässt also keine andere die Liste von gestern ausliefern. Und wo die Änderung kein Tabellen-Write ist, verwirft Server-Code Einträge imperativ: revalidatePath, revalidateTable und revalidateTag erreichen jede Web-Replica, auf jedem Dialekt — auch denen ohne Change-Data-Capture.
Ein Primitiv für das Read-Through-Muster
Das Wrap-Primitiv macht aus „Cache prüfen, bei Miss berechnen, ablegen, zurückgeben" einen Aufruf mit Key und TTL. Das ist die Form, die jeder von Hand schreibt und subtil falsch macht — meist, indem er einen Fehlschlag cacht oder beim Ablauf eine Stampede auslöst.
Inspizieren und leeren ohne Redeploy
`voltro cache status` zeigt, was das Backend tatsächlich tut, `flush` leert ihn, `invalidate` trifft einen Key oder ein Muster. Eine Stale-Data-Meldung zu diagnostizieren beginnt nicht damit, Logging einzubauen und einen Build auszuliefern.
Derselbe Seam in dev und prod
Der Cache, nach dem ein Handler greift, wird von `voltro dev` und `voltro serve` identisch bereitgestellt. Diese Parität ist Absicht — eine frühere Fassung hatte `ctx.cache` in der Entwicklung verdrahtet und in Produktion still als No-op, exakt die Fehlerklasse, gegen die das Framework heute beide Boot-Pfade absichert.
Eine gecachte Seite mit Löchern pro Request
Eine Seite wird über ihren Render-Modus gecacht: static beim Build, isr vorgerendert und dann revalidiert, ssr gar nicht. Eine isr-Seite, die ppr = true exportiert, teilt die Differenz — gecacht wird nur die anonyme Shell, die deferred Felder streamen dahinter auf derselben Response und erreichen den Cache nie. Ein Credential in der Shell-Hälfte zu lesen ist ein benannter Fehler statt still leerer Subject-Daten in einem Artefakt, das alle bekommen; die Revalidierung verwirft die Shell, denn an den Löchern gibt es nichts zu invalidieren.
Warum Invalidierung dem Framework gehört.
Das Framework weiß bereits, wann sich die Daten geändert haben
Caching auf Anwendungsebene ist Raterei, weil die App vorhersagen muss, welche Schreibvorgänge welche Lesevorgänge betreffen — in einer Codebasis, in der beides in Bewegung ist. Voltro muss nicht raten: es konsumiert natives Change-Data-Capture, um Live-Queries zu treiben, die Tatsache „diese Tabelle hat sich gerade geändert" fließt also ohnehin durch die Runtime. Dieselbe Tatsache an den Cache zu hängen macht aus Invalidierung statt einer Disziplin, die du pflegst, eine Eigenschaft des Systems — und der Fehler, den es verhindert (eine Flotte, die aus verschiedenen Replicas verschiedene Antworten gibt), ist der, den Nutzer als „die App spukt" melden.
Invalidierung — der Teil, den alle falsch machen.
const key = `orders:${tenantId}:open` // agreed in two places
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const rows = await db.orders.where({ status: 'open' })
await redis.set(key, JSON.stringify(rows), 'EX', 60)
// …and somewhere else entirely, in the write path:
await redis.del(`orders:${tenantId}:open`)
// ^ spelled again. Move either one and it goes stale.export const openOrders = defineQuery({
name: 'orders.open',
source: 'orders', // ← what it reads is what invalidates it
cache: { ttl: '60s' },
output: Schema.Array(Order),
})
// A write to `orders` drops the entry. There is no key to spell
// twice, so there is no pair of spellings that can disagree.Ein Cache-Key ist eine Absprache zwischen dem Code, der ihn füllt, und dem, der ihn leert — und die beiden driften, sobald einer von ihnen umzieht. Die Invalidierung aus den gelesenen Tabellen abzuleiten schafft die Absprache ab: ein veralteter Read wird unmöglich statt unwahrscheinlich, und es bleibt nichts zu erinnern.
Caching im Detail.
Warum ist Invalidierung der schwere Teil, und wer löst ihn hier?
Weil eine Cache-Bibliothek dir `get` und `set` gibt und dir die eigentliche Frage überlässt: wann hat dieser Wert aufgehört zu stimmen? Sie zu beantworten heißt vorherzusagen, welche Schreibvorgänge welche Lesevorgänge betreffen — in einer Codebasis, in der beides in Bewegung ist — und falsch zu liegen ist unsichtbar, bis ein Nutzer die Daten von gestern sieht.
Voltro muss nicht vorhersagen. Es konsumiert den Change-Feed der Datenbank ohnehin, um Live-Queries zu treiben — die Tatsache „diese Tabelle hat sich gerade geändert“ fließt also durch die Runtime. Sie an den Cache zu hängen macht aus Invalidierung statt einer Disziplin, die du pflegst, eine Eigenschaft des Systems.
Deshalb braucht eine gecachte Query gar kein Key-Management: die Query deklariert die Tabellen, die sie liest, und dasselbe Change-Event, das ein Delta in den Browser schiebt, verwirft die veralteten Einträge — auf jeder Replica, nicht nur auf der, die den Schreibvorgang bearbeitet hat.
Was bekomme ich, ohne etwas zu konfigurieren?
Einen Memory-Cache, der immer da ist. `ctx.cache` funktioniert ab der ersten Zeile des ersten Handlers in einem frischen Projekt — nichts lokal zu installieren, nichts für einen Test hochzufahren und kein Zweig im Code für „Cache nicht konfiguriert“.
Das Read-Through-Muster ist ein Aufruf: ein Key, eine Lebensdauer und die Arbeit für den Miss. Das ist die Form, die jeder von Hand schreibt und subtil falsch macht — meist, indem er einen Fehlschlag cacht oder jeden Request denselben Wert neu berechnen lässt, sobald er abläuft.
Wächst du über einen Prozess hinaus, lässt das Zeigen der Config auf ein Redis-kompatibles Backend jede bestehende Aufrufstelle weiterlaufen. Die Schnittstelle ändert sich nicht — von einem Container auf eine Flotte zu skalieren ist Konfiguration statt eines Refactorings aller Stellen, an denen du gecacht hast.
Wie sehe und steuere ich, was gecacht ist?
`voltro cache status` meldet, was das Backend tatsächlich tut, `flush` leert ihn und `invalidate` trifft einen Key oder ein Muster. Eine Stale-Data-Meldung zu diagnostizieren beginnt nicht damit, Logging einzubauen und einen Build auszuliefern.
Trefferquoten und Cache-Verhalten reiten auf derselben Metrik-Registry wie alles andere — Dashboard und jeder hinzugefügte Exporter lesen also eine Quelle. Zwei Wahrheitsquellen über einen Cache sind der Weg, auf dem ein Team darüber streitet, ob ein Fix gewirkt hat.
Cache-Einträge sind mandantenbewusst, weil die invalidierenden Change-Events den Mandanten tragen. Der Schreibvorgang eines Mandanten kann einem anderen keine veraltete — oder schlimmer: fremde — Liste ausliefern.
Verhält sich der Cache in Entwicklung und Produktion gleich?
Ja, und diese Parität ist Absicht, kein Zufall. Eine frühere Fassung hatte `ctx.cache` in der Entwicklung verdrahtet und in Produktion still als No-op: es funktionierte lokal, tat nichts, wo es zählte, und nichts scheiterte laut genug, um aufzufallen.
Diese Fehlerklasse — eine Fähigkeit, die in einem Boot-Pfad verdrahtet ist und im anderen nicht — hat dieses Framework wiederholt getroffen. Der Cache kommt deshalb heute aus einem gemeinsamen Builder, den beide Pfade rufen, und ein abgeleiteter Check kippt den Build, wenn eine neue Verdrahtung nur einen davon erreicht.
Die Lehre reicht über Caching hinaus: brauchen zwei Pfade dasselbe, extrahier das Ding, statt daran zu denken, es zweimal zu verdrahten. Ein Check, der eine Datei liest, kann einen zweiten Pfad nicht sehen, von dem er nichts weiß.
Was die Schicht übernimmt.
| Aspekt | Wie er behandelt wird |
|---|---|
| Standard-Backend | Memory, immer verfügbar — nichts lokal zu provisionieren und nichts für einen Test hochzufahren. |
| Geteiltes Backend | Ein Redis-kompatibler Store hinter derselben Schnittstelle; eine Config-Änderung, kein Refactoring. |
| Query-Invalidierung | Aus den deklarierten Tabellen abgeleitet und vom Change-Stream getrieben, auf jeder Replica. |
| Read-Through | Ein Aufruf mit Key und TTL — das Muster, das jeder von Hand schreibt und subtil falsch macht. |
| Betrieb | status, flush und gezieltes invalidate aus der CLI, ohne für die Untersuchung einen Build auszuliefern. |
| Dev/Prod-Parität | Ein gemeinsamer Builder für beide Boot-Pfade, mit abgeleitetem Check gegen einseitige Verdrahtung. |
Häufige Fragen
Brauche ich Redis, um den Cache zu nutzen?
Nein. Memory ist der Default und immer verfügbar — genau das macht `ctx.cache` ab dem ersten Handler in einem frischen Projekt nutzbar. Redis zählt, wenn du mehrere Instanzen hast und sie sich einen Cache teilen sollen.
Muss ich gecachte Queries selbst invalidieren?
Nein. Eine Query deklariert die Tabellen, die sie liest, und das Change-Event, das ohnehin Live-Subscriptions treibt, verwirft die veralteten Einträge — auch auf Replicas, die den Schreibvorgang nicht bearbeitet haben. Manuelle Invalidierung bleibt für Werte verfügbar, die keine Query-Ergebnisse sind.
Ist das mit mehreren Mandanten sicher?
Ja. Die invalidierenden Change-Events tragen den Mandanten — der Schreibvorgang eines Mandanten kann also keinen anderen mit einer veralteten oder fremden Liste zurücklassen. Das Scoping wird geerbt statt auf der Cache-Ebene nachgebaut.
Wie debugge ich veraltete Daten?
`voltro cache status` zeigt, was das Backend tut, `invalidate` trifft einen Key oder ein Muster, und Trefferquoten reiten auf derselben Metrik-Registry, die das Dashboard liest. Du untersuchst ein laufendes System, statt Logging einzubauen und neu zu deployen.
Verhält sich der Cache in Produktion gleich?
Ja, und das ist bewacht. Eine frühere Fassung war in der Entwicklung verdrahtet und in Produktion ein stiller No-op — exakt die Fehlerklasse, die das Framework heute verhindert, indem der Cache über einen gemeinsamen Builder beider Boot-Pfade entsteht, mit einem abgeleiteten Check, der scheitert, wenn eine Verdrahtung nur einen erreicht.
Woneben Caching sitzt.
Öffne das Framework. Schau es dir selbst an.
Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.