feat(stores): 补上易失存储实现,stores 拆成四个文件

design 0003 的否决方案一节承诺过「显式命名的、明确不提供恢复的内存实现」,那个实现一直
没写,migrations/dissect.md 还专门登记着「不要照那句话去找一个不存在的类」。

**名字取 VolatileRunStore 不取「不提供恢复」**:一个真的读不回自己写过的东西的存储过不了
自家的契约套件(套件第一条要的就是「写进去的意图读得回来」),而这一层的准入标准就是那套
套件。一个过不了自家准入标准的实现不该存在。它不提供的是跨进程恢复,Volatile 说的正是这
件事。

桶里存编码后的载荷、读的时候才解码,两个方向的别名都堵上:调用方写完再改自己手里那个 dict
改不到日志,读回来的日志被就地改动也污染不了存储本身。落盘那个实现每次重新解析文件,天然
如此,这个实现靠同一条路径对齐它。**写入只编码不解码**——落盘那边写的时候只做 json.dumps,
一条字段类型不对的记录写得进去、读的时候才炸,两边现在一致。

两张平行的表(记录类→标签、标签→解码器)合成一张三元组再派生视图。加上易失实现要用的第三
张视图之后,三张表之间那个谁也不检查的一致性要求就不可能被违反了。
This commit is contained in:
2026-08-27 03:58:29 -04:00
parent e701563a0b
commit 92785eb3e1
5 changed files with 755 additions and 312 deletions
+208 -8
View File
@@ -1,8 +1,12 @@
"""逐行追加那份存储实现的行为。
"""份存储实现的行为:逐行追加进文件的那个,以及只留在进程内存里的那个
**行为契约本身在 `tests/contract/test_run_store.py`**,那套套件现在就接着这个实现跑。这里只写
契约套件覆盖不到的部分:文件长什么样、坏行怎么算、`fsync` 在哪几处、以及那条契约测试明说
「这一层验不了」的原子性——它点名要在这里用可注入的故障点补上
**行为契约本身在契约套件里**,那套套件不针对任何具体实现,写的是「不管你怎么实现,都必须满足
这些行为」。这里只写套件覆盖不到的部分:文件长什么样、坏行怎么算、`fsync` 在哪几处、那条契约
测试明说「这一层验不了」的原子性它点名要在这里用可注入的故障点补上),以及易失那个实现独有
的几条——它的独占判据、它靠存载荷换来的两个方向的别名免疫。
还有几条跨着两个实现跑:坏记录在哪一步炸、写入失败留不留痕。那种「两个实现必须一样」的事只在
这里守得住——契约套件对每个实现分别跑,两边各自全绿并不代表它们一致。
"""
import asyncio
@@ -12,9 +16,9 @@ from pathlib import Path
import pytest
from polyloop import stores
from polyloop.serialization import DecodeError
from polyloop.stores import RECORD_KEY, JsonlRunStore
from polyloop.stores import RECORD_KEY, JsonlRunStore, VolatileRunStore
from polyloop.stores import _jsonl as jsonl_module
from polyloop.types import (
ActionOutcome,
ActionStatus,
@@ -248,7 +252,7 @@ async def test_an_unknown_record_type_is_refused_not_skipped(tmp_path: Path) ->
async def test_a_write_that_dies_halfway_leaves_nothing_readable(tmp_path: Path) -> None:
"""崩在一次写中途,那条记录**整条不可见**,不是半条可见。
这是 `tests/contract/test_run_store.py` 里那条 `xfail` 点名要在这一层补的:契约套件跑在
这是 `polyloop.testing.RunStoreContract` 里那条跳过点名要在这一层补的:契约套件跑在
一个进程里、面对一个已经装配好的实现,没有位置插入那次崩溃。这里靠替换掉那个内部的
「把这些字节写进去」来造它。
@@ -456,7 +460,7 @@ async def test_creating_the_log_file_syncs_the_directory_entry(
这条持久性没有别的进程内可观测形态,所以只能盯着那次调用本身。
"""
synced: list[Path] = []
monkeypatch.setattr(stores, "_fsync_directory", synced.append)
monkeypatch.setattr(jsonl_module, "_fsync_directory", synced.append)
store = JsonlRunStore(directory=tmp_path)
await store.write_run_started(_started())
@@ -487,3 +491,199 @@ async def test_concurrent_writes_to_one_run_do_not_interleave(tmp_path: Path) ->
log = await store.read_log("r1")
assert len(log.intents) == 6
# ---------------------------------------------------------------------------
# 易失存储:只在进程内存里
# ---------------------------------------------------------------------------
async def test_a_volatile_store_reads_back_what_it_wrote() -> None:
"""写进去的读得回来,五种记录都算数。
「不提供恢复」说的是跨进程那一层。进程内读不回来的话它连自家的准入标准都过不了——契约
套件第一条要的就是「写进去的意图读得回来」。
"""
store = VolatileRunStore()
result = RunResult(
run_id="r1", stop_reason=StopReason.TASK_COMPLETED, final_answer="42", steps=()
)
model_result = ModelCallResult(
run_id="r1",
result_id="m0",
reply=ModelReply(call_id="c1", content="hi", thinking=""),
failure=None,
)
finished = RunFinished(run_id="r1", result=result)
await store.write_run_started(_started())
await store.write_intent(_intent())
await store.write_model_call_result(model_result)
await store.write_step_completed(_step())
await store.write_run_finished(finished)
log = await store.read_log("r1")
assert log.started == _started()
assert log.intents == (_intent(),)
assert log.model_results == (model_result,)
assert log.steps == (_step(),)
assert log.finished == finished
async def test_a_volatile_store_keeps_two_runs_apart() -> None:
"""按运行标识分桶,两次运行互相看不见对方的记录。
端口不许持有「当前运行」的隐式状态:那种实现会在并发下把 A 的意图写进 B 的日志,而这种
错在单线程测试里永远不出现。
"""
store = VolatileRunStore()
await store.write_intent(_intent("run-a", call_index=0))
await store.write_intent(_intent("run-b", call_index=1))
assert (await store.read_log("run-a")).intents == (_intent("run-a", call_index=0),)
assert (await store.read_log("run-b")).intents == (_intent("run-b", call_index=1),)
async def test_a_volatile_store_refuses_to_start_the_same_run_twice() -> None:
"""独占和逐行追加那个实现对齐:这个标识已经有日志了就直接失败。
重开一个已经开过的标识,交错的记录序会让恢复读到同一步的两条意图、判成日志被并发写过,
于是这次运行从此续不了——而两边的模型调用都已经花过钱了。
"""
store = VolatileRunStore()
await store.write_run_started(_started())
with pytest.raises(FileExistsError):
await store.write_run_started(_started())
async def test_a_volatile_store_refuses_to_start_a_run_that_already_has_records() -> None:
"""判据是「这个标识有没有日志」,不是「有没有一条运行开始记录」。
逐行追加那边任何一次写都会把文件建出来,于是独占创建会拦下这一种;两处判据不一样的话,
同一段代码在两个实现上一个通过一个失败。
"""
store = VolatileRunStore()
await store.write_intent(_intent())
with pytest.raises(FileExistsError):
await store.write_run_started(_started())
async def test_an_unwritten_run_reads_back_empty_from_a_volatile_store() -> None:
"""驱动入口靠这条判断「这个标识是不是已经有日志了」。
抛异常的话那个判断就得写成捕获异常,而用捕获异常做流程控制会把真正的存储故障一起吞掉。
"""
store = VolatileRunStore()
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_mutating_the_snapshot_after_writing_does_not_change_the_log() -> None:
"""写进去之后调用方改自己手上那个映射,读回来的还是写进去时那份。
参数快照是一个 `Mapping`,存对象引用就有别名 bug:续跑时拿它和当前装配比对,而它已经跟着
调用方后来的改动变了,于是一次真的参数漂移被判成没漂移。逐行追加那个实现因为要序列化成
JSON 文本,天然免疫这件事;这个实现靠桶里存载荷换到同样的免疫。
"""
store = VolatileRunStore()
snapshot = {"model": "m-1"}
await store.write_run_started(RunStarted(run_id="r1", parameter_snapshot=snapshot))
snapshot["model"] = "m-2"
log = await store.read_log("r1")
assert log.started is not None
assert log.started.parameter_snapshot == {"model": "m-1"}
async def test_a_bad_field_type_lands_in_both_stores_and_fails_on_read_the_same_way(
tmp_path: Path,
) -> None:
"""字段类型不对的记录两个实现都收得下,都要到 `read_log` 才抛同一个 `DecodeError`。
落盘那个实现写入时只做 `json.dumps`,而一个本该是整数的字符串是合法 JSON——它写得进去。
易失那个实现要是在写入时就解一遍、当场拒绝,同一段下游代码在它上面写就红、换成落盘存储
要到读才红,而两个实现的准入标准是同一套契约套件、套件里没有一条覆盖得到这处分歧。
这两个实现在「什么时候炸」上必须一致,所以这条测试对两个都跑,而且比对异常文本本身。
"""
bad = Intent(
run_id="r1",
kind=IntentKind.MODEL_CALL,
call_index="zero", # type: ignore[arg-type]
result_id="m0",
replay_policy=ReplayPolicy.NEVER,
)
jsonl = JsonlRunStore(directory=tmp_path)
volatile = VolatileRunStore()
await jsonl.write_intent(bad)
await volatile.write_intent(bad)
with pytest.raises(DecodeError) as jsonl_error:
await jsonl.read_log("r1")
with pytest.raises(DecodeError) as volatile_error:
await volatile.read_log("r1")
assert str(volatile_error.value) == str(jsonl_error.value)
async def test_a_record_class_this_layer_does_not_know_leaves_no_trace_in_either_store(
tmp_path: Path,
) -> None:
"""这一层不认得的记录类两个实现都在写入时拒绝,而且什么都不留下。
「不留痕迹」这一半要紧的地方在易失那边:失败的那次写要是先建了桶,这个标识就再也开不了
新运行,`write_run_started` 的独占会撞上那个空桶。落盘那边对应的是「不留下一个空文件」。
"""
# 步记录是别的记录的字段,不是一条日志行——它编得出来,但存储这一层不收它。
not_a_record = _step().step
jsonl = JsonlRunStore(directory=tmp_path)
volatile = VolatileRunStore()
with pytest.raises(KeyError):
await jsonl.write_intent(not_a_record) # type: ignore[arg-type]
with pytest.raises(KeyError):
await volatile.write_intent(not_a_record) # type: ignore[arg-type]
assert list(tmp_path.iterdir()) == []
assert (await volatile.read_log("r1")).started is None
await volatile.write_run_started(_started())
assert (await volatile.read_log("r1")).started == _started()
async def test_mutating_a_record_read_back_from_a_volatile_store_does_not_change_the_log() -> None:
"""读回来之后改它,再读一次还是原来的值。
桶里存的是载荷,每次读现解出一批新对象。把桶里那份直接交出去的话,一次普通读取就足以
篡改日志——`RunStarted` 带的参数快照是个 dict,改它一下,续跑的参数漂移判断读到的就是
改过的那份,于是一次真的漂移被判成没漂移。
"""
store = VolatileRunStore()
await store.write_run_started(RunStarted(run_id="r1", parameter_snapshot={"model": "m-1"}))
first = await store.read_log("r1")
assert first.started is not None
first.started.parameter_snapshot["model"] = "m-2" # type: ignore[index]
second = await store.read_log("r1")
assert second.started is not None
assert second.started.parameter_snapshot == {"model": "m-1"}
def test_the_volatile_store_reports_only_its_kind() -> None:
"""快照里只有形态,没有实例状态。
把「现在攒了几次运行」这类状态写进去的话,同一个存储对象在两次比对之间会给出不同的答案,
于是一次装配完全没变的续跑被判成参数漂移。
"""
store = VolatileRunStore()
assert store.parameters() == {"kind": "volatile"}