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

  1. Retry the write after a short backoff — the error is explicitly designed as retryable ('please retry shortly')
  2. Serialize portfolio writes through a single process/thread or an application-level lock so BEGIN IMMEDIATE contention cannot occur
  3. 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
  4. 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

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


AI-assisted analysis of ZhuLinsen/daily_stock_analysis@5159bd72e8 (2026-08-15). Data as JSON: /api/errors/34a40948d90b4f47. Report an issue: GitHub.