Przejdź do głównej zawartości

Warstwa cache KV przed bazą D1

Wspólny cache odczytów oparty o Cloudflare KV (APP_CACHE), chroniący bazę D1 przed przeciążeniem (AP-737).

👤 Dla kogo

Dokument ma dwie części. Sekcja dla użytkownika wyjaśnia, co zmiana oznacza w praktyce — dlaczego dane na kilku ekranach mogą być odświeżane z opóźnieniem i jak wymusić świeży odczyt. Sekcja techniczna opisuje helper getCached, kluczowanie, inwalidację i metryki — przeczytaj ją, zanim obejmiesz cachem kolejny odczyt.

🌍 Problem

Incydent z 26.06.2026: strona /dashboard/payments przewracała się z błędem D1_ERROR: D1 DB is overloaded. Too many requests queued. Doraźnie ograniczono liczbę zapytań (AP-677) i dołożono retry (AP-679), ale obie poprawki leczą objaw — przy równoległych wejściach na ciężkie ekrany D1 dalej dostaje ten sam ruch. Systemowym rozwiązaniem jest niewykonywanie tych samych ciężkich odczytów w kółko.

🧭 Dla użytkownika

Co się zmienia

Wybrane ekrany serwują dane z pamięci podręcznej zamiast pytać bazę przy każdym wejściu. Efekt: strony ładują się szybciej, a przy dużym ruchu przestają się wywalać błędem bazy.

Jak świeże są dane

ObszarDane są odświeżane
Finanse (podsumowanie i lista transakcji)co ~45 sekund
Statystyki i analitykaco 10 minut
Listy słownikowe (miasta, ulice, korty, zajęcia)natychmiast po edycji

Listy słownikowe nie czekają na upływ czasu: każda edycja kortu lub typu zajęć od razu unieważnia cache, więc zmiana jest widoczna po odświeżeniu strony.

Co zrobić, gdy dane wyglądają na stare

  1. Odśwież stronę po kilkudziesięciu sekundach — dane finansowe same się przeterminują.
  2. Jeśli chodzi o korty, miasta lub typy zajęć, a zmiana nie jest widoczna — zgłoś to. Tam cache powinien znikać natychmiast po zapisie i brak odświeżenia oznacza błąd.
  3. Nigdy nie jest tak, że jeden użytkownik widzi dane finansowe innego — wpisy w cache są rozdzielone per uprawnienia i per konto.

🔧 Dokumentacja techniczna

Helper

lib/utils/cache.ts:

getCached(namespace, keyParts, ttlSeconds, loader, options?);
invalidateCache(namespace);
  • Klucz: namespace:część1:część2:.... Namespace zawiera tenantId (np. finances:ace-park), dzięki czemu dane tenantów nigdy się nie mieszają.
  • Koperta: w KV leży { data, timestamp, version }. TTL egzekwuje timestamp, nie wygasanie KV.
  • Inwalidacja wersją: invalidateCache(namespace) inkrementuje licznik pod namespace:__ver. Wpisy ze starą wersją są traktowane jak brak wpisu — jedno zapisanie unieważnia całą przestrzeń bez listowania kluczy.
  • Minimalny TTL KV: KV odrzuca expirationTtl poniżej 60 s, więc zapis podnosi tę wartość do 60. Krótkie TTL-e (finanse: 45 s) egzekwuje timestamp w kopercie.

Stale-while-revalidate

options.staleTtlSeconds włącza serwowanie przeterminowanego wpisu:

getCached(ns, ['cities'], 600, loader, { staleTtlSeconds: 3600 });

Po upływie ttlSeconds wpis jest nadal zwracany (do ttlSeconds + staleTtlSeconds), a odświeżenie leci w tle przez ctx.waitUntil. Użytkownik nie czeka na bazę, a D1 dostaje jedno zapytanie zamiast jednego na request. Zasady:

  • Równoległe odczyty tego samego klucza współdzielą jedno odświeżenie (mapa refreshesInFlight).
  • Nieudane odświeżenie nie psuje wpisu — stara wartość jest serwowana do końca okna stale.
  • Zmiana wersji namespace’u wyłącza serwowanie stale: po edycji dane są ładowane od nowa.
  • Okno stale dokłada się do TTL. Maksymalny wiek danych to ttlSeconds + staleTtlSeconds, więc dobierając okno pamiętaj, że zmieniasz obietnicę świeżości złożoną na tym ekranie. Statystyki mają z tego powodu tylko 2 min stale przy 10 min TTL, a nie 10 + 10.

Co jest objęte cachem

MiejsceNamespaceTTLStaleInwalidacja
lib/actions/court.ts — korty, miasta, ulice, nawierzchniecourts:{tenant}600 s1 hmutacje kortów
lib/actions/activity-types.ts — typy zajęćactivity-types600 s1 hmutacje typów zajęć
lib/actions/finances.ts — transakcje i podsumowanie finansowefinances:{tenant}45 s15 sbrak (krótki TTL)
lib/analytics/cache.ts — statystyki i analitykaanalytics:{tenant}10 min / 24 h2 mininvalidateAnalyticsCache
ustawienia aplikacji, rezerwacji, święta, instruktorzywłasne, per tenantmutacje ustawień

Dane finansowe świadomie żyją na krótkim TTL bez inwalidacji — wpięcie unieważniania we wszystkie ścieżki zapisu płatności byłoby szersze niż zysk, a 45 s mieści się w tolerancji kasy.

Kluczowanie danych wrażliwych

Odczyty finansowe zależą od uprawnień, konta i języka, więc klucz zawiera:

  • financeScopeKey(permissions)staff dla ADMIN/BACKOFFICE, user:{email} dla pozostałych. Personel widzi wszystko i współdzieli wpis; klient dostaje własny, bo zapytanie jest zawężone do jego player_id.
  • locale — opisy transakcji sklepowych są tłumaczone w loaderze, więc pl i en nie mogą trafić do jednego wpisu.
  • financeFilterKey(filters) — wszystkie filtry, z posortowanymi tablicami (['cash','card'] i ['card','cash'] to ten sam klucz). Funkcja jest typowana jako Record<keyof FinanceFilters, string>, więc dołożenie filtra do interfejsu bez dopisania go do klucza nie kompiluje się — inaczej dwa różne zestawy wyników po cichu dzieliłyby jeden wpis.
  • limit i offset.

Zasada: dane per-user wolno cachować tylko z użytkownikiem w kluczu. Jeśli nowy odczyt zależy od sesji, dołóż jego zakres do keyParts.

Metryki hit/miss

lib/utils/cache-metrics.ts liczy hit, stale, miss i bypass per namespace i wypisuje je do logów workera:

[cache] miss finances:ace-park:transactions:pl:staff:... hit=42 stale=3 miss=7 bypass=0 hitRate=0.87

Dwie świadome decyzje:

  • Logowanie idzie na console, nie przez lib/logger. Logger zapisuje wpisy od poziomu INFO do tabeli logs w D1 — metryka pisana loggerem oznaczałaby jeden INSERT na każdy odczyt z cache’u, czyli obciążenie bazy, przed którą ten cache stoi. Z tego samego powodu withAnalyticsCache przestało logować per odczyt.
  • Trafienia nie są logowane. Logowane są tylko miss, stale i bypass, czyli te odczyty, które faktycznie idą do bazy — objętość logów śledzi obciążenie D1, a nie ruch. Bieżący hit rate i tak jest w każdej linii dzięki licznikom.

getCacheMetrics() zwraca migawkę liczników (per izolat), resetCacheMetrics() czyści je w testach.

Jak objąć cachem kolejny odczyt

  1. Ustal namespace per tenant: const ns = (tenantId: string) => `moduł:${tenantId}` .
  2. Owiń loader w getCached — do keyParts trafiają wszystkie parametry zapytania i zakres uprawnień, jeśli odczyt zależy od sesji.
  3. Dobierz TTL: dane słownikowe 600 s + stale, agregacje 30–60 s.
  4. Jeśli dane mają własne ścieżki zapisu — wywołaj invalidateCache(ns(tenantId)) w każdej z nich.
  5. Sprawdź hit rate w logach workera po wdrożeniu.

Ograniczenia

  • Liczniki metryk żyją w izolacie i znikają wraz z nim — służą do oceny hit rate w oknie życia izolatu, nie jako szereg czasowy.
  • activity-types nie jest kluczowane per tenant, bo zapytania do activity_types też nie filtrują po tenant_id. Jeśli tabela dostanie kolumnę tenanta, namespace trzeba zmienić razem z zapytaniami.
  • KV jest spójne ostatecznie (eventually consistent) między regionami: po inwalidacji pojedynczy odczyt z odległego regionu może przez chwilę dostać starą wersję licznika.