deer-flow/backend/tests/test_db_event_store_lock_lifecycle.py
spud 3776f6f5ec
fix(threads): clean persisted records safely on thread deletion (#5535)
* fix(events): serialize DB deletion with thread writers

* fix(runs): delete thread history without dropping reservations

* fix(feedback): support owner-scoped thread cleanup

* fix(threads): clean persisted records on deletion

* fix(threads): correct the feedback cleanup rationale

* test(runs): drop the wall-clock probe from the in-flight delete test

* docs: record the thread-delete and event-store fence contracts

* fix(threads): preserve legacy event-store delete compatibility
2026-09-18 18:32:42 +08:00

155 lines
4.5 KiB
Python

import asyncio
import weakref
import pytest
from deerflow.runtime.events.store.db import DbRunEventStore
class _PausedDeleteSession:
"""Fake session that pauses the deletion on its first aggregate read.
The fixed deletion path opens the session, takes the thread mutation fence
(a no-op on this fake's dialect) and only then reads the expected count, so
pausing in ``scalar`` proves the caller already owns the thread's mutation
critical section.
"""
def __init__(self, scalar_started: asyncio.Event, allow_scalar: asyncio.Event) -> None:
self._scalar_started = scalar_started
self._allow_scalar = allow_scalar
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get_bind(self):
# Non-PostgreSQL dialect: ``_acquire_thread_mutation_fence`` must not
# emit advisory-lock SQL here — the in-process lock is the only fence.
return None
def begin(self):
return self
async def scalar(self, _stmt):
self._scalar_started.set()
await self._allow_scalar.wait()
return 1
async def execute(self, _stmt, _params=None):
return None
async def commit(self) -> None:
return None
@pytest.mark.anyio
async def test_delete_waiter_handoff_keeps_one_write_lock_generation():
scalar_started = asyncio.Event()
allow_scalar = asyncio.Event()
session = _PausedDeleteSession(scalar_started, allow_scalar)
store = DbRunEventStore(lambda: session)
old_lock = store._get_write_lock("t1")
old_lock_ref = weakref.ref(old_lock)
await old_lock.acquire()
waiter_resolved = asyncio.Event()
waiter_entered = asyncio.Event()
release_waiter = asyncio.Event()
async def queued_writer() -> None:
lock = store._get_write_lock("t1")
waiter_resolved.set()
async with lock:
waiter_entered.set()
await release_waiter.wait()
delete_task = asyncio.create_task(store.delete_by_thread("t1", user_id=None))
await asyncio.sleep(0)
# B1 contract: deletion is queued behind the existing holder instead of
# running concurrently with it.
assert not scalar_started.is_set()
assert not delete_task.done()
waiter_task = asyncio.create_task(queued_writer())
await waiter_resolved.wait()
await asyncio.sleep(0)
assert not waiter_entered.is_set()
# Resume deletion first, then release the holder. asyncio.Lock.release()
# marks the lock unlocked before the queued waiter resumes, so the old
# implementation can evict the registry entry in that handoff window.
old_lock.release()
del old_lock
# Delete now owns the same generation, and the queued writer is still
# waiting behind it.
await scalar_started.wait()
assert not waiter_entered.is_set()
allow_scalar.set()
await delete_task
await waiter_entered.wait()
try:
# The waiter still owns the old generation. Any later writer must
# resolve that exact lock rather than creating a concurrent generation.
assert store._get_write_lock("t1") is old_lock_ref()
finally:
release_waiter.set()
await waiter_task
@pytest.mark.anyio
async def test_delete_by_thread_waits_for_thread_write_lock():
"""Deletion must not enter its DB mutation while a writer holds the lock."""
scalar_started = asyncio.Event()
allow_scalar = asyncio.Event()
allow_scalar.set()
session = _PausedDeleteSession(scalar_started, allow_scalar)
store = DbRunEventStore(lambda: session)
lock = store._get_write_lock("t1")
await lock.acquire()
task = asyncio.create_task(store.delete_by_thread("t1", user_id=None))
await asyncio.sleep(0)
assert not scalar_started.is_set()
assert not task.done()
lock.release()
assert await task == 1
assert scalar_started.is_set()
@pytest.mark.anyio
async def test_delete_by_run_waits_for_thread_write_lock():
"""delete_by_run shares the same fence as delete_by_thread."""
scalar_started = asyncio.Event()
allow_scalar = asyncio.Event()
allow_scalar.set()
session = _PausedDeleteSession(scalar_started, allow_scalar)
store = DbRunEventStore(lambda: session)
lock = store._get_write_lock("t1")
await lock.acquire()
task = asyncio.create_task(store.delete_by_run("t1", "r1", user_id=None))
await asyncio.sleep(0)
assert not scalar_started.is_set()
assert not task.done()
lock.release()
assert await task == 1
assert scalar_started.is_set()