docs: Sonnet SQLite question + review
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
# Вопрос для Sonnet (без ссылок на файлы)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Мигрировали сервис "Сверка Договоров" с ВМ на Flask (managed Python, Штурвал).
|
||||||
|
Код готов — 22 эндпоинта, вся логика внутри Flask:
|
||||||
|
- upload (XHR + progress), unzip, convert-doc
|
||||||
|
- классификация (LLM, ThreadPoolExecutor внутри Flask-потока для >10 файлов)
|
||||||
|
- группировка
|
||||||
|
- сравнение через SSE с heartbeat
|
||||||
|
- CRUD документов, supplements, промптов
|
||||||
|
- чат с LLM по результатам спецификации
|
||||||
|
|
||||||
|
Архитектура: db/ (чистый CRUD) → services/ (бизнес-логика) → routes/ (Flask blueprints).
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Сейчас БД — PostgreSQL. Но данные ВРЕМЕННЫЕ:
|
||||||
|
- При загрузке страницы cleanup() → DELETE ALL из всех таблиц (кроме prompts)
|
||||||
|
- Юзер загрузил файлы → обработал → закрыл → данные не нужны
|
||||||
|
- Файлы хранятся как base64 в БД (конфиденциально, должны умереть с сессией)
|
||||||
|
|
||||||
|
Поднимать отдельный PostgreSQL-сервис в кластере ради временных данных — overkill.
|
||||||
|
|
||||||
|
## Предложение
|
||||||
|
|
||||||
|
Заменить PostgreSQL на SQLite (stdlib sqlite3):
|
||||||
|
- База — файл /tmp/contracts.db внутри Flask-контейнера
|
||||||
|
- connection.py переписать (~80 строк), db/*.py — мелкие правки (~30 строк)
|
||||||
|
- Остальной код (services, routes) — без изменений
|
||||||
|
- cleanup → os.remove() — атомарно, никаких DELETE, данные гарантированно исчезли
|
||||||
|
- Упал контейнер → файл исчез
|
||||||
|
|
||||||
|
## Детальнее про конкурентность
|
||||||
|
|
||||||
|
В НАШЕЙ архитектуре:
|
||||||
|
1. Classify запускается ВНУТРИ Flask-процесса как daemon-поток (threading.Thread), НЕ отдельный процесс
|
||||||
|
2. ThreadPoolExecutor(max_workers=4) внутри classify — 4 потока шлют запросы к LLM API
|
||||||
|
3. SSE-стриминг — читает БД, один writer (classify), один reader (SSE)
|
||||||
|
4. Обычно classify синхронный (≤10 файлов) — вообще один поток
|
||||||
|
|
||||||
|
То есть: ОДИН процесс Flask, несколько потоков. Несколько процессов нет.
|
||||||
|
|
||||||
|
## Что меняется в коде
|
||||||
|
|
||||||
|
connection.py:
|
||||||
|
- psycopg2.pool → sqlite3 с thread-local соединениями
|
||||||
|
- threading.local() + get_conn() на каждый поток
|
||||||
|
- PRAGMA journal_mode=WAL
|
||||||
|
- check_same_thread=False
|
||||||
|
|
||||||
|
db/*.py:
|
||||||
|
- %s → ? (sqlite-стиль placeholders)
|
||||||
|
- RETURNING * → lastrowid + отдельный SELECT
|
||||||
|
- ::jsonb → json.dumps()
|
||||||
|
- BOOLEAN → INTEGER (0/1)
|
||||||
|
|
||||||
|
Остальное без изменений.
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
|
||||||
|
Есть ли подводные камни с SQLite для этой конкретной архитектуры (один процесс, несколько потоков, WAL, thread-local connections)?
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Sonnet Review: SQLite для Flask-миграции
|
||||||
|
|
||||||
|
**Дата:** 2026-07-15
|
||||||
|
**Вердикт:** Жизнеспособно. Один реальный баг, остальное — конфигурация.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ Критичный баг: inode split-brain
|
||||||
|
|
||||||
|
**Симптом:** `ThreadPoolExecutor` переиспользует потоки. После classify 4 воркера остаются с открытыми соединениями. `cleanup()` делает `os.remove()` → новый `connect()` создаёт новый файл (новый inode). Но старые воркеры продолжают писать в старый inode (Linux не удаляет файл пока открыт fd). Воркеры пишут в призрак, Flask читает из нового пустого файла.
|
||||||
|
|
||||||
|
**Решение:** глобальный ключ сессии в `get_conn()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_local = threading.local()
|
||||||
|
_db_inode = None
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
conn = getattr(_local, 'conn', None)
|
||||||
|
if conn is None or getattr(_local, 'inode', None) != _db_inode:
|
||||||
|
if conn: conn.close()
|
||||||
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
_local.conn = conn
|
||||||
|
_local.inode = _db_inode
|
||||||
|
return conn
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Важные нюансы
|
||||||
|
|
||||||
|
### WAL-сателлиты
|
||||||
|
`os.remove("contracts.db")` не удаляет `.db-wal` и `.db-shm`. SQLite следующего connect'а может применить старый WAL к новому файлу. Удалять все три.
|
||||||
|
|
||||||
|
### Инициализация схемы после cleanup
|
||||||
|
После `os.remove()` → `connect()` создаёт пустую БД. Нужен немедленный `CREATE TABLE IF NOT EXISTS`.
|
||||||
|
|
||||||
|
### busy_timeout
|
||||||
|
4 воркера могут финишировать одновременно → параллельная запись. WAL сериализует, но default timeout = 0 (сразу SQLITE_BUSY). `PRAGMA busy_timeout=5000`.
|
||||||
|
|
||||||
|
### Gunicorn workers = 1
|
||||||
|
Если workers > 1 — несколько процессов поделят один `/tmp/contracts.db`. `cleanup()` одного убьёт файл другого. Зафиксировать `--workers 1`.
|
||||||
|
|
||||||
|
## ✅ Что не проблема
|
||||||
|
|
||||||
|
- WAL + несколько потоков — штатный сценарий, readers не блокируют writers
|
||||||
|
- Большие base64 в SQLite — работает
|
||||||
|
- stdlib sqlite3 — ноль зависимостей
|
||||||
|
- /tmp при перезапуске контейнера — файл исчезает, что и нужно
|
||||||
Reference in New Issue
Block a user