2026-03-09 02:52:38 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
from collections.abc import Coroutine, Generator
|
|
|
|
|
from concurrent.futures import Future
|
|
|
|
|
from contextlib import contextmanager
|
2026-03-09 03:48:20 +08:00
|
|
|
from types import SimpleNamespace
|
2026-03-09 02:52:38 +08:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from src.services.provider_keys import batch_delete_task as taskmod
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def _fake_db_context() -> Generator[object, None, None]:
|
|
|
|
|
yield object()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fake_get_redis_client(*, require_redis: bool = False) -> object:
|
|
|
|
|
_ = require_redis
|
|
|
|
|
return object()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _noop_delete_side_effects(**_kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_run_batch_delete_waits_for_progress_updates_before_completion(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
release_progress = asyncio.Event()
|
|
|
|
|
updates: list[dict[str, object]] = []
|
|
|
|
|
|
|
|
|
|
async def fake_update_task_field(
|
|
|
|
|
task_id: str,
|
|
|
|
|
r: object | None = None,
|
|
|
|
|
**fields: object,
|
|
|
|
|
) -> None:
|
|
|
|
|
_ = task_id, r
|
|
|
|
|
updates.append(dict(fields))
|
|
|
|
|
|
|
|
|
|
def fake_sync_delete(
|
|
|
|
|
provider_id: str,
|
|
|
|
|
key_ids: list[str],
|
|
|
|
|
progress_callback: object | None = None,
|
|
|
|
|
) -> int:
|
|
|
|
|
_ = provider_id, key_ids
|
|
|
|
|
assert callable(progress_callback)
|
|
|
|
|
progress_callback(1)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
def fake_run_coroutine_threadsafe(
|
|
|
|
|
coro: Coroutine[object, object, object],
|
|
|
|
|
loop: asyncio.AbstractEventLoop,
|
|
|
|
|
) -> Future[object]:
|
|
|
|
|
future: Future[object] = Future()
|
|
|
|
|
|
|
|
|
|
async def runner() -> None:
|
|
|
|
|
await release_progress.wait()
|
|
|
|
|
try:
|
|
|
|
|
result = await coro
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
future.set_exception(exc)
|
|
|
|
|
else:
|
|
|
|
|
future.set_result(result)
|
|
|
|
|
|
|
|
|
|
loop.call_soon_threadsafe(lambda: asyncio.create_task(runner()))
|
|
|
|
|
return future
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(taskmod, "get_redis_client", _fake_get_redis_client)
|
|
|
|
|
monkeypatch.setattr(taskmod, "_update_task_field", fake_update_task_field)
|
|
|
|
|
monkeypatch.setattr(taskmod, "_sync_delete", fake_sync_delete)
|
|
|
|
|
monkeypatch.setattr(taskmod.asyncio, "run_coroutine_threadsafe", fake_run_coroutine_threadsafe)
|
|
|
|
|
monkeypatch.setattr("src.database.get_db_context", _fake_db_context)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider_keys.key_side_effects.run_delete_key_side_effects",
|
|
|
|
|
_noop_delete_side_effects,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(taskmod._run_batch_delete("task-1", "provider-1", ["key-1"]))
|
|
|
|
|
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
|
|
|
|
assert not task.done()
|
|
|
|
|
assert updates == [{"status": taskmod.STATUS_RUNNING}]
|
|
|
|
|
|
|
|
|
|
release_progress.set()
|
|
|
|
|
await task
|
|
|
|
|
|
|
|
|
|
assert updates == [
|
|
|
|
|
{"status": taskmod.STATUS_RUNNING},
|
|
|
|
|
{"deleted": 1},
|
|
|
|
|
{
|
|
|
|
|
"status": taskmod.STATUS_COMPLETED,
|
|
|
|
|
"deleted": 1,
|
|
|
|
|
"message": "1 keys deleted",
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-03-09 03:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_delete_reports_progress_after_each_batch(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
class _FakeColumn:
|
|
|
|
|
def __eq__(self, other: object) -> tuple[str, object]: # type: ignore[override]
|
|
|
|
|
return ("eq", other)
|
|
|
|
|
|
|
|
|
|
def in_(self, values: list[str]) -> tuple[str, tuple[str, ...]]:
|
|
|
|
|
return ("in", tuple(values))
|
|
|
|
|
|
|
|
|
|
class _FakeProviderAPIKey:
|
|
|
|
|
provider_id = _FakeColumn()
|
|
|
|
|
id = _FakeColumn()
|
|
|
|
|
|
|
|
|
|
class _FakeDeleteStatement:
|
|
|
|
|
def where(self, *_conditions: object) -> "_FakeDeleteStatement":
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
class _FakeSession:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.rowcounts = [2, 1]
|
|
|
|
|
self.commits = 0
|
|
|
|
|
self.closed = False
|
2026-03-14 01:31:39 +08:00
|
|
|
self.text_statements: list[str] = []
|
2026-03-09 03:48:20 +08:00
|
|
|
|
|
|
|
|
def execute(self, _statement: object) -> SimpleNamespace:
|
2026-03-14 01:31:39 +08:00
|
|
|
# SET LOCAL statement_timeout / lock_timeout 不消耗 rowcount
|
2026-03-09 03:48:20 +08:00
|
|
|
if hasattr(_statement, "text"):
|
2026-03-14 01:31:39 +08:00
|
|
|
self.text_statements.append(_statement.text)
|
2026-03-09 03:48:20 +08:00
|
|
|
return SimpleNamespace(rowcount=0)
|
|
|
|
|
return SimpleNamespace(rowcount=self.rowcounts.pop(0))
|
|
|
|
|
|
|
|
|
|
def commit(self) -> None:
|
|
|
|
|
self.commits += 1
|
|
|
|
|
|
|
|
|
|
def rollback(self) -> None:
|
|
|
|
|
raise AssertionError("rollback should not be called")
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
self.closed = True
|
|
|
|
|
|
|
|
|
|
session = _FakeSession()
|
|
|
|
|
progress_updates: list[int] = []
|
2026-03-12 01:11:35 +08:00
|
|
|
cleanup_batches: list[list[str]] = []
|
2026-03-09 03:48:20 +08:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(taskmod, "_CLEANUP_BATCH_SIZE", 2)
|
2026-03-12 01:11:35 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
taskmod,
|
|
|
|
|
"cleanup_key_references",
|
2026-03-14 01:31:39 +08:00
|
|
|
lambda _db, batch, **_kwargs: cleanup_batches.append(list(batch)),
|
2026-03-12 01:11:35 +08:00
|
|
|
)
|
2026-03-09 03:48:20 +08:00
|
|
|
monkeypatch.setattr("src.database.create_session", lambda: session)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.models.database.ProviderAPIKey",
|
|
|
|
|
_FakeProviderAPIKey,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(taskmod, "sa_delete", lambda _model: _FakeDeleteStatement())
|
|
|
|
|
|
|
|
|
|
affected = taskmod._sync_delete(
|
|
|
|
|
"provider-1",
|
|
|
|
|
["key-1", "key-2", "key-3"],
|
|
|
|
|
progress_updates.append,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert affected == 3
|
|
|
|
|
assert progress_updates == [2, 3]
|
2026-03-12 01:11:35 +08:00
|
|
|
assert cleanup_batches == [["key-1", "key-2"], ["key-3"]]
|
2026-03-09 03:48:20 +08:00
|
|
|
assert session.commits == 2
|
2026-03-14 01:31:39 +08:00
|
|
|
assert session.text_statements == [
|
|
|
|
|
"SET LOCAL statement_timeout = '30000'",
|
|
|
|
|
"SET LOCAL lock_timeout = '5000'",
|
|
|
|
|
"SET LOCAL statement_timeout = '30000'",
|
|
|
|
|
"SET LOCAL lock_timeout = '5000'",
|
|
|
|
|
]
|
|
|
|
|
assert session.closed is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_delete_retries_with_smaller_batches_after_timeout(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
class _FakeColumn:
|
|
|
|
|
def __eq__(self, other: object) -> tuple[str, object]: # type: ignore[override]
|
|
|
|
|
return ("eq", other)
|
|
|
|
|
|
|
|
|
|
def in_(self, values: list[str]) -> tuple[str, tuple[str, ...]]:
|
|
|
|
|
return ("in", tuple(values))
|
|
|
|
|
|
|
|
|
|
class _FakeProviderAPIKey:
|
|
|
|
|
provider_id = _FakeColumn()
|
|
|
|
|
id = _FakeColumn()
|
|
|
|
|
|
|
|
|
|
class _FakeDeleteStatement:
|
|
|
|
|
def where(self, *_conditions: object) -> "_FakeDeleteStatement":
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
class _FakeSession:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.rowcounts = [2, 2]
|
|
|
|
|
self.commits = 0
|
|
|
|
|
self.rollbacks = 0
|
|
|
|
|
self.closed = False
|
|
|
|
|
|
|
|
|
|
def execute(self, _statement: object) -> SimpleNamespace:
|
|
|
|
|
if hasattr(_statement, "text"):
|
|
|
|
|
return SimpleNamespace(rowcount=0)
|
|
|
|
|
return SimpleNamespace(rowcount=self.rowcounts.pop(0))
|
|
|
|
|
|
|
|
|
|
def commit(self) -> None:
|
|
|
|
|
self.commits += 1
|
|
|
|
|
|
|
|
|
|
def rollback(self) -> None:
|
|
|
|
|
self.rollbacks += 1
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
self.closed = True
|
|
|
|
|
|
|
|
|
|
session = _FakeSession()
|
|
|
|
|
progress_updates: list[int] = []
|
|
|
|
|
cleanup_batches: list[list[str]] = []
|
|
|
|
|
|
|
|
|
|
def fake_cleanup(_db: object, batch: list[str], **_kwargs: object) -> None:
|
|
|
|
|
cleanup_batches.append(list(batch))
|
|
|
|
|
if len(batch) >= 4:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"(psycopg2.errors.QueryCanceled) canceling statement due to statement timeout"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(taskmod, "_CLEANUP_BATCH_SIZE", 4)
|
|
|
|
|
monkeypatch.setattr(taskmod, "_MIN_RETRY_BATCH_SIZE", 1)
|
|
|
|
|
monkeypatch.setattr(taskmod, "cleanup_key_references", fake_cleanup)
|
|
|
|
|
monkeypatch.setattr("src.database.create_session", lambda: session)
|
|
|
|
|
monkeypatch.setattr("src.models.database.ProviderAPIKey", _FakeProviderAPIKey)
|
|
|
|
|
monkeypatch.setattr(taskmod, "sa_delete", lambda _model: _FakeDeleteStatement())
|
|
|
|
|
|
|
|
|
|
affected = taskmod._sync_delete(
|
|
|
|
|
"provider-1",
|
|
|
|
|
["key-1", "key-2", "key-3", "key-4"],
|
|
|
|
|
progress_updates.append,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert affected == 4
|
|
|
|
|
assert progress_updates == [4]
|
|
|
|
|
assert cleanup_batches == [
|
|
|
|
|
["key-1", "key-2", "key-3", "key-4"],
|
|
|
|
|
["key-1", "key-2"],
|
|
|
|
|
["key-3", "key-4"],
|
|
|
|
|
]
|
|
|
|
|
assert session.commits == 2
|
|
|
|
|
assert session.rollbacks == 1
|
2026-03-09 03:48:20 +08:00
|
|
|
assert session.closed is True
|