feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция

SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
Dmitry
2026-09-18 13:43:31 +03:00
parent 220f027652
commit 295438914d
19 changed files with 3406 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
"""Rebuild every metric_* table after data changed (plan §3).
Order (each step is a function taking an AsyncSession and committing its own tables):
fx -> classify -> net worth -> cash flow -> spending -> runway -> data quality
Phase 2 inserts prices/lots/valuation/holdings/returns between fx and net worth.
`refresh_all` is what the worker calls after a sync that reported `changed=True`, what the
CLI `fintracker metrics refresh` runs, and what `POST /metrics/refresh` queues.
Every step replaces its whole table, so two refreshes at once would race (a unique violation
at best, half of one run's rows at worst). A session-level Postgres advisory lock on its own
connection serialises them: a refresh that arrives during a sync WAITS and then runs, because
`POST /rules/apply` must take effect, not be silently dropped.
"""
from __future__ import annotations
import logging
import traceback
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.models import MetricRefreshLog
log = logging.getLogger(__name__)
LOCK_KEY = "metrics:refresh"
Step = Callable[[AsyncSession], Awaitable[None]]
STEPS: list[tuple[str, Step]] = []
"""Filled by analytics modules via `register_step`; order of registration == run order."""
def register_step(name: str, step: Step) -> None:
STEPS.append((name, step))
async def refresh_all(session: AsyncSession, trigger: str) -> MetricRefreshLog:
# imported here (not at module scope) so the analytics import graph stays out of the
# worker/CLI import path until a refresh actually runs; registration is idempotent
from fintracker.analytics import register_steps
from fintracker.db import get_engine
from fintracker.worker.locks import advisory_lock
register_steps()
# the lock lives on its own connection, so it is independent of `session`'s transactions
async with get_engine().connect() as lock_conn, advisory_lock(lock_conn, LOCK_KEY):
entry = MetricRefreshLog(trigger=trigger)
session.add(entry)
await session.commit()
try:
for name, step in STEPS:
log.info("metrics: %s", name)
await step(session)
await session.commit()
except Exception as exc:
await session.rollback()
entry.error = f"{type(exc).__name__}: {exc}\n{traceback.format_exc()[-4000:]}"
log.exception("metrics refresh failed at step")
entry.finished_at = datetime.now(UTC)
await session.commit()
return entry