Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

197 lines
6.8 KiB
Python

# -*- coding: utf-8 -*-
"""Regression tests for :class:`IndexWorker.process` lease handling.
The pipeline must stop the moment the lease has been stolen by the
sweeper — otherwise the original worker and the worker that just took
over both write the same document into the vector store, producing
duplicate chunks (PR #1926 unresolved review #discussion_r3479544207).
"""
import asyncio
from datetime import timedelta
from typing import Any
from unittest import IsolatedAsyncioTestCase
from agentscope.app._service._index_worker import IndexWorker
class _LeaseStorage:
"""Minimal storage stub recording lifecycle calls.
Driven by a per-document ``renew_results`` queue so tests can stage
a "renew returns True a few times, then False" pattern that mirrors
a sweeper reaping a slow worker.
"""
def __init__(self) -> None:
self.acquire_returns: bool = True
self.renew_results: list[bool] = []
self.released: list[dict] = []
self.status_updates: list[dict] = []
self.renew_calls = 0
async def acquire_knowledge_document_lease(
self,
**kwargs: Any,
) -> bool:
"""Return the staged ``acquire_returns`` flag."""
del kwargs
return self.acquire_returns
async def renew_knowledge_document_lease(
self,
**kwargs: Any,
) -> bool:
"""Pop next staged renew result; default to ``True`` once drained."""
del kwargs
self.renew_calls += 1
if not self.renew_results:
return True
return self.renew_results.pop(0)
async def release_knowledge_document_lease(
self,
**kwargs: Any,
) -> None:
"""Record the release call so tests can assert it ran."""
self.released.append(kwargs)
async def update_knowledge_document_status(
self,
user_id: str,
knowledge_base_id: str,
document_id: str,
status: str,
error: str | None = None,
chunk_count: int | None = None,
) -> None:
"""Record the status transition for later assertion."""
del user_id, knowledge_base_id, document_id
self.status_updates.append(
{
"status": status,
"error": error,
"chunk_count": chunk_count,
},
)
class _SlowPipelineWorker(IndexWorker):
"""Replaces ``_run_pipeline`` with a long sleep so we can race the
lease timer.
The whole point of the regression is "what happens if a worker is
*still in_progress* when its lease is taken away" — the only way to
test that deterministically without standing up an embedding model
and a vector store is to make the pipeline trivially long-running.
"""
def __init__(self, storage: _LeaseStorage, pipeline_seconds: float):
# Skip the real __init__ — we only need a handful of fields.
self._storage = storage # type: ignore[assignment]
self._node_id = "test-node"
self._lease_ttl = timedelta(seconds=10)
self._sem = asyncio.Semaphore(4)
# Renew quickly so a False result is visible within the test.
self._renew_interval = timedelta(seconds=0.05)
self._pipeline_seconds = pipeline_seconds
self.pipeline_started = asyncio.Event()
self.pipeline_cancelled = False
self.pipeline_completed = False
async def _run_pipeline(
self,
user_id: str,
knowledge_base_id: str,
document_id: str,
) -> None:
"""Sleep ``pipeline_seconds`` so the test can race the heartbeat."""
del user_id, knowledge_base_id, document_id
self.pipeline_started.set()
try:
await asyncio.sleep(self._pipeline_seconds)
self.pipeline_completed = True
except asyncio.CancelledError:
self.pipeline_cancelled = True
raise
class IndexWorkerLeaseTest(IsolatedAsyncioTestCase):
"""Pipeline-vs-heartbeat race coverage."""
async def test_lost_lease_cancels_pipeline_and_marks_error(self) -> None:
"""Renew returning False mid-pipeline must abort the pipeline.
Otherwise the original worker keeps running while the new
worker (that took over the lease) also runs — both end up
inserting the same chunks into the vector store.
"""
storage = _LeaseStorage()
# First renew succeeds, second renew fails (sweeper stole it).
storage.renew_results = [True, False]
worker = _SlowPipelineWorker(storage, pipeline_seconds=5.0)
# Bound the test so a regression hangs visibly rather than
# silently passing.
await asyncio.wait_for(
worker.process("u", "kb", "doc-1"),
timeout=3.0,
)
self.assertTrue(
worker.pipeline_started.is_set(),
"Pipeline never started.",
)
self.assertTrue(
worker.pipeline_cancelled,
"Pipeline was NOT cancelled after the lease was lost — "
"this is the regression PR #1926 review flagged.",
)
self.assertFalse(
worker.pipeline_completed,
"Pipeline ran to completion despite the lost lease.",
)
# _mark_error must have recorded the lost-lease reason.
errors = [u for u in storage.status_updates if u["status"] == "error"]
self.assertEqual(len(errors), 1)
self.assertIn("Lost lease", errors[0]["error"])
# Release is still called (and is a safe no-op server-side).
self.assertEqual(len(storage.released), 1)
async def test_happy_path_cancels_heartbeat_and_releases(self) -> None:
"""Normal completion still tears the heartbeat down cleanly."""
storage = _LeaseStorage()
# Heartbeat always succeeds.
storage.renew_results = []
worker = _SlowPipelineWorker(storage, pipeline_seconds=0.05)
await asyncio.wait_for(
worker.process("u", "kb", "doc-ok"),
timeout=2.0,
)
self.assertTrue(worker.pipeline_completed)
self.assertFalse(worker.pipeline_cancelled)
# No error update on the happy path.
self.assertEqual(
[u for u in storage.status_updates if u["status"] == "error"],
[],
)
self.assertEqual(len(storage.released), 1)
async def test_not_acquired_short_circuits(self) -> None:
"""When the lease is already held by another worker, do nothing."""
storage = _LeaseStorage()
storage.acquire_returns = False
worker = _SlowPipelineWorker(storage, pipeline_seconds=5.0)
await asyncio.wait_for(
worker.process("u", "kb", "doc-locked"),
timeout=1.0,
)
self.assertFalse(worker.pipeline_started.is_set())
# No release either — we never held the lease.
self.assertEqual(storage.released, [])