Skip to content

perf(memory-storage): avoid O(n) deque scan on forefront re-add#2074

Open
anxkhn wants to merge 1 commit into
apify:masterfrom
anxkhn:perf/memory-rq-forefront-reposition
Open

perf(memory-storage): avoid O(n) deque scan on forefront re-add#2074
anxkhn wants to merge 1 commit into
apify:masterfrom
anxkhn:perf/memory-rq-forefront-reposition

Conversation

@anxkhn

@anxkhn anxkhn commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

MemoryRequestQueueClient.add_batch_of_requests repositioned an already-pending
request to the forefront by calling self._pending_requests.remove(existing_request)
on the pending deque. deque.remove is O(n): it linearly scans the deque to find
the entry. Re-adding a batch of K already-pending requests to a pending deque of N
entries is therefore O(K*N).

Forefront re-adds of still-pending requests are a normal runtime path, not an edge
case:

  • RequestManagerTandem.fetch_next_request re-enqueues every request it takes from
    the read-only loader with forefront=True
    (src/crawlee/request_loaders/_request_manager_tandem.py).
  • Tiered-proxy retries set request.forefront = True before re-adding
    (src/crawlee/proxy_configuration.py).

This change removes the linear scan:

  • The forefront reposition now just appendlefts the request and leaves the old
    entry in the deque. The new object is registered in _requests_by_unique_key,
    superseding the old one.
  • fetch_next_request and is_empty skip any popped entry whose object is no longer
    the one currently registered for its unique_key (a stale tombstone). Because a
    forefront reposition always enqueues the live copy ahead of the entry it supersedes,
    is_empty can safely prune stale entries from the front until it reaches a live one.
  • A regular (non-forefront) re-add of an already-pending request is left as a true
    no-op: neither the deque entry nor its registered object is touched, so the identity
    check never misfires and no request is dropped. This matches the sibling file-system
    client, which likewise does not re-store an already-pending request on a
    non-forefront re-add.

Forefront-LIFO / regular-FIFO ordering, deduplication, and the metadata counts
(total/pending/handled) are unchanged.

This is the memory-client counterpart to #2011, which fixes the same class of
quadratic reposition behavior in the file-system request queue client. The memory
client was not covered there.

Issues

Testing

Added regression tests in
tests/unit/storage_clients/_memory/test_memory_rq_client.py:

  • test_forefront_readd_repositions_without_deque_scan - wraps the pending deque in a
    deque subclass that counts remove calls; re-adding 20 still-pending requests with
    forefront=True now performs 0 remove calls (was 20 before the change). Guards the
    performance fix.
  • test_forefront_readd_preserves_order_and_dedup - forefront re-add of a subset keeps
    LIFO order, no duplicates linger, and the queue drains to empty/finished.
  • test_regular_readd_of_pending_request_is_not_dropped - a non-forefront re-add of a
    still-pending URL as a distinct Request object stays fetchable exactly once, with
    consistent counts (guards against the lazy-tombstone skip dropping a live request).
  • test_regular_readd_does_not_reorder_pending_queue - a non-forefront re-add leaves
    FIFO order untouched.

Commands run locally (offline):

uv run pytest tests/unit/storage_clients/_memory/test_memory_rq_client.py -v   # 7 passed
uv run pytest tests/unit/storages/test_request_queue.py tests/unit/storage_clients/ \
  tests/unit/storages/test_request_manager_tandem.py                            # 404 passed, 2 skipped
uv run ruff check <changed files>                                              # clean
uv run ruff format --check <changed files>                                     # clean
uv run poe type-check                                                          # clean on changed files

Checklist

  • CI passed

`MemoryRequestQueueClient.add_batch_of_requests` repositioned an
already-pending request to the forefront with `deque.remove`, a linear
scan of the whole pending deque. Re-adding a batch of K already-pending
requests to a deque of N was O(K*N). Forefront re-adds are a real path:
tiered-proxy retries set `request.forefront = True` and re-enqueue while
the request is still pending.

The forefront reposition now just `appendleft`s the request and leaves
the old entry in place. The new object is registered in
`_requests_by_unique_key`, superseding the old one, and
`fetch_next_request` and `is_empty` skip entries whose object is no
longer the one registered for their unique key. Because a forefront
reposition always enqueues the live copy ahead of the entry it
supersedes, `is_empty` can safely prune stale entries from the front.

Only forefront re-adds create such tombstones. A regular re-add of an
already-pending request leaves both the deque entry and its registered
object untouched, so the identity check never misfires and no request is
dropped. Forefront-LIFO / regular-FIFO order and deduplication are
unchanged.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants