walletpress/backend/core/db_pool.py
Rug Munch Media LLC 85d8ef5eac
refactor(routers): split chain_vault.py god router — WP-085..WP-087
Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.

What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
  /webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}

What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
  /import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
  /distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate

Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
  P3-7 fix: removed dead _webhooks global references in test/retry
  endpoints — now uses _PersistentStore.all('webhooks')

Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
  /vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
  (these were inlined in original chain_vault.py body — now in
  the request schemas section)

P3-10 — WP plugin supported_chains() rewritten
  Plugin used to advertise chains the backend doesn't support
  ('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
  to use correct backend keys + added a 'backend' field for clarity.
  Now matches ADDRESS_GENERATION.md truth table.

main.py: updated import + include_router for wallet_admin.router.

Test results: 80 passed, 5 skipped (no regressions).

Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
2026-06-30 21:40:45 +07:00

133 lines
4.1 KiB
Python

"""Shared SQLite connection pool — WAL mode, thread-safe, connection reuse.
Every module that needs SQLite should use this instead of creating
ad-hoc connections. Fixes the "database is locked" errors from
connection-per-call patterns across agent_safety, smart_wallet,
scheduler, and x402 modules.
"""
from __future__ import annotations
import sqlite3
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any
class _PooledConn:
__slots__ = ("conn", "last_used", "in_use")
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self.last_used = time.monotonic()
self.in_use = False
class DbPool:
"""Thread-safe SQLite connection pool with WAL mode and LRU eviction.
Usage:
pool = DbPool("/data/myapp.db", max_size=8)
with pool.connect() as conn:
conn.execute("SELECT 1")
"""
def __init__(self, db_path: Path | str, max_size: int = 8, busy_timeout: int = 5000):
self._path = Path(db_path)
self._max_size = max_size
self._busy_timeout = busy_timeout
self._lock = threading.Lock()
self._pool: OrderedDict[str, _PooledConn] = OrderedDict()
self._path.parent.mkdir(parents=True, exist_ok=True)
def _new_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._path), timeout=self._busy_timeout / 1000, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(f"PRAGMA busy_timeout={self._busy_timeout}")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@property
def path(self) -> Path:
return self._path
def connect(self):
"""Context manager that returns a pooled connection."""
return _DbConnection(self)
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
with self.connect() as conn:
cur = conn.execute(sql, params)
conn.commit()
return cur
def executescript(self, sql: str) -> None:
with self.connect() as conn:
conn.executescript(sql)
def _acquire(self) -> sqlite3.Connection:
with self._lock:
now = time.monotonic()
for key in list(self._pool.keys()):
pc = self._pool[key]
if not pc.in_use:
pc.in_use = True
pc.last_used = now
self._pool.move_to_end(key)
return pc.conn
if len(self._pool) < self._max_size:
key = f"conn_{len(self._pool)}"
conn = self._new_conn()
pc = _PooledConn(conn)
pc.in_use = True
self._pool[key] = pc
return conn
oldest_key = next(iter(self._pool.keys()))
pc = self._pool[oldest_key]
try:
pc.conn.close()
except Exception:
pass
conn = self._new_conn()
pc.conn = conn
pc.in_use = True
pc.last_used = now
self._pool.move_to_end(oldest_key)
return conn
def _release(self, conn: sqlite3.Connection) -> None:
with self._lock:
for pc in self._pool.values():
if pc.conn is conn:
pc.in_use = False
pc.last_used = time.monotonic()
return
def close_all(self) -> None:
with self._lock:
for pc in self._pool.values():
try:
pc.conn.close()
except Exception:
pass
self._pool.clear()
class _DbConnection:
def __init__(self, pool: DbPool):
self._pool = pool
self._conn: sqlite3.Connection | None = None
def __enter__(self) -> sqlite3.Connection:
self._conn = self._pool._acquire()
return self._conn
def __exit__(self, *args: Any) -> None:
if self._conn is not None:
self._pool._release(self._conn)
self._conn = None