Przejdź do głównej zawartości

Odporność na przeciążenie bazy

Dokument opisuje, co się dzieje, gdy baza danych przez chwilę odmawia obsługi zapytań, oraz jakie mechanizmy chronią panel przed pokazaniem pracownikowi ekranu błędu. Mechanizmy powstały dla Cloudflare D1 (D1 DB is overloaded); na VPS chronią przed tym samym objawem w wydaniu SQLite (SQLITE_BUSY, database is locked).

👤 Instrukcja dla pracownika​

Objaw​

Strona panelu (najczęściej Dashboard ➔ Grafik) zamiast danych pokazuje ekran błędu. W zgłoszeniu wysyłanym przyciskiem „Zgłoś błąd" widnieje komunikat:

SQLITE_BUSY: database is locked

(na Cloudflare brzmiał: D1_ERROR: D1 DB is overloaded. Requests queued for too long.). Oznacza to, że baza danych chwilowo odrzucała zapytania — nie jest to błąd danych ani uprawnień.

Co robić​

  1. Odśwież stronę. Takie przeciążenia trwają zwykle kilkanaście–kilkadziesiąt sekund.
  2. Jeśli po minucie strona nadal się nie ładuje, zgłoś błąd przyciskiem „Zgłoś błąd" — zgłoszenie zawiera godzinę, ścieżkę i identyfikator (digest) potrzebne do analizy.
  3. Nic nie ginie: przeciążenie dotyczy odczytu danych, a operacje zapisu (płatności, zapisy na zajęcia) albo wykonują się w całości, albo zwracają błąd — nie zostają wykonane „w połowie".

🛠 Dokumentacja techniczna​

Skąd bierze się błąd​

SQLite w trybie WAL dopuszcza wielu czytających i jednego piszącego. Gdy zapis trwa dłużej niż busy_timeout (5 s, lib/runtime/sqlite.ts), kolejne zapytanie dostaje SQLITE_BUSY / database is locked. Zapytanie nie zostaje wykonane, więc jego ponowienie jest bezpieczne. Na Cloudflare odpowiednikiem było D1 DB is overloaded. Requests queued for too long.

Incydent z 28.08.2026 (06:35–06:36 UTC, jeszcze na D1) pokazał trzy warstwy problemu:

  1. Render /dashboard/schedule nie miał żadnego ponowienia — pierwszy odrzucony SELECT kończył się ekranem błędu Server Components.
  2. Odczyt sesji (getServerSession) ponawiał próby w oknie ~0,65 s, czyli krócej niż trwało przeciążenie; po trzech próbach zwracał APIError: Failed to get session.
  3. Każdy zalogowany błąd to INSERT do tabeli logs — w trakcie przeciążenia logger dokładał kolejne zapytania do przepełnionej kolejki i sam się wywracał („Failed to save log to database"), przez co incydent nie zapisał się w bazie logów.

Mechanizmy​

withD1Retry (lib/db-retry.ts) — ponawia operację przy błędzie rozpoznanym jako przejściowy (lib/d1-transient.ts: SQLITE_BUSY, SQLITE_LOCKED, database is locked, a z czasów D1 przeciążenie i zapchana kolejka). Opóźnienia: 200/600/1500/3000 ms, każde z jitterem ±50%, czyli maksymalnie 5 prób.

Wspólne okno ponowień (RETRY_BUDGET_MS = 6 s) — wszystkie ponowienia w obrębie jednego renderu czerpią z tego samego budżetu, otwieranego przy pierwszym przejściowym błędzie. Bez tego strona, która czeka kolejno na kilka grup zapytań (/dashboard/schedule ma dwie plus odczyt sesji), mnożyłaby budżet i kazała pracownikowi patrzeć na pustą kartę przez ~16 s, zanim pokaże błąd. Okno jest per-request dzięki React.cache; poza renderem serwerowym (kontener cron) każda operacja dostaje własne okno — tam nikt nie czeka.

Ponowienia logowane są przez logDebug, czyli tylko na konsolę. WARN trafiłby do tabeli logs, czyli zapisem do tej samej bazy, która właśnie odrzuca zapytania.

getRetryingDb() / withReadRetry() — zwraca D1Database opakowaną w Proxy. Statementy, których SQL zaczyna się od SELECT/WITH/PRAGMA/EXPLAIN i nie zawiera słowa kluczowego modyfikującego dane, dostają ponowienie na all(), first(), raw() i run(). Zapisy przechodzą bez zmian — ponowienie INSERT-a lub UPDATE-a mogłoby zdublować operację, więc write kończy się błędem, a decyzję o powtórzeniu podejmuje kod wyższego poziomu.

Moduły korzystające z getRetryingDb() (pełna ścieżka renderowania grafiku): game, court, users-db, holidays, activity-types, app-settings, cash-management. Finanse i płatności używają withD1Retry punktowo.

Odczyt sesji (lib/session.ts) — getServerSession ponawia przy błędzie przejściowym bazy oraz przy APIError 5xx z Better Auth, z opóźnieniami 150/500/1500/3000 ms.

Logger (lib/logger.ts) — gdy zapis logu do bazy padnie z powodu przeciążenia, zapisy do bazy są wstrzymywane na 30 sekund (DB_WRITE_PAUSE_MS). W czasie pauzy:

  • INFO/WARN nie są zapisywane (nadal idą na console, czyli do docker logs kontenera),
  • ERROR jest zapisywany, ale najwyżej raz na 5 sekund (PAUSED_ERROR_WRITE_INTERVAL_MS) — awaria to moment, w którym tabela logs musi przyjmować błędy, a nie moment na ciszę,
  • po wygaśnięciu pauzy pierwszy zapis poprzedzony jest wierszem Log persistence paused while D1 was overloaded z liczbą pominiętych wpisów, żeby luka w logach była widoczna zamiast wyglądać jak „nic się nie działo".

Diagnostyka incydentu​

Logi aplikacji z okna awarii szukaj w trzech miejscach:

  1. tabela logs — na hoście, na kopii bazy (VACUUM INTO z /data/backups albo litestream restore), żeby nie dokładać odczytów produkcji:

    sqlite3 /tmp/kopia.db "SELECT level, context, message, created_at FROM logs WHERE created_at >= '2026-09-11T06:20' ORDER BY created_at DESC LIMIT 50"
  2. docker logs web-<uuid> --since 30m — tu trafia wszystko, także wpisy, których logger nie zdążył zapisać w czasie pauzy,

  3. GlitchTip — każdy logError z tagiem context i wersją (GlitchTip).

Jeżeli tabela logs jest pusta w oknie awarii, to sam objaw przeciążenia — logi z tego okna nie miały jak się zapisać; wtedy zostają punkty 2 i 3.

Czego to nie rozwiązuje​

Ponowienia maskują przeciążenia trwające sekundy — dokładnie tyle, ile mieści wspólne okno 6 s. Nie pomogą, gdy baza jest zablokowana minutami — wtedy trzeba szukać długiej transakcji zapisu (zadanie wsadowe w kontenerze cron, zapytanie pełnoskanowe bez indeksu; EXPLAIN QUERY PLAN dla podejrzanych) albo blokady z zewnątrz (sqlite3 otwarte na żywej bazie w trybie zapisu). Tabela logs ma retencję 90 dni (cron logs-retention).