"""两份存储实现的行为:逐行追加进文件的那个,以及只留在进程内存里的那个。 **行为契约本身在契约套件里**,那套套件不针对任何具体实现,写的是「不管你怎么实现,都必须满足 这些行为」。这里只写套件覆盖不到的部分:文件长什么样、坏行怎么算、`fsync` 在哪几处、那条契约 测试明说「这一层验不了」的原子性(它点名要在这里用可注入的故障点补上),以及易失那个实现独有 的几条——它的独占判据、它靠存载荷换来的两个方向的别名免疫。 还有几条跨着两个实现跑:坏记录在哪一步炸、写入失败留不留痕。那种「两个实现必须一样」的事只在 这里守得住——契约套件对每个实现分别跑,两边各自全绿并不代表它们一致。 """ import asyncio import json import os from pathlib import Path import pytest from polyloop.serialization import DecodeError from polyloop.stores import RECORD_KEY, JsonlRunStore, VolatileRunStore from polyloop.stores import _jsonl as jsonl_module from polyloop.types import ( ActionOutcome, ActionStatus, Intent, IntentKind, ModelCallResult, ModelReply, ReplayPolicy, RunFinished, RunResult, RunStarted, StepCompleted, StepRecord, StopReason, ) pytestmark = pytest.mark.unit def _started(run_id: str = "r1") -> RunStarted: return RunStarted(run_id=run_id, parameter_snapshot={"model": "m-1"}) def _intent(run_id: str = "r1", *, call_index: int = 0) -> Intent: return Intent( run_id=run_id, kind=IntentKind.MODEL_CALL, call_index=call_index, result_id=f"m{call_index}", replay_policy=ReplayPolicy.NEVER, ) def _step(run_id: str = "r1", *, step_idx: int = 0) -> StepCompleted: return StepCompleted( run_id=run_id, result_id=f"a{step_idx}", action_outcome=ActionOutcome( status=ActionStatus.EXECUTED, observation="输出", observation_is_synthetic=False, env_reported_completion=False, observation_truncated_chars=0, ), step=StepRecord( step_idx=step_idx, raw_output="说的话", content_chars=3, thinking_chars=0, action="做点事", parse_ok=True, parse_error=None, observation="输出", observation_is_synthetic=False, observation_truncated_chars=0, prompt_chars=10, call_id="c1", step_wall_ms=1, ), ) def _log_file(store_dir: Path, run_id: str = "r1") -> Path: return store_dir / f"{run_id}.jsonl" # --------------------------------------------------------------------------- # 文件长什么样 # --------------------------------------------------------------------------- async def test_one_run_one_file_named_after_the_run_id(tmp_path: Path) -> None: """按运行标识去目录里找文件是最自然的用法,所以文件名就是标识。""" store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started("my-run.1")) assert (tmp_path / "my-run.1.jsonl").exists() async def test_each_record_is_one_line_tagged_with_its_type(tmp_path: Path) -> None: """一行一条记录,行首带一个类型标签。 `serialization` 编出来的载荷只有记录类自己的字段——标签是存储这一层加的,因为「哪一行是 哪种记录」本来就是文件布局的事。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) await store.write_intent(_intent()) await store.write_step_completed(_step()) lines = _log_file(tmp_path).read_text(encoding="utf-8").splitlines() assert [json.loads(line)[RECORD_KEY] for line in lines] == [ "run_started", "intent", "step_completed", ] async def test_a_record_never_contains_a_raw_newline(tmp_path: Path) -> None: """一条记录占一行,靠的是 JSON 把换行转义掉。 转义要是失效了,一条带换行的观察会被拆成两行,其中一行必然解不开——而读那边会把它当成 撕裂的尾行丢掉,于是一条完整写下去的记录静默消失了。 """ store = JsonlRunStore(directory=tmp_path) record = _step() multiline = StepCompleted( run_id=record.run_id, result_id=record.result_id, action_outcome=record.action_outcome, step=StepRecord( **{ **{ field: getattr(record.step, field) for field in ( "step_idx", "raw_output", "content_chars", "thinking_chars", "action", "parse_ok", "parse_error", "observation_is_synthetic", "observation_truncated_chars", "prompt_chars", "call_id", "step_wall_ms", ) }, "observation": "第一行\n第二行\n第三行", } ), ) await store.write_step_completed(multiline) assert len(_log_file(tmp_path).read_text(encoding="utf-8").splitlines()) == 1 (readback,) = (await store.read_log("r1")).steps assert readback.step.observation == "第一行\n第二行\n第三行" async def test_two_runs_go_to_two_files(tmp_path: Path) -> None: """两次并发运行写同一个文件的话,前缀持久性就从「同一文件的追加序」退化成两条交错的序。""" store = JsonlRunStore(directory=tmp_path) await store.write_intent(_intent("run-a")) await store.write_intent(_intent("run-b")) assert (await store.read_log("run-a")).intents == (_intent("run-a"),) assert (await store.read_log("run-b")).intents == (_intent("run-b"),) @pytest.mark.parametrize("run_id", ["../escape", "a/b", ".hidden", "", "有中文", "a b"]) async def test_a_run_id_that_is_not_a_safe_file_name_is_refused( tmp_path: Path, run_id: str ) -> None: """运行标识是调用方给的不透明字符串,里面出现 `../` 的话写文件会跑到目录外面去。 不转义也不哈希:那样文件名就不再等于运行标识,而按标识去目录里找文件是最自然的用法。 """ store = JsonlRunStore(directory=tmp_path) with pytest.raises(ValueError, match="文件名"): await store.read_log(run_id) # --------------------------------------------------------------------------- # 坏行 # --------------------------------------------------------------------------- async def test_a_torn_last_line_is_dropped(tmp_path: Path) -> None: """进程被杀在一次写中途,末尾留下半行。那次写从来没被确认过,所以它就是「没发生」。""" store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) await store.write_intent(_intent()) with _log_file(tmp_path).open("a", encoding="utf-8") as handle: handle.write('{"record": "step_completed", "run_id": "r1", "resu') log = await store.read_log("r1") assert log.started == _started() assert log.intents == (_intent(),) assert log.steps == () async def test_a_bad_line_in_the_middle_is_corruption_not_a_torn_tail(tmp_path: Path) -> None: """追加写只在末尾产生撕裂。中间读不了说明别的东西动过这个文件。 跳过那一行接着读会拼出一份少了几条记录、看起来却完整的日志,而恢复会照它做判断。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) await store.write_intent(_intent()) path = _log_file(tmp_path) lines = path.read_text(encoding="utf-8").splitlines() path.write_text(f"{lines[0]}\n半行不完整\n{lines[1]}\n", encoding="utf-8") with pytest.raises(DecodeError, match="被别的东西动过"): await store.read_log("r1") async def test_a_blank_line_is_not_a_torn_tail(tmp_path: Path) -> None: """空行不携带记录,也不是撕裂的证据。""" store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) path = _log_file(tmp_path) path.write_text(path.read_text(encoding="utf-8") + "\n\n", encoding="utf-8") assert (await store.read_log("r1")).started == _started() async def test_an_unknown_record_type_is_refused_not_skipped(tmp_path: Path) -> None: """一行完整的 JSON 带着认不得的标签,说明这份文件不是本库写的,不是被杀在写一半。""" store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) with _log_file(tmp_path).open("a", encoding="utf-8") as handle: handle.write('{"record": "something_else", "run_id": "r1"}\n') with pytest.raises(DecodeError, match="认不得"): await store.read_log("r1") # --------------------------------------------------------------------------- # 原子写:契约套件明说它验不了的那一半 # --------------------------------------------------------------------------- async def test_a_write_that_dies_halfway_leaves_nothing_readable(tmp_path: Path) -> None: """崩在一次写中途,那条记录**整条不可见**,不是半条可见。 这是 `polyloop.testing.RunStoreContract` 里那条跳过点名要在这一层补的:契约套件跑在 一个进程里、面对一个已经装配好的实现,没有位置插入那次崩溃。这里靠替换掉那个内部的 「把这些字节写进去」来造它。 不成立的话,恢复会读到一条残缺的步记录——而那一步的历史文本就永远丢了。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) def _die_halfway(descriptor: int, payload: bytes) -> None: os.write(descriptor, payload[: len(payload) // 2]) raise OSError("磁盘满了") store._write_all = _die_halfway # noqa: SLF001 — 那个可注入的故障点 with pytest.raises(OSError, match="磁盘满了"): await store.write_step_completed(_step()) log = await store.read_log("r1") assert log.started == _started() assert log.steps == () async def test_the_records_written_before_a_dead_write_survive(tmp_path: Path) -> None: """崩在第 k 次写中途,前 k-1 次照样读得回来。 这是前缀持久性在「读得回来」这一层的影子。真正的前缀持久性是掉电之后的性质,这一层验 不了——它靠形态满足(同一个文件的追加写),只能靠评审看。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) await store.write_intent(_intent(call_index=0)) await store.write_step_completed(_step(step_idx=0)) def _die(descriptor: int, payload: bytes) -> None: raise OSError("盘掉了") store._write_all = _die # noqa: SLF001 — 那个可注入的故障点 with pytest.raises(OSError): await store.write_intent(_intent(call_index=1)) log = await store.read_log("r1") assert log.intents == (_intent(call_index=0),) assert len(log.steps) == 1 # --------------------------------------------------------------------------- # 并发与耐久 # --------------------------------------------------------------------------- async def test_starting_the_same_run_twice_fails_instead_of_interleaving(tmp_path: Path) -> None: """独占创建把「先读后写」那个窗口关掉。 两个进程同时跑同一个运行标识,交错的记录序会让恢复读到同一步的两条意图、判成日志被并发 写过,于是这次运行从此续不了——而两边的模型调用都已经花过钱了。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) with pytest.raises(FileExistsError): await store.write_run_started(_started()) async def test_writes_do_not_block_the_event_loop(tmp_path: Path) -> None: """写入与 `fsync` 走线程,不压在事件循环上。 `fsync` 在忙盘上可以到几十毫秒,直接在循环里做会把同一个循环上所有并发运行一起卡住。 """ store = JsonlRunStore(directory=tmp_path) ticks = 0 async def _tick() -> None: nonlocal ticks while True: ticks += 1 await asyncio.sleep(0) ticker = asyncio.ensure_future(_tick()) await store.write_run_started(_started()) ticker.cancel() assert ticks > 0 async def test_an_unwritten_run_reads_back_empty(tmp_path: Path) -> None: """驱动入口靠这条判断「这个标识是不是已经有日志了」。""" store = JsonlRunStore(directory=tmp_path) log = await store.read_log("never-written") assert log.started is None assert log.intents == () assert log.finished is None async def test_the_directory_is_created_on_first_write(tmp_path: Path) -> None: """构造廉价:目录在第一次真的要写的时候才建,不在构造时。""" nested = tmp_path / "a" / "b" store = JsonlRunStore(directory=nested) assert not nested.exists() await store.write_run_started(_started()) assert nested.exists() async def test_a_whole_log_round_trips(tmp_path: Path) -> None: """五种记录写进去、读回来逐字段相等。""" store = JsonlRunStore(directory=tmp_path) result = RunResult( run_id="r1", stop_reason=StopReason.TASK_COMPLETED, final_answer="42", steps=() ) records = [ _started(), _intent(), ModelCallResult( run_id="r1", result_id="m0", reply=ModelReply(call_id="c1", content="hi", thinking=""), failure=None, ), _step(), RunFinished(run_id="r1", result=result), ] await store.write_run_started(records[0]) # type: ignore[arg-type] await store.write_intent(records[1]) # type: ignore[arg-type] await store.write_model_call_result(records[2]) # type: ignore[arg-type] await store.write_step_completed(records[3]) # type: ignore[arg-type] await store.write_run_finished(records[4]) # type: ignore[arg-type] log = await store.read_log("r1") assert log.started == records[0] assert log.intents == (records[1],) assert log.model_results == (records[2],) assert log.steps == (records[3],) assert log.finished == records[4] def test_the_directory_does_not_enter_the_parameter_snapshot(tmp_path: Path) -> None: """目录不进快照:目录不一样就根本读不到这份日志,也走不到比对那一步。 记进去只会在换一台机器、挂载点变了的时候报出一次假的漂移,而那次续跑其实完全正常。 """ assert JsonlRunStore(directory=tmp_path).parameters() == {"kind": "jsonl"} async def test_a_torn_tail_that_happens_to_parse_is_still_dropped(tmp_path: Path) -> None: """短写正好写完整个 JSON、只差最后那个换行——这条记录照样不算数。 判据是「有没有被换行终结」,不是「能不能解析」。照后者判会漏掉一个很具体的场景:那次写 从来没有被确认过(调用方那个 await 还没返回),而它会被当成一条有效的动作意图读回来, 恢复据此判成「状态未知」并可能重放——可那个动作一定没执行过,因为调用方是在写意图返回 之后才去执行的。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) intact = json.dumps( { "record": "intent", **{ "run_id": "r1", "kind": "action", "call_index": 0, "result_id": "a0", "replay_policy": "never", }, }, ensure_ascii=False, ) with _log_file(tmp_path).open("a", encoding="utf-8") as handle: handle.write(intact) # 完整 JSON,但没有换行 log = await store.read_log("r1") assert log.started == _started() assert log.intents == () async def test_a_complete_bad_line_is_corruption_even_at_the_end(tmp_path: Path) -> None: """被换行终结的坏行是损坏,哪怕它在文件末尾。 当成撕裂尾行吞掉的话,一份被外部追加过一行垃圾的日志会读成「少了一条记录但看起来完整」, 而恢复会照它做判断。 """ store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) with _log_file(tmp_path).open("a", encoding="utf-8") as handle: handle.write("{}\n") # 合法 JSON、完整终结,但没有类型标签 with pytest.raises(DecodeError, match="标签"): await store.read_log("r1") async def test_creating_the_log_file_syncs_the_directory_entry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """新建文件要把目录项也刷下去,往已有文件追加则不必。 `os.fsync(fd)` 刷的是文件内容,刷不到「这个目录里多了一个文件」这条目录项。掉电之后内容 可能在、而文件根本不存在——那时 read_log 走「文件不存在」返回空日志,驱动入口判成一次全新 的运行,于是一次已经花过钱的运行静默没了留痕。 这条持久性没有别的进程内可观测形态,所以只能盯着那次调用本身。 """ synced: list[Path] = [] monkeypatch.setattr(jsonl_module, "_fsync_directory", synced.append) store = JsonlRunStore(directory=tmp_path) await store.write_run_started(_started()) assert synced == [tmp_path] await store.write_intent(_intent()) assert synced == [tmp_path] # 追加不改目录项 async def test_concurrent_writes_to_one_run_do_not_interleave(tmp_path: Path) -> None: """同一个运行标识上的写被串起来,一条记录不会被另一条切开。 一条记录可能由不止一次 os.write 写完(os.write 允许短写),而 O_APPEND 只保证每一次 os.write 的追加位置原子。两个协程同时写同一个文件时,一次短写会让两条记录交错成一段谁也 解不开的字节。 """ store = JsonlRunStore(directory=tmp_path) real = store._write_all # noqa: SLF001 def _short_write(descriptor: int, payload: bytes) -> None: """每次只写一半,逼出「一条记录两次 write」那个窗口。""" real(descriptor, payload[: len(payload) // 2]) real(descriptor, payload[len(payload) // 2 :]) store._write_all = _short_write # noqa: SLF001 await asyncio.gather(*(store.write_intent(_intent(call_index=index)) for index in range(6))) 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"}