"""数据库接口层(云端控制面单机阶段)。 本模块分两层,调用方按需选用: 1. 模块级函数(历史接口,原样保留,见下文):``connect`` / ``is_locked_error`` / ``execute_with_retry`` / ``read_all`` / ``transaction``。server.py 与旧测试仍走这条路,行为不变。 2. ``Database`` 接口 + 两个实现:``SQLiteDatabase``(现有 SQLite 行为的类封装)与 ``PostgresDatabase``(骨架,见下文)。 新代码应走接口 + ``create_database`` 工厂,不要再直接拼 sqlite3 连接。 只解决一件事:ThreadingHTTPServer 多线程 + 单个 SQLite 文件时的写冲突。 不碰任何业务逻辑、不定义新 schema、不改 server.py 的接线: 本模块只提供连接与读写 helper,协调员后续把 server 的 _connect 换成这里的 connect 即可(表名/字段与 server.py 的 init_db、 memory.py 的 summaries 完全一致,因为本模块根本不建表)。 使用约定(必须遵守,否则 helper 帮不了你): - 每个线程用自己的连接:绝不在多线程间共用一个 sqlite3 连接 (sqlite3 默认 check_same_thread=True,共用会直接报错)。 典型用法是每请求/每线程调用一次 connect(),用完 close()。 - 写操作走 execute_with_retry + transaction:一次语句失败重试是 安全的(单条 INSERT/UPDATE 被锁时 SQLite 已回滚该语句); 不要把多条非幂等语句包在一个重试里——重试只针对单条语句。 单机 SQLite 的上限(诚实边界,见模块末尾注释): - WAL + busy_timeout + 有限重试只能缓解单机多线程写冲突, 不能变成多机/高并发数据库:写仍是单写者串行,读多写少尚可, 写密集或跨机器时必须换 Postgres(下一阶段,不在本轮假装解决)。 后端选择(环境变量,见 cloud/README.md 数据库一节): - ``CLOUD_DB_BACKEND``:``sqlite``(默认)或 ``postgres``。 - Postgres 连接参数全部走环境变量,不硬编码地址或密码: ``CLOUD_DB_HOST``、``CLOUD_DB_PORT``、``CLOUD_DB_NAME``、 ``CLOUD_DB_USER``、``CLOUD_DB_PASSWORD``。 - Postgres 骨架的诚实边界:标准库没有 Postgres 驱动, ``PostgresDatabase`` 初始化时找不到 psycopg/psycopg2 会立即 明确报错(``PostgresDriverMissingError``,文案含 psycopg), 绝不静默回落到 SQLite;即使驱动已安装,其连接/执行/查询/ 事务方法目前仍是 ``NotImplementedError`` 骨架,尚未接通真实 Postgres——换生产数据库还需要真实部署、驱动安装与迁移脚本, 不是改一个环境变量就自动完成。 """ import os import sqlite3 import time from abc import ABC, abstractmethod from contextlib import contextmanager # 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待; # PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位 # 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。 DEFAULT_BUSY_TIMEOUT_MS = 5000 DEFAULT_CONNECT_TIMEOUT_S = 30 DEFAULT_MAX_RETRIES = 3 # 指数退避:0.05s、0.1s、0.2s;重试耗尽即抛原始错误,绝不无限循环。 DEFAULT_BACKOFF_SECONDS = (0.05, 0.1, 0.2) _LOCKED_MARKER = "database is locked" # Postgres 连接参数的环境变量名(名字即文档,见 cloud/README.md)。 POSTGRES_ENV_VARS = { "host": "CLOUD_DB_HOST", "port": "CLOUD_DB_PORT", "dbname": "CLOUD_DB_NAME", "user": "CLOUD_DB_USER", "password": "CLOUD_DB_PASSWORD", } BACKEND_ENV_VAR = "CLOUD_DB_BACKEND" DEFAULT_BACKEND = "sqlite" DEFAULT_SQLITE_PATH = "cloud-control-plane.db" def connect(db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS, timeout=DEFAULT_CONNECT_TIMEOUT_S): """返回一个已设好 WAL + busy_timeout + Row 的新连接。 - PRAGMA journal_mode=WAL:读不阻塞写、写不阻塞读,是多线程 server 的前提;该模式随库文件持久化(:memory: 库除外, 内存库的 journal_mode 恒为 memory,属 SQLite 本身行为)。 - PRAGMA busy_timeout:写冲突时语句级等待窗口,单位毫秒; 它是每个连接的属性,不随库文件持久化,所以每次 connect 都设。 - row_factory=sqlite3.Row:与 server.py 各 handler 在 _db() 里 事后设置的 row_factory 一致,行可用 row["id"] 按列名访问。 - 不设 check_same_thread=False:跨线程共用连接是错误用法, 保持默认 True 让误用尽早报错,而不是静默出竞态。 """ conn = sqlite3.connect(str(db_path), timeout=timeout) try: conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=%d" % int(busy_timeout_ms)) conn.row_factory = sqlite3.Row except Exception: conn.close() raise return conn def is_locked_error(exc): """只有 OperationalError 且文案含 database is locked 才算写锁冲突。 其它 OperationalError(如 no such table、语法错误)重试无用, 必须立即抛出,不能被重试掩盖成“锁问题”。 """ return ( isinstance(exc, sqlite3.OperationalError) and _LOCKED_MARKER in str(exc).lower() ) def execute_with_retry(conn, sql, params=(), max_retries=DEFAULT_MAX_RETRIES, backoff_seconds=DEFAULT_BACKOFF_SECONDS): """执行单条写语句,遇写锁冲突时有限重试,返回 cursor。 重试次数 = max_retries(默认 3 次,即最多尝试 1 + 3 = 4 次), 每次重试前按 backoff_seconds 依次 sleep(默认 0.05/0.1/0.2s, 退避表短于重试次数时取最后一项,不越界)。 重试耗尽后把最后一次的原始 OperationalError 原样抛出,不吞掉、 不包装成别的异常类型,调用方看到的仍是 sqlite3.OperationalError。 非锁错误一次都不重试。 """ attempts = 0 while True: try: return conn.execute(sql, params) except sqlite3.OperationalError as exc: if not is_locked_error(exc) or attempts >= max_retries: raise delay = backoff_seconds[ min(attempts, len(backoff_seconds) - 1) ] time.sleep(delay) attempts += 1 def read_all(conn, sql, params=()): """读操作 helper:不重试,只要求连接本身已走 WAL + busy_timeout。 读在 WAL 下不阻塞写,一般不需要重试;如果调用方传的是旧式 连接(非本模块 connect 建的),读结果仍正确,只是没有 WAL/ busy_timeout 保障——这属于调用方误用,本函数不静默替它建连接。 """ return conn.execute(sql, params).fetchall() @contextmanager def transaction(conn): """单连接事务上下文:成功 commit,异常 rollback 后原样抛出。 与 execute_with_retry 配合的典型写法:: conn = connect(db_path) try: with transaction(conn): execute_with_retry(conn, "INSERT INTO tasks(...) ...", (...)) finally: conn.close() 注意:重试发生在语句级(transaction 内部),事务本身不重试; 若事务内某条语句最终抛错,整个事务回滚,不会留下半写状态。 """ try: yield conn conn.commit() except Exception: try: conn.rollback() except Exception: pass raise # ---------------------------------------------------------------------- # Database 接口层 # ---------------------------------------------------------------------- # 方法名按实际代码为准,只覆盖模块级函数已提供的能力,不 invent # 不存在的方法名:connect(连接 + WAL 设置)、execute_with_retry # (执行)、read_all(查询)、transaction(事务)、is_locked_error # (锁冲突判定)。两个后端的方法签名必须一致,见 test_db_backend.py # 的接口契约测试(按类检查方法名与签名,不依赖驱动是否安装)。 class Database(ABC): """数据库后端接口。实现者不得静默回落到另一个后端。""" @abstractmethod def connect(self): """返回一个新连接(SQLite:已设 WAL + busy_timeout + Row)。""" @abstractmethod def execute_with_retry(self, conn, sql, params=(), max_retries=DEFAULT_MAX_RETRIES, backoff_seconds=DEFAULT_BACKOFF_SECONDS): """执行单条写语句,遇写锁冲突时有限重试,返回 cursor。""" @abstractmethod def read_all(self, conn, sql, params=()): """执行查询并返回全部行。""" @abstractmethod def transaction(self, conn): """返回单连接事务上下文:成功 commit,异常 rollback 后原样抛出。""" @abstractmethod def is_locked_error(self, exc): """判定异常是否为可重试的写锁冲突;非锁错误必须返回 False。""" class SQLiteDatabase(Database): """Database 的 SQLite 实现:原样保留 WAL/busy_timeout/有界重试。 模块级函数仍是同一份实现,本类只是类封装,逐方法委托, 因此旧调用与新接口看到的行为完全一致。 """ def __init__(self, db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS, timeout=DEFAULT_CONNECT_TIMEOUT_S): self.db_path = str(db_path) self.busy_timeout_ms = int(busy_timeout_ms) self.timeout = timeout def __repr__(self): return ("SQLiteDatabase(db_path=%r, busy_timeout_ms=%r, " "timeout=%r)" % (self.db_path, self.busy_timeout_ms, self.timeout)) def connect(self): return connect(self.db_path, busy_timeout_ms=self.busy_timeout_ms, timeout=self.timeout) def execute_with_retry(self, conn, sql, params=(), max_retries=DEFAULT_MAX_RETRIES, backoff_seconds=DEFAULT_BACKOFF_SECONDS): return execute_with_retry(conn, sql, params, max_retries=max_retries, backoff_seconds=backoff_seconds) def read_all(self, conn, sql, params=()): return read_all(conn, sql, params) @contextmanager def transaction(self, conn): with transaction(conn) as cur: yield cur def is_locked_error(self, exc): return is_locked_error(exc) class PostgresDriverMissingError(RuntimeError): """Postgres 驱动缺失:明确报错,绝不静默回落到 SQLite。""" def _load_postgres_driver(): """返回 (驱动名, 模块);两者都缺时抛 PostgresDriverMissingError。 优先 psycopg(v3),其次 psycopg2;只做 import 探测,不连接、 不读任何真实数据库、不碰任何密码。 """ for name in ("psycopg", "psycopg2"): try: module = __import__(name) except ImportError: continue return name, module raise PostgresDriverMissingError( "Postgres 后端需安装 psycopg 驱动(例如 pip install psycopg)," "当前环境未找到 psycopg/psycopg2;已拒绝静默回落到 SQLite," "请安装驱动后再选 CLOUD_DB_BACKEND=postgres。" ) _POSTGRES_NOT_IMPLEMENTED = ( "PostgresDatabase 是骨架,尚未实现:需要真实 Postgres 部署、" "psycopg 驱动安装与迁移脚本后才能接通,当前不假装已接通 Postgres。" ) class PostgresDatabase(Database): """Database 的 Postgres 骨架(未接通真实 Postgres)。 连接参数全部走环境变量(见 POSTGRES_ENV_VARS),显式参数优先 于环境变量;未给出的参数保持 None,不硬编码任何地址或密码。 初始化时即探测驱动:缺驱动立即抛 PostgresDriverMissingError, 因此 server 选 postgres 后端时在启动阶段就失败,不会在运行时 中途才炸。即使驱动已安装,connect/execute/read/transaction 目前仍抛 NotImplementedError——骨架不是可用后端。 本类的 __repr__ 绝不输出 password。 """ def __init__(self, host=None, port=None, dbname=None, user=None, password=None): env = POSTGRES_ENV_VARS self.host = host if host is not None else os.environ.get(env["host"]) raw_port = port if port is not None else os.environ.get(env["port"]) if raw_port is None or raw_port == "": self.port = None else: try: self.port = int(raw_port) except (TypeError, ValueError): raise ValueError( "环境变量 %s 须为整数端口,当前值无法解析" % env["port"]) self.dbname = (dbname if dbname is not None else os.environ.get(env["dbname"])) self.user = (user if user is not None else os.environ.get(env["user"])) # 只保存、不输出、不记录日志;调用方负责传入方式的安全。 self.password = (password if password is not None else os.environ.get(env["password"])) # 驱动探测在初始化时完成:缺驱动这里就报错,不等到首次查询。 self.driver_name, self._driver = _load_postgres_driver() def __repr__(self): return ("PostgresDatabase(host=%r, port=%r, dbname=%r, user=%r)" % (self.host, self.port, self.dbname, self.user)) def connect(self): raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED) def execute_with_retry(self, conn, sql, params=(), max_retries=DEFAULT_MAX_RETRIES, backoff_seconds=DEFAULT_BACKOFF_SECONDS): raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED) def read_all(self, conn, sql, params=()): raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED) def transaction(self, conn): raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED) def is_locked_error(self, exc): raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED) def create_database(backend=None, db_path=None, **kwargs): """后端工厂:按 backend(或环境变量)返回 Database 实现。 - backend 为 None 时读 ``CLOUD_DB_BACKEND``,未设则默认 sqlite。 - ``sqlite``:返回 SQLiteDatabase(db_path 未给则用默认文件名, 与 server 旧默认值一致);kwargs 可传 busy_timeout_ms/timeout。 - ``postgres``/``postgresql``:返回 PostgresDatabase;驱动缺失 时初始化即抛 PostgresDriverMissingError(文案含 psycopg), 绝不回落到 SQLite;kwargs 可传 host/port/dbname/user/password 覆盖环境变量(测试只能用 fake/test 占位)。 - 其它值抛 ValueError,不静默当成 sqlite。 """ resolved = backend if resolved is None: resolved = os.environ.get(BACKEND_ENV_VAR) or DEFAULT_BACKEND resolved = str(resolved).strip().lower() if resolved == "sqlite": path = db_path if db_path is not None else DEFAULT_SQLITE_PATH return SQLiteDatabase(path, **kwargs) if resolved in ("postgres", "postgresql"): return PostgresDatabase(**kwargs) raise ValueError( "未知数据库后端 %r(%s),仅支持 sqlite/postgres" % (resolved, BACKEND_ENV_VAR)) def database_from_env(db_path=None, **kwargs): """create_database 的环境变量版:backend 一律读 CLOUD_DB_BACKEND。""" return create_database(backend=None, db_path=db_path, **kwargs) __all__ = [ "DEFAULT_BUSY_TIMEOUT_MS", "DEFAULT_CONNECT_TIMEOUT_S", "DEFAULT_MAX_RETRIES", "DEFAULT_BACKOFF_SECONDS", "POSTGRES_ENV_VARS", "BACKEND_ENV_VAR", "DEFAULT_BACKEND", "connect", "is_locked_error", "execute_with_retry", "read_all", "transaction", "Database", "SQLiteDatabase", "PostgresDatabase", "PostgresDriverMissingError", "create_database", "database_from_env", ]