Files
codex-app-server/cloud/tests/test_db.py
T

250 lines
9.5 KiB
Python
Raw 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.
"""cloud/db.py 单测:SQLite 并发 helper。
只测 db.py,不改 server.py / host.py / 其它模块。
重试逻辑的覆盖方式(本文件写清楚,供协调员核对):
- 确定性 fake 方式:FakeConn 前两次抛 database is locked、第三次
成功,断言重试后成功且退避序列为 0.05/0.1/0.2s;另测重试耗尽
抛原始错误、非锁错误一次都不重试。
- 真实并发方式:8 线程各写 20 条(server.py 同款 tasks 表),
断言无错误、最终 160 条、id 无重复。
两者都测了,前者证重试逻辑本身正确,后者证真实写冲突不再直接炸掉。
"""
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
# 与 server.py init_db 的 tasks 表定义逐字段一致(db.py 本身不建表,
# 这里复刻定义只是为了让并发测试写进同款表,证明 schema 兼容)。
TASKS_DDL = (
"CREATE TABLE IF NOT EXISTS tasks("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"prompt TEXT, "
"status TEXT, "
"idempotency_key TEXT, "
"depends_on TEXT, "
"priority INTEGER, "
"created_by TEXT, "
"created_at REAL, "
"updated_at REAL, "
"last_heartbeat REAL, "
"UNIQUE(project_id, idempotency_key))"
)
class FakeConn:
"""受控 fake:前 fail_times 次 execute 抛锁错误,之后返回哨兵。"""
def __init__(self, fail_times, error_text="database is locked"):
self.fail_times = fail_times
self.error_text = error_text
self.calls = 0
def execute(self, sql, params=()):
self.calls += 1
if self.calls <= self.fail_times:
raise sqlite3.OperationalError(self.error_text)
return "ok-sentinel"
class DbHelperTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "concurrency.db")
conn = connect(self.db_path)
try:
conn.execute(TASKS_DDL)
conn.commit()
finally:
conn.close()
def tearDown(self):
self.tmp.cleanup()
# -- 连接属性:WAL + busy_timeout + Row ------------------------------
def test_connect_sets_wal_busy_timeout_row_factory(self):
conn = connect(self.db_path)
try:
self.assertEqual(conn.row_factory, sqlite3.Row)
journal = conn.execute(
"PRAGMA journal_mode").fetchone()[0]
self.assertEqual(journal, "wal")
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 5000)
# Row 行为:按列名访问,与 server.py 的 _task_to_dict 兼容。
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-row", "p1", "hello"),
)
conn.commit()
row = read_all(
conn, "SELECT * FROM tasks WHERE id = ?", ("t-row",))[0]
self.assertEqual(row["id"], "t-row")
self.assertEqual(row["prompt"], "hello")
finally:
conn.close()
def test_connect_custom_busy_timeout(self):
conn = connect(self.db_path, busy_timeout_ms=1234)
try:
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 1234)
finally:
conn.close()
# -- 锁判定 ---------------------------------------------------------
def test_is_locked_error_only_for_locked_operational_error(self):
self.assertTrue(is_locked_error(
sqlite3.OperationalError("database is locked")))
self.assertTrue(is_locked_error(
sqlite3.OperationalError("Database Is Locked (retry)")))
self.assertFalse(is_locked_error(
sqlite3.OperationalError("no such table: tasks")))
self.assertFalse(is_locked_error(ValueError("database is locked")))
# -- 重试逻辑:fake 受控方式(确定性)--------------------------------
def test_retry_succeeds_after_two_locked_failures(self):
fake = FakeConn(fail_times=2)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
result = execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(result, "ok-sentinel")
self.assertEqual(fake.calls, 3) # 1 次初始 + 2 次重试
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2][:2])
def test_retry_backoff_sequence_is_exponential(self):
fake = FakeConn(fail_times=3)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2])
def test_retry_exhausted_reraises_original_error(self):
fake = FakeConn(fail_times=99)
with mock.patch.object(db_module.time, "sleep"):
with self.assertRaises(sqlite3.OperationalError) as ctx:
execute_with_retry(fake, "INSERT ...", ())
self.assertIn("database is locked", str(ctx.exception))
# 默认 max_retries=3:1 次初始 + 3 次重试 = 4 次尝试,不多不少。
self.assertEqual(fake.calls, 4)
def test_non_locked_error_is_not_retried(self):
fake = FakeConn(fail_times=99, error_text="no such table: tasks")
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
with self.assertRaises(sqlite3.OperationalError):
execute_with_retry(fake, "SELECT ...", ())
self.assertEqual(fake.calls, 1)
sleep_mock.assert_not_called()
# -- 事务:成功提交 / 失败回滚 ---------------------------------------
def test_transaction_commits_and_rolls_back(self):
conn = connect(self.db_path)
try:
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "ok"),
)
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
with self.assertRaises(sqlite3.IntegrityError):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "dup"),
)
# 冲突行回滚后仍只有原来 1 条,不留半写。
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
finally:
conn.close()
# -- 真实并发:8 线程 x 20 条 ----------------------------------------
def test_concurrent_writes_8_threads_x_20_no_loss(self):
errors = []
barrier = threading.Barrier(8)
def worker(thread_idx):
try:
barrier.wait(timeout=10)
conn = connect(self.db_path)
try:
for i in range(20):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', ?, '[]',"
" 0, 'worker', ?, ?, NULL)",
("t-%d-%d" % (thread_idx, i),
"p-concurrent",
"prompt %d-%d" % (thread_idx, i),
"key-%d-%d" % (thread_idx, i),
time.time(), time.time()),
)
finally:
conn.close()
except Exception as exc: # noqa: BLE001 - 汇总后统一断言
errors.append(exc)
threads = [threading.Thread(target=worker, args=(n,))
for n in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
self.assertFalse([t for t in threads if t.is_alive()],
"worker 线程未在 60s 内结束")
self.assertEqual(errors, [], "并发写不应有任何异常: %r" % (errors,))
conn = connect(self.db_path)
try:
rows = read_all(
conn,
"SELECT id FROM tasks WHERE project_id = ?",
("p-concurrent",))
finally:
conn.close()
self.assertEqual(len(rows), 160)
self.assertEqual(len({r["id"] for r in rows}), 160)
if __name__ == "__main__":
unittest.main()