Skip to content

#2129 FetcherBolt: lock-free fetch queues ordered by next fetch time - #2132

Open
GGraziadei wants to merge 3 commits into
apache:mainfrom
GGraziadei:perf/fetch-queues-lock-free
Open

#2129 FetcherBolt: lock-free fetch queues ordered by next fetch time#2132
GGraziadei wants to merge 3 commits into
apache:mainfrom
GGraziadei:perf/fetch-queues-lock-free

Conversation

@GGraziadei

@GGraziadei GGraziadei commented Sep 6, 2026

Copy link
Copy Markdown
Member

Fixes #2129.

FetchItemQueues used a single monitor for adding, taking and finishing items, and getFetchItem() rotated a LinkedHashMap linearly over every queue whose crawl delay had not elapsed. With many hosts the fetcher threads held that lock most of the time and the executor thread calling execute() stalled on every incoming tuple.

Change

  • Queues live in a ConcurrentHashMap; the ones that may have an item ready are referenced from a DelayQueue of tickets ordered by their next fetch time. Taking an item is O(log n) and adding never waits for the fetcher threads.
  • Per-queue state (size bound, in-progress count, next fetch time, crawl delays) uses atomics; a per-queue monitor is used only for the add/reap race. Empty queues are removed from the map as soon as they drain.
  • FetchItem is built before any lock is taken, so a DNS lookup in byIP mode no longer blocks the fetcher threads.

Behaviour preserved

Politeness per queue, asap release, fetcher.threads.per.queue and fetcher.maxThreads.*, fetcher.max.queue.size, crawl delay overrides from metadata and robots.txt, queue modes, metrics and the debug dump.

Behaviour change: order in which hosts are served

The old implementation served hosts round-robin in LinkedHashMap insertion order, rotating past the ones whose crawl delay had not elapsed. The new one serves them in strict nextFetchTime order: the host that became ready the longest time ago goes first, and hosts whose next fetch time falls in the same millisecond are ordered arbitrarily (DelayQueue does not guarantee FIFO among equal delays).

In practice:

  • A host is never starved: once its delay has elapsed it is ahead of every host that became ready later.
  • Newly added hosts are ready immediately, so under a steady stream of new hosts they are served before hosts still waiting on their delay, exactly as before.
  • Operators who relied on the insertion-order rotation to spread fetches evenly across a fixed set of hosts will see a different, but not less fair, interleaving. Per-host rate is unchanged: it is still bounded by the crawl delay.

Two smaller observable details, both stated in the javadoc: getQueueSize() can transiently over-report by one while an offer is being rejected, and the debug dump produced by the trigger file is no longer a point-in-time snapshot but a smear over the time it takes to iterate.

Tests

New FetchItemQueuesTest: politeness, asap, several hosts back to back, queue size bound, multiple threads per queue, crawl delay from metadata, reaping of empty queues, and a concurrent producers/consumers test checking that nothing is lost or duplicated. Existing FetcherBoltTest and SimpleFetcherBoltTest pass unchanged.

Benchmark: the case this targets

The cost the change removes is the linear rotation over queues that are not ready. It shows when a fetcher instance holds many hosts and most of them are waiting on their crawl delay, which is the steady state of a broad crawl: every getFetchItem() walked all of them under the global monitor, and the executor thread calling execute() queued behind the fetcher threads for that monitor.

Micro-benchmark on FetchItemQueues alone (main vs this branch): 50 fetcher threads, 1 producer thread emulating the executor, 20 URLs per host, fetcher.server.delay 1s so that almost every queue is waiting, instant fetch, 8s per run, p99 from sampled calls.

hosts metric before after
20000 getFetchItem avg 1.58 ms 24 us
20000 getFetchItem p99 5.0 ms 24 us
20000 addFetchItem avg 14.1 ms 24 us
20000 addFetchItem p99 458 ms 0.11 ms
2000 addFetchItem p99 2.5 ms 0.13 ms
20000 fetched/s 20001 20000

Throughput is unchanged, as expected: it is bounded by politeness. What goes away is the executor stall and the CPU burnt scanning queues that are not ready. With fetcher.server.delay 0 (every queue always ready, no rotation) both versions do ~53k fetch/s, i.e. the new structure is not slower on the path where the old one was cheap.

With a few hundred hosts per fetcher instance the difference is not measurable; with tens of thousands it is the numbers above.

Verification

  • FetcherBoltTest (8) and SimpleFetcherBoltTest (8) pass unchanged; FetchItemQueuesTest adds 9 tests including a concurrent producers/consumers run and a deterministic reproduction of the lost-wakeup race fixed in the second commit.
  • Full mvn clean verify on the whole project: 675 tests, 0 failures, 0 errors (Docker-backed modules included).
  • CI on this PR is green.

For all changes

  • Is there a issue associated with this PR? Is it referenced in the commit message?
  • Does your PR title start with #XXXX where XXXX is the issue number you are trying to resolve?
  • Has your PR been rebased against the latest commit within the target branch (typically main)?
  • Is your initial contribution a single, squashed commit?
  • Is the code properly formatted with mvn git-code-format:format-code -Dgcf.globPattern="**/*" -Dskip.format.code=false?

For code changes

  • Have you ensured that the full suite of tests is executed via mvn clean verify?
  • Have you written or updated unit tests to verify your changes?
  • If adding new dependencies to the code, are these dependencies licensed in a way that is compatible for inclusion under ASF 2.0? (no new dependencies)
  • If applicable, have you updated the LICENSE file, including the main LICENSE file? (not applicable)
  • If applicable, have you updated the NOTICE file, including the main NOTICE file? (not applicable)

… time

FetchItemQueues used a single monitor for adding, taking and finishing
items, and getFetchItem() rotated a LinkedHashMap linearly over all the
queues that were not ready yet. With many hosts the fetcher threads held
that lock most of the time and the executor thread calling execute()
stalled on every incoming tuple.

Queues are now kept in a ConcurrentHashMap and the ones that may have an
item ready are referenced from a DelayQueue of tickets ordered by their
next fetch time, so taking an item is O(log n) and adding never waits for
the fetcher threads. Per-queue state (size bound, in-progress count, next
fetch time, crawl delays) is handled with atomics and a per-queue monitor
only for the add/reap race. Empty queues are removed from the map as
soon as they drain.

Fixes apache#2129.

Behaviour preserved: politeness per queue, asap release, max threads per
queue, max queue size, crawl delay overrides from metadata and
robots.txt, queue modes, metrics and the debug dump. Covered by the new
FetchItemQueuesTest, including a concurrent producers/consumers test.

Benchmark (50 fetcher threads, 1 producer, 20 URLs per host, delay 1s):
  20000 hosts  getFetchItem avg 1.58 ms -> 24 us
               addFetchItem p99 458 ms -> 0.11 ms, max 571 ms -> 0.8 ms
  throughput unchanged (bounded by politeness)
@rzo1
rzo1 requested review from dpol1 and jnioche September 6, 2026 13:38
@rzo1 rzo1 added this to the 4.0.0 milestone Sep 6, 2026

@rzo1 rzo1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently I am short on time, so first round AI-based review)

The design holds up: one ticket per queue guarded by the scheduled CAS, an immutable ordering key in QueueTicket, and FetchItem.create (which does DNS in byIP mode) moved out of the critical section.

One lost-wakeup window, inline on line 538.

Other comments:

  • Fairness changes from LinkedHashMap round-robin to strict nextFetchTime order, with arbitrary tie-breaking among queues sharing a millisecond. This is a component operators tune by feel, so please put the change in the description.
  • offer increments size before the bound check and decrements on overflow, so getQueueSize() can transiently over-report. Harmless, but worth a comment.
  • logQueuesContent is no longer synchronized. Safe with weakly consistent iterators, but the dump is now a smear rather than a snapshot. Say so in its javadoc.
  • This rewrites the hottest path in the crawler. Please include before/after numbers for the case it targets, and confirm the existing FetcherBolt integration tests pass.

Comment thread core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java
@rzo1
rzo1 requested review from mvolikas and sigee September 6, 2026 14:07
…shes during the free-slot check

In getFetchItem(), a fetch on the same queue could finish between
hasFreeSlot() returning false and the clearing of the scheduled flag.
finishFetchItem() then found the flag still set and did not issue a
ticket, after which the flag was cleared: the queue was left with items
and a free slot but no ticket, and stalled until an unrelated URL for
the same host arrived.

Clear the flag first and re-check, as the other branches already do.
Covered by a deterministic test that pauses the poller inside the
free-slot check while the in-progress fetch finishes.
…fer() and the smeared debug dump

offer() increments the size before checking the bound and decrements on
overflow, so getQueueSize() can transiently over-report by one while an
offer is rejected; the value only feeds metrics and the debug dump.

logQueuesContent() is no longer synchronized: it iterates the queues and
their items with weakly consistent iterators while the fetcher threads
keep working, so the dump is a smear rather than a point-in-time
snapshot. Both are now stated in the javadoc.
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.

FetcherBolt: global lock and linear scan in FetchItemQueues stall the executor with many hosts

2 participants