Skip to content

Commit 02429c4

Browse files
committed
fix(memory-storage): preserve request queue semantics
Replace lazy deque tombstones with an ordered mapping so forefront repositioning remains constant-time without stale-entry growth. Keep request updates on regular re-adds, update reclaimed request instances in the index, and leave is_empty side-effect free. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
1 parent 6ff853b commit 02429c4

2 files changed

Lines changed: 44 additions & 53 deletions

File tree

src/crawlee/storage_clients/_memory/_request_queue_client.py

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from collections import deque
3+
from collections import OrderedDict
44
from datetime import datetime, timezone
55
from logging import getLogger
66
from typing import TYPE_CHECKING
@@ -41,7 +41,7 @@ def __init__(
4141
"""
4242
self._metadata = metadata
4343

44-
self._pending_requests = deque[Request]()
44+
self._pending_requests = OrderedDict[str, Request]()
4545
"""Pending requests are those that have been added to the queue but not yet fetched for processing."""
4646

4747
self._handled_requests = dict[str, Request]()
@@ -174,23 +174,21 @@ async def add_batch_of_requests(
174174
)
175175
continue
176176

177-
# If the request is already in the queue but not handled, we only reposition it when `forefront`
178-
# is set; a regular re-add leaves the already-pending entry untouched.
177+
# If the request is already in the queue but not handled, update it without changing its position.
179178
if was_already_present and existing_request:
179+
self._requests_by_unique_key[request.unique_key] = request
180+
self._pending_requests[request.unique_key] = request
181+
180182
if forefront:
181-
# Move the request to the front. The old entry is left in the deque instead of being
182-
# located and removed (`deque.remove` is O(n), which makes a batch of forefront re-adds
183-
# O(n^2)); registering the new object here supersedes the old one, and the stale entry is
184-
# skipped lazily by `fetch_next_request` and `is_empty`.
185-
self._requests_by_unique_key[request.unique_key] = request
186-
self._pending_requests.appendleft(request)
183+
self._pending_requests.move_to_end(request.unique_key, last=False)
187184

188185
# Add the new request to the queue.
189186
else:
190187
if forefront:
191-
self._pending_requests.appendleft(request)
188+
self._pending_requests[request.unique_key] = request
189+
self._pending_requests.move_to_end(request.unique_key, last=False)
192190
else:
193-
self._pending_requests.append(request)
191+
self._pending_requests[request.unique_key] = request
194192

195193
# Update indexes.
196194
self._requests_by_unique_key[request.unique_key] = request
@@ -218,12 +216,7 @@ async def add_batch_of_requests(
218216
@override
219217
async def fetch_next_request(self) -> Request | None:
220218
while self._pending_requests:
221-
request = self._pending_requests.popleft()
222-
223-
# Skip stale entries left behind when a request was repositioned to the forefront while already
224-
# pending. Only the object currently registered for the unique key is live.
225-
if self._requests_by_unique_key.get(request.unique_key) is not request:
226-
continue
219+
_, request = self._pending_requests.popitem(last=False)
227220

228221
# Skip if already handled (shouldn't happen, but safety check).
229222
if request.was_already_handled:
@@ -290,11 +283,13 @@ async def reclaim_request(
290283
# Remove from in-progress.
291284
del self._in_progress_requests[request.unique_key]
292285

286+
# Update index with the possibly modified request.
287+
self._requests_by_unique_key[request.unique_key] = request
288+
293289
# Add request back to pending queue.
290+
self._pending_requests[request.unique_key] = request
294291
if forefront:
295-
self._pending_requests.appendleft(request)
296-
else:
297-
self._pending_requests.append(request)
292+
self._pending_requests.move_to_end(request.unique_key, last=False)
298293

299294
# Update metadata timestamps.
300295
await self._update_metadata(update_modified_at=True)
@@ -309,13 +304,6 @@ async def reclaim_request(
309304
async def is_empty(self) -> bool:
310305
await self._update_metadata(update_accessed_at=True)
311306

312-
# Discard stale entries left at the front by forefront repositioning; a live request is always
313-
# enqueued ahead of the stale entry it supersedes, so pruning stops at the first live request.
314-
while self._pending_requests and (
315-
self._requests_by_unique_key.get(self._pending_requests[0].unique_key) is not self._pending_requests[0]
316-
):
317-
self._pending_requests.popleft()
318-
319307
# Queue is empty if there are no pending requests.
320308
return len(self._pending_requests) == 0
321309

tests/unit/storage_clients/_memory/test_memory_rq_client.py

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections import deque
54
from typing import TYPE_CHECKING
65

76
import pytest
@@ -96,26 +95,15 @@ async def test_memory_metadata_updates(rq_client: MemoryRequestQueueClient) -> N
9695
assert metadata.accessed_at > accessed_after_read
9796

9897

99-
async def test_forefront_readd_repositions_without_deque_scan(rq_client: MemoryRequestQueueClient) -> None:
100-
"""Test that re-adding pending requests to the forefront does not do an O(n) `deque.remove` scan."""
101-
102-
class CountingDeque(deque): # type: ignore[type-arg]
103-
remove_calls = 0
104-
105-
def remove(self, value: object) -> None:
106-
CountingDeque.remove_calls += 1
107-
super().remove(value)
108-
109-
rq_client._pending_requests = CountingDeque(rq_client._pending_requests)
110-
98+
async def test_forefront_readd_does_not_grow_pending_requests(rq_client: MemoryRequestQueueClient) -> None:
99+
"""Test that repeatedly repositioning pending requests does not create duplicate entries."""
111100
requests = [Request.from_url(f'https://example.com/{i}') for i in range(20)]
112101
await rq_client.add_batch_of_requests(requests)
113102

114-
# Re-add the same, still-pending requests with `forefront=True`. Previously each re-add scanned the
115-
# whole pending deque with `deque.remove` to reposition the existing entry, which is O(n) per request.
116-
await rq_client.add_batch_of_requests(requests, forefront=True)
103+
for _ in range(10):
104+
await rq_client.add_batch_of_requests(requests, forefront=True)
117105

118-
assert CountingDeque.remove_calls == 0
106+
assert len(rq_client._pending_requests) == len(requests)
119107

120108

121109
async def test_forefront_readd_preserves_order_and_dedup(rq_client: MemoryRequestQueueClient) -> None:
@@ -144,18 +132,14 @@ async def test_forefront_readd_preserves_order_and_dedup(rq_client: MemoryReques
144132

145133

146134
async def test_regular_readd_of_pending_request_is_not_dropped(rq_client: MemoryRequestQueueClient) -> None:
147-
"""Test that a regular (non-forefront) re-add of a still-pending request keeps it fetchable.
148-
149-
The lazy-tombstone skip in `fetch_next_request`/`is_empty` keys off object identity, so a regular re-add
150-
must not repoint the registered object away from the entry still sitting in the pending deque, otherwise
151-
the genuinely-live request would be treated as stale and silently dropped.
152-
"""
135+
"""Test that a regular re-add updates a still-pending request without dropping it."""
153136
original = Request.from_url('https://example.com/page')
154137
await rq_client.add_batch_of_requests([original])
155138

156139
# Re-add the same URL while still pending, as a distinct object (as the higher-level API does when it
157140
# rebuilds requests). `forefront` defaults to False.
158141
duplicate = Request.from_url('https://example.com/page')
142+
duplicate.user_data['version'] = 2
159143
assert duplicate is not original
160144
assert duplicate.unique_key == original.unique_key
161145
await rq_client.add_batch_of_requests([duplicate])
@@ -164,8 +148,8 @@ async def test_regular_readd_of_pending_request_is_not_dropped(rq_client: Memory
164148
assert await rq_client.is_empty() is False
165149

166150
fetched = await rq_client.fetch_next_request()
167-
assert fetched is not None
168-
assert fetched.url == 'https://example.com/page'
151+
assert fetched is duplicate
152+
assert fetched.user_data['version'] == 2
169153
await rq_client.mark_request_as_handled(fetched)
170154

171155
assert await rq_client.fetch_next_request() is None
@@ -196,3 +180,22 @@ async def test_regular_readd_does_not_reorder_pending_queue(rq_client: MemoryReq
196180
'https://example.com/1',
197181
'https://example.com/2',
198182
]
183+
184+
185+
async def test_reclaim_modified_request_after_forefront_readd(rq_client: MemoryRequestQueueClient) -> None:
186+
"""Test reclaiming a modified request after it was repositioned to the forefront."""
187+
request = Request.from_url('https://example.com/page')
188+
await rq_client.add_batch_of_requests([request])
189+
await rq_client.add_batch_of_requests([request], forefront=True)
190+
191+
fetched = await rq_client.fetch_next_request()
192+
assert fetched is request
193+
194+
modified = request.model_copy(deep=True)
195+
modified.user_data['reclaimed'] = True
196+
await rq_client.reclaim_request(modified)
197+
198+
assert await rq_client.is_empty() is False
199+
reclaimed = await rq_client.fetch_next_request()
200+
assert reclaimed is modified
201+
assert reclaimed.user_data['reclaimed'] is True

0 commit comments

Comments
 (0)