ZhuLinsen/daily_stock_analysis · warning · PortfolioBusyError
portfolio_busy
portfolio_busy
Error message
Portfolio ledger is busy; please retry shortly.
What it means
PortfolioBusyError (code portfolio_busy) raised from portfolio_write_session when the SQLite 'BEGIN IMMEDIATE' used to serialize ledger writes cannot acquire the write lock — another connection holds it. It is a transient, retryable contention signal: the code distinguishes SQLite 'database is locked' OperationalErrors from real failures and re-raises others unchanged.
Source
Thrown at src/repositories/portfolio_repo.py:144
if row is None:
return False
row.is_active = False
row.updated_at = datetime.now()
session.commit()
return True
# ------------------------------------------------------------------
# Event writes
# ------------------------------------------------------------------
@contextmanager
def portfolio_write_session(self):
session = self.db.get_session()
try:
session.connection().exec_driver_sql("BEGIN IMMEDIATE")
except OperationalError as exc:
session.close()
if self._is_sqlite_locked_error(exc):
raise PortfolioBusyError("Portfolio ledger is busy; please retry shortly.") from exc
raise
try:
yield session
session.commit()
except OperationalError as exc:
session.rollback()
if self._is_sqlite_locked_error(exc):
raise PortfolioBusyError("Portfolio ledger is busy; please retry shortly.") from exc
raise
except Exception:
session.rollback()
raise
finally:
session.close()
def add_trade(
self,View on GitHub (pinned to 5159bd72e8)
Solutions
- Retry the write after a short backoff — the error is explicitly designed as retryable ('please retry shortly')
- Serialize portfolio writes through a single process/thread or an application-level lock so BEGIN IMMEDIATE contention cannot occur
- Set a nonzero busy_timeout on the SQLite engine (e.g. sqlite3 connect timeout / SQLAlchemy connect_args) so brief lock holders release before the error fires
- Move high-concurrency deployments off a single SQLite file to a client-server database if contention is constant
Example fix
# before
with repo.portfolio_write_session() as s:
repo.add_trade(...)
# after
for attempt in range(3):
try:
with repo.portfolio_write_session() as s:
repo.add_trade(...)
break
except PortfolioBusyError:
time.sleep(0.2 * (attempt + 1))
else:
raise Defensive patterns
Strategy: retry
Type guard
from src.repositories.portfolio_repo import PortfolioBusyError
def is_portfolio_busy(exc: BaseException) -> bool:
return isinstance(exc, PortfolioBusyError) or getattr(exc, "code", None) == "portfolio_busy" Try / catch
from src.repositories.portfolio_repo import PortfolioBusyError
for attempt in range(5):
try:
with repo.portfolio_write_session() as session:
repo.add_trade(account_id=1, trade_uid=uid, ...)
break
except PortfolioBusyError:
if attempt == 4:
raise
time.sleep(0.1 * 2 ** attempt) Prevention
- Give every ledger write a stable trade_uid so retries are idempotent
- Route all portfolio writes through one queue/worker to avoid lock races
- Configure a nonzero SQLite busy_timeout to absorb micro-contention
When it happens
Trigger: Two concurrent add_trade/ledger writes hit the same SQLite database file: the second session's BEGIN IMMEDIATE hits the lock and raises this immediately (no implicit long busy-wait). Typical with the web API (FastAPI thread pool) and a scheduled analysis run writing trades simultaneously.
Common situations: API request racing the scheduler; multiple worker processes on one SQLite file; long-running write transactions elsewhere (manual sqlite3 session, migrations) holding the write lock; SQLite busy_timeout set too low or 0.
Related errors
- history deletion made no progress
- 题材新闻搜索并发已满,请稍后重试
- {task_name} ranking fetch timeout after {timeout_value:g}s
- {task_name} ranking fetch cooling down after previous timeou
- Backend executable not found: ${backendPath}
AI-assisted analysis of ZhuLinsen/daily_stock_analysis@5159bd72e8 (2026-08-15).
Data as JSON: /api/errors/34a40948d90b4f47.
Report an issue: GitHub.