Files
blackbird/db/dbv2.py
qing 101aed48be feat(pymsg): RpcClient 新增 retry_on_refused 阻塞等待模式,解决微服务启动顺序依赖
pymsg 客户端:
- MsgClient.call() 新增 retry_on_refused/retry_interval 参数
  - retry_on_refused=True 时,ConnectionRefusedError 不抛出,按 retry_interval(默认1s)阻塞重试
  - 重试期间每5秒向 stderr 打印等待提示,避免日志刷屏
  - 仅对 ConnectionRefusedError 重试;TimeoutError/ConnectionError/RpcError 仍立即抛出
  - 默认 retry_on_refused=False,完全向后兼容
- RpcClient 构造函数新增同名关键字参数,call() 支持单次调用覆盖
- 新增 time 模块导入
- 更新 README 文档(新增"启动等待模式"章节和参数表)

各服务(db/dnfs/static):
- _fetch_from_confs() 改用 retry_on_refused=True,移除手动 ConnectionRefusedError 捕获和 sys.exit
- 保留 RpcError(如配置文件不存在)的友好错误处理和 sys.exit(1)
- 启动时不再因 confs 未就绪而退出:pymsg 自动阻塞等待 confs 启动后自动连接成功
- 启动顺序从"刚性要求 confs 先启动"变为"不严格要求启动顺序"
- log_client 保持默认 retry_on_refused=False,后台线程不阻塞(fail-safe 丢弃日志)
- 更新各服务 README 和 confs README 的启动顺序描述

测试验证:
- 基本 call() 正常工作
- retry_on_refused=False 连接拒绝立即抛 ConnectionRefusedError
- retry_on_refused=True 阻塞等待(延迟1.5s启动服务后成功连接,重试日志正常输出)
2026-08-01 12:47:08 +08:00

555 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
替代 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,新增 ``_route`` 字段对应原 HTTP 路由路径。
请求示例::
{
"_route": "execute",
"action": "select",
"table": "Users",
"where": {"age": {"gt": 20}},
"order_by": [{"column": "id", "desc": true}],
"limit": 10
}
支持的路由::
execute → 通用 CRUD(select / insert / update / delete)
日期/时间列::
values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串
(如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储),
或哨兵值 "$now"(服务端当前 UTC 时间)。
响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
"""
import configparser
import json
import os
import threading
import time
import traceback
from datetime import datetime, date, time as dt_time, timezone, timedelta
from sqlalchemy import (
create_engine, MetaData, Table,
select, insert, update, delete, func, and_,
event,
)
from sqlalchemy import types as sa_types
from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import RpcServer # noqa: E402
# ── 配置加载(两层:confs 强依赖 → 环境变量覆盖)──────────────────────
# confs 是 db 启动的强依赖:必须成功从 confs 拉取 db.ini,否则直接退出。
# 环境变量优先级最高(Docker 兼容),可覆盖 confs 中的配置值。
# confs 自身的连接地址(host/port)通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT
# 优先,缺省使用 127.0.0.1:8010(与其他服务一致的约定)。
# 引导配置:confs 连接地址(不依赖 confs 本身,可通过环境变量覆盖)
_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1')
_confs_port = int(os.environ.get('CONFS_PORT', '8010'))
# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段
_db_config = {
'server': {'host': '', 'port': '', 'workers': ''},
'database': {'url': ''},
'logs': {
'retention_days': '',
'max_records': '',
'cleanup_interval': '',
},
}
def _merge_ini(text):
"""把 ini 文本合并进 _db_config(仅覆盖已知的 section/key)。"""
ini = configparser.ConfigParser(interpolation=None)
ini.read_string(text)
for section in _db_config:
if ini.has_section(section):
for key, value in ini[section].items():
if key in _db_config[section]:
_db_config[section][key] = value
def _fetch_from_confs():
"""从 confs 统一配置服务拉取 db.ini 全文。
使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次,
每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。
RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。
"""
from pymsg import RpcClient, RpcError # noqa: E402
client = RpcClient(_confs_host, _confs_port, 'db', timeout=5,
retry_on_refused=True)
try:
data = client.call('get', {'name': 'db.ini'})
except RpcError as e:
print(f'[dbv2] 致命错误: 从 confs 获取 db.ini 失败: {e}',
file=_sys.stderr)
_sys.exit(1)
return data.get('content')
_confs_text = _fetch_from_confs()
_merge_ini(_confs_text)
_config_source = 'confs (db.ini)'
print(f'[dbv2] 配置来源: confs 统一配置服务 (db.ini @ {_confs_host}:{_confs_port})')
# 环境变量覆盖(最高优先级,保持 Docker 兼容)
if os.environ.get('LISTEN_ADDR'):
_db_config['server']['host'] = os.environ['LISTEN_ADDR']
if os.environ.get('LISTEN_PORT'):
_db_config['server']['port'] = os.environ['LISTEN_PORT']
if os.environ.get('WORKERS'):
_db_config['server']['workers'] = os.environ['WORKERS']
if os.environ.get('DATABASE_URL'):
_db_config['database']['url'] = os.environ['DATABASE_URL']
# 配置完整性校验
_required = {
'server': ['host', 'port', 'workers'],
'database': ['url'],
'logs': ['retention_days', 'max_records', 'cleanup_interval'],
}
for section, keys in _required.items():
for key in keys:
if not _db_config[section][key]:
print(f'[dbv2] 致命错误: 配置项 [{section}] {key} 缺失(db.ini 中未配置且无环境变量覆盖)',
file=_sys.stderr)
_sys.exit(1)
# 解析为最终常量
DATABASE_URL = _db_config['database']['url']
DEFAULT_RETENTION_DAYS = int(_db_config['logs']['retention_days'])
DEFAULT_MAX_RECORDS = int(_db_config['logs']['max_records'])
DEFAULT_CLEANUP_INTERVAL = int(_db_config['logs']['cleanup_interval'])
# ── SQLite 连接配置(针对 Docker/多线程环境优化)──────────────────────
def _configure_sqlite(dbapi_connection, connection_record):
"""为每个 SQLite 连接设置 PRAGMA,优化并发与持久化性能。
- WAL 模式:读写不互斥,并发性能大幅提升,Docker 卷挂载下更稳定
- NORMAL 同步级别:在断电时可能丢失最近提交的事务(但不会损坏数据库)
配合 WAL checkpoint 足够安全,性能显著提升
- foreign_keys=ON:启用外键约束
- busy_timeout:锁等待超时 5 秒,避免 SQLITE_BUSY 错误
- mmap_size:使用内存映射 I/O 提升读取性能
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA mmap_size=268435456") # 256MB
cursor.close()
# ── 日志模块常量 ────────────────────────────────────────────────────────
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
MAX_EXTRA_LEN = 4096 # extra 上限 4KB
#: where/values 中的哨兵值:服务端当前 UTC 时间
NOW_SENTINEL = "$now"
def _json_default(o):
"""json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。"""
if isinstance(o, (datetime, date, dt_time)):
return o.isoformat()
return str(o)
# ═══════════════════════════════════════════════════════════════════════
# DBServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
class DBServer:
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。"""
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
self.addr = addr
self.port = port
self.engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
)
# 为 SQLite 连接应用 PRAGMA 优化
if DATABASE_URL.startswith("sqlite"):
event.listen(self.engine, "connect", _configure_sqlite)
self.metadata = MetaData()
self._server = RpcServer(
addr, port, workers=workers,
json_default=_json_default,
on_error=lambda route, e: traceback.print_exc(),
)
self._server.add_route("execute", self._handle_execute)
# 日志表缓存(避免重复反射)
self._logs_table_cache = {}
# 业务表缓存:仅缓存"已完整反射"的表。
# 注意不能用 metadata.tables 作完成标记 —— Table(autoload_with=...)
# 会先注册进 metadata.tables(此时无列)再填充列,锁外快路径可能
# 读到反射半途的空列表。
self._tables_cache = {}
# 表反射锁:workers>0 时多个 worker 线程可能对同一新表并发做
# check-then-act 反射(Table(..., autoload_with=...) 对已定义表名会
# 抛 InvalidRequestError),反射操作需串行化
self._reflect_lock = threading.Lock()
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前)
self._start_cleanup_thread()
self._server.start()
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 表反射 ---------------------------------------------------------
def _get_table(self, table_name):
# double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL
# 下安全),锁外快路径读到的要么是完整表要么是 None
table = self._tables_cache.get(table_name)
if table is None:
with self._reflect_lock:
table = self._tables_cache.get(table_name)
if table is None:
# 持锁期间无其他反射在进行,metadata 中的表必然完整
table = self.metadata.tables.get(table_name)
if table is None:
table = Table(table_name, self.metadata,
autoload_with=self.engine)
self._tables_cache[table_name] = table
return table
# ── 日期/时间类型适配 ------------------------------------------------
def _coerce_value(self, column, value):
"""按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。
JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE
类型只接受 Python datetime/date 对象,字符串会被直接拒绝
(TypeError: SQLite DateTime type only accepts Python datetime ...)。
这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC
并去掉 tzinfo,与 SQLite 朴素存储保持一致。
"""
if not isinstance(value, str):
return value
col_type = column.type
if isinstance(col_type, sa_types.DateTime):
if value == NOW_SENTINEL:
return datetime.now(timezone.utc).replace(tzinfo=None)
text = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
dt = datetime.fromisoformat(text)
except ValueError:
raise ValueError(
f"列 {column.name} 的日期时间值无法解析"
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
)
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
if isinstance(col_type, sa_types.Date):
if value == NOW_SENTINEL:
return datetime.now(timezone.utc).date()
try:
return date.fromisoformat(value)
except ValueError:
raise ValueError(
f"列 {column.name} 的日期值无法解析"
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
)
if isinstance(col_type, sa_types.Time):
try:
return dt_time.fromisoformat(value)
except ValueError:
raise ValueError(
f"列 {column.name} 的时间值无法解析"
f"(需 ISO 8601): {value!r}"
)
return value
def _build_where(self, table, conditions):
if not conditions:
return None
clauses = []
for col, val in conditions.items():
column = table.columns[col]
if isinstance(val, dict):
if "eq" in val:
clauses.append(column == self._coerce_value(column, val["eq"]))
if "ne" in val:
clauses.append(column != self._coerce_value(column, val["ne"]))
if "gt" in val:
clauses.append(column > self._coerce_value(column, val["gt"]))
if "lt" in val:
clauses.append(column < self._coerce_value(column, val["lt"]))
if "ge" in val:
clauses.append(column >= self._coerce_value(column, val["ge"]))
if "le" in val:
clauses.append(column <= self._coerce_value(column, val["le"]))
if "like" in val:
# like 是字符串模式匹配,不做日期转换
clauses.append(column.like(val["like"]))
if "in" in val:
clauses.append(column.in_(
[self._coerce_value(column, v) for v in val["in"]]
))
else:
clauses.append(column == self._coerce_value(column, val))
return and_(*clauses)
# ═══════════════════════════════════════════════════════════════════
# 路由处理 — execute
# ═══════════════════════════════════════════════════════════════════
def _handle_execute(self, data):
action = data.get("action", "select")
table_name = data.get("table")
if not table_name:
raise ValueError("缺少 table 字段")
try:
table = self._get_table(table_name)
except Exception as e:
raise ValueError(f"表加载失败: {e}")
with self.engine.connect() as conn:
if action == "select":
return self._do_select(conn, table, data)
elif action == "insert":
return self._do_insert(conn, table, data)
elif action == "update":
return self._do_update(conn, table, data)
elif action == "delete":
return self._do_delete(conn, table, data)
else:
raise ValueError(f"不支持的操作: {action}")
def _do_select(self, conn, table, data):
columns = data.get("columns")
if columns:
cols = [table.columns[c] for c in columns]
else:
cols = [table]
stmt = select(*cols)
where_cond = self._build_where(table, data.get("where"))
if where_cond is not None:
stmt = stmt.where(where_cond)
order_by = data.get("order_by")
if order_by:
for item in order_by:
col = table.columns[item["column"]]
stmt = stmt.order_by(col.desc() if item.get("desc") else col.asc())
limit = data.get("limit")
if limit is not None:
stmt = stmt.limit(limit)
offset = data.get("offset")
if offset is not None:
stmt = stmt.offset(offset)
res = conn.execute(stmt)
return [dict(row._mapping) for row in res.fetchall()]
def _do_insert(self, conn, table, data):
values = data.get("values", {})
values = {
k: self._coerce_value(table.columns[k], v)
for k, v in values.items()
}
stmt = insert(table).values(**values)
res = conn.execute(stmt)
conn.commit()
return [{"lastrowid": res.lastrowid}] if hasattr(res, "lastrowid") else []
def _do_update(self, conn, table, data):
values = data.get("values", {})
values = {
k: self._coerce_value(table.columns[k], v)
for k, v in values.items()
}
where_cond = self._build_where(table, data.get("where"))
stmt = update(table).values(**values)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
def _do_delete(self, conn, table, data):
where_cond = self._build_where(table, data.get("where"))
stmt = delete(table)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
# ═══════════════════════════════════════════════════════════════════
# 日志表管理 + 后台清理
# ═══════════════════════════════════════════════════════════════════
# ── 日志表管理 ────────────────────────────────────────────────────
def _ensure_logs_table(self):
if "Logs" in self._logs_table_cache:
return self._logs_table_cache["Logs"]
# 与 _get_table 共用反射锁,double-check 保证并发下只建/反射一次;
# Logs 可能已被 _get_table("Logs") 反射进 metadata,需幂等处理
with self._reflect_lock:
if "Logs" in self._logs_table_cache:
return self._logs_table_cache["Logs"]
table = self.metadata.tables.get("Logs")
if table is None:
with self.engine.connect() as conn:
conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS Logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
service TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
trace_id TEXT,
extra TEXT
)
""")
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
)
conn.commit()
table = Table("Logs", self.metadata, autoload_with=self.engine)
self._logs_table_cache["Logs"] = table
return table
# ── 内部日志(直接写库,无 HTTP)──────────────────────────────────
def _log_internal(self, service, level, message,
trace_id=None, extra=None):
try:
table = self._ensure_logs_table()
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
if len(message) > MAX_MESSAGE_LEN:
message = message[:MAX_MESSAGE_LEN]
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
extra_json = extra_json[:MAX_EXTRA_LEN]
with self.engine.connect() as conn:
conn.execute(insert(table).values(
ts=datetime.now(timezone.utc).isoformat(),
service=service,
level=level,
message=message,
trace_id=trace_id,
extra=extra_json,
))
conn.commit()
except Exception:
pass
# ── 后台自动清理线程 ──────────────────────────────────────────────
def _start_cleanup_thread(self):
def _loop():
while True:
time.sleep(DEFAULT_CLEANUP_INTERVAL)
try:
self._do_cleanup()
except Exception:
pass
import threading
t = threading.Thread(target=_loop, daemon=True, name="dbv2-cleanup")
t.start()
def _do_cleanup(self):
table = self._ensure_logs_table()
cutoff = (
datetime.now(timezone.utc) - timedelta(days=DEFAULT_RETENTION_DAYS)
).isoformat()
with self.engine.connect() as conn:
result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff)
)
conn.commit()
expired_deleted = result.rowcount
count = conn.execute(
select(func.count()).select_from(table)
).scalar() or 0
trimmed = 0
if count > DEFAULT_MAX_RECORDS:
excess = count - DEFAULT_MAX_RECORDS
cutoff_row = conn.execute(
select(table.columns["ts"])
.order_by(table.columns["ts"].asc())
.limit(1).offset(DEFAULT_MAX_RECORDS)
).fetchone()
if cutoff_row:
trim_result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff_row[0])
)
conn.commit()
trimmed = trim_result.rowcount
if expired_deleted or trimmed:
self._log_internal(
"db", "INFO",
f"自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条"
)
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
import argparse
default_addr = _db_config['server']['host']
default_port = int(_db_config['server']['port'])
default_workers = int(_db_config['server']['workers'])
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
parser.add_argument("--addr", default=default_addr, help="监听地址")
parser.add_argument("--port", type=int, default=default_port, help="监听端口")
parser.add_argument("--workers", type=int, default=default_workers,
help="回调并发线程数(0=同步串行模式)")
args = parser.parse_args()
server = DBServer(args.addr, args.port, workers=args.workers)
print(f'[dbv2] 配置来源: {_config_source}')
try:
server.start()
except KeyboardInterrupt:
server.stop()