Files
SQLmem/project.md
T

15 KiB

SQLmem — Project Specification

Cíl

Python modul fungující jako transparentní cache vrstva mezi SQLAlchemy a databází. Aplikace volá SQLAlchemy stejně jako dosud — SQLmem sedí mezi nimi, zachytává SELECT dotazy a vrací výsledky z in-memory SQLite cache. Zápisy (INSERT/UPDATE/DELETE) jsou přepouštěny přímo do databáze bez zásahu.


Architektura

Aplikace (SQLAlchemy)
        │
        ▼
   [ SQLmem Proxy ]
   ┌───────────────────────────────┐
   │  SQL Parser                   │  → rozezná SELECT vs. zápis
   │  Cache Manager (SQLite RAM)   │  → drží data v paměti
   │  Query Executor               │  → cache hit / miss logika
   └───────────────────────────────┘
        │
        ▼
   Databáze (přes původní SQLAlchemy engine)

API

Modul implementuje SQLAlchemy event hooks / custom engine wrapper — aplikace nemusí měnit způsob volání ani formát vrácených dat.

from sqlmem import CachingEngine
from sqlalchemy import create_engine

base_engine = create_engine("postgresql://...")
engine = CachingEngine(base_engine)

# Dál se pracuje stejně jako s běžným SQLAlchemy enginem:
with engine.connect() as conn:
    result = conn.execute(text("SELECT * FROM users WHERE status = 'active'"))

CachingEngine vrací objekty kompatibilní s CursorResultfetchall(), fetchone(), keys() atd. fungují stejně.


Cache backend

Dva režimy (volí se CachingEngine(engine, in_memory=...) nebo env SQLMEM_IN_MEMORY):

In-memory (výchozí, in_memory=True)

  • SQLite in-memory jako primární úložiště — veškeré dotazy běží v RAM.
  • Persistence na disk (cache.db) ve třech situacích:
    • Při startu: pokud soubor existuje, načte se do paměti (ATTACH + kopie).
    • Periodicky každou hodinu: snapshot in-memory SQLite se zapíše na disk (backup API).
    • Při vypnutí: finální zápis na disk před ukončením (signal handler + context manager).

Disk-backed (in_memory=False)

  • Dotazy běží přímo nad on-disk souborem cache.dbžádná kopie v RAM, cache může přesáhnout dostupnou paměť.
  • Každý zápis se rovnou ukládá na disk (WAL + synchronous=NORMAL); odpadá hodinový backup thread i načítání do paměti při startu.
  • Při otevření se cache s nesedícím schema_version zahodí a postaví znovu; engine.reset() smaže tabulky a provede VACUUM (soubor neodlinkuje).

Celé tabulky se při cache miss načtou z databáze (v obou režimech).


Komponenty

1. SQL Parser

  • Detekuje typ dotazu (SELECT / zápis); zápisy vyhodí ReadOnlyError.
  • Extrahuje názvy tabulek z FROM a JOIN klauzulí (podpora více tabulek).
  • Mapuje požadované sloupce na tabulky přes aliasy (columns_by_table).
  • Detekuje SELECT * a alias.* → tabulka se načte celá (wildcard_tables).
  • Parsuje přes dialekt SQLMEM_SQL_DIALECT (default tsql) a renderuje in-memory dotaz do SQLite (stripuje catalog/schema prefixy).
  • Parametry (? / :name) předává beze změny do in-memory SQLite.

2. Column Registry

Modul se za běhu učí, jaké sloupce z každé tabulky aplikace potřebuje — nevyžaduje žádnou předem danou konfiguraci.

Logika při každém příchozím dotazu:

  1. Parser extrahuje (tabulka, sloupce) pro každou tabulku v dotazu (i přes JOIN).
  2. Registry provede union nově požadovaných sloupců s již známými.
  3. Cache Manager zkontroluje, zda cache pro danou tabulku obsahuje všechny potřebné sloupce:
    • Ano → dotaz jde přímo do SQLite RAM (cache hit).
    • Ne → re-fetch tabulky z DB s rozšířenou sadou sloupců → přepíše cache → dotaz do SQLite RAM.
  4. SELECT * načte celou tabulku a označí ji jako is_full → další dotazy na libovolný sloupec jsou cache hit.

Příklad akumulace sloupců:

Dotaz 1: SELECT A, B FROM T3   → Registry: T3 = {A, B}   → fetch T3(A,B) z DB
Dotaz 2: SELECT A, D FROM T3   → Registry: T3 = {A, B, D} → re-fetch T3(A,B,D) z DB
Dotaz 3: SELECT B FROM T3      → cache hit, žádný DB dotaz
Dotaz 4: SELECT * FROM T3      → full load všech sloupců, tabulka označena is_full
Dotaz 5: SELECT A FROM T3 JOIN T4 ON … → každá tabulka cachována zvlášť, JOIN běží v RAM

Metadata tabulka _sqlmem_columns (uložena v SQLite):

CREATE TABLE _sqlmem_columns (
    table_name  TEXT NOT NULL,
    column_name TEXT NOT NULL,
    PRIMARY KEY (table_name, column_name)
);
  • Při startu se načte z cache.db — Registry ví, co bylo kešováno v minulé session.
  • Při každém rozšíření sady sloupců se záznam aktualizuje.

3. Cache Manager

  • Drží in-memory SQLite instanci.
  • Sleduje, které tabulky jsou již načteny.
  • Podporuje TTL (Time-to-Live) pro automatické vypršení cache.
  • Umožňuje manuální invalidaci konkrétní tabulky.
  • Persistence:
    • Při inicializaci načte cache.db ze disku (pokud existuje) do paměti.
    • Spustí background vlákno, které každou hodinu provede sqlite3 backup API (conn.backup(disk_conn)).
    • Zaregistruje atexit handler a SIGTERM handler pro finální zápis při vypnutí.
  • Metadata tabulky (_sqlmem_meta) uložená přímo v SQLite cache:
CREATE TABLE _sqlmem_meta (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);
-- Záznamy:
-- app_version       verze sqlmem balíčku (např. "0.1.0")
-- schema_version    verze schématu cache.db (integer, pro migrace)
-- created_at        ISO timestamp prvního vytvoření cache.db

CREATE TABLE _sqlmem_tables (
    table_name      TEXT PRIMARY KEY,
    last_refresh_at TEXT NOT NULL,  -- ISO 8601 UTC timestamp
    row_count       INTEGER
);
  • last_refresh_at se aktualizuje pokaždé, když se tabulka znovu načte z databáze.
  • Při načtení cache.db ze disku se zkontroluje schema_version — pokud nesedí, cache se zahodí a načte znovu z DB.

3. Query Executor

  • Cache hit: Spustí dotaz přímo v in-memory SQLite, vrátí výsledek.
  • Cache miss: Načte potřebné tabulky z databáze → uloží do cache → spustí dotaz.
  • Zápisy (INSERT / UPDATE / DELETE): Vyhodí výjimku ReadOnlyError a dotaz zablokuje. Modul je striktně read-only.

Rozsah MVP

  • CachingEngine wrapper kompatibilní se SQLAlchemy
  • Načtení cache.db při startu do in-memory SQLite
  • Periodický zápis na disk každou hodinu (background vlákno)
  • Zápis na disk při vypnutí (atexit + SIGTERM)
  • Parser pro detekci tabulek, sloupců a typu dotazu
  • Column Registry s akumulační logikou (union sloupců, wildcard detekce)
  • Re-fetch cache při rozšíření sady sloupců
  • Cache Manager s SQLite in-memory backendem
  • Cache hit/miss logika v Query Executoru
  • TTL podpora na úrovni tabulky
  • Manuální invalidace cache (engine.cache.invalidate("tabulka"))
  • Testy pokrývající cache hit, cache miss a blokování zápisů (ReadOnlyError)

Co modul NEŘEŠÍ (mimo scope)

  • INSERT/UPDATE/DELETE — tyto operace jsou zakázány a vyhodí ReadOnlyError
  • Redis nebo jiný distribuovaný backend
  • Transakční konzistence

Logování

Řízeno přes loguru. Úroveň se nastavuje v .env:

SQLMEM_DEBUG=true   # DEBUG level — podrobný výpis každého dotazu, cache operace, backup
                    # false (výchozí) — INFO a výše
Level Kdy
DEBUG Každý příchozí dotaz, extrahované tabulky/sloupce, cache hit/miss/re-fetch, backup start/konec
INFO Start/stop modulu, načtení cache.db, periodický backup, refresh tabulky
WARNING Re-fetch tabulky kvůli novým sloupcům, blížící se TTL expirace
ERROR ReadOnlyError, UnsupportedQueryError, selhání připojení k DB, selhání zápisu cache.db

Hotové funkce (dříve TODO)

  • Parametrizované dotazy: execute(sql, params) — poziční ? i pojmenované :name.
  • Podpora SELECT * (wildcard): Načte celou tabulku do cache, označí ji jako is_full — další dotazy na libovolný sloupec jsou vždy cache hit bez re-fetch.
  • Podpora JOIN: Parser extrahuje sloupce z každé joinované tabulky zvlášť, Column Registry je sleduje nezávisle. Cache Manager zajistí, že všechny potřebné tabulky jsou v paměti před spuštěním dotazu.
  • Třídílné názvy tabulek: [catalog].[schema].[table] se cachuje pod base name, in-memory dotaz prefix stripuje.
  • Inkrementální (delta) refresh: per-tabulku DeltaConfig(change_column, key_columns) — sync jen změněných řádků přes datový watermark max(change_column) (>= + idempotentní upsert podle klíče), catch-up na startu + background thread (SQLMEM_REFRESH_INTERVAL, default 300 s). PK se auto-zjistí ze zdrojové DB, pro views nutno zadat ručně.
  • engine.reset(): smaže celou cache (RAM + cache.db) pro čistý rebuild po strukturální změně.
  • Sekundární indexy: indexes={"VW_X": ["col", ["a","b"]]} — indexy na in-memory cache pro zrychlení WHERE/JOIN; index-sloupce se auto-dotáhnou, indexy se obnoví po každém (re)loadu.
  • TTL na úrovni tabulky: ttl={"VW_X": 300} — pro tabulky bez timestamp sloupce. Garantuje, že cache není starší než interval (full reload při čtení po expiraci + proaktivně na pozadí).
  • Disk-backed cache: in_memory=False (nebo SQLMEM_IN_MEMORY=false) — dotazy běží přímo nad on-disk cache.db (WAL), bez kopie v RAM; cache může přesáhnout paměť, zápisy se rovnou persistují.
    • V disk módu čtení běží přes per-thread read-only WAL connection → souběžné čtení neblokuje zápisy ani ostatní čtenáře.
  • Chyby refresh/load ve stats: TableStats.last_error / last_error_at / consecutive_failures + Stats.errors. Delta, který selže před streamem, označí tabulku jako error (dřív zůstával ready).
  • last_upsert vs last_refresh: last_upsert = perzistovaný čas posledního zápisu dat (přežije restart); last_refresh = in-memory čas posledního běhu refresh cyklu (liveness — tiká i když cyklus nic nezapsal, None do prvního běhu). Prázdný delta cyklus posune last_refresh, ne last_upsert.
  • Per-engine konfigurace: CachingEngine(..., cache_db_path=, backup_interval=, refresh_interval=, fetch_batch=, dialect=) — každý parametr defaultuje na env/config; dva enginy s vlastními cache soubory v jednom procesu.
  • Neblokující startup catch-up: výchozí chování — startup catch-up (delta/TTL po restartu) běží na pozadí, neblokuje start aplikace. blocking_startup_refresh=True pro synchronní dohnání před servírováním.
  • Quoting identifikátorů: názvy tabulek/sloupců se kvótují (SQLite "x" pro cache, dialekt zdroje — T-SQL [x] — pro source), takže rezervovaná slova i mezery fungují.
  • Lazy source connection: execute() neotevírá spojení ke zdroji při cache hitu (neobsazuje pool slot).
  • Idempotentní add_sink: opakované volání pro stejný sink je no-op (žádné duplicitní logy).
  • Ladění SQLite vrstvy (pragmas=): CachingEngine(..., pragmas={...}) aplikuje libovolné PRAGMA na cache spojení (mmap_size, cache_size, temp_store, page_size, auto_vacuum). page_size a auto_vacuum jsou layout-pragmata — platí jen na čerstvém souboru (page_size na existující cache se ignoruje s warningem). Neznámá pragmata SQLite tiše ignoruje.
  • hard_reset(): smaže on-disk soubor (+ WAL/SHM) a otevře nový s aktuálními pragmaty — na rozdíl od reset() umožní změnit page_size/auto_vacuum. Jen disk mód (v memory módu fallback na reset()).
  • vacuum(incremental=, pages=): údržbový VACUUM cache souboru — inkrementální (uvolní volné stránky po delta INSERT OR REPLACE, vyžaduje auto_vacuum=INCREMENTAL) nebo plný (přepíše soubor, jen v maintenance okně). V memory módu no-op.
  • Nativní INTEGER ukládání datetime sloupců (datetime_columns=): datetime_columns={"VW_X": ["CHANGE_DATE"]} — vyjmenované datetime sloupce se ukládají jako INTEGER µs-od-epochy místo ~28 B ISO TEXT (úspora místa + nativní celočíselné porovnání indexu). Opt-in per sloupec. Breaking: SCHEMA_VERSION 3→4, cache se při upgrade smaže a načte znovu. Watermark se persistuje jako int a _bind_watermark(epoch_us=True) ho rekonstruuje zpět na datetime pro zdroj.
    • Param coercion: WHERE col > ? s ISO/datetime parametrem se zkoercuje na epoch µs (scoped na tabulky dotazu), takže porovnání INTEGER sloupce sedí (dřív vracelo 0 řádků).
    • Read-time coercion: čtení vrací datetime objekt místo raw int (transparentní proxy); opt-out CachingEngine(..., return_datetime=False).
    • Veřejný helper from sqlmem import datetime_to_epoch_us pro konstrukci parametrů bez duplicitní logiky.
  • vacuum(incremental=True) varuje bez auto_vacuum=INCREMENTAL: dřív tichý no-op; teď zaloguje warning (a jak to opravit) a vrátí se.
  • Stats.db_size_bytes: velikost cache souboru na disku (0 v memory módu) ve stats pro monitoring.
  • Ochrana proti cache stampede: load_table dělá double-checked locking — po získání _load_lock znovu ověří, zda tabulku mezitím nenahrál souběžný loader (cached + sloupce + ne-stale), a redundantní reload přeskočí. Bez toho druhý dotaz během studeného loadu velké tabulky spustil druhý plný reload (212M řádků = +2 h).
  • Deklarativní inicializace (tables=[TableSpec(...)]): předem se deklaruje každá tabulka (TableSpec(name, columns, indexes, refresh=Delta(...)|TTL(...), datetime_columns, preload)). preload=True se na pozadí (nebo blokujícně) přednahraje při startu; co je v persistentní cache čerstvé, se přeskočí (warm restart = instant). Nedeklarovaná tabulka/sloupec (i SELECT * na sloupcově omezené tabulce) → UndeclaredError (fail-fast) místo tichého líného loadu; columns=None = celá tabulka + libovolný sloupec. Plně zpětně kompatibilní: bez tables= fungují staré delta=/ttl=/indexes=/datetime_columns= jako dřív (kombinace obojího → ValueError).

TODO — budoucí funkce

  • (zatím žádné otevřené položky)

Technologie

Vrstva Knihovna
SQL parsing sqlglot
Cache úložiště sqlite3 (stdlib)
Integrace SQLAlchemy events / engine wrapper
Logování loguru, python-dotenv
Testování pytest