fix(chv-muse-r12fix-5): UsageLedger.record() 读-改-写原子化(P11)

R12 真机:4 线程 × 10 条记账 0 写错,但 verify_chain 返回
(False, first_bad=11),链 G、链 H 两次复现同一断点;根因是
record() 先 SELECT 链头、再算摘要、再 INSERT 提交,无锁无
事务,两线程读到同一 prev_hash 各写一条,链分叉。修法:
同一实例用 threading.Lock 串行化;SELECT 前 BEGIN IMMEDIATE
持库级写锁(WAL + busy_timeout 下等待而非立即失败),使另一
实例/进程读到的链头必是已提交最新值。record_warning 委托
record(),同款覆盖。新增并发测试:同款 4×10 复测两轮,
verify_chain 全真、40 条一条不丢(旧代码下该测试失败)。
This commit is contained in:
host committed 2026-11-08 04:32:35 +00:00
1 parent 0ebcdfb2a7
commit a18a530bfd
2 files changed
+66 -20

No files matched your search

+30
View File
@@ -288,6 +288,36 @@ class LedgerTestCase(unittest.TestCase):
self.assertNotIn("token", cols)
self.assertGreater(row_id, 0)
def test_concurrent_records_keep_chain_intact(self):
# round-12 修复轮(项 5,P11):R12 真机同款并发复测——
# 4 线程 × 10 条,0 写错,但旧 record() 读-改-写无锁,
# verify_chain 返回 (False, first_bad=11),链 G、链 H
# 两次复现同一断点。修后两轮都须全真、40 条一条不丢。
for _round in range(2):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
ledger = UsageLedger(db_path)
errors = []
def _worker():
try:
for _ in range(10):
ledger.record("u1", "message", 1)
except Exception as e: # noqa: BLE001 - 汇总后断言
errors.append(e)
threads = [threading.Thread(target=_worker)
for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(errors, [])
self.assertEqual(ledger.sum_kind("u1", "message"), 40)
ok, bad = ledger.verify_chain("u1")
self.assertTrue(ok, bad)
self.assertIsNone(bad)
class WarningTestCase(unittest.TestCase):
def test_once_per_window_at_80_percent(self):
+36 -20
View File
@@ -32,6 +32,7 @@ import hashlib
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
@@ -227,6 +228,13 @@ class UsageLedger:
self._conn = _db_connect(db_path)
self._conn.execute(self._DDL)
self._conn.commit()
# round-12 修复轮(项 5,P11):record() 读-改-写的
# 串行锁。无锁时两线程读到同一 prev_hash、各写一条,
# 链分叉(R12 真机:4 线程 × 10 条,0 写错,
# verify_chain (False, first_bad=11),链 G、链 H 两次
# 同断点)。锁只串行化同一实例;跨实例/进程的并发由
# record() 里的 BEGIN IMMEDIATE 持写锁兜底。
self._lock = threading.Lock()
def _new_conn(self) -> sqlite3.Connection:
"""Fresh connection per write/read (mirrors CostStore).
@@ -256,26 +264,34 @@ class UsageLedger:
created_at = time.time()
conn = self._new_conn()
try:
prev = conn.execute(
"SELECT entry_hash FROM usage_ledger"
" WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(user_id,)).fetchone()
prev_hash = prev[0] if prev is not None else None
fields = {"user_id": user_id, "kind": kind,
"amount": int(amount), "unit": unit,
"task_id": task_id or "", "model": model or "",
"source": source or "", "day": day,
"created_at": created_at}
digest = _entry_hash(prev_hash, fields)
cursor = conn.execute(
"""INSERT INTO usage_ledger
(user_id, kind, amount, unit, task_id, model,
source, day, created_at, prev_hash, entry_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, kind, int(amount), unit, task_id, model,
source, day, created_at, prev_hash, digest))
conn.commit()
return int(cursor.lastrowid or 0)
# 读-改-写原子化:锁串行化同一实例;BEGIN IMMEDIATE
# 在 SELECT 前即持库级写锁(WAL + busy_timeout 下等
# 待而非立即失败),使另一实例/进程读到的链头必是已
# 提交的最新值——链不断、条不丢。锁与事务缺一不可:
# 光有事务、同一实例的两线程仍可能各持一个连接同时读
# 到同一链头。
with self._lock:
conn.execute("BEGIN IMMEDIATE")
prev = conn.execute(
"SELECT entry_hash FROM usage_ledger"
" WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(user_id,)).fetchone()
prev_hash = prev[0] if prev is not None else None
fields = {"user_id": user_id, "kind": kind,
"amount": int(amount), "unit": unit,
"task_id": task_id or "", "model": model or "",
"source": source or "", "day": day,
"created_at": created_at}
digest = _entry_hash(prev_hash, fields)
cursor = conn.execute(
"""INSERT INTO usage_ledger
(user_id, kind, amount, unit, task_id, model,
source, day, created_at, prev_hash, entry_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, kind, int(amount), unit, task_id, model,
source, day, created_at, prev_hash, digest))
conn.commit()
return int(cursor.lastrowid or 0)
finally:
if conn is not self._conn:
conn.close()