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
197 lines
6.8 KiB
Python
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, [])
|