PR Title Check / check-title (pull_request) Successful in 9s
Code Quality Pipeline / code-quality (pull_request) Failing after 53s
Test Python Package / unit-tests (pull_request) Successful in 1m1s
Test Python Package / integration-tests (pull_request) Successful in 1m44s
Test Python Package / coverage-report (pull_request) Successful in 13s
Provide a generic disk-backed FIFO queue with configurable path, retention, and dedup keys so consumers can buffer items across restarts without optional extras. Co-authored-by: Cursor <[email protected]>
242 lines
8.2 KiB
Python
242 lines
8.2 KiB
Python
"""Unit tests for FileBackedQueueAdapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from python_repositories.adapters.file_backed_queue_adapter import (
|
|
FileBackedQueueAdapter,
|
|
)
|
|
from python_repositories.adapters.memory_queue_adapter import MemoryQueueAdapter
|
|
from python_repositories.config.file_queue_config import FileQueueConfig
|
|
from python_repositories.interfaces.queue_repository_interface import (
|
|
QueueRepositoryInterface,
|
|
)
|
|
|
|
|
|
def _config(path: Path, **kwargs: object) -> FileQueueConfig:
|
|
return FileQueueConfig(path=path, **kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
def test_implements_interface() -> None:
|
|
assert issubclass(FileBackedQueueAdapter, QueueRepositoryInterface)
|
|
|
|
|
|
def test_ops_require_connect(tmp_path: Path) -> None:
|
|
queue = FileBackedQueueAdapter(config=_config(tmp_path / "q.jsonl"))
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
queue.enqueue([{"id": 1}])
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
queue.dequeue_batch()
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
queue.size()
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
queue.evict_older_than(datetime.now(UTC))
|
|
|
|
|
|
def test_enqueue_dequeue_persists_and_compacts(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
with FileBackedQueueAdapter(config=_config(path, dedup_keys=("id",))) as queue:
|
|
queue.enqueue([{"id": 1}, {"id": 2}, {"id": 1}])
|
|
assert queue.size() == 2
|
|
assert path.is_file()
|
|
lines = path.read_text(encoding="utf-8").strip().splitlines()
|
|
assert len(lines) == 2
|
|
batch = queue.dequeue_batch(max_items=1)
|
|
assert batch == [{"id": 1}]
|
|
remaining = path.read_text(encoding="utf-8").strip().splitlines()
|
|
assert len(remaining) == 1
|
|
assert queue.size() == 1
|
|
|
|
|
|
def test_replay_on_connect(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24)
|
|
now = datetime.now(UTC)
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
queue.enqueue(
|
|
[
|
|
{"id": 1, "created_at": now - timedelta(hours=1)},
|
|
{"id": 2, "created_at": now - timedelta(minutes=10)},
|
|
]
|
|
)
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
assert queue.size() == 2
|
|
assert [item["id"] for item in queue.dequeue_batch(max_items=10)] == [1, 2]
|
|
|
|
|
|
def test_replay_filters_by_max_age(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
now = datetime.now(UTC)
|
|
path.write_text(
|
|
"\n".join(
|
|
[
|
|
f'{{"id": "old", "created_at": "{(now - timedelta(hours=30)).isoformat()}"}}',
|
|
f'{{"id": "new", "created_at": "{(now - timedelta(hours=1)).isoformat()}"}}',
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24)
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
assert queue.size() == 1
|
|
assert queue.dequeue_batch(max_items=10)[0]["id"] == "new"
|
|
|
|
|
|
def test_connect_skips_corrupt_and_invalid_lines(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
path.write_text(
|
|
"\n".join(
|
|
[
|
|
"",
|
|
"not-json",
|
|
"[1, 2]",
|
|
'{"id": 1}',
|
|
'{"name": "missing-id"}',
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
config = _config(path, dedup_keys=("id",))
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
assert queue.size() == 1
|
|
assert queue.dequeue_batch(max_items=10) == [{"id": 1}]
|
|
|
|
|
|
def test_connect_skips_missing_or_invalid_age_on_replay(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
now = datetime.now(UTC)
|
|
path.write_text(
|
|
"\n".join(
|
|
[
|
|
'{"id": 1}',
|
|
'{"id": 2, "created_at": "not-a-date"}',
|
|
f'{{"id": 3, "created_at": "{now.isoformat()}"}}',
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
config = _config(path, dedup_keys=("id",), age_key="created_at")
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
assert queue.size() == 1
|
|
assert queue.dequeue_batch(max_items=10)[0]["id"] == 3
|
|
|
|
|
|
def test_connect_is_idempotent(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
queue = FileBackedQueueAdapter(config=_config(path))
|
|
queue.connect()
|
|
queue.enqueue([{"id": 1}])
|
|
queue.connect()
|
|
assert queue.size() == 1
|
|
queue.disconnect()
|
|
assert not queue.is_connected()
|
|
|
|
|
|
def test_evict_compacts_file(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24)
|
|
now = datetime.now(UTC)
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
queue.enqueue(
|
|
[
|
|
{"id": 1, "created_at": now - timedelta(hours=2)},
|
|
{"id": 2, "created_at": now - timedelta(minutes=5)},
|
|
]
|
|
)
|
|
removed = queue.evict_older_than(now - timedelta(hours=1))
|
|
assert removed == 1
|
|
assert queue.size() == 1
|
|
lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line]
|
|
assert len(lines) == 1
|
|
|
|
|
|
def test_auto_evict_on_enqueue(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=1)
|
|
now = datetime.now(UTC)
|
|
with FileBackedQueueAdapter(config=config) as queue:
|
|
queue.enqueue([{"id": 1, "created_at": now - timedelta(hours=2)}])
|
|
# Item is enqueued then immediately age-evicted.
|
|
assert queue.size() == 0
|
|
|
|
|
|
def test_from_env_when_config_omitted(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
path = tmp_path / "from-env.jsonl"
|
|
monkeypatch.setenv("FILE_QUEUE_PATH", str(path))
|
|
with FileBackedQueueAdapter() as queue:
|
|
queue.enqueue([{"id": 1}])
|
|
assert queue.size() == 1
|
|
|
|
|
|
def test_injected_memory(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
memory = MemoryQueueAdapter(dedup_keys=("id",))
|
|
config = _config(path, dedup_keys=("id",))
|
|
with FileBackedQueueAdapter(config=config, memory=memory) as queue:
|
|
queue.enqueue([{"id": 1}])
|
|
assert memory.size() == 1
|
|
|
|
|
|
def test_empty_enqueue_and_dequeue_skip_compact(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
with FileBackedQueueAdapter(config=_config(path)) as queue:
|
|
queue.enqueue([])
|
|
assert queue.dequeue_batch(max_items=10) == []
|
|
assert queue.evict_older_than(datetime.now(UTC)) == 0
|
|
assert not path.exists() or path.read_text(encoding="utf-8") == ""
|
|
|
|
|
|
def test_reconnect_replays_after_disconnect(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
queue = FileBackedQueueAdapter(config=_config(path, dedup_keys=("id",)))
|
|
queue.connect()
|
|
queue.enqueue([{"id": 1}])
|
|
queue.disconnect()
|
|
queue.connect()
|
|
assert queue.size() == 1
|
|
queue.disconnect()
|
|
|
|
|
|
def test_json_default_rejects_unsupported() -> None:
|
|
from python_repositories.adapters.file_backed_queue_adapter import _json_default
|
|
|
|
with pytest.raises(TypeError):
|
|
_json_default(object())
|
|
|
|
|
|
def test_disconnect_ignores_fsync_oserror(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
queue = FileBackedQueueAdapter(config=_config(path))
|
|
queue.connect()
|
|
|
|
def boom(_fd: int) -> None:
|
|
raise OSError("fsync failed")
|
|
|
|
monkeypatch.setattr(
|
|
"python_repositories.adapters.file_backed_queue_adapter.os.fsync",
|
|
boom,
|
|
)
|
|
queue.disconnect()
|
|
assert not queue.is_connected()
|
|
|
|
|
|
def test_append_items_requires_open_handle(tmp_path: Path) -> None:
|
|
path = tmp_path / "items.jsonl"
|
|
queue = FileBackedQueueAdapter(config=_config(path))
|
|
queue.connect()
|
|
queue._append_handle = None # noqa: SLF001
|
|
with pytest.raises(RuntimeError, match="append handle is not open"):
|
|
queue._append_items([{"id": 1}]) # noqa: SLF001
|
|
queue._connected = False # noqa: SLF001
|