cloud: worker dispatch, DB abstraction, packaging 0.1.0, repo CI workflow

This commit is contained in:
host committed 2026-10-26 01:44:15 +08:00
1 parent 076ba01c74
commit 07ef869267
13 files changed
+1916 -9

No files matched your search

+255 -1
View File
@@ -1,4 +1,14 @@
"""SQLite 并发 helper(云端控制面单机阶段)。
"""数据库接口层(云端控制面单机阶段)。
本模块分两层,调用方按需选用:
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 的接线:
@@ -18,10 +28,25 @@ memory.py 的 summaries 完全一致,因为本模块根本不建表)。
- 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 保持一致的连接级等待;
@@ -34,6 +59,18 @@ DEFAULT_MAX_RETRIES = 3
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):
@@ -133,3 +170,220 @@ def transaction(conn):
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",
]