Skip to content

Commit 010e878

Browse files
committed
fix: harden shared request queue lock skip and completion handling
1 parent 7c79317 commit 010e878

2 files changed

Lines changed: 129 additions & 11 deletions

File tree

src/apify/storage_clients/_apify/_request_queue_shared_client.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,14 @@ async def fetch_next_request(self) -> Request | None:
267267
self._requests_in_progress.discard(next_request_id)
268268
return None
269269

270-
if lock_info is not None and (cached := self._requests_cache.get(next_request_id)) is not None:
270+
# A `None` response means the lock was not (re)acquired, so another consumer may hold it. Skip the request
271+
# rather than hand out one whose lock we do not hold.
272+
if lock_info is None:
273+
logger.debug(f'Lock of request {next_request_id} could not be re-acquired, skipping it')
274+
self._requests_in_progress.discard(next_request_id)
275+
return None
276+
277+
if (cached := self._requests_cache.get(next_request_id)) is not None:
271278
cached.lock_expires_at = lock_info.lock_expires_at
272279

273280
request = await self._get_or_hydrate_request(next_request_id)
@@ -395,7 +402,9 @@ async def is_finished(self) -> bool:
395402
"""Specific implementation of this method for the RQ shared access mode."""
396403
async with self._fetch_lock:
397404
# Order of operations is important here, because affects on `_queue_has_locked_requests`.
398-
return await self._is_empty() and not self._queue_has_locked_requests
405+
# A request handed out locally but not yet handled or reclaimed keeps the queue unfinished, even if
406+
# the platform head lists empty and reports no locked requests.
407+
return await self._is_empty() and not self._queue_has_locked_requests and not self._requests_in_progress
399408

400409
async def _is_empty(self) -> bool:
401410
"""Check whether anything is available to fetch. Lock-free core of `is_empty`, caller must hold the lock."""
@@ -457,7 +466,8 @@ async def _get_or_hydrate_request(self, request_id: str) -> Request | None:
457466
if not request:
458467
return None
459468

460-
# Update cache with hydrated request
469+
# Update cache with hydrated request, preserving any known lock expiry so the lock-liveness check in
470+
# `fetch_next_request` is not silently lost when an unhydrated head entry is hydrated here.
461471
self._cache_request(
462472
cache_key=request_id,
463473
processed_request=ProcessedRequest(
@@ -467,6 +477,7 @@ async def _get_or_hydrate_request(self, request_id: str) -> Request | None:
467477
was_already_handled=request.handled_at is not None,
468478
),
469479
hydrated_request=request,
480+
lock_expires_at=cached_entry.lock_expires_at if cached_entry else None,
470481
)
471482
except Exception as exc:
472483
logger.debug(f'Error fetching request {request_id}: {exc!s}')

tests/unit/storage_clients/test_apify_request_queue_client.py

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
RequestQueueStats,
2020
)
2121
from apify_client._models import Request as ClientRequest
22-
from crawlee.storage_clients.models import AddRequestsResponse, RequestQueueMetadata
22+
from crawlee.storage_clients.models import AddRequestsResponse, ProcessedRequest, RequestQueueMetadata
2323

2424
from apify import Request
2525
from apify.storage_clients._apify._models import ApifyRequestQueueMetadata
@@ -350,11 +350,6 @@ async def test_partial_unprocessed_commits_only_accepted_requests(access: str) -
350350
assert [request['uniqueKey'] for request in resent] == [rejected.unique_key]
351351

352352

353-
# The shared client hands out requests locked on the platform. To prevent duplicate processing it must keep those
354-
# locks alive while a request is being processed, refuse to hand out a queued request whose lock has already lapsed
355-
# (another consumer may have taken it over), and never re-hand a request it is already processing.
356-
357-
358353
def _locked_item(request: Request, *, lock_expires_at: datetime) -> LockedHeadRequest:
359354
"""Build a single locked head entry for `request` with the given lock expiry."""
360355
return LockedHeadRequest(
@@ -367,18 +362,32 @@ def _locked_item(request: Request, *, lock_expires_at: datetime) -> LockedHeadRe
367362
)
368363

369364

370-
def _locked_head(items: Sequence[LockedHeadRequest]) -> LockedRequestQueueHead:
365+
def _locked_head(
366+
items: Sequence[LockedHeadRequest],
367+
*,
368+
queue_has_locked_requests: bool = True,
369+
) -> LockedRequestQueueHead:
371370
"""Build a `list_and_lock_head` response wrapping the given locked entries."""
372371
return LockedRequestQueueHead(
373372
limit=25,
374373
queue_modified_at=datetime.now(tz=UTC),
375-
queue_has_locked_requests=True,
374+
queue_has_locked_requests=queue_has_locked_requests,
376375
had_multiple_clients=True,
377376
lock_secs=180,
378377
items=list(items),
379378
)
380379

381380

381+
def _processed(request: Request, *, was_already_handled: bool = False) -> ProcessedRequest:
382+
"""Build the `update_request` result returned by the API for a handled or reclaimed request."""
383+
return ProcessedRequest(
384+
id=unique_key_to_request_id(request.unique_key),
385+
unique_key=request.unique_key,
386+
was_already_present=True,
387+
was_already_handled=was_already_handled,
388+
)
389+
390+
382391
def _client_request(request: Request) -> ClientRequest:
383392
"""Build the API client's `Request` returned by `get_request` for a hydrated fetch."""
384393
return ClientRequest(
@@ -493,3 +502,101 @@ async def test_fetch_next_request_skips_when_lock_prolong_fails() -> None:
493502
assert first is None
494503
assert second is not None
495504
assert second.unique_key == request.unique_key
505+
506+
507+
async def test_fetch_next_request_skips_when_lock_not_reacquired() -> None:
508+
"""A `None` from `prolong_request_lock` (lock not re-acquired) is a skip, not a successful hand-out."""
509+
client, api_client = _make_shared_client()
510+
request = Request.from_url('https://example.com/1')
511+
future = datetime.now(tz=UTC) + timedelta(seconds=180)
512+
513+
api_client.list_and_lock_head = AsyncMock(
514+
return_value=_locked_head([_locked_item(request, lock_expires_at=future)])
515+
)
516+
api_client.get_request = AsyncMock(return_value=_client_request(request))
517+
# First call reports the lock as not (re)acquired via a `None` return; the second re-acquires it.
518+
api_client.prolong_request_lock = AsyncMock(side_effect=[None, RequestLockInfo(lock_expires_at=future)])
519+
520+
first = await client.fetch_next_request()
521+
second = await client.fetch_next_request()
522+
523+
assert first is None
524+
assert second is not None
525+
assert second.unique_key == request.unique_key
526+
527+
528+
async def test_is_finished_false_while_request_in_progress() -> None:
529+
"""`is_finished` stays False while a fetched request is still in progress, even if the head lists empty."""
530+
client, api_client = _make_shared_client()
531+
request = Request.from_url('https://example.com/1')
532+
future = datetime.now(tz=UTC) + timedelta(seconds=180)
533+
534+
api_client.list_and_lock_head = AsyncMock(
535+
side_effect=[
536+
_locked_head([_locked_item(request, lock_expires_at=future)]),
537+
# The in-progress request is locked, so the platform head lists empty and reports no locked requests.
538+
_locked_head([], queue_has_locked_requests=False),
539+
]
540+
)
541+
api_client.get_request = AsyncMock(return_value=_client_request(request))
542+
api_client.prolong_request_lock = AsyncMock(return_value=RequestLockInfo(lock_expires_at=future))
543+
544+
fetched = await client.fetch_next_request()
545+
546+
assert fetched is not None
547+
# The request is handed out but not yet handled or reclaimed, so the queue is not finished.
548+
assert await client.is_finished() is False
549+
550+
551+
async def test_mark_request_as_handled_clears_in_progress() -> None:
552+
"""Marking a fetched request handled stops tracking it as in progress, so the queue can finish."""
553+
client, api_client = _make_shared_client()
554+
request = Request.from_url('https://example.com/1')
555+
request_id = unique_key_to_request_id(request.unique_key)
556+
future = datetime.now(tz=UTC) + timedelta(seconds=180)
557+
558+
api_client.list_and_lock_head = AsyncMock(
559+
return_value=_locked_head([_locked_item(request, lock_expires_at=future)])
560+
)
561+
api_client.get_request = AsyncMock(return_value=_client_request(request))
562+
api_client.prolong_request_lock = AsyncMock(return_value=RequestLockInfo(lock_expires_at=future))
563+
api_client.update_request = AsyncMock(return_value=_processed(request))
564+
565+
fetched = await client.fetch_next_request()
566+
567+
assert fetched is not None
568+
assert request_id in client._requests_in_progress
569+
570+
await client.mark_request_as_handled(fetched)
571+
572+
assert request_id not in client._requests_in_progress
573+
574+
575+
async def test_reclaim_request_frees_in_progress() -> None:
576+
"""Reclaiming a fetched request stops tracking it as in progress so it can be handed out again."""
577+
client, api_client = _make_shared_client()
578+
request = Request.from_url('https://example.com/1')
579+
request_id = unique_key_to_request_id(request.unique_key)
580+
future = datetime.now(tz=UTC) + timedelta(seconds=180)
581+
582+
api_client.list_and_lock_head = AsyncMock(
583+
return_value=_locked_head([_locked_item(request, lock_expires_at=future)])
584+
)
585+
api_client.get_request = AsyncMock(return_value=_client_request(request))
586+
api_client.prolong_request_lock = AsyncMock(return_value=RequestLockInfo(lock_expires_at=future))
587+
api_client.update_request = AsyncMock(return_value=_processed(request))
588+
589+
first = await client.fetch_next_request()
590+
591+
assert first is not None
592+
assert request_id in client._requests_in_progress
593+
594+
await client.reclaim_request(first)
595+
596+
assert request_id not in client._requests_in_progress
597+
598+
# After reclaim the same request is eligible to be handed out again.
599+
second = await client.fetch_next_request()
600+
601+
assert second is not None
602+
assert second.unique_key == request.unique_key

0 commit comments

Comments
 (0)