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:
+83
-5
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user