458 lines
17 KiB
Python
458 lines
17 KiB
Python
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import models
|
|
from src.deriver.queue_manager import QueueManager
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestQueueProcessing:
|
|
"""Test suite for queue processing functionality"""
|
|
|
|
async def test_get_and_claim_work_units(
|
|
self,
|
|
sample_queue_items: list[models.QueueItem],
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
) -> None:
|
|
"""Test that get_and_claim_work_units correctly identifies unprocessed work"""
|
|
session, _peers = sample_session_with_peers # pyright: ignore[reportUnusedVariable]
|
|
|
|
# Verify we have queue items from our test setup
|
|
assert len(sample_queue_items) == 9 # 6 representation + 3 summary
|
|
|
|
# Create a queue manager instance
|
|
queue_manager = QueueManager()
|
|
|
|
# Get available work units
|
|
work_units = await queue_manager.get_and_claim_work_units()
|
|
|
|
# Should have some work units available (may include items from other tests)
|
|
assert len(work_units) > 0
|
|
|
|
# Check that all work units have the expected structure
|
|
for work_unit in work_units:
|
|
assert isinstance(work_unit, str)
|
|
assert work_unit.split(":")[0] in ["representation", "summary"]
|
|
|
|
# The test is mainly verifying that get_and_claim_work_units works without errors
|
|
# and returns properly structured work unit key strings
|
|
|
|
async def test_work_unit_claiming(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_queue_items: list[models.QueueItem], # noqa: ARG001 # pyright: ignore[reportUnusedParameter]
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
) -> None:
|
|
"""Test that work units can be claimed and are not available to other workers"""
|
|
_session, _peers = sample_session_with_peers
|
|
|
|
# Create a queue manager instance
|
|
queue_manager = QueueManager()
|
|
|
|
# Get available work units
|
|
work_units = await queue_manager.get_and_claim_work_units()
|
|
assert len(work_units) > 0
|
|
|
|
# The API already claimed returned units; verify it's tracked and not returned again
|
|
from sqlalchemy import select
|
|
|
|
work_unit = work_units[0]
|
|
tracked = (
|
|
await db_session.execute(
|
|
select(models.ActiveQueueSession).where(
|
|
models.ActiveQueueSession.work_unit_key == work_unit
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
assert tracked is not None
|
|
|
|
# Get available work units again - the claimed one should not be available
|
|
remaining_work_units = await queue_manager.get_and_claim_work_units()
|
|
|
|
# The claimed work unit should not be in the remaining list
|
|
assert work_unit not in remaining_work_units
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_and_claim_excludes_already_claimed(
|
|
self,
|
|
sample_queue_items: list[models.QueueItem], # noqa: ARG001 # pyright: ignore[reportUnusedParameter]
|
|
) -> None:
|
|
queue_manager = QueueManager()
|
|
first_batch = await queue_manager.get_and_claim_work_units()
|
|
assert len(first_batch) > 0
|
|
|
|
# Call again; previously claimed keys should not appear
|
|
second_batch = await queue_manager.get_and_claim_work_units()
|
|
assert all(k not in second_batch for k in first_batch)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_work_unit_conflict_returns_false(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_queue_items: list[models.QueueItem], # noqa: ARG001 # pyright: ignore[reportUnusedParameter]
|
|
) -> None:
|
|
# Pre-create an active session for a key
|
|
queue_manager = QueueManager()
|
|
claimed = await queue_manager.get_and_claim_work_units()
|
|
assert len(claimed) > 0
|
|
key = claimed[0]
|
|
|
|
# Trying to claim the same key again via the API should return empty list
|
|
claimed_again = await queue_manager.claim_work_units(db_session, [key])
|
|
assert claimed_again == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_next_message_orders_and_filters_simple(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
create_queue_payload: Callable[..., Any],
|
|
add_queue_items: Callable[..., Any],
|
|
) -> None:
|
|
from sqlalchemy import select
|
|
|
|
session, peers = sample_session_with_peers
|
|
peer = peers[0]
|
|
|
|
payloads: list[Any] = []
|
|
for i in range(3):
|
|
payloads.append(
|
|
create_queue_payload( # type: ignore[reportUnknownArgumentType]
|
|
message=models.Message(
|
|
id=i,
|
|
session_name=session.name,
|
|
workspace_name=session.workspace_name,
|
|
peer_name=peer.name,
|
|
content="hello",
|
|
), # include id for payload builder
|
|
task_type="representation",
|
|
sender_name=peer.name,
|
|
target_name=peer.name,
|
|
)
|
|
)
|
|
|
|
items = await add_queue_items(payloads, session.id)
|
|
# Determine ascending order by DB id
|
|
ordered = (
|
|
(
|
|
await db_session.execute(
|
|
select(models.QueueItem)
|
|
.where(models.QueueItem.work_unit_key == items[0].work_unit_key)
|
|
.order_by(models.QueueItem.id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
first, second = ordered[0], ordered[1]
|
|
|
|
qm = QueueManager()
|
|
batch = await qm.get_message_batch(first.work_unit_key, limit=1)
|
|
nxt = batch[0] if batch else None
|
|
assert nxt is not None and nxt.id == first.id
|
|
|
|
# Mark first processed, next should be the second
|
|
first.processed = True
|
|
await db_session.commit()
|
|
batch2 = await qm.get_message_batch(first.work_unit_key, limit=1)
|
|
nxt2 = batch2[0] if batch2 else None
|
|
assert nxt2 is not None and nxt2.id == second.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_work_unit_removes_row(
|
|
self,
|
|
sample_queue_items: list[models.QueueItem], # noqa: ARG001 # pyright: ignore[reportUnusedParameter]
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
from sqlalchemy import select
|
|
|
|
qm = QueueManager()
|
|
claimed = await qm.get_and_claim_work_units()
|
|
assert len(claimed) > 0
|
|
key = claimed[0]
|
|
|
|
removed = await qm._cleanup_work_unit(key) # pyright: ignore[reportPrivateUsage]
|
|
assert removed is True
|
|
|
|
remaining = (
|
|
await db_session.execute(
|
|
select(models.ActiveQueueSession).where(
|
|
models.ActiveQueueSession.work_unit_key == key
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
assert remaining is None
|
|
|
|
async def test_stale_work_unit_cleanup(
|
|
self,
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
) -> None:
|
|
"""Test that stale work units are cleaned up properly"""
|
|
_session, _peers = sample_session_with_peers
|
|
|
|
# Create an active queue session with an old timestamp
|
|
# from datetime import datetime, timedelta, timezone
|
|
|
|
# datetime.now(timezone.utc) - timedelta(minutes=10)
|
|
|
|
# We'll test this by checking that the cleanup logic works in get_and_claim_work_units
|
|
# which is called by the queue manager during normal operation
|
|
queue_manager = QueueManager()
|
|
|
|
# Get available work units - this should clean up stale entries
|
|
work_units = await queue_manager.get_and_claim_work_units()
|
|
|
|
# This test ensures the cleanup logic doesn't break, though we don't have stale entries yet
|
|
assert isinstance(work_units, list)
|
|
|
|
async def test_work_unit_key_format(
|
|
self, sample_session_with_peers: tuple[models.Session, list[models.Peer]]
|
|
) -> None:
|
|
"""Test that work unit keys have the correct format"""
|
|
session, peers = sample_session_with_peers
|
|
peer1, peer2, _ = peers
|
|
|
|
# Create a representation work unit key
|
|
# Format: task_type:workspace:session:sender:target
|
|
work_unit_key = (
|
|
f"representation:workspace1:{session.name}:{peer1.name}:{peer2.name}"
|
|
)
|
|
|
|
# Check that the key contains the expected information
|
|
assert session.name in work_unit_key
|
|
assert peer1.name in work_unit_key
|
|
assert peer2.name in work_unit_key
|
|
assert "representation" in work_unit_key
|
|
assert "workspace1" in work_unit_key
|
|
|
|
# Create a summary work unit key
|
|
# Summary work units use None for sender/target
|
|
summary_work_unit_key = f"summary:workspace1:{session.name}:None:None"
|
|
|
|
assert session.name in summary_work_unit_key
|
|
assert "None" in summary_work_unit_key
|
|
assert "summary" in summary_work_unit_key
|
|
assert "workspace1" in summary_work_unit_key
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_representation_batching_respects_token_limits(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
create_queue_payload: Callable[..., Any],
|
|
) -> None:
|
|
"""Test that representation tasks are batched based on token limits"""
|
|
from unittest.mock import patch
|
|
|
|
session, peers = sample_session_with_peers
|
|
peer = peers[0]
|
|
|
|
# Create messages with token counts that exceed batch limit
|
|
# Total: 2000 + 2000 + 3000 = 7000 tokens (> 4096 limit defined in settings)
|
|
messages = [
|
|
models.Message(
|
|
id=i,
|
|
session_name=session.name,
|
|
workspace_name=session.workspace_name,
|
|
peer_name=peer.name,
|
|
content=f"Test message {i}",
|
|
token_count=token_count,
|
|
)
|
|
for i, token_count in enumerate([2000, 2000, 3000])
|
|
]
|
|
|
|
# Create queue items with token counts
|
|
payloads = [
|
|
create_queue_payload( # type: ignore[reportUnknownArgumentType]
|
|
message=msg,
|
|
task_type="representation",
|
|
sender_name=peer.name,
|
|
target_name=peer.name,
|
|
)
|
|
for msg in messages
|
|
]
|
|
|
|
# Add items with token counts
|
|
from src.deriver.utils import get_work_unit_key
|
|
|
|
queue_items: list[models.QueueItem] = []
|
|
for payload, message in zip(payloads, messages, strict=False):
|
|
task_type = payload.get("task_type", "unknown")
|
|
work_unit_key = get_work_unit_key(task_type, payload)
|
|
|
|
queue_item = models.QueueItem(
|
|
session_id=session.id,
|
|
task_type=task_type,
|
|
work_unit_key=work_unit_key,
|
|
payload=payload,
|
|
processed=False,
|
|
token_count=message.token_count,
|
|
)
|
|
db_session.add(queue_item)
|
|
queue_items.append(queue_item)
|
|
|
|
await db_session.commit()
|
|
for item in queue_items:
|
|
await db_session.refresh(item)
|
|
|
|
# Mock process_items to capture batches
|
|
processed_batches: list[dict[str, Any]] = []
|
|
|
|
async def mock_process_items(
|
|
task_type: str, queue_payloads: list[dict[str, Any]]
|
|
) -> None:
|
|
processed_batches.append(
|
|
{
|
|
"task_type": task_type,
|
|
"payload_count": len(queue_payloads),
|
|
}
|
|
)
|
|
|
|
# Process work unit and verify batching
|
|
qm = QueueManager()
|
|
with patch(
|
|
"src.deriver.queue_manager.process_items", side_effect=mock_process_items
|
|
):
|
|
await qm.process_work_unit(queue_items[0].work_unit_key)
|
|
|
|
# Should create 2 batches due to token limits
|
|
assert len(processed_batches) == 2
|
|
assert processed_batches[0]["payload_count"] == 2 # 2000 + 2000
|
|
assert processed_batches[1]["payload_count"] == 1 # 3000
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hard_batch_size_limit(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
create_queue_payload: Callable[..., Any],
|
|
) -> None:
|
|
"""Test that get_message_batch respects the hard limit of 10 messages"""
|
|
session, peers = sample_session_with_peers
|
|
peer = peers[0]
|
|
|
|
# Create 15 messages to test batch size limit
|
|
messages = [
|
|
models.Message(
|
|
id=i,
|
|
session_name=session.name,
|
|
workspace_name=session.workspace_name,
|
|
peer_name=peer.name,
|
|
content=f"Test message {i}",
|
|
token_count=100, # Small tokens to avoid token-based batching
|
|
)
|
|
for i in range(15)
|
|
]
|
|
|
|
payloads = [
|
|
create_queue_payload(msg, "representation", peer.name, peer.name)
|
|
for msg in messages
|
|
]
|
|
|
|
# Create queue items
|
|
from src.deriver.utils import get_work_unit_key
|
|
|
|
queue_items: list[models.QueueItem] = []
|
|
for payload, message in zip(payloads, messages, strict=False):
|
|
task_type = payload.get("task_type", "unknown")
|
|
work_unit_key = get_work_unit_key(task_type, payload)
|
|
|
|
queue_item = models.QueueItem(
|
|
session_id=session.id,
|
|
task_type=task_type,
|
|
work_unit_key=work_unit_key,
|
|
payload=payload,
|
|
processed=False,
|
|
token_count=message.token_count,
|
|
)
|
|
db_session.add(queue_item)
|
|
queue_items.append(queue_item)
|
|
|
|
await db_session.commit()
|
|
|
|
# Test batch size limit
|
|
qm = QueueManager()
|
|
batch = await qm.get_message_batch(queue_items[0].work_unit_key, limit=10)
|
|
assert len(batch) == 10 # Should respect hard limit
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_processing(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
|
create_queue_payload: Callable[..., Any],
|
|
) -> None:
|
|
"""Test that multiple summary messages in same work unit are processed separately"""
|
|
from unittest.mock import patch
|
|
|
|
session, peers = sample_session_with_peers
|
|
peer = peers[0]
|
|
|
|
# Create two summary messages
|
|
messages = [
|
|
models.Message(
|
|
id=999,
|
|
session_name=session.name,
|
|
workspace_name=session.workspace_name,
|
|
peer_name=peer.name,
|
|
content="First summary message",
|
|
token_count=500,
|
|
),
|
|
models.Message(
|
|
id=1000,
|
|
session_name=session.name,
|
|
workspace_name=session.workspace_name,
|
|
peer_name=peer.name,
|
|
content="Second summary message",
|
|
token_count=600,
|
|
),
|
|
]
|
|
|
|
# Create payloads and queue items
|
|
queue_items: list[models.QueueItem] = []
|
|
for i, message in enumerate(messages):
|
|
payload = create_queue_payload(
|
|
message, "summary", message_seq_in_session=i + 1
|
|
)
|
|
from src.deriver.utils import get_work_unit_key
|
|
|
|
work_unit_key = get_work_unit_key("summary", payload)
|
|
|
|
queue_item = models.QueueItem(
|
|
session_id=session.id,
|
|
task_type="summary",
|
|
work_unit_key=work_unit_key,
|
|
payload=payload,
|
|
processed=False,
|
|
token_count=message.token_count,
|
|
)
|
|
db_session.add(queue_item)
|
|
queue_items.append(queue_item)
|
|
|
|
await db_session.commit()
|
|
|
|
# Mock and process work unit
|
|
processed_batches: list[dict[str, Any]] = []
|
|
|
|
async def mock_process_items(
|
|
task_type: str, queue_payloads: list[dict[str, Any]]
|
|
) -> None:
|
|
processed_batches.append(
|
|
{"task_type": task_type, "payload_count": len(queue_payloads)}
|
|
)
|
|
|
|
qm = QueueManager()
|
|
work_unit_key = queue_items[0].work_unit_key
|
|
with patch(
|
|
"src.deriver.queue_manager.process_items", side_effect=mock_process_items
|
|
):
|
|
await qm.process_work_unit(work_unit_key)
|
|
|
|
# Verify both messages were processed in separate batches
|
|
assert len(processed_batches) == 2
|
|
assert all(batch["task_type"] == "summary" for batch in processed_batches)
|
|
assert all(batch["payload_count"] == 1 for batch in processed_batches)
|