fix: long-held connections in queue manager (#189)

* fix: long-held connections in queue manager

* fix: CR comments. tests

* fix (deriver): Limit # of work units claimed based on available workers

* fix: detached instance

* fix: batch claim work units

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
Rajat Ahuja 2025-08-13 12:54:02 -04:00 committed by GitHub
parent 7a9746ca8b
commit 8533256ca0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 262 additions and 125 deletions

View File

@ -8,8 +8,7 @@ from logging import getLogger
import sentry_sdk
from dotenv import load_dotenv
from sentry_sdk.integrations.asyncio import AsyncioIntegration
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
@ -117,37 +116,73 @@ class QueueManager:
# Polling and Scheduling #
##########################
async def get_available_work_units(self, db: AsyncSession) -> Sequence[str]:
async def get_and_claim_work_units(self) -> Sequence[str]:
"""
Get available work units that aren't being processed.
Returns a list of work unit keys.
"""
# Clean up stale work units
five_minutes_ago = datetime.now(timezone.utc) - timedelta(
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
)
await db.execute(
delete(models.ActiveQueueSession).where(
models.ActiveQueueSession.last_updated < five_minutes_ago
claimed_units: list[str] = []
async with tracked_db("get_available_work_units") as db:
# Clean up stale work units
five_minutes_ago = datetime.now(timezone.utc) - timedelta(
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
)
await db.execute(
delete(models.ActiveQueueSession).where(
models.ActiveQueueSession.last_updated < five_minutes_ago
)
)
# Get number of available workers
limit: int = max(0, self.workers - len(self.owned_work_units))
query = (
select(models.QueueItem.work_unit_key)
.outerjoin(
models.ActiveQueueSession,
models.QueueItem.work_unit_key
== models.ActiveQueueSession.work_unit_key,
)
.where(~models.QueueItem.processed)
.where(models.QueueItem.work_unit_key.isnot(None))
.where(models.ActiveQueueSession.work_unit_key.is_(None))
.distinct()
.limit(limit)
)
result = await db.execute(query)
available_units = result.scalars().all()
if not available_units:
await db.commit()
return []
claimed_units = await self.claim_work_units(db, available_units)
await db.commit()
for work_unit in claimed_units:
self.track_work_unit(work_unit)
return claimed_units
async def claim_work_units(
self, db: AsyncSession, work_unit_keys: Sequence[str]
) -> list[str]:
from sqlalchemy.dialects.postgresql import insert
values = [{"work_unit_key": key} for key in work_unit_keys]
stmt = (
insert(models.ActiveQueueSession)
.values(values)
.on_conflict_do_nothing()
.returning(models.ActiveQueueSession.work_unit_key)
)
query = (
select(models.QueueItem.work_unit_key)
.outerjoin(
models.ActiveQueueSession,
models.QueueItem.work_unit_key
== models.ActiveQueueSession.work_unit_key,
)
.where(~models.QueueItem.processed)
.where(models.QueueItem.work_unit_key.isnot(None))
.where(models.ActiveQueueSession.work_unit_key.is_(None))
.distinct()
.limit(self.workers)
)
result = await db.execute(query)
return result.scalars().all()
result = await db.execute(stmt)
claimed_units = result.scalars().all()
logger.debug(f"Claimed {len(claimed_units)} work units")
return list(claimed_units)
async def polling_loop(self) -> None:
"""Main polling loop to find and process new work units"""
@ -166,55 +201,27 @@ class QueueManager:
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
continue
# Use the dependency for transaction safety
async with tracked_db("queue_polling_loop") as db:
try:
new_work_units = await self.get_available_work_units(db)
if new_work_units and not self.shutdown_event.is_set():
for work_unit in new_work_units:
try:
# Try to claim the work unit using work_unit_key
await db.execute(
insert(models.ActiveQueueSession).values(
work_unit_key=work_unit
)
)
await db.commit()
# Track this work unit
self.track_work_unit(work_unit)
logger.debug(
f"Claimed work unit {work_unit} for processing"
)
# Create a new task for processing this work unit
if not self.shutdown_event.is_set():
task: Task[None] = asyncio.create_task(
self.process_work_unit(work_unit)
)
self.add_task(task)
except IntegrityError:
# Rollback the failed transaction to clear the error state
await db.rollback()
logger.debug(
f"Failed to claim work unit {work_unit}, already owned by another worker"
)
# If we couldn't claim any work units, avoid tight loop
else:
self.queue_empty_flag.set()
await asyncio.sleep(
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
)
except Exception as e:
logger.exception("Error in polling loop")
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Note: rollback is handled by tracked_db dependency
try:
new_work_units = await self.get_and_claim_work_units()
if new_work_units:
for work_unit in new_work_units:
# Create a new task for processing this work unit
if not self.shutdown_event.is_set():
task: Task[None] = asyncio.create_task(
self.process_work_unit(work_unit)
)
self.add_task(task)
else:
self.queue_empty_flag.set()
await asyncio.sleep(
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
)
except Exception as e:
logger.exception("Error in polling loop")
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Note: rollback is handled by tracked_db dependency
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
finally:
logger.info("Polling loop stopped")
@ -227,13 +234,12 @@ class QueueManager:
"""Process all messages for a specific work unit by routing to the correct handler."""
logger.debug(f"Starting to process work unit {work_unit_key}")
async with (
self.semaphore,
tracked_db("queue_process_work_unit") as db,
self.semaphore
): # Hold the semaphore for the entire work unit duration
message_count = 0
try:
while not self.shutdown_event.is_set():
message = await self.get_next_message(db, work_unit_key)
message = await self.get_next_message(work_unit_key)
if not message:
logger.debug(f"No more messages for work unit {work_unit_key}")
break
@ -263,15 +269,22 @@ class QueueManager:
sentry_sdk.capture_exception(e)
# Prevent malformed messages from stalling queue indefinitely
message.processed = True
async with tracked_db("process_message") as db:
await db.execute(
update(models.QueueItem)
.where(models.QueueItem.id == message.id)
.values(processed=True)
)
# Update last_updated timestamp to show this work unit is still being processed
await db.execute(
update(models.ActiveQueueSession)
.where(models.ActiveQueueSession.work_unit_key == work_unit_key)
.values(last_updated=func.now())
)
await db.commit()
await db.execute(
update(models.ActiveQueueSession)
.where(
models.ActiveQueueSession.work_unit_key == work_unit_key
)
.values(last_updated=func.now())
)
await db.commit()
if self.shutdown_event.is_set():
logger.debug(
@ -286,15 +299,10 @@ class QueueManager:
finally:
# Remove work unit from active_queue_sessions when done
logger.debug(f"Removing work unit {work_unit_key} from active sessions")
delete_result = await db.execute(
delete(models.ActiveQueueSession).where(
models.ActiveQueueSession.work_unit_key == work_unit_key
)
)
await db.commit()
removed = await self._cleanup_work_unit(work_unit_key)
# Only publish webhook if we actually removed an active session
if delete_result.rowcount > 0 and message_count > 0:
if removed and message_count > 0:
# Only publish webhook if we actually removed an active session
try:
from src.deriver.utils import parse_work_unit_key
from src.webhooks.events import (
@ -330,20 +338,32 @@ class QueueManager:
self.untrack_work_unit(work_unit_key)
@sentry_sdk.trace
async def get_next_message(
self, db: AsyncSession, work_unit_key: str
) -> QueueItem | None:
async def get_next_message(self, work_unit_key: str) -> QueueItem | None:
"""Get the next unprocessed message for a specific work unit."""
async with tracked_db("get_next_message") as db:
query = (
select(models.QueueItem)
.where(models.QueueItem.work_unit_key == work_unit_key)
.where(~models.QueueItem.processed)
.order_by(models.QueueItem.id)
.limit(1)
)
result = await db.execute(query)
message = result.scalar_one_or_none()
# Important: commit to avoid tracked_db's rollback expiring the instance
# We rely on expire_on_commit=False to keep attributes accessible post-close
await db.commit()
return message
query = (
select(models.QueueItem)
.where(models.QueueItem.work_unit_key == work_unit_key)
.where(~models.QueueItem.processed)
.order_by(models.QueueItem.id)
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def _cleanup_work_unit(self, work_unit_key: str) -> bool:
async with tracked_db("cleanup_work_unit") as db:
result = await db.execute(
delete(models.ActiveQueueSession).where(
models.ActiveQueueSession.work_unit_key == work_unit_key
)
)
await db.commit()
return result.rowcount > 0
async def main():

View File

@ -512,7 +512,10 @@ def mock_tracked_db(db_session: AsyncSession):
async def mock_tracked_db_context(_: str | None = None):
yield db_session
with patch("src.dependencies.tracked_db", mock_tracked_db_context):
with (
patch("src.dependencies.tracked_db", mock_tracked_db_context),
patch("src.deriver.queue_manager.tracked_db", mock_tracked_db_context),
):
yield

View File

@ -1,3 +1,6 @@
from collections.abc import Callable
from typing import Any
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@ -9,13 +12,12 @@ from src.deriver.queue_manager import QueueManager
class TestQueueProcessing:
"""Test suite for queue processing functionality"""
async def test_get_available_work_units(
async def test_get_and_claim_work_units(
self,
db_session: AsyncSession,
sample_queue_items: list[models.QueueItem],
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
):
"""Test that get_available_work_units correctly identifies unprocessed work"""
) -> 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
@ -25,7 +27,7 @@ class TestQueueProcessing:
queue_manager = QueueManager()
# Get available work units
work_units = await queue_manager.get_available_work_units(db_session)
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
@ -35,7 +37,7 @@ class TestQueueProcessing:
assert isinstance(work_unit, str)
assert work_unit.split(":")[0] in ["representation", "summary"]
# The test is mainly verifying that get_available_work_units works without errors
# 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(
@ -43,7 +45,7 @@ class TestQueueProcessing:
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
@ -51,29 +53,141 @@ class TestQueueProcessing:
queue_manager = QueueManager()
# Get available work units
work_units = await queue_manager.get_available_work_units(db_session)
work_units = await queue_manager.get_and_claim_work_units()
assert len(work_units) > 0
# Claim a work unit by creating an ActiveQueueSession entry
# The API already claimed returned units; verify it's tracked and not returned again
from sqlalchemy import select
work_unit = work_units[0]
active_session = models.ActiveQueueSession(
work_unit_key=work_unit,
)
db_session.add(active_session)
await db_session.commit()
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_available_work_units(db_session)
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
# We can't assert exact count difference because work units are grouped by unique combinations
async def test_stale_work_unit_cleanup(
@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()
nxt = await qm.get_next_message(first.work_unit_key)
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()
nxt2 = await qm.get_next_message(first.work_unit_key)
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
@ -82,19 +196,19 @@ class TestQueueProcessing:
# datetime.now(timezone.utc) - timedelta(minutes=10)
# We'll test this by checking that the cleanup logic works in get_available_work_units
# 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_available_work_units(db_session)
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