deer-flow/backend/tests/test_jsonl_event_store_lock_lifecycle.py
NanPan fc4e0c32ba
fix(events): keep JSONL lock generation stable across deletion (#5455)
* fix(events): preserve JSONL lock generation across deletion

* test(events): cover JSONL lock generation after delete

* chore(events): clarify JSONL delete lock lifecycle

* style(events): restore JSONL trailing newline
2026-09-16 15:18:29 +08:00

107 lines
3.3 KiB
Python

"""Lock-registry lifecycle regressions for the JSONL run-event store."""
from __future__ import annotations
import asyncio
import threading
import pytest
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
def _event(content: str = "message") -> dict:
return {
"thread_id": "t1",
"run_id": "r1",
"event_type": "message",
"category": "message",
"content": content,
}
class _PausedDelete:
def __init__(self, operation):
self.operation = operation
self.loop = asyncio.get_running_loop()
self.entered = asyncio.Event()
self.finished = asyncio.Event()
self.release = threading.Event()
def __call__(self, *args):
self.loop.call_soon_threadsafe(self.entered.set)
try:
if not self.release.wait(10):
raise TimeoutError("test did not release paused delete")
return self.operation(*args)
finally:
self.loop.call_soon_threadsafe(self.finished.set)
async def _checkpoint() -> None:
await asyncio.sleep(0)
await asyncio.sleep(0)
@pytest.mark.anyio
async def test_delete_keeps_one_lock_generation_while_waiter_exists(tmp_path, monkeypatch):
store = JsonlRunEventStore(tmp_path)
await store.put(**_event("baseline"))
paused_delete = _PausedDelete(store._delete_thread_files)
monkeypatch.setattr(store, "_delete_thread_files", paused_delete)
original_get_lock = store._get_write_lock
second_lookup = asyncio.Event()
lookup_count = 0
def observed_get_lock(thread_id: str):
nonlocal lookup_count
lock = original_get_lock(thread_id)
lookup_count += 1
if lookup_count == 2:
second_lookup.set()
return lock
monkeypatch.setattr(store, "_get_write_lock", observed_get_lock)
release_queued = asyncio.Event()
queued_entered = asyncio.Event()
later_entered = asyncio.Event()
deletion = asyncio.create_task(store.delete_by_thread("t1"))
queued = None
later = None
try:
await asyncio.wait_for(paused_delete.entered.wait(), 5)
async def queued_operation():
queued_entered.set()
await release_queued.wait()
queued = asyncio.create_task(store._run_mutation("t1", queued_operation))
await asyncio.wait_for(second_lookup.wait(), 5)
await _checkpoint()
paused_delete.release.set()
assert await deletion == 1
await asyncio.wait_for(queued_entered.wait(), 5)
async def later_operation():
later_entered.set()
later = asyncio.create_task(store._run_mutation("t1", later_operation))
await _checkpoint()
assert not later_entered.is_set(), "a later mutation acquired a new lock generation while the old waiter still owned the thread"
release_queued.set()
await queued
await later
assert later_entered.is_set()
await _checkpoint()
assert "t1" not in store._write_locks
finally:
paused_delete.release.set()
release_queued.set()
await asyncio.gather(deletion, *([queued] if queued is not None else []), *([later] if later is not None else []), return_exceptions=True)
await asyncio.wait_for(paused_delete.finished.wait(), 5)