Skip to main content

Warstwa cache przed bazą

Wspólny cache odczytów (APP_CACHE, API w kształcie KV nad plikiem cache.db), chroniący bazę przed powtarzaniem ciężkich odczytów (AP-737). Powstał na Cloudflare KV; po migracji na VPS ten sam kod działa nad shimem lib/runtime/kv-sqlite.ts.

👤 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 (jeszcze na Cloudflare D1): strona /dashboard/payments przewracała się z błędem 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. Na SQLite problem ma inną postać — każde zapytanie na ścieżce żądania blokuje synchronicznie proces Node — ale rozwiązanie jest to samo: nie wykonywać 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 cache leży { data, timestamp, version }. TTL egzekwuje timestamp, nie wygasanie wpisu.
  • 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 wpisu: zapis ustawia expirationTtl nie mniejsze niż 60 s (ograniczenie odziedziczone po Cloudflare KV, zachowane dla zgodności). Krótkie TTL-e (finanse: 45 s) egzekwuje timestamp w kopercie. Przeterminowane wiersze cache.db usuwa cron kv-expire co 5 minut.

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 runInBackground (after() z next/server). Użytkownik nie czeka na bazę, a baza 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 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) → 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 na stdout procesu (docker logs web-<uuid>):

[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 — 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 bazy, a nie ruch. Bieżący hit rate i tak jest w każdej linii dzięki licznikom.

getCacheMetrics() zwraca migawkę liczników (per proces), 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 kontenera web po wdrożeniu.

Ograniczenia​

  • Liczniki metryk żyją w procesie i znikają przy restarcie — służą do oceny hit rate między wdrożeniami, 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.
  • Cache jest lokalny dla serwera (cache.db na wolumenie), więc inwalidacja jest natychmiastowa i spójna. Kontener cron dzieli ten sam plik, więc zadania wsadowe widzą te same wpisy co żądania.