Angenommen, ein KI-Assistent liefert einen Patch, der einen Werte-Cache durch einen Promise-Cache ersetzt. Die Zusammenfassung verspricht: Gleichzeitige Aufrufer teilen einen Lesevorgang, fehlgeschlagene Lesevorgänge werden für erneute Versuche entfernt, und Invalidierung erzwingt neues Lesen. Der Code ist kurz und vertraut. Bei einer bestimmten Ereignisreihenfolge bricht er trotzdem.
Festhalten, was der Cache leisten soll
„Caching verbessern“ überlässt jedes Detail dem Autor. Für dieses Review gelten fünf Regeln:
- Aufrufe für denselben Schlüssel teilen einen laufenden Lesevorgang.
- Ein erfolgreiches Ergebnis bleibt bis zur Invalidierung im Cache.
- Ein fehlgeschlagener Lesevorgang bleibt nicht hängen; der nächste Aufruf darf erneut versuchen.
- Invalidierung entfernt den aktuellen Eintrag, damit der nächste Aufruf neu liest.
- Ein alter, bereits entfernter Lesevorgang darf beim Abschluss keinen neueren Eintrag entfernen oder ersetzen.
Regel 4 betrifft nur spätere Zugriffe. Sie bricht weder laufende Lesevorgänge ab noch nimmt sie Werte zurück, auf die ein Aufrufer wartet. Muss jeder Aufrufer die neuesten Daten sehen, reicht dieser Cache selbst ohne Fehler nicht aus.
Der Cache-Schlüssel muss außerdem alles enthalten, was das Ergebnis verändert. Für lokale Dateien reicht vielleicht ein Pfad; ein HTTP-Cache braucht eventuell Benutzer, Rechte, Anfrage oder Revision.
Der Patch
Wenn Sie erst nach await read(key) den Wert cachen, verfehlen zwei gleichzeitige Aufrufe beide den Cache und lesen doppelt. Das Speichern des laufenden Promise löst dies. Mit Fehlerbereinigung entsteht etwa folgender Code:
function load(key) {
if (entries.has(key)) return entries.get(key);
const pending = Promise.resolve().then(() => read(key));
entries.set(key, pending);
void pending.catch(() => {
entries.delete(key);
});
return pending;
}
function invalidate(key) {
entries.delete(key);
}
Das Promise wird vor Beginn des Lesens gespeichert, sodass ein zweiter Aufrufer es teilen kann. Schlägt das Lesen fehl, löscht der Handler den Schlüssel. Er löscht aber den gerade dort befindlichen Eintrag – nicht unbedingt das fehlgeschlagene Promise.
Beachten Sie, dass invalidate im Patch unverändert blieb. Solchen Code übersieht man beim Review leicht; ohne ihn wirkt die neue Bereinigung gewöhnlich. Erst beim gemeinsamen Lesen beider Funktionen wird der Fehler sichtbar.
Die fehlerauslösende Reihenfolge
Nennen wir zwei Lesevorgänge desselben Schlüssels A und B:
| Schritt | Ereignis | Cache-Eintrag |
|---|---|---|
| 1 | Erster load(key) startet A |
Promise A |
| 2 | invalidate(key) läuft |
fehlt |
| 3 | Zweiter load(key) startet B |
Promise B |
| 4 | A scheitert; seine Bereinigung löscht key |
fehlt |
| 5 | Dritter load(key) läuft, während B wartet |
Promise C; doppelter Lesevorgang |
In Schritt 4 löscht der fehlgeschlagene Lesevorgang den inzwischen B gehörenden Eintrag. Hier ist der sichtbare Schaden zusätzliche Arbeit: B und C lesen gleichzeitig denselben Schlüssel. Nennen Sie es keine Datenbeschädigung, wenn reale Seiteneffekte nichts beschädigen können.
Die Tabelle zeigt den genauen Auslöser: Invalidierung während eines laufenden Lesens, ein neuer Zugriff, dann Fehler des alten Lesens. Ein Test, der erst alles abschließt und danach invalidiert und erneut lädt, trifft ihn nie.
Die Korrektur: Eigentümerschaft des Eintrags prüfen
Prüfen Sie vor dem Löschen, ob im Cache noch das fehlgeschlagene Promise steht:
export function createCache(read) {
const entries = new Map();
return {
load(key) {
if (entries.has(key)) return entries.get(key);
const pending = Promise.resolve().then(() => read(key));
entries.set(key, pending);
void pending.catch(() => {
if (entries.get(key) === pending) entries.delete(key);
});
return pending;
},
invalidate(key) {
entries.delete(key);
},
};
}
Der Identitätsvergleich ist die gesamte Korrektur. Ein vorhandener Schlüssel bedeutet nicht, dass der Eintrag noch Ihnen gehört; eine neue Anfrage kann den Platz übernommen haben. entries.has(key) im Handler wäre weiterhin falsch.
Zwei kleinere Details sind relevant. Promise.resolve().then(() => read(key)) verwandelt einen synchronen Fehler aus read in ein abgelehntes Promise und garantiert, dass der Eintrag vor read gespeichert ist. Ist read immer asynchron, geht das auch anders; entscheiden Sie bewusst über synchrone Fehler und wiedereintretende Aufrufe. Die Bereinigung hängt außerdem an einem separaten .catch(), sodass Aufrufer das ursprüngliche Promise und dessen Ablehnung erhalten. Fehler in einen Ersatzwert umzuwandeln würde die API ändern und ist eine eigene Entscheidung.
Bei noch laufenden Promises testen
Steuern Sie den Abschluss jedes Lesens mit Deferred Promises statt Timern. Dieser Regressionstest ist entscheidend:
const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // Let the injected reader start A.
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // Let the injected reader start B.
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);
a steuert das erste Lesen; das vollständige Beispiel enthält auch b für das zweite und löst es am Ende auf. assert.rejects wird vor der Ablehnung von A angehängt, damit der Test keine eigene unbehandelte Ablehnung erzeugt.
Promise-Identität lässt sich hier vergleichen, zählen Sie aber auch die tatsächlichen Lesevorgänge. Zwei Wrapper-Promises können ein Lesen teilen, und zwei getrennte Lesevorgänge können gleiche Werte liefern. Gefordert ist ein Lesevorgang, nicht ein bestimmtes Promise-Objekt.
Decken Sie danach die benachbarten Zustände ab:
| Test | Zu prüfende Aussage |
|---|---|
| Zwei Aufrufe vor Abschluss | Ein Lesevorgang; beide Aufrufer erhalten den Wert |
| Späterer Aufruf nach Erfolg | Cache-Wert, kein zusätzliches Lesen |
| Fehler, dann neuer Aufruf | Neues Lesen kann erfolgreich sein |
| Alter Fehler nach Invalidierung | Ersatz bleibt im Cache |
| Alter Erfolg nach Invalidierung | Ursprünglicher Aufrufer erhält alte Daten; spätere behalten neue |
| Synchroner Lesefehler | Ablehnung erreicht Aufrufer; erneuter Versuch bleibt möglich |
Die Zeile zum alten Erfolg verdient einen zweiten Blick. Dieser Cache schreibt bei Erfolg nie in die Map, daher kann ein invalidierter Lesevorgang sich beim Auflösen nicht wieder einsetzen. Sein ursprünglicher Aufrufer bekommt dennoch den alten Wert. Prüfen Sie, ob das gewünscht ist.
Einen überprüfbaren Review-Kommentar schreiben
Ein Review-Kommentar könnte lauten:
Der Rejection-Handler löscht nach Schlüssel, ohne zu prüfen, ob noch das fehlgeschlagene Promise im Eintrag steht. Wird A während des Lesens invalidiert, dann B gecacht und danach A abgelehnt, entfernt As Bereinigung B. Der nächste Zugriff startet einen dritten Lesevorgang, während B noch läuft. Das verletzt „ein Lesen pro aktuellem Eintrag“. Vergleiche vor dem Löschen die Promise-Identität; der Test invalidate-then-reject reproduziert den Fehler.
Der Kommentar nennt Stelle, Auslöser, Wirkung und Test. Die Schwere hängt von realen Aufrufern ab: Doppelte lokale Dateizugriffe kosten Leistung, doppelte bezahlte API-Aufrufe kosten Geld. Klären Sie das vor der Priorisierung.
Prüfen Sie auch die Basisrevision. Bereits vorher defekte Stellen gehören in einen separaten Befund und sind nicht der neuen Bereinigung anzulasten. Hat der KI-Patch zusätzlich formatiert oder Abhängigkeiten erhöht, fragen Sie nach dem Grund. Unabhängige Änderungen verdecken die eigentliche Verhaltensänderung.
Für lokale Reviews hält Yenpos Git-Diff-Viewer für Mac den gewählten Commit- oder Branch-Vergleich bereit, während Sie den umgebenden Code untersuchen.
Selbst ausführen
Laden Sie promise-cache.mjs herunter und führen Sie es mit Node.js 22 oder neuer aus:
node --test promise-cache.mjs
Einer der sechs Tests reproduziert den Fehler und besteht, wenn die unsichere Version ihn zeigt. Die übrigen fünf prüfen den korrigierten Cache. Das Beispiel arbeitet nur im Speicher und hat keine Verdrängung, Prozesskoordination oder Abbruchlogik. Bei einem echten Patch prüfen Sie auch diese Aspekte sowie die Aufrufer, die Schlüssel bilden und invalidate aufrufen.
Öffnen Sie für Yenpos AI Reader einen Git-Vergleich und klicken Sie auf „Diesen Vergleich von der KI prüfen lassen“. Prüfen Sie den Anhang und ersetzen Sie den Entwurf durch eine konkrete Anfrage:
Prüfe diese Cache-Änderung. Finde Aufrufer, die laufendes Lesen invalidieren und einen Ersatz starten können. Prüfe, ob Erfolg oder Fehler des alten Lesens den Ersatz beeinflussen. Zitiere relevanten Code, zeige die auslösende Reihenfolge und trenne bestehende Probleme von Regressionen.
Prüfen Sie, ob die Aufrufer diese Reihenfolge auslösen können, und führen Sie den Test im Terminal aus.
Ändert ein Patch auch Tests, prüfen Sie die zusammen mit der Implementierung geänderten Assertions. Ein grünes Ergebnis kann eine Aufruferregression verbergen, wenn eine Fehlerprüfung durch eine schwächere Bedingung ersetzt wurde.