Skip to content

Enforce robots.txt Crawl-delay in URLFrontier queues#2000

Draft
dpol1 wants to merge 8 commits into
mainfrom
feat/1979-robots-delay
Draft

Enforce robots.txt Crawl-delay in URLFrontier queues#2000
dpol1 wants to merge 8 commits into
mainfrom
feat/1979-robots-delay

Conversation

@dpol1

@dpol1 dpol1 commented Jul 15, 2026

Copy link
Copy Markdown
Member

Closes #1979. Completes Stage 3: the queue-side politeness work discussed in #867, after explicit Retry-After handling in #1973 and adaptive back-off for headerless rate limits in #1984.

When fetcher.max.crawl.delay.force is enabled, the fetcher caps an excessive robots.txt Crawl-delay locally and continues fetching. Until now, that meant losing the original interval and potentially crawling the host faster than requested. Both fetcher bolts now preserve the requested delay in robots.crawl.delay, and QueueRegulatorBolt forwards it to URLFrontier through setDelay.

Design

  • The signal is emitted only when the fetcher actually caps a robots.txt Crawl-delay. Fractional delays are rounded up to whole seconds, so frontier pacing is never more aggressive than robots.txt.
  • The signal is rebuilt from the locally parsed robots rules after protocol metadata is merged. Stale metadata or a colliding HTTP response header therefore cannot inject or alter the queue delay.
  • Robots pacing is opt-in through urlfrontier.robots.crawl.delay.enabled. CrawlDelayPolicy caps values with urlfrontier.backoff.max.secs, keeps one RPC in flight per host, and coalesces concurrent updates so the latest value eventually wins.
  • Successful values are deduplicated for urlfrontier.backoff.decay.secs. A failed RPC retries only a value received while the previous call was in flight; otherwise a later queue signal reasserts it.
  • setDelay(key, 0) is deliberately never sent. If a site removes its Crawl-delay, its queue may remain slower than necessary, but the crawler never becomes less polite or erases a frontier-side default.

Configuration contract

The feature fails fast unless the queue and metadata configuration can enforce a per-host delay safely:

partition.url.mode: byHost
fetcher.max.crawl.delay.force: true
urlfrontier.robots.crawl.delay.enabled: true
urlfrontier.max.urls.per.bucket: 1
urlfrontier.backoff.max.secs: 86400

metadata.persist:
  - robots.crawl.delay

robots.crawl.delay must not appear in metadata.transfer, including through wildcards such as robots.*: an outlink must not inherit the delay of its parent host. Custom metadata.transfer.class implementations remain responsible for preserving the same contract.

URLFrontier applies the delay between queue hand-outs; it does not shrink an existing batch or recall URLs already emitted. Requiring one URL per bucket bounds each new hand-out, while already prefetched or concurrent requests may still complete during the transition.

Tests

Coverage includes both fetcher implementations, fractional delay rounding, stale and colliding metadata, opt-in and startup validation, asynchronous last-value-wins serialization, failed and stale callbacks, deduplication, caps, and a Testcontainers test against a real URLFrontier proving that the second URL remains unavailable during the configured delay.

The core and URLFrontier module suites pass with 334 and 55 tests respectively. Configuration, module README, defaults and extension documentation have been updated.

### 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? (kept as eight atomic commits so the producer, policy, integration, safety validation and documentation changes can be reviewed separately)
  • 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? (core and URLFrontier module suites pass; full reactor pending)
  • 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)

dpol1 added 8 commits July 15, 2026 09:13
When a robots.txt Crawl-delay exceeds fetcher.max.crawl.delay and the
force flag makes the fetcher keep going anyway, FetcherBolt now writes
the requested delay in seconds to the new robots.crawl.delay metadata
key. A frontier-side consumer can then enforce the pace at the source
instead of losing it, which is the gap #1979 describes for #867.

The unforced path is untouched and keeps emitting Status.ERROR with
error.cause=crawl_delay, pinned by a regression test.
Same signal as in FetcherBolt: when maxCrawlDelayForce caps a long
Crawl-delay, the original robots value lands in robots.crawl.delay so
the frontier side of #1979 sees both fetchers alike. The shared test
harness moves up to AbstractFetcherBoltTest rather than being copied.
Package-private decision logic for the queue-stream consumer, same
split as HostBackoff: reads robots.crawl.delay from the tuple metadata,
caps it with urlfrontier.backoff.max.secs since sites do request absurd
values (#1979), and deduplicates per host. An unchanged value is
re-sent at most once per urlfrontier.backoff.decay.secs window, which
also lets a lost fire-and-forget RPC converge. The shared _DEFAULT_
queue is skipped, as one host must not slow down the catch-all bucket.
QueueRegulatorBolt now lives up to its name: besides blocking queues on
rate limits, it forwards the robots.crawl.delay carried by the queue
stream to URLFrontier through setDelay, closing the loop of #1979. The
call is fire-and-forget with the same short deadline as the blocks, and
setDelay(key, 0) is deliberately never sent because it would erase a
server-side default for the queue: a site that drops its Crawl-delay
keeps the old pace, which costs throughput on that host but never
politeness.

The startup metadata.persist probe now checks robots.crawl.delay too,
since the signal dies silently if the filter strips it.
Testcontainers regression against a real frontier. The delay does not
shrink a batch, it gates the queue between hand-outs (lastProduced +
delay, first hand-out always served): so the test takes one URL out,
then asserts a later getURLs comes back empty although a second URL is
neither served nor in-flight. The pause between the two calls also pins
the unit of setDelayRequestable as seconds, a millisecond window would
have expired. Seeding is generalised to seedUrls(paths...) with the old
seedOneUrl() delegating, existing tests untouched.
Round fractional delays up so frontier pacing never becomes more aggressive than robots.txt. Rebuild the control signal from the locally parsed rules after protocol metadata is merged, preventing stale metadata or a colliding response header from changing it.
Keep frontier pacing opt-in and fail fast unless queues are partitioned by host, hand out one URL at a time, and carry robots.crawl.delay as persist-only metadata. Serialize setDelay calls per queue so asynchronous completions cannot violate the last-value-wins contract.
Describe the opt-in wiring and the host partitioning, single-URL hand-out, persist-only metadata, and delay-cap requirements for #1979.
@dpol1

dpol1 commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

While reviewing the distributed path, I found that URLFrontier 2.5 does not appear to propagate setDelay or blockQueueUntil when local=false. This is now tracked upstream in crawler-commons/url-frontier#146.

I am moving this PR back to draft while I:

  • make host-level robots pacing conservative with max-observed-wins;
  • introduce a robots-specific maximum delay;
  • clarify or enforce the supported deployment model until the upstream behaviour is resolved;
  • update tests and documentation to reflect the revised semantics.

The existing single-node implementation and tests remain valid. Suggestions are welcome here ;-)

@dpol1
dpol1 marked this pull request as draft July 16, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Propagate robots.txt crawl-delay to URLFrontier via setDelay

1 participant