1919 RequestQueueStats ,
2020)
2121from 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
2424from apify import Request
2525from 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-
358353def _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+
382391def _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