Skip to content

Commit fae204b

Browse files
anxkhnvdusek
andauthored
fix(memory-storage): stop re-adds from duplicating and resetting pending requests (#2074)
- Store pending requests in an `OrderedDict` keyed by unique key instead of a `deque`. Repositioning a request to the forefront is now O(1) instead of an O(n) `deque.remove()` scan, so re-adding K already-pending requests is O(K) instead of O(K*N) — measured 22.4s → 0.004s for 2000 forefront re-adds into a 20k-request queue. The scan only triggered for requests already waiting in the queue, which is what `add_requests(..., forefront=True)` hits when a handler re-discovers URLs that are still pending. - Fix the same URL being handed out repeatedly. The old scan silently failed once an earlier regular re-add had replaced the registered request object with a differently-valued one, leaving a stale entry behind and appending a second one for the same unique key. Five URLs re-added over three rounds yielded 20 fetches instead of 5 and left `pending_request_count` at -15. Duplicates are now impossible by construction. - Keep the originally enqueued request on a re-add. The old code replaced the registered request with the incoming duplicate, which on a forefront re-add also replaced the queued one — resetting `retry_count` (a request could retry forever), `label` (dispatch to a different handler) and `user_data`. On a regular re-add it left the two out of sync, so `get_request` and `fetch_next_request` disagreed. This restores the documented contract ("Duplicates will be identified but not re-added to the queue") and matches the file-system, SQL and Redis clients, as well as `@crawlee/memory-storage` in Crawlee for JS. - Make `get_request` consistent after a reclaim: it now returns the reclaimed object, the same one `fetch_next_request` hands back. - Write the queue metadata once per batch instead of once per new request, saving N-1 `datetime.now()` calls on a batch add. *✍️ Drafted by Claude Code* --------- Co-authored-by: Vlada Dusek <v.dusek96@gmail.com>
1 parent 9acf0c4 commit fae204b

2 files changed

Lines changed: 296 additions & 66 deletions

File tree

src/crawlee/storage_clients/_memory/_request_queue_client.py

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

3-
from collections import deque
4-
from contextlib import suppress
3+
from collections import OrderedDict
54
from datetime import datetime, timezone
65
from logging import getLogger
76
from typing import TYPE_CHECKING
@@ -42,18 +41,22 @@ def __init__(
4241
"""
4342
self._metadata = metadata
4443

45-
self._pending_requests = deque[Request]()
46-
"""Pending requests are those that have been added to the queue but not yet fetched for processing."""
44+
# The three stores below are keyed by unique key and disjoint - a request known to the queue lives in
45+
# exactly one of them, so together they also serve as the lookup by unique key.
46+
47+
self._pending_requests = OrderedDict[str, Request]()
48+
"""Pending requests are those that have been added to the queue but not yet fetched for processing.
49+
50+
Ordered from the front of the queue to its end, which keeps both fetching and repositioning a request
51+
to the forefront O(1).
52+
"""
4753

4854
self._handled_requests = dict[str, Request]()
4955
"""Handled requests are those that have been processed and marked as handled."""
5056

5157
self._in_progress_requests = dict[str, Request]()
5258
"""In-progress requests are those that have been fetched but not yet marked as handled or reclaimed."""
5359

54-
self._requests_by_unique_key = dict[str, Request]()
55-
"""Unique key -> Request mapping for fast lookup by unique key."""
56-
5760
@override
5861
async def get_metadata(self) -> RequestQueueMetadata:
5962
return self._metadata
@@ -111,7 +114,6 @@ async def open(
111114
async def drop(self) -> None:
112115
self._pending_requests.clear()
113116
self._handled_requests.clear()
114-
self._requests_by_unique_key.clear()
115117
self._in_progress_requests.clear()
116118

117119
await self._update_metadata(
@@ -126,7 +128,6 @@ async def drop(self) -> None:
126128
async def purge(self) -> None:
127129
self._pending_requests.clear()
128130
self._handled_requests.clear()
129-
self._requests_by_unique_key.clear()
130131
self._in_progress_requests.clear()
131132

132133
await self._update_metadata(
@@ -145,13 +146,14 @@ async def add_batch_of_requests(
145146
forefront: bool = False,
146147
) -> AddRequestsResponse:
147148
processed_requests = []
148-
for request in requests:
149-
# Check if the request is already in the queue by unique_key.
150-
existing_request = self._requests_by_unique_key.get(request.unique_key)
149+
new_total_request_count = self._metadata.total_request_count
150+
new_pending_request_count = self._metadata.pending_request_count
151151

152-
was_already_present = existing_request is not None
153-
was_already_handled = was_already_present and existing_request and existing_request.handled_at is not None
152+
for request in requests:
153+
# Check which of the stores, if any, the request is already in.
154+
was_already_handled = request.unique_key in self._handled_requests
154155
is_in_progress = request.unique_key in self._in_progress_requests
156+
was_already_present = was_already_handled or is_in_progress or request.unique_key in self._pending_requests
155157

156158
# If the request is already in the queue and handled, don't add it again.
157159
if was_already_handled:
@@ -175,35 +177,17 @@ async def add_batch_of_requests(
175177
)
176178
continue
177179

178-
# If the request is already in the queue but not handled, update it.
179-
if was_already_present and existing_request:
180-
# Update indexes.
181-
self._requests_by_unique_key[request.unique_key] = request
182-
183-
# We only update `forefront` by updating its position by shifting it to the left.
184-
if forefront:
185-
# Update the existing request with any new data and
186-
# remove old request from pending queue if it's there.
187-
with suppress(ValueError):
188-
self._pending_requests.remove(existing_request)
189-
190-
# Add updated request back to queue.
191-
self._pending_requests.appendleft(request)
192-
193-
# Add the new request to the queue.
194-
else:
195-
if forefront:
196-
self._pending_requests.appendleft(request)
197-
else:
198-
self._pending_requests.append(request)
199-
200-
# Update indexes.
201-
self._requests_by_unique_key[request.unique_key] = request
202-
203-
await self._update_metadata(
204-
new_total_request_count=self._metadata.total_request_count + 1,
205-
new_pending_request_count=self._metadata.pending_request_count + 1,
206-
)
180+
# A new request is appended to the end of the queue. A re-add of a still-pending request keeps the
181+
# originally enqueued object: the incoming duplicate is typically a freshly built one that lost the
182+
# state accumulated so far (e.g. `retry_count`).
183+
if not was_already_present:
184+
self._pending_requests[request.unique_key] = request
185+
new_total_request_count += 1
186+
new_pending_request_count += 1
187+
188+
# The only effect a re-add may have is repositioning the request to the front of the queue.
189+
if forefront:
190+
self._pending_requests.move_to_end(request.unique_key, last=False)
207191

208192
processed_requests.append(
209193
ProcessedRequest(
@@ -213,7 +197,12 @@ async def add_batch_of_requests(
213197
)
214198
)
215199

216-
await self._update_metadata(update_accessed_at=True, update_modified_at=True)
200+
await self._update_metadata(
201+
update_accessed_at=True,
202+
update_modified_at=True,
203+
new_total_request_count=new_total_request_count,
204+
new_pending_request_count=new_pending_request_count,
205+
)
217206

218207
return AddRequestsResponse(
219208
processed_requests=processed_requests,
@@ -222,27 +211,23 @@ async def add_batch_of_requests(
222211

223212
@override
224213
async def fetch_next_request(self) -> Request | None:
225-
while self._pending_requests:
226-
request = self._pending_requests.popleft()
227-
228-
# Skip if already handled (shouldn't happen, but safety check).
229-
if request.was_already_handled:
230-
continue
231-
232-
# Skip if already in progress (shouldn't happen, but safety check).
233-
if request.unique_key in self._in_progress_requests:
234-
continue
214+
if not self._pending_requests:
215+
return None
235216

236-
# Mark as in progress.
237-
self._in_progress_requests[request.unique_key] = request
238-
return request
217+
_, request = self._pending_requests.popitem(last=False)
239218

240-
return None
219+
# Mark as in progress.
220+
self._in_progress_requests[request.unique_key] = request
221+
return request
241222

242223
@override
243224
async def get_request(self, unique_key: str) -> Request | None:
244225
await self._update_metadata(update_accessed_at=True)
245-
return self._requests_by_unique_key.get(unique_key)
226+
return (
227+
self._pending_requests.get(unique_key)
228+
or self._in_progress_requests.get(unique_key)
229+
or self._handled_requests.get(unique_key)
230+
)
246231

247232
@override
248233
async def mark_request_as_handled(self, request: Request) -> ProcessedRequest | None:
@@ -257,9 +242,6 @@ async def mark_request_as_handled(self, request: Request) -> ProcessedRequest |
257242
# Move request to handled storage.
258243
self._handled_requests[request.unique_key] = request
259244

260-
# Update index (keep the request in indexes for get_request to work).
261-
self._requests_by_unique_key[request.unique_key] = request
262-
263245
# Remove from in-progress.
264246
del self._in_progress_requests[request.unique_key]
265247

@@ -290,11 +272,11 @@ async def reclaim_request(
290272
# Remove from in-progress.
291273
del self._in_progress_requests[request.unique_key]
292274

293-
# Add request back to pending queue.
275+
# Add the request back to the pending queue. Unlike a re-add, a reclaim carries the state accumulated
276+
# while the request was in progress, so the reclaimed object supersedes the one that was fetched.
277+
self._pending_requests[request.unique_key] = request
294278
if forefront:
295-
self._pending_requests.appendleft(request)
296-
else:
297-
self._pending_requests.append(request)
279+
self._pending_requests.move_to_end(request.unique_key, last=False)
298280

299281
# Update metadata timestamps.
300282
await self._update_metadata(update_modified_at=True)

0 commit comments

Comments
 (0)