Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)

Trzy niezależne usługi FastAPI komunikujące się przez HTTP/JSON, każda
zna tylko adres warstwy bezpośrednio pod nią:

- presentation (:8000) — strona WWW + formularz
- logic (:8001) — reguły biznesowe, pośrednik
- data (:8002) — wyszukiwanie danych za interfejsem DataProvider

Warstwa danych: czytanie setek plików .xlsx z wykrywaniem nagłówka i
mapowaniem układu kolumn na schemat kanoniczny, z 4-poziomowym cache
(schemat L1, Parquet L2, wyniki zapytań L3, odwrócony indeks L4) i
unieważnianiem po odcisku pliku. Gotowa ścieżka migracji do SQL
(ingest/to_sql.py + SqlDataProvider, przełączane przez DATA_PROVIDER).

Zawiera docker-compose, Makefile, generator danych przykładowych.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:35:09 +02:00
parent a43d1ff955
commit 16d35c16dc
52 changed files with 1491 additions and 1 deletions
View File
+22
View File
@@ -0,0 +1,22 @@
"""Odcisk pliku = klucz unieważniania cache.
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
"""
from __future__ import annotations
import hashlib
import os
def fingerprint(path: str, strong: bool = False) -> str:
if strong:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()[:16]
st = os.stat(path)
return f"mt:{int(st.st_mtime)}:{st.st_size}"
+35
View File
@@ -0,0 +1,35 @@
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
(dziesiątkisetki ms na duży plik). Po pierwszym wczytaniu zapisujemy
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
pliku. Kolejne odczyty ładują Parquet — zwykle 10100x szybciej niż .xlsx i bez
ponownego wykrywania nagłówka.
Plik Parquet jest też naturalnym formatem pośrednim przy migracji do SQL.
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
class FrameCache:
def __init__(self, cache_dir: Path) -> None:
self.dir = cache_dir / "frames"
self.dir.mkdir(parents=True, exist_ok=True)
def _path(self, fp: str, sheet: str) -> Path:
safe = fp.replace(":", "_") + "__" + str(sheet).replace("/", "_")
return self.dir / f"{safe}.parquet"
def get(self, fp: str, sheet: str) -> pd.DataFrame | None:
p = self._path(fp, sheet)
if p.exists():
return pd.read_parquet(p)
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
frame.to_parquet(self._path(fp, sheet), index=False)
+68
View File
@@ -0,0 +1,68 @@
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
class InvertedIndex:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
key TEXT, -- kanoniczne pole, np. 'name'
value TEXT, -- znormalizowana (lower) wartość
file TEXT, -- ścieżka pliku
sheet TEXT
)
"""
)
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
self._db.execute(
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
)
self._db.commit()
def file_fingerprint(self, file: str) -> str | None:
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
row = cur.fetchone()
return row[0] if row else None
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
"""rows: lista (key, value, sheet) dla jednego pliku."""
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
self._db.executemany(
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
)
self._db.execute(
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
)
self._db.commit()
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
if exact:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
(key, value.lower()),
)
else:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
(key, f"%{value.lower()}%"),
)
return [(r[0], r[1]) for r in cur.fetchall()]
def count_files(self) -> int:
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
+39
View File
@@ -0,0 +1,39 @@
"""Cache wyników zapytań: in-memory LRU + TTL.
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
między instancjami.
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any
class QueryCache:
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
self.max_size = max_size
self.ttl = ttl
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
def get(self, key: str) -> Any | None:
item = self._store.get(key)
if item is None:
return None
ts, value = item
if time.time() - ts > self.ttl:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def put(self, key: str, value: Any) -> None:
self._store[key] = (time.time(), value)
self._store.move_to_end(key)
while len(self._store) > self.max_size:
self._store.popitem(last=False)
def clear(self) -> None:
self._store.clear()
+45
View File
@@ -0,0 +1,45 @@
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
pomijają skanowanie.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
class SchemaCache:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_cache (
fingerprint TEXT,
sheet TEXT,
header_row INTEGER,
mapping TEXT,
PRIMARY KEY (fingerprint, sheet)
)
"""
)
self._db.commit()
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
cur = self._db.execute(
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
(fp, sheet),
)
row = cur.fetchone()
if row is None:
return None
return int(row[0]), json.loads(row[1])
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
self._db.execute(
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
)
self._db.commit()