deer-flow/backend/tests/test_jsonl_event_store_seq_watermark.py
Daoyuan Li be44c36b2b
fix(events): preserve JSONL sequences after run deletion and restart (#6546)
* fix(events): preserve JSONL sequences after run deletion and restart

* fix(events): match JSONL watermark permissions to run files

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 08:00:47 +08:00

186 lines
7.4 KiB
Python

"""Durable cursor allocation after JSONL run deletion and store reopening."""
import os
from pathlib import Path
import pytest
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
def event(run_id):
return {"thread_id": "t1", "run_id": run_id, "event_type": "human_message", "category": "message", "content": run_id}
async def write(store, run_id, method):
if method == "put_batch":
return (await store.put_batch([event(run_id)]))[0]
if method == "put_if_absent":
return (await store.put_if_absent(**event(run_id)))[0]
return await store.put(**event(run_id))
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["put", "put_batch", "put_if_absent"])
@pytest.mark.parametrize("reopen_before_delete", [False, True])
@pytest.mark.parametrize("delete_all", [False, True])
async def test_run_deletion_preserves_cursor_across_reopen(tmp_path, method, reopen_before_delete, delete_all):
store = JsonlRunEventStore(tmp_path)
await write(store, "r1", method)
last = await write(store, "r2", method)
assert last["seq"] == 2
if reopen_before_delete:
store = JsonlRunEventStore(tmp_path)
assert await store.delete_by_run("t1", "r2") == 1
if delete_all:
assert await store.delete_by_run("t1", "r1") == 1
reopened = JsonlRunEventStore(tmp_path)
new = await write(reopened, "r3", method)
assert new["seq"] == 3
assert [row["seq"] for row in await reopened.list_messages("t1", after_seq=last["seq"])] == [3]
@pytest.mark.anyio
async def test_thread_deletion_resets_watermark_after_all_runs_deleted(tmp_path):
store = JsonlRunEventStore(tmp_path)
await store.put(**event("r1"))
await store.delete_by_run("t1", "r1")
assert await JsonlRunEventStore(tmp_path).delete_by_thread("t1") == 0
recreated = await JsonlRunEventStore(tmp_path).put(**event("r2"))
assert recreated["seq"] == 1
@pytest.mark.anyio
async def test_missing_run_deletion_does_not_create_storage(tmp_path):
store = JsonlRunEventStore(tmp_path)
assert await store.delete_by_run("t1", "missing") == 0
assert not (tmp_path / "threads").exists()
@pytest.mark.anyio
async def test_legacy_directory_and_surviving_higher_seq(tmp_path):
runs = tmp_path / "threads" / "t1" / "runs"
runs.mkdir(parents=True)
(runs / "legacy.jsonl").write_text('{"seq":9,"run_id":"legacy","category":"message"}\n', encoding="utf-8")
store = JsonlRunEventStore(tmp_path)
assert await store.delete_by_run("t1", "legacy") == 1
assert (await store.put(**event("r1")))["seq"] == 10
# The persisted floor is 9; recovery must also consider newer run records.
assert (await JsonlRunEventStore(tmp_path).put(**event("r2")))["seq"] == 11
@pytest.mark.anyio
async def test_deleting_older_run_cannot_lower_watermark(tmp_path):
store = JsonlRunEventStore(tmp_path)
await store.put(**event("r1"))
await store.put(**event("r2"))
await store.delete_by_run("t1", "r2")
await JsonlRunEventStore(tmp_path).delete_by_run("t1", "r1")
assert (await JsonlRunEventStore(tmp_path).put(**event("r3")))["seq"] == 3
@pytest.mark.anyio
@pytest.mark.parametrize("invalid", ["garbage", "-1"])
async def test_invalid_watermark_fails_closed(tmp_path, invalid):
runs = tmp_path / "threads" / "t1" / "runs"
runs.mkdir(parents=True)
(runs / ".seq-watermark").write_text(invalid, encoding="utf-8")
with pytest.raises(ValueError):
await JsonlRunEventStore(tmp_path).put(**event("r1"))
assert not (runs / "r1.jsonl").exists()
@pytest.mark.anyio
async def test_failed_watermark_publish_keeps_previous_floor_and_run(tmp_path, monkeypatch):
store = JsonlRunEventStore(tmp_path)
await store.put(**event("r1"))
await store.delete_by_run("t1", "r1")
await store.put(**event("r2"))
replace = Path.replace
def fail_publish(path, target):
if Path(target).name == ".seq-watermark":
raise OSError("synthetic watermark publication failure")
return replace(path, target)
monkeypatch.setattr(Path, "replace", fail_publish)
with pytest.raises(OSError, match="publication failure"):
await store.delete_by_run("t1", "r2")
assert len(await store.list_events("t1", "r2")) == 1
runs = tmp_path / "threads" / "t1" / "runs"
assert (runs / ".seq-watermark").read_text(encoding="utf-8") == "1"
assert not list(runs.glob(".seq-*.tmp"))
assert (await JsonlRunEventStore(tmp_path).put(**event("r3")))["seq"] == 3
@pytest.mark.anyio
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not supported on Windows")
@pytest.mark.parametrize("run_mode", [0o600, 0o640, 0o644])
@pytest.mark.parametrize("replace_existing", [False, True])
async def test_watermark_matches_run_permissions_before_publication(tmp_path, monkeypatch, run_mode, replace_existing):
store = JsonlRunEventStore(tmp_path)
if replace_existing:
await store.put(**event("r0"))
await store.delete_by_run("t1", "r0")
last = await store.put(**event("r1"))
runs = tmp_path / "threads" / "t1" / "runs"
(runs / "r1.jsonl").chmod(run_mode)
replace = Path.replace
def check_permissions(path, target):
if Path(target).name == ".seq-watermark":
assert path.stat().st_mode & 0o777 == run_mode
return replace(path, target)
monkeypatch.setattr(Path, "replace", check_permissions)
assert await store.delete_by_run("t1", "r1") == 1
watermark = runs / ".seq-watermark"
assert watermark.stat().st_mode & 0o777 == run_mode
assert watermark.read_text(encoding="utf-8") == str(last["seq"])
assert not (runs / "r1.jsonl").exists()
assert (await JsonlRunEventStore(tmp_path).put(**event("r2")))["seq"] == last["seq"] + 1
@pytest.mark.anyio
@pytest.mark.parametrize("replace_existing", [False, True])
async def test_failed_watermark_permissions_keep_previous_floor_and_run(tmp_path, monkeypatch, replace_existing):
store = JsonlRunEventStore(tmp_path)
if replace_existing:
await store.put(**event("r1"))
await store.delete_by_run("t1", "r1")
last = await store.put(**event("r2"))
chmod = Path.chmod
def fail_permissions(path, mode, **kwargs):
if path.name.startswith(".seq-") and path.suffix == ".tmp":
raise OSError("synthetic watermark permission failure")
return chmod(path, mode, **kwargs)
monkeypatch.setattr(Path, "chmod", fail_permissions)
with pytest.raises(OSError, match="permission failure"):
await store.delete_by_run("t1", "r2")
assert len(await store.list_events("t1", "r2")) == 1
runs = tmp_path / "threads" / "t1" / "runs"
watermark = runs / ".seq-watermark"
if replace_existing:
assert watermark.read_text(encoding="utf-8") == "1"
else:
assert not watermark.exists()
assert not list(runs.glob(".seq-*.tmp"))
assert (await JsonlRunEventStore(tmp_path).put(**event("r3")))["seq"] == last["seq"] + 1
@pytest.mark.anyio
async def test_failed_run_unlink_keeps_safe_allocation_floor(tmp_path, monkeypatch):
store = JsonlRunEventStore(tmp_path)
await store.put(**event("r1"))
def fail_delete(*args):
raise OSError("synthetic unlink failure")
monkeypatch.setattr(store, "_delete_run_file", fail_delete)
with pytest.raises(OSError, match="unlink failure"):
await store.delete_by_run("t1", "r1")
assert len(await store.list_messages("t1")) == 1
assert (await JsonlRunEventStore(tmp_path).put(**event("r2")))["seq"] == 2