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
| Obszar | Dane są odświeżane |
|---|---|
| Finanse (podsumowanie i lista transakcji) | co ~45 sekund |
| Statystyki i analityka | co 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
- Odśwież stronę po kilkudziesięciu sekundach — dane finansowe same się przeterminują.
- 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.
- 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 zawieratenantId(np.finances:ace-park), dzięki czemu dane tenantów nigdy się nie mieszają. - Koperta: w KV leży
{ data, timestamp, version }. TTL egzekwujetimestamp, nie wygasanie KV. - Inwalidacja wersją:
invalidateCache(namespace)inkrementuje licznik podnamespace:__ver. Wpisy ze starą wersją są traktowane jak brak wpisu — jedno zapisanie unieważnia całą przestrzeń bez listowania kluczy. - Minimalny TTL KV: KV odrzuca
expirationTtlponiżej 60 s, więc zapis podnosi tę wartość do 60. Krótkie TTL-e (finanse: 45 s) egzekwujetimestampw 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
| Miejsce | Namespace | TTL | Stale | Inwalidacja |
|---|---|---|---|---|
lib/actions/court.ts — korty, miasta, ulice, nawierzchnie | courts:{tenant} | 600 s | 1 h | mutacje kortów |
lib/actions/activity-types.ts — typy zajęć | activity-types | 600 s | 1 h | mutacje typów zajęć |
lib/actions/finances.ts — transakcje i podsumowanie finansowe | finances:{tenant} | 45 s | 15 s | brak (krótki TTL) |
lib/analytics/cache.ts — statystyki i analityka | analytics:{tenant} | 10 min / 24 h | 2 min | invalidateAnalyticsCache |
| ustawienia aplikacji, rezerwacji, święta, instruktorzy | własne, per tenant | — | — | mutacje 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)→staffdla ADMIN/BACKOFFICE,user:{email}dla pozostałych. Personel widzi wszystko i współdzieli wpis; klient dostaje własny, bo zapytanie jest zawężone do jegoplayer_id.locale— opisy transakcji sklepowych są tłumaczone w loaderze, więcpliennie mogą trafić do jednego wpisu.financeFilterKey(filters)— wszystkie filtry, z posortowanymi tablicami (['cash','card']i['card','cash']to ten sam klucz). Funkcja jest typowana jakoRecord<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.limitioffset.
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 przezlib/logger. Logger zapisuje wpisy od poziomu INFO do tabelilogsw 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 powoduwithAnalyticsCacheprzestało logować per odczyt. - Trafienia nie są logowane. Logowane są tylko
miss,staleibypass, 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
- Ustal namespace per tenant:
const ns = (tenantId: string) => `moduł:${tenantId}`. - Owiń loader w
getCached— dokeyPartstrafiają wszystkie parametry zapytania i zakres uprawnień, jeśli odczyt zależy od sesji. - Dobierz TTL: dane słownikowe 600 s + stale, agregacje 30–60 s.
- Jeśli dane mają własne ścieżki zapisu — wywołaj
invalidateCache(ns(tenantId))w każdej z nich. - 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-typesnie jest kluczowane per tenant, bo zapytania doactivity_typesteż nie filtrują potenant_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.