feat: DB-first architecture with lock-safe writes

- price_monitor: writes live prices to both JSON and mofin.db (holdings + live_prices + portfolio_summary)
- mofin_db: added execute_with_retry/commit_with_retry with exponential backoff on 'database is locked'
- mofin_db: increased timeout 5s->15s, added PRAGMA busy_timeout=15000
- price_monitor retry loop: fixed break-before-if-ok bug (was not retrying on write failure)
- DB connection: WAL mode + retry decorator for all write operations
- cash sync: preserves DB authoritative cash (JSON cash not pushed to DB)

This is the DB-first version. JSON writes remain for dashboard compatibility.
Next step: remove JSON writes entirely for full DB-only architecture.
This commit is contained in:
知微
2026-07-06 12:02:11 +08:00
parent 687155487d
commit e185b4e4dc
30 changed files with 1856 additions and 2042 deletions
+83 -5
View File
@@ -15,9 +15,11 @@
import sqlite3
import json
import time
import functools
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, Callable
DATA_DIR = Path(__file__).parent / "data"
DB_PATH = DATA_DIR / "mofin.db"
@@ -27,15 +29,87 @@ DB_PATH = DATA_DIR / "mofin.db"
# ═══════════════════════════════════════════════════════════
def get_conn() -> sqlite3.Connection:
"""获取数据库连接(WAL 模式,外键约束,Row 工厂,5秒超时防并发锁)"""
"""获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁)"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH), timeout=5)
conn = sqlite3.connect(str(DB_PATH), timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (),
max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor:
"""执行SQL并自动重试(捕获 database is locked"""
last_err = None
for attempt in range(max_retries + 1):
try:
return conn.execute(sql, params)
except sqlite3.OperationalError as e:
if "database is locked" not in str(e) and "cannot commit" not in str(e):
raise # 非锁错误直接抛
last_err = e
if attempt < max_retries:
delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s
time.sleep(delay)
else:
raise sqlite3.OperationalError(
f"DB锁重试{max_retries}次仍失败: {e}"
)
# unreachable -- both paths in loop either return or raise
if last_err:
raise last_err # type: ignore[misc]
def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3,
base_delay: float = 1.0) -> None:
"""提交事务并自动重试"""
last_err = None
for attempt in range(max_retries + 1):
try:
conn.commit()
return
except sqlite3.OperationalError as e:
if "database is locked" not in str(e) and "cannot commit" not in str(e):
raise
last_err = e
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
time.sleep(delay)
else:
raise sqlite3.OperationalError(
f"DB提交重试{max_retries}次仍失败: {e}"
)
raise last_err
def retry_db_write(func: Callable) -> Callable:
"""装饰器:为 DB 写函数自动添加重试"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
max_retries = 3
base_delay = 1.0
last_err = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except sqlite3.OperationalError as e:
if "database is locked" not in str(e) and "cannot commit" not in str(e):
raise
last_err = e
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
time.sleep(delay)
else:
raise sqlite3.OperationalError(
f"DB写重试{max_retries}次仍失败({func.__name__}): {e}"
)
raise last_err
return wrapper
# ═══════════════════════════════════════════════════════════
# 建表(幂等)
# ═══════════════════════════════════════════════════════════
@@ -1058,6 +1132,7 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool
def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
"""批量写入持仓(替代 portfolio.json holdings[]"""
try:
conn.execute("BEGIN IMMEDIATE")
for h in holdings:
currency = str(h.get('currency', 'CNY')).upper()
if currency not in ('CNY', 'HKD'):
@@ -1082,11 +1157,12 @@ def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
except sqlite3.IntegrityError as e:
conn.rollback()
return False, f"币种约束: {e}"
except sqlite3.OperationalError as e:
return False, f"DB锁冲突(重试耗尽): {e}"
def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
"""写入持仓汇总(替代 portfolio.json 顶层)"""
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("""
INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
@@ -1106,6 +1182,8 @@ def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
return True, "汇总已写入"
except sqlite3.IntegrityError as e:
return False, f"约束: {e}"
except sqlite3.OperationalError as e:
return False, f"DB锁冲突: {e}"
def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: