This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/tests/test_auth_db_concurrency.py
T
snowy-yang 70ca21974b refactor: 拆掉 auth_system 包内循环依赖,路径常量收敛到 db 单一来源
为什么:auth_system 的 11 个子模块此前构成一个强连通分量——db.py 与 backup.py
为了「动态读取路径常量」而在模块级 `import auth_system`(包自身),而包 __init__
又再导出这两个子模块的全部符号,互相指成环。

这个环当前并不崩(实测 8/8 种导入姿势全通过),它的代价是**双绑定**:
`auth_system.DB_PATH is auth_system.db.DB_PATH` 初始为 True,但只 patch 其一
另一处不跟随。于是测试不得不把 4 个路径常量**两处都 patch**
(setattr(auth,...) + setattr(db,...))——而漏 patch 的后果是静默写生产库,
commit fc7fc21 修的那次「黄金测试写进生产库」正是这么来的。

改法:让路径常量回归**模块属性单一来源**。
- db.py:删掉 `import auth_system`,内部改用本模块常量(DATA_DIR / DB_PATH /
  SECRET_KEY_PATH)。这些常量本就定义在本模块,绕道包属性纯属多余。
- backup.py:改用 `import auth_system.db as db` 做模块限定访问,路径走
  db.BACKUP_DIR / db.DB_PATH——保留动态读取能力,来源从「包属性」换成
  「db 模块」,与唯一来源一致。
  注意**不能**写成 `from . import db`:该形式在静态图里同时指向包本身与子模块,
  会把这个环重新连上(实测确实如此,SCC 从 11 降到 2 就卡住了)。
- admin_site.py:5 处 `auth.DB_PATH` / `auth.BACKUP_DIR`(auth 是包别名)同样
  改为经 db 模块访问。**这处不修就会出事**——它读的是包属性快照,而测试已改为
  patch db 模块,于是备份上传会静默落到生产 data/backups/(本次实测真的发生了,
  2 个 upload_*.db 已清理,副本留在临时目录)。
- 测试:7 个文件的 patch 目标从包属性迁到 auth_system.db.*。
- __init__.py:补注释说明这些常量在此只是 import 快照,禁止经包属性重定向。

验证:
- Tarjan 重算:非平凡强连通分量 11 → **0**(import 图已是 DAG)
- AST 全仓扫描:经包属性访问路径常量的点 **0 处残留**
- 行为:子进程内 patch auth_system.db.* 后,get_conn 连的库 / create_backup
  落盘目录 / get_secret_key 目标路径**三者全部跟随**
- 导入姿势回归 8/8 通过
- 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed
2026-09-26 14:37:58 +08:00

88 lines
2.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.
# -*- coding: utf-8 -*-
"""auth_system 同步栈的读写并发验证:读不持锁、写仍串行(WAL)。"""
import sqlite3
import threading
import time
import pytest
import auth_system
@pytest.fixture()
def temp_site(tmp_path, monkeypatch):
monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db')
monkeypatch.setattr(auth_system.db, 'BACKUP_DIR', tmp_path / 'backups')
monkeypatch.setattr(auth_system.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
auth_system.init_db()
return tmp_path / 'site.db'
def test_q_does_not_hold_lock(temp_site):
"""_q 读不应获取 _DB_LOCK(否则读会被写挡住)。"""
# 让另一个线程持有写锁,读仍应在短时间返回(不被阻塞)
held = threading.Event()
release = threading.Event()
def holder():
with auth_system._DB_LOCK:
held.set()
release.wait(5)
t = threading.Thread(target=holder, daemon=True)
t.start()
held.wait(2)
try:
start = time.monotonic()
rows = auth_system._q('SELECT COUNT(*) AS n FROM users')
elapsed = time.monotonic() - start
assert rows is not None
assert elapsed < 1.0, f'读被写锁阻塞了 {elapsed:.2f}s(WAL 下不应发生)'
finally:
release.set()
t.join(2)
def test_concurrent_reads_and_writes(temp_site):
"""多读线程 + 一个写线程并发:无异常、结果一致。"""
errors = []
stop = threading.Event()
def writer():
try:
for i in range(50):
auth_system.set_setting(f'k{i}', str(i))
except Exception as e:
errors.append(('w', e))
finally:
stop.set()
def reader():
while not stop.is_set():
try:
auth_system.get_setting('k0')
except Exception as e:
errors.append(('r', e))
ws = threading.Thread(target=writer, daemon=True)
rs = [threading.Thread(target=reader, daemon=True) for _ in range(4)]
ws.start()
for r in rs:
r.start()
ws.join(20)
for r in rs:
r.join(2)
assert not errors, f'并发读写异常: {errors[:3]}'
assert auth_system.get_setting('k49') == '49'
def test_wal_enabled(temp_site):
conn = sqlite3.connect(str(temp_site))
try:
mode = conn.execute('PRAGMA journal_mode').fetchone()[0]
finally:
conn.close()
assert str(mode).lower() == 'wal'