Skip to content

Commit ea5f22a

Browse files
committed
add is_finished method to ApifyRequestQueueClient
1 parent 175393f commit ea5f22a

6 files changed

Lines changed: 62 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ apify-client = false
243243
apify_fingerprint_datapoints = false
244244
crawlee = false
245245

246+
[tool.uv.sources]
247+
crawlee = { git = "https://github.com/Mantisus/crawlee-python", branch = "queue-client-is-finished" }
248+
246249
# Run tasks with: uv run poe <task>
247250
[tool.poe.tasks]
248251
clean = "rm -rf .coverage .pytest_cache .ruff_cache .ty_cache build dist htmlcov"

src/apify/storage_clients/_apify/_request_queue_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,7 @@ async def reclaim_request(
198198
@override
199199
async def is_empty(self) -> bool:
200200
return await self._implementation.is_empty()
201+
202+
@override
203+
async def is_finished(self) -> bool:
204+
return await self._implementation.is_finished()

src/apify/storage_clients/_apify/_request_queue_shared_client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,14 @@ async def is_empty(self) -> bool:
293293
# Without the lock the `is_empty` is prone to falsely report True with some low probability race condition.
294294
async with self._fetch_lock:
295295
head = await self._list_head(limit=1)
296-
return len(head.items) == 0 and not self._queue_has_locked_requests
296+
return len(head.items) == 0
297+
298+
async def is_finished(self) -> bool:
299+
"""Specific implementation of this method for the RQ shared access mode."""
300+
if not await self.is_empty():
301+
return False
302+
303+
return not self._queue_has_locked_requests
297304

298305
async def _get_metadata_estimate(self) -> RequestQueueMetadata:
299306
"""Try to get cached metadata first. If multiple clients, fuse with global metadata.

src/apify/storage_clients/_apify/_request_queue_single_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ async def is_empty(self) -> bool:
279279
"""Specific implementation of this method for the RQ single access mode."""
280280
# Without the lock the `is_empty` is prone to falsely report True with some low probability race condition.
281281
await self._ensure_head_is_non_empty()
282-
return not self._head_requests and not self._requests_in_progress
282+
return not self._head_requests
283+
284+
async def is_finished(self) -> bool:
285+
"""Specific implementation of this method for the RQ single access mode."""
286+
return await self.is_empty() and not self._requests_in_progress
283287

284288
async def _ensure_head_is_non_empty(self) -> None:
285289
"""Ensure that the queue head has requests if they are available in the queue."""

tests/integration/test_request_queue.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,44 @@ async def test_empty_rq_behavior(request_queue_apify: RequestQueue, rq_poll_time
651651
assert metadata.pending_request_count == 0, f'metadata.pending_request_count={metadata.pending_request_count}'
652652

653653

654+
async def test_is_empty_and_is_finished(request_queue_apify: RequestQueue, rq_poll_timeout: int) -> None:
655+
"""Test `is_empty` and `is_finished` across the queue lifecycle."""
656+
657+
rq = request_queue_apify
658+
Actor.log.info('Request queue opened')
659+
660+
# Initially the queue is empty and finished.
661+
is_empty = await poll_until_condition(rq.is_empty, timeout=rq_poll_timeout, backoff_factor=2)
662+
is_finished = await poll_until_condition(rq.is_finished, timeout=rq_poll_timeout, backoff_factor=2)
663+
assert is_empty is True, f'is_empty={is_empty}'
664+
assert is_finished is True, f'is_finished={is_finished}'
665+
666+
# After adding a request it is neither empty nor finished.
667+
await rq.add_request('https://example.com')
668+
is_empty = await poll_until_condition(rq.is_empty, condition=lambda e: e is False, timeout=rq_poll_timeout)
669+
is_finished = await poll_until_condition(rq.is_finished, condition=lambda f: f is False, timeout=rq_poll_timeout)
670+
assert is_empty is False, f'is_empty={is_empty}'
671+
assert is_finished is False, f'is_finished={is_finished}'
672+
673+
# Fetch the request without handling it.
674+
request = await poll_until_condition(rq.fetch_next_request, timeout=rq_poll_timeout, backoff_factor=2)
675+
assert request is not None, f'request={request}'
676+
677+
# The queue is empty, because there is no request available for fetching.
678+
is_empty = await poll_until_condition(rq.is_empty, timeout=rq_poll_timeout, backoff_factor=2)
679+
assert is_empty is True, f'is_empty={is_empty}'
680+
# The queue is not finished, because there is a request being processed.
681+
is_finished = await poll_until_condition(rq.is_finished, condition=lambda f: f is False, timeout=rq_poll_timeout)
682+
assert is_finished is False, f'is_finished={is_finished}'
683+
684+
# After marking the request as handled the queue is empty and finished again.
685+
await rq.mark_request_as_handled(request)
686+
is_empty = await poll_until_condition(rq.is_empty, timeout=rq_poll_timeout, backoff_factor=2)
687+
is_finished = await poll_until_condition(rq.is_finished, timeout=rq_poll_timeout, backoff_factor=2)
688+
assert is_empty is True, f'is_empty={is_empty}'
689+
assert is_finished is True, f'is_finished={is_finished}'
690+
691+
654692
async def test_large_batch_operations(
655693
request_queue_apify: RequestQueue,
656694
rq_poll_timeout: int,

uv.lock

Lines changed: 4 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)