crawlee-python/tests/unit/storages/test_request_queue.py

1529 lines
49 KiB
Python

from __future__ import annotations
import asyncio
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from crawlee import Request, service_locator
from crawlee.configuration import Configuration
from crawlee.storage_clients import MemoryStorageClient, StorageClient
from crawlee.storage_clients.models import AddRequestsResponse, ProcessedRequest, UnprocessedRequest
from crawlee.storages import RequestQueue
from crawlee.storages._storage_instance_manager import StorageInstanceManager
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Sequence
from crawlee.storage_clients import StorageClient
@pytest.fixture
async def rq(
storage_client: StorageClient,
) -> AsyncGenerator[RequestQueue, None]:
"""Fixture that provides a request queue instance for each test."""
rq = await RequestQueue.open(
storage_client=storage_client,
)
yield rq
await rq.drop()
async def test_open_creates_new_rq(
storage_client: StorageClient,
) -> None:
"""Test that open() creates a new request queue with proper metadata."""
rq = await RequestQueue.open(
name='new-request-queue',
storage_client=storage_client,
)
# Verify request queue properties
assert rq.id is not None
assert rq.name == 'new-request-queue'
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 0
assert metadata.handled_request_count == 0
assert metadata.total_request_count == 0
await rq.drop()
async def test_open_existing_rq(
rq: RequestQueue,
storage_client: StorageClient,
) -> None:
"""Test that open() loads an existing request queue correctly."""
# Open the same request queue again
reopened_rq = await RequestQueue.open(
name=rq.name,
storage_client=storage_client,
)
# Verify request queue properties
assert rq.id == reopened_rq.id
assert rq.name == reopened_rq.name
# Verify they are the same object (from cache)
assert id(rq) == id(reopened_rq)
async def test_open_with_id_and_name(
storage_client: StorageClient,
) -> None:
"""Test that open() raises an error when both id and name are provided."""
with pytest.raises(
ValueError,
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
r'were specified: "id", "name".',
):
await RequestQueue.open(
id='some-id',
name='some-name',
storage_client=storage_client,
)
async def test_open_by_id(
storage_client: StorageClient,
) -> None:
"""Test opening a request queue by its ID."""
# First create a request queue by name
rq1 = await RequestQueue.open(
name='rq-by-id-test',
storage_client=storage_client,
)
# Add a request to identify it
await rq1.add_request('https://example.com/open-by-id-test')
# Open the request queue by ID
rq2 = await RequestQueue.open(
id=rq1.id,
storage_client=storage_client,
)
# Verify it's the same request queue
assert rq2.id == rq1.id
assert rq2.name == 'rq-by-id-test'
# Verify the request is still there
request = await rq2.fetch_next_request()
assert request is not None
assert request.url == 'https://example.com/open-by-id-test'
# Clean up
await rq2.drop()
async def test_add_request_string_url(rq: RequestQueue) -> None:
"""Test adding a request with a string URL."""
# Add a request with a string URL
url = 'https://example.com'
result = await rq.add_request(url)
# Verify request was added
assert result is not None
assert result.unique_key is not None
assert result.was_already_present is False
assert result.was_already_handled is False
# Verify the queue stats were updated
metadata = await rq.get_metadata()
assert metadata.total_request_count == 1
assert metadata.pending_request_count == 1
async def test_add_request_object(rq: RequestQueue) -> None:
"""Test adding a request object."""
# Create and add a request object
request = Request.from_url(url='https://example.com', user_data={'key': 'value'})
result = await rq.add_request(request)
# Verify request was added
assert result is not None
assert result.unique_key is not None
assert result.was_already_present is False
assert result.was_already_handled is False
# Verify the queue stats were updated
metadata = await rq.get_metadata()
assert metadata.total_request_count == 1
assert metadata.pending_request_count == 1
async def test_add_duplicate_request(rq: RequestQueue) -> None:
"""Test adding a duplicate request to the queue."""
# Add a request
url = 'https://example.com'
first_result = await rq.add_request(url)
assert first_result is not None
# Add the same request again
second_result = await rq.add_request(url)
# Verify the second request was detected as duplicate
assert second_result is not None
assert second_result.was_already_present is True
assert second_result.unique_key == first_result.unique_key
# Verify the queue stats weren't incremented twice
metadata = await rq.get_metadata()
assert metadata.total_request_count == 1
assert metadata.pending_request_count == 1
async def test_add_requests_batch(rq: RequestQueue) -> None:
"""Test adding multiple requests in a batch."""
# Create a batch of requests
urls = [
'https://example.com/page1',
'https://example.com/page2',
'https://example.com/page3',
]
# Add the requests
await rq.add_requests(urls)
# Wait for all background tasks to complete
await asyncio.sleep(0.1)
# Verify the queue stats
metadata = await rq.get_metadata()
assert metadata.total_request_count == 3
assert metadata.pending_request_count == 3
async def test_add_requests_batch_with_forefront(rq: RequestQueue) -> None:
"""Test adding multiple requests in a batch with forefront option."""
# Add some initial requests
await rq.add_request('https://example.com/page1')
await rq.add_request('https://example.com/page2')
# Add a batch of priority requests at the forefront
await rq.add_requests(
[
'https://example.com/priority1',
'https://example.com/priority2',
'https://example.com/priority3',
],
forefront=True,
)
# Wait for all background tasks to complete
await asyncio.sleep(0.1)
# Fetch requests - they should come out in priority order first
next_request1 = await rq.fetch_next_request()
assert next_request1 is not None
assert next_request1.url.startswith('https://example.com/priority')
next_request2 = await rq.fetch_next_request()
assert next_request2 is not None
assert next_request2.url.startswith('https://example.com/priority')
next_request3 = await rq.fetch_next_request()
assert next_request3 is not None
assert next_request3.url.startswith('https://example.com/priority')
# Now we should get the original requests
next_request4 = await rq.fetch_next_request()
assert next_request4 is not None
assert next_request4.url == 'https://example.com/page1'
next_request5 = await rq.fetch_next_request()
assert next_request5 is not None
assert next_request5.url == 'https://example.com/page2'
# Queue should be empty now
next_request6 = await rq.fetch_next_request()
assert next_request6 is None
async def test_add_requests_with_forefront(rq: RequestQueue) -> None:
"""Test adding requests to the front of the queue."""
# Add some initial requests
await rq.add_request('https://example.com/page1')
await rq.add_request('https://example.com/page2')
# Add a priority request at the forefront
await rq.add_request('https://example.com/priority', forefront=True)
# Fetch the next request - should be the priority one
next_request = await rq.fetch_next_request()
assert next_request is not None
assert next_request.url == 'https://example.com/priority'
@pytest.mark.parametrize('forefront', [True, False])
async def test_add_requests_retry_preserves_forefront(
monkeypatch: pytest.MonkeyPatch,
*,
forefront: bool,
) -> None:
"""Regression test: when ``add_batch_of_requests`` returns unprocessed requests, the retry must preserve the
original `forefront` value rather than silently falling back to the parameter default."""
rq = await RequestQueue.open(storage_client=MemoryStorageClient())
forefront_calls: list[bool] = []
async def patched_add_batch(
requests: Sequence[Request],
*,
forefront: bool = False,
) -> AddRequestsResponse:
forefront_calls.append(forefront)
if len(forefront_calls) == 1:
return AddRequestsResponse(
processed_requests=[],
unprocessed_requests=[UnprocessedRequest(unique_key=r.unique_key, url=r.url) for r in requests],
)
return AddRequestsResponse(
processed_requests=[
ProcessedRequest(
unique_key=r.unique_key,
was_already_present=False,
was_already_handled=False,
)
for r in requests
],
unprocessed_requests=[],
)
monkeypatch.setattr(rq._client, 'add_batch_of_requests', patched_add_batch)
try:
await rq.add_requests(
['https://example.com/a', 'https://example.com/b'],
forefront=forefront,
wait_time_between_batches=timedelta(seconds=0),
)
finally:
await rq.drop()
assert forefront_calls == [forefront, forefront], (
f'retry must propagate the original forefront={forefront} flag, got: {forefront_calls}'
)
async def test_add_requests_mixed_forefront(rq: RequestQueue) -> None:
"""Test the ordering when adding requests with mixed forefront values."""
# Add normal requests
await rq.add_request('https://example.com/normal1')
await rq.add_request('https://example.com/normal2')
# Add a batch with forefront=True
await rq.add_requests(
['https://example.com/priority1', 'https://example.com/priority2'],
forefront=True,
)
# Add another normal request
await rq.add_request('https://example.com/normal3')
# Add another priority request
await rq.add_request('https://example.com/priority3', forefront=True)
# Wait for background tasks
await asyncio.sleep(0.1)
# The expected order should be:
# 1. priority3 (most recent forefront)
# 2. priority1 (from batch, forefront)
# 3. priority2 (from batch, forefront)
# 4. normal1 (oldest normal)
# 5. normal2
# 6. normal3 (newest normal)
requests = []
while True:
req = await rq.fetch_next_request()
if req is None:
break
requests.append(req)
await rq.mark_request_as_handled(req)
assert len(requests) == 6
assert requests[0].url == 'https://example.com/priority3'
# The next two should be from the forefront batch (exact order within batch may vary)
batch_urls = {requests[1].url, requests[2].url}
assert 'https://example.com/priority1' in batch_urls
assert 'https://example.com/priority2' in batch_urls
# Then the normal requests in order
assert requests[3].url == 'https://example.com/normal1'
assert requests[4].url == 'https://example.com/normal2'
assert requests[5].url == 'https://example.com/normal3'
async def test_fetch_next_request_and_mark_handled(rq: RequestQueue) -> None:
"""Test fetching and marking requests as handled."""
# Add some requests
await rq.add_request('https://example.com/page1')
await rq.add_request('https://example.com/page2')
# Fetch first request
request1 = await rq.fetch_next_request()
assert request1 is not None
assert request1.url == 'https://example.com/page1'
# Mark the request as handled
result = await rq.mark_request_as_handled(request1)
assert result is not None
assert result.was_already_handled is True
# Fetch next request
request2 = await rq.fetch_next_request()
assert request2 is not None
assert request2.url == 'https://example.com/page2'
# Mark the second request as handled
await rq.mark_request_as_handled(request2)
# Verify counts
metadata = await rq.get_metadata()
assert metadata.total_request_count == 2
assert metadata.handled_request_count == 2
assert metadata.pending_request_count == 0
# Verify queue is empty
empty_request: Request | None = await rq.fetch_next_request()
assert empty_request is None
async def test_get_request_by_id(rq: RequestQueue) -> None:
"""Test retrieving a request by its ID."""
# Add a request
added_result = await rq.add_request('https://example.com')
assert added_result is not None
unique_key = added_result.unique_key
# Retrieve the request by ID
retrieved_request = await rq.get_request(unique_key)
assert retrieved_request is not None
assert retrieved_request.unique_key == unique_key
assert retrieved_request.url == 'https://example.com'
async def test_handled_request_records_persistence(rq: RequestQueue) -> None:
request = Request.from_url('https://example.com/1')
await rq.add_request(request)
fetched_request = await rq.fetch_next_request()
assert isinstance(fetched_request, Request)
await rq.mark_request_as_handled(fetched_request)
fetched_request = await rq.get_request(request.unique_key)
assert isinstance(fetched_request, Request)
assert fetched_request.unique_key == request.unique_key
async def test_get_non_existent_request(rq: RequestQueue) -> None:
"""Test retrieving a request that doesn't exist."""
non_existent_request = await rq.get_request('non-existent-id')
assert non_existent_request is None
async def test_reclaim_request(rq: RequestQueue) -> None:
"""Test reclaiming a request that failed processing."""
# Add a request
await rq.add_request('https://example.com')
# Fetch the request
request = await rq.fetch_next_request()
assert request is not None
# Reclaim the request
result = await rq.reclaim_request(request)
assert result is not None
assert result.was_already_handled is False
# Verify we can fetch it again
reclaimed_request = await rq.fetch_next_request()
assert reclaimed_request is not None
assert reclaimed_request.unique_key == request.unique_key
assert reclaimed_request.url == 'https://example.com'
async def test_reclaim_request_with_forefront(rq: RequestQueue) -> None:
"""Test reclaiming a request to the front of the queue."""
# Add requests
await rq.add_request('https://example.com/first')
await rq.add_request('https://example.com/second')
# Fetch the first request
first_request = await rq.fetch_next_request()
assert first_request is not None
assert first_request.url == 'https://example.com/first'
# Reclaim it to the forefront
await rq.reclaim_request(first_request, forefront=True)
# The reclaimed request should be returned first (before the second request)
next_request = await rq.fetch_next_request()
assert next_request is not None
assert next_request.url == 'https://example.com/first'
async def test_is_empty(rq: RequestQueue) -> None:
"""Test checking if a request queue is empty."""
# Initially the queue should be empty
assert await rq.is_empty() is True
# Add a request
await rq.add_request('https://example.com')
assert await rq.is_empty() is False
# Fetch and handle the request
request = await rq.fetch_next_request()
assert request is not None
await rq.mark_request_as_handled(request)
# Queue should be empty again
assert await rq.is_empty() is True
@pytest.mark.parametrize(
('wait_for_all'),
[
pytest.param(True, id='wait for all'),
pytest.param(False, id='do not wait for all'),
],
)
async def test_add_requests_wait_for_all(
rq: RequestQueue,
*,
wait_for_all: bool,
) -> None:
"""Test adding requests with wait_for_all_requests_to_be_added option."""
urls = [f'https://example.com/{i}' for i in range(15)]
# Add requests without waiting
await rq.add_requests(
urls,
batch_size=5,
wait_for_all_requests_to_be_added=wait_for_all,
wait_time_between_batches=timedelta(milliseconds=50),
)
if not wait_for_all:
# Immediately after adding, the total count may be less than 15 due to background processing
assert await rq.get_total_count() <= 15
# Wait for background tasks to complete
while await rq.get_total_count() < 15: # noqa: ASYNC110
await asyncio.sleep(0.1)
# Verify all requests were added
assert await rq.get_total_count() == 15
async def test_is_finished(rq: RequestQueue) -> None:
"""Test checking if a request queue is finished."""
# Initially the queue should be finished (empty and no background tasks)
assert await rq.is_finished() is True
# Add a request
await rq.add_request('https://example.com')
assert await rq.is_finished() is False
# Add requests in the background
await rq.add_requests(
['https://example.com/1', 'https://example.com/2'],
wait_for_all_requests_to_be_added=False,
)
# Queue shouldn't be finished while background tasks are running
assert await rq.is_finished() is False
# Wait for background tasks to finish
await asyncio.sleep(0.2)
# Process all requests
while True:
request = await rq.fetch_next_request()
if request is None:
break
await rq.mark_request_as_handled(request)
# Now queue should be finished
assert await rq.is_finished() is True
async def test_mark_non_existent_request_as_handled(rq: RequestQueue) -> None:
"""Test marking a non-existent request as handled."""
# Create a request that hasn't been added to the queue
request = Request.from_url(url='https://example.com', id='non-existent-id')
# Attempt to mark it as handled
result = await rq.mark_request_as_handled(request)
assert result is None
async def test_reclaim_non_existent_request(rq: RequestQueue) -> None:
"""Test reclaiming a non-existent request."""
# Create a request that hasn't been added to the queue
request = Request.from_url(url='https://example.com', id='non-existent-id')
# Attempt to reclaim it
result = await rq.reclaim_request(request)
assert result is None
async def test_drop(
storage_client: StorageClient,
) -> None:
"""Test dropping a request queue removes it from cache and clears its data."""
rq = await RequestQueue.open(
name='drop-test',
storage_client=storage_client,
)
# Add a request
await rq.add_request('https://example.com')
# Drop the request queue
await rq.drop()
# Verify request queue is empty (by creating a new one with the same name)
new_rq = await RequestQueue.open(
name='drop-test',
storage_client=storage_client,
)
# Verify the queue is empty
assert await new_rq.is_empty() is True
metadata = await new_rq.get_metadata()
assert metadata.total_request_count == 0
assert metadata.pending_request_count == 0
await new_rq.drop()
async def test_reopen_default(
storage_client: StorageClient,
) -> None:
"""Test reopening the default request queue."""
# First clean up any storage instance caches
storage_instance_manager = service_locator.storage_instance_manager
storage_instance_manager.clear_cache()
# Open the default request queue
rq1 = await RequestQueue.open(
storage_client=storage_client,
)
# If a request queue already exists (due to previous test run), purge it to start fresh
try:
await rq1.purge()
except Exception:
# If purge fails, try dropping and recreating
await rq1.drop()
rq1 = await RequestQueue.open(
storage_client=storage_client,
)
# Verify we're starting fresh
metadata1 = await rq1.get_metadata()
assert metadata1.pending_request_count == 0
# Add a request
await rq1.add_request('https://example.com/')
# Verify the request was added
metadata1 = await rq1.get_metadata()
assert metadata1.pending_request_count == 1
# Open the default request queue again
rq2 = await RequestQueue.open(
storage_client=storage_client,
)
# Verify they are the same queue
assert rq1.id == rq2.id
assert rq1.name == rq2.name
metadata1 = await rq1.get_metadata()
metadata2 = await rq2.get_metadata()
assert metadata1.total_request_count == metadata2.total_request_count
assert metadata1.pending_request_count == metadata2.pending_request_count
assert metadata1.handled_request_count == metadata2.handled_request_count
# Verify the request is accessible
request = await rq2.fetch_next_request()
assert request is not None
assert request.url == 'https://example.com/'
# Clean up after the test
await rq1.drop()
async def test_purge(
storage_client: StorageClient,
) -> None:
"""Test purging a request queue removes all requests but keeps the queue itself."""
# First create a request queue
rq = await RequestQueue.open(
name='purge-test-queue',
storage_client=storage_client,
)
# Add some requests
await rq.add_requests(
[
'https://example.com/page1',
'https://example.com/page2',
'https://example.com/page3',
]
)
# Verify requests were added
metadata = await rq.get_metadata()
assert metadata.total_request_count == 3
assert metadata.pending_request_count == 3
assert metadata.handled_request_count == 0
# Record the queue ID
queue_id = rq.id
# Purge the queue
await rq.purge()
# Verify the queue still exists but is empty
assert rq.id == queue_id # Same ID preserved
assert rq.name == 'purge-test-queue' # Same name preserved
# Queue should be empty now
metadata = await rq.get_metadata()
assert metadata.total_request_count == 0
assert metadata.pending_request_count == 0
assert metadata.handled_request_count == 0
assert await rq.is_empty() is True
# Verify we can add new requests after purging
await rq.add_request('https://example.com/new-after-purge')
request = await rq.fetch_next_request()
assert request is not None
assert request.url == 'https://example.com/new-after-purge'
# Clean up
await rq.drop()
async def test_purge_falls_back_to_drop_for_unnamed_queue_when_not_implemented(
storage_client: StorageClient,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that `purge` falls back to drop+recreate for unnamed queues when the client raises `NotImplementedError`.
Some storage clients (e.g. the Apify platform client) do not support purging. For the default unnamed queue
used by `BasicCrawler`, `purge` should drop and recreate the queue so that callers keep working on repeated
runs. Named queues are handled separately to avoid destroying persistent data.
"""
rq = await RequestQueue.open(storage_client=storage_client)
assert rq.name is None
await rq.add_requests(['https://example.com/1', 'https://example.com/2'])
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 2
async def _raise_not_implemented(self: object) -> None:
raise NotImplementedError('Purge is not supported.')
monkeypatch.setattr(type(rq._client), 'purge', _raise_not_implemented)
with caplog.at_level('WARNING'):
await rq.purge()
assert any(
'does not support purging' in rec.message and 'dropping and recreating' in rec.message for rec in caplog.records
)
# The queue should be empty, usable, and backed by a fresh client (id may differ for backends that mint new ids).
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 0
assert metadata.total_request_count == 0
assert metadata.handled_request_count == 0
assert rq.id is not None
await rq.add_request('https://example.com/after-purge')
request = await rq.fetch_next_request()
assert request is not None
assert request.url == 'https://example.com/after-purge'
await rq.drop()
async def test_purge_skips_named_queue_when_not_implemented(
storage_client: StorageClient,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that `purge` is a logged no-op for named queues when the client raises `NotImplementedError`.
Named queues are considered persistent (e.g. shared across runs on the Apify platform), so falling back
to drop+recreate would silently destroy user data. Instead `purge` logs a warning and leaves the queue
intact.
"""
rq = await RequestQueue.open(
name='purge-fallback-named-test',
storage_client=storage_client,
)
original_id = rq.id
await rq.add_requests(['https://example.com/1', 'https://example.com/2'])
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 2
async def _raise_not_implemented(self: object) -> None:
raise NotImplementedError('Purge is not supported.')
monkeypatch.setattr(type(rq._client), 'purge', _raise_not_implemented)
with caplog.at_level('WARNING'):
await rq.purge()
assert any(
'does not support purging' in rec.message and 'Skipping purge for named queue' in rec.message
for rec in caplog.records
)
# Queue identity and contents must be preserved.
assert rq.id == original_id
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 2
assert metadata.total_request_count == 2
await rq.drop()
async def test_open_with_alias(
storage_client: StorageClient,
) -> None:
"""Test opening request queues with alias parameter for NDU functionality."""
# Create request queues with different aliases
rq_1 = await RequestQueue.open(
alias='test_alias_1',
storage_client=storage_client,
)
rq_2 = await RequestQueue.open(
alias='test_alias_2',
storage_client=storage_client,
)
# Verify they have different IDs but no names (unnamed)
assert rq_1.id != rq_2.id
assert rq_1.name is None
assert rq_2.name is None
# Add different requests to each
await rq_1.add_request('https://example.com/1')
await rq_1.add_request('https://example.com/2')
await rq_2.add_request('https://example.com/3')
# Verify data isolation
request_1 = await rq_1.fetch_next_request()
request_2 = await rq_2.fetch_next_request()
assert request_1 is not None
assert request_2 is not None
assert request_1.url == 'https://example.com/1'
assert request_2.url == 'https://example.com/3'
# Clean up
await rq_1.drop()
await rq_2.drop()
async def test_alias_caching(
storage_client: StorageClient,
) -> None:
"""Test that request queues with same alias return same instance (cached)."""
# Open rq with alias
rq_1 = await RequestQueue.open(
alias='cache_test',
storage_client=storage_client,
)
# Open again with same alias
rq_2 = await RequestQueue.open(
alias='cache_test',
storage_client=storage_client,
)
# Should be same instance
assert rq_1 is rq_2
assert rq_1.id == rq_2.id
# Clean up
await rq_1.drop()
async def test_alias_with_id_error(
storage_client: StorageClient,
) -> None:
"""Test that providing both alias and id raises error."""
with pytest.raises(
ValueError,
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
r'were specified: "id", "alias".',
):
await RequestQueue.open(
id='some-id',
alias='some-alias',
storage_client=storage_client,
)
async def test_alias_with_name_error(
storage_client: StorageClient,
) -> None:
"""Test that providing both alias and name raises error."""
with pytest.raises(
ValueError,
match=r'Only one of "id", "name", "alias" can be specified, but following arguments '
r'were specified: "name", "alias".',
):
await RequestQueue.open(
name='some-name',
alias='some-alias',
storage_client=storage_client,
)
async def test_alias_with_special_characters(
storage_client: StorageClient,
) -> None:
"""Test alias functionality with special characters."""
special_aliases = [
'alias-with-dashes',
'alias_with_underscores',
'alias.with.dots',
'alias123with456numbers',
'CamelCaseAlias',
]
queues = []
for alias in special_aliases:
rq = await RequestQueue.open(
alias=alias,
storage_client=storage_client,
)
queues.append(rq)
# Add request with the alias as identifier in URL
await rq.add_request(f'https://example.com/{alias}')
# Verify all work correctly
for i, rq in enumerate(queues):
request = await rq.fetch_next_request()
assert request is not None
assert f'/{special_aliases[i]}' in request.url
# Clean up
for rq in queues:
await rq.drop()
async def test_alias_request_operations(
storage_client: StorageClient,
) -> None:
"""Test that request operations work correctly with alias queues."""
rq = await RequestQueue.open(
alias='request_ops_test',
storage_client=storage_client,
)
# Test adding multiple requests
urls = [
'https://example.com/page1',
'https://example.com/page2',
'https://example.com/page3',
]
for url in urls:
result = await rq.add_request(url)
assert result is not None
assert result.was_already_present is False
# Test queue metadata
metadata = await rq.get_metadata()
assert metadata.total_request_count == 3
assert metadata.pending_request_count == 3
assert metadata.handled_request_count == 0
# Test fetching and handling requests
processed_urls = []
while not await rq.is_empty():
request = await rq.fetch_next_request()
if request:
processed_urls.append(request.url)
await rq.mark_request_as_handled(request)
# Verify all requests were processed
assert len(processed_urls) == 3
assert set(processed_urls) == set(urls)
# Verify final state
metadata = await rq.get_metadata()
assert metadata.pending_request_count == 0
assert metadata.handled_request_count == 3
assert await rq.is_empty() is True
# Clean up
await rq.drop()
async def test_alias_forefront_operations(
storage_client: StorageClient,
) -> None:
"""Test forefront operations work correctly with alias queues."""
rq = await RequestQueue.open(
alias='forefront_test',
storage_client=storage_client,
)
# Add normal requests
await rq.add_request('https://example.com/normal1')
await rq.add_request('https://example.com/normal2')
# Add priority request to forefront
await rq.add_request('https://example.com/priority', forefront=True)
# Priority request should come first
priority_request = await rq.fetch_next_request()
assert priority_request is not None
assert priority_request.url == 'https://example.com/priority'
# Then normal requests
normal_request = await rq.fetch_next_request()
assert normal_request is not None
assert normal_request.url == 'https://example.com/normal1'
# Clean up
await rq.drop()
async def test_alias_batch_operations(
storage_client: StorageClient,
) -> None:
"""Test batch operations work correctly with alias queues."""
rq = await RequestQueue.open(
alias='batch_test',
storage_client=storage_client,
)
# Test batch adding
batch_urls = [
'https://example.com/batch1',
'https://example.com/batch2',
'https://example.com/batch3',
]
await rq.add_requests(batch_urls)
# Wait for background processing
await asyncio.sleep(0.1)
# Verify all requests were added
metadata = await rq.get_metadata()
assert metadata.total_request_count == 3
# Clean up
await rq.drop()
async def test_named_vs_alias_conflict_detection(
storage_client: StorageClient,
) -> None:
"""Test that conflicts between named and alias storages are detected."""
# Test 1: Create named storage first, then try alias with same name
named_rq = await RequestQueue.open(
name='conflict-test',
storage_client=storage_client,
)
assert named_rq.name == 'conflict-test'
# Try to create alias with same name - should raise error
with pytest.raises(ValueError, match=r'Cannot create alias storage "conflict-test".*already exists'):
await RequestQueue.open(alias='conflict-test', storage_client=storage_client)
# Clean up
await named_rq.drop()
# Test 2: Create alias first, then try named with same name
alias_rq = await RequestQueue.open(alias='conflict-test2', storage_client=storage_client)
assert alias_rq.name is None # Alias storages have no name
# Try to create named with same name - should raise error
with pytest.raises(ValueError, match=r'Cannot create named storage "conflict-test2".*already exists'):
await RequestQueue.open(name='conflict-test2', storage_client=storage_client)
# Clean up
await alias_rq.drop()
# Test 3: Different names should work fine
named_rq_ok = await RequestQueue.open(name='different-name')
alias_rq_ok = await RequestQueue.open(alias='different-alias')
assert named_rq_ok.name == 'different-name'
assert alias_rq_ok.name is None
# Clean up
await named_rq_ok.drop()
await alias_rq_ok.drop()
async def test_alias_parameter(
storage_client: StorageClient,
) -> None:
"""Test request queue creation and operations with alias parameter."""
# Create request queue with alias
alias_rq = await RequestQueue.open(
alias='test_alias',
storage_client=storage_client,
)
# Verify alias request queue properties
assert alias_rq.id is not None
assert alias_rq.name is None # Alias storages should be unnamed
# Test data operations
await alias_rq.add_request('https://example.com/alias')
metadata = await alias_rq.get_metadata()
assert metadata.pending_request_count == 1
await alias_rq.drop()
async def test_alias_vs_named_isolation(
storage_client: StorageClient,
) -> None:
"""Test that alias and named request queues with same identifier are isolated."""
# Create named request queue
named_rq = await RequestQueue.open(
name='test-identifier',
storage_client=storage_client,
)
# Verify named request queue
assert named_rq.name == 'test-identifier'
await named_rq.add_request('https://named.example.com')
# Clean up named request queue first
await named_rq.drop()
# Now create alias request queue with same identifier (should work after cleanup)
alias_rq = await RequestQueue.open(
alias='test-identifier',
storage_client=storage_client,
)
# Should be different instance
assert alias_rq.name is None
await alias_rq.add_request('https://alias.example.com')
# Verify alias data
alias_request = await alias_rq.fetch_next_request()
assert alias_request is not None
assert alias_request.url == 'https://alias.example.com'
await alias_rq.drop()
async def test_default_vs_alias_default_equivalence(
storage_client: StorageClient,
) -> None:
"""Test that default request queue and alias='default' are equivalent."""
# Open default request queue
default_rq = await RequestQueue.open(
storage_client=storage_client,
)
alias_default_rq = await RequestQueue.open(
alias=StorageInstanceManager._DEFAULT_STORAGE_ALIAS,
storage_client=storage_client,
)
# Should be the same
assert default_rq.id == alias_default_rq.id
assert default_rq.name is None
assert alias_default_rq.name is None
# Data should be shared
await default_rq.add_request('https://default.example.com')
metadata = await alias_default_rq.get_metadata()
assert metadata.pending_request_count == 1
await default_rq.drop()
async def test_multiple_alias_isolation(
storage_client: StorageClient,
) -> None:
"""Test that different aliases create separate request queues."""
request_queues = []
for i in range(3):
rq = await RequestQueue.open(
alias=f'alias_{i}',
storage_client=storage_client,
)
await rq.add_request(f'https://example.com/alias_{i}')
request_queues.append(rq)
# All should be different
for i in range(3):
for j in range(i + 1, 3):
assert request_queues[i].id != request_queues[j].id
# Verify data isolation
for i, rq in enumerate(request_queues):
request = await rq.fetch_next_request()
assert request is not None
assert request.url == f'https://example.com/alias_{i}'
await rq.drop()
async def test_purge_on_start_enabled(storage_client: StorageClient) -> None:
"""Test purge behavior when purge_on_start=True: named storages retain data, unnamed storages are purged."""
# Skip this test for memory storage since it doesn't persist data between client instances.
if isinstance(storage_client, MemoryStorageClient):
pytest.skip('Memory storage does not persist data between client instances.')
configuration = Configuration(purge_on_start=True)
# First, create all storage types with purge enabled and add data.
default_rq = await RequestQueue.open(
storage_client=storage_client,
configuration=configuration,
)
alias_rq = await RequestQueue.open(
alias='purge-test-alias',
storage_client=storage_client,
configuration=configuration,
)
named_rq = await RequestQueue.open(
name='purge-test-named',
storage_client=storage_client,
configuration=configuration,
)
await default_rq.add_requests(
[
'https://default.example.com/1',
'https://default.example.com/2',
'https://default.example.com/3',
]
)
await alias_rq.add_requests(
[
'https://alias.example.com/1',
'https://alias.example.com/2',
'https://alias.example.com/3',
]
)
await named_rq.add_requests(
[
'https://named.example.com/1',
'https://named.example.com/2',
'https://named.example.com/3',
]
)
default_request = await default_rq.fetch_next_request()
alias_request = await alias_rq.fetch_next_request()
named_request = await named_rq.fetch_next_request()
assert default_request is not None
assert alias_request is not None
assert named_request is not None
await default_rq.mark_request_as_handled(default_request)
await alias_rq.mark_request_as_handled(alias_request)
await named_rq.mark_request_as_handled(named_request)
# Verify data was added
default_metadata = await default_rq.get_metadata()
alias_metadata = await alias_rq.get_metadata()
named_metadata = await named_rq.get_metadata()
assert default_metadata.pending_request_count == 2
assert alias_metadata.pending_request_count == 2
assert named_metadata.pending_request_count == 2
assert default_metadata.handled_request_count == 1
assert alias_metadata.handled_request_count == 1
assert named_metadata.handled_request_count == 1
assert default_metadata.total_request_count == 3
assert alias_metadata.total_request_count == 3
assert named_metadata.total_request_count == 3
# Verify that default and alias storages are unnamed
assert default_metadata.name is None
assert alias_metadata.name is None
assert named_metadata.name == 'purge-test-named'
# Clear storage cache to simulate "reopening" storages
service_locator.storage_instance_manager.clear_cache()
# Now "reopen" all storages
default_rq_2 = await RequestQueue.open(
storage_client=storage_client,
configuration=configuration,
)
alias_rq_2 = await RequestQueue.open(
alias='purge-test-alias',
storage_client=storage_client,
configuration=configuration,
)
named_rq_2 = await RequestQueue.open(
name='purge-test-named',
storage_client=storage_client,
configuration=configuration,
)
# Check the data after purge
default_metadata_after = await default_rq_2.get_metadata()
alias_metadata_after = await alias_rq_2.get_metadata()
named_metadata_after = await named_rq_2.get_metadata()
# Unnamed storages (alias and default) should be purged (data removed)
assert default_metadata_after.pending_request_count == 0
assert alias_metadata_after.pending_request_count == 0
assert named_metadata_after.pending_request_count == 2
assert default_metadata_after.handled_request_count == 0
assert alias_metadata_after.handled_request_count == 0
assert named_metadata_after.handled_request_count == 1
assert default_metadata_after.total_request_count == 0
assert alias_metadata_after.total_request_count == 0
assert named_metadata_after.total_request_count == 3
# Clean up
await named_rq_2.drop()
await alias_rq_2.drop()
await default_rq_2.drop()
async def test_purge_on_start_disabled(storage_client: StorageClient) -> None:
"""Test purge behavior when purge_on_start=False: all storages retain data regardless of type."""
# Skip this test for memory storage since it doesn't persist data between client instances.
if isinstance(storage_client, MemoryStorageClient):
pytest.skip('Memory storage does not persist data between client instances.')
configuration = Configuration(purge_on_start=False)
# First, create all storage types with purge disabled and add data.
default_rq = await RequestQueue.open(
storage_client=storage_client,
configuration=configuration,
)
alias_rq = await RequestQueue.open(
alias='purge-test-alias',
storage_client=storage_client,
configuration=configuration,
)
named_rq = await RequestQueue.open(
name='purge-test-named',
storage_client=storage_client,
configuration=configuration,
)
await default_rq.add_requests(
[
'https://default.example.com/1',
'https://default.example.com/2',
'https://default.example.com/3',
]
)
await alias_rq.add_requests(
[
'https://alias.example.com/1',
'https://alias.example.com/2',
'https://alias.example.com/3',
]
)
await named_rq.add_requests(
[
'https://named.example.com/1',
'https://named.example.com/2',
'https://named.example.com/3',
]
)
default_request = await default_rq.fetch_next_request()
alias_request = await alias_rq.fetch_next_request()
named_request = await named_rq.fetch_next_request()
assert default_request is not None
assert alias_request is not None
assert named_request is not None
await default_rq.mark_request_as_handled(default_request)
await alias_rq.mark_request_as_handled(alias_request)
await named_rq.mark_request_as_handled(named_request)
# Verify data was added
default_metadata = await default_rq.get_metadata()
alias_metadata = await alias_rq.get_metadata()
named_metadata = await named_rq.get_metadata()
assert default_metadata.pending_request_count == 2
assert alias_metadata.pending_request_count == 2
assert named_metadata.pending_request_count == 2
assert default_metadata.handled_request_count == 1
assert alias_metadata.handled_request_count == 1
assert named_metadata.handled_request_count == 1
assert default_metadata.total_request_count == 3
assert alias_metadata.total_request_count == 3
assert named_metadata.total_request_count == 3
# Verify that default and alias storages are unnamed
assert default_metadata.name is None
assert alias_metadata.name is None
assert named_metadata.name == 'purge-test-named'
# Clear storage cache to simulate "reopening" storages
service_locator.storage_instance_manager.clear_cache()
# Now "reopen" all storages
default_rq_2 = await RequestQueue.open(
storage_client=storage_client,
configuration=configuration,
)
alias_rq_2 = await RequestQueue.open(
alias='purge-test-alias',
storage_client=storage_client,
configuration=configuration,
)
named_rq_2 = await RequestQueue.open(
name='purge-test-named',
storage_client=storage_client,
configuration=configuration,
)
# Check the data after purge
default_metadata_after = await default_rq_2.get_metadata()
alias_metadata_after = await alias_rq_2.get_metadata()
named_metadata_after = await named_rq_2.get_metadata()
# Unnamed storages (alias and default) should be purged (data removed)
assert default_metadata_after.pending_request_count == 2
assert alias_metadata_after.pending_request_count == 2
assert named_metadata_after.pending_request_count == 2
assert default_metadata_after.handled_request_count == 1
assert alias_metadata_after.handled_request_count == 1
assert named_metadata_after.handled_request_count == 1
assert default_metadata_after.total_request_count == 3
assert alias_metadata_after.total_request_count == 3
assert named_metadata_after.total_request_count == 3
# Clean up
await named_rq_2.drop()
await alias_rq_2.drop()
await default_rq_2.drop()
async def test_name_default_not_allowed(storage_client: StorageClient) -> None:
"""Test that storage can't have default alias as name, to prevent collisions with unnamed storage alias."""
with pytest.raises(
ValueError,
match=f'Storage name cannot be "{StorageInstanceManager._DEFAULT_STORAGE_ALIAS}" as '
f'it is reserved for default alias.',
):
await RequestQueue.open(name=StorageInstanceManager._DEFAULT_STORAGE_ALIAS, storage_client=storage_client)
@pytest.mark.parametrize(
('name', 'is_valid'),
[
pytest.param('F', True, id='single-char'),
pytest.param('7', True, id='single-digit'),
pytest.param('FtghdfseySds', True, id='mixed-case'),
pytest.param('125673450', True, id='all-digits'),
pytest.param('Ft2134Sfe0O1hf', True, id='mixed-alphanumeric'),
pytest.param('name-with-dashes', True, id='dashes'),
pytest.param('1-value', True, id='number start'),
pytest.param('value-1', True, id='number end'),
pytest.param('test-1-value', True, id='number middle'),
pytest.param('test-------value', True, id='multiple-dashes'),
pytest.param('test-VALUES-test', True, id='multiple-cases'),
pytest.param('name_with_underscores', False, id='underscores'),
pytest.param('name with spaces', False, id='spaces'),
pytest.param('-test', False, id='dashes start'),
pytest.param('test-', False, id='dashes end'),
],
)
async def test_validate_name(storage_client: StorageClient, name: str, *, is_valid: bool) -> None:
"""Test name validation logic."""
if is_valid:
# Should not raise
dataset = await RequestQueue.open(name=name, storage_client=storage_client)
assert dataset.name == name
await dataset.drop()
else:
with pytest.raises(ValueError, match=rf'Invalid storage name "{name}".*'):
await RequestQueue.open(name=name, storage_client=storage_client)
async def test_reclaim_request_with_change_state(rq: RequestQueue) -> None:
"""Test reclaiming a request and changing its state."""
# Add a request
await rq.add_request(Request.from_url('https://example.com/original', user_data={'state': 'original'}))
# Fetch the request
request = await rq.fetch_next_request()
assert request is not None
assert request.url == 'https://example.com/original'
assert request.user_data['state'] == 'original'
# Reclaim the request with modified user data
request.user_data['state'] = 'modified'
result = await rq.reclaim_request(request)
assert result is not None
assert result.was_already_handled is False
# Fetch the reclaimed request
reclaimed_request = await rq.fetch_next_request()
assert reclaimed_request is not None
assert reclaimed_request.url == 'https://example.com/original'
assert reclaimed_request.user_data['state'] == 'modified'
async def test_request_with_noascii_chars(rq: RequestQueue) -> None:
"""Test handling requests with non-ASCII characters in user data."""
data_with_special_chars = {
'record_1': 'Supermaxi El Jardín',
'record_2': 'záznam dva',
'record_3': '記録三',
}
init_request = Request.from_url('https://crawlee.dev', user_data=data_with_special_chars)
# Add a request with special user data
await rq.add_request(init_request)
# Get the request and verify
request = await rq.fetch_next_request()
assert request is not None
assert request.url == 'https://crawlee.dev'
assert request.user_data == init_request.user_data