Skip to content

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

Open
dpol1 wants to merge 9 commits into
mainfrom
feat/1979-robots-delay
Open

Enforce robots.txt Crawl-delay in URLFrontier queues#2000
dpol1 wants to merge 9 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 the dedicated urlfrontier.robots.delay.max.secs, keeps one RPC in flight per host, and coalesces concurrent updates conservatively: the maximum observed value wins, so a shorter delay cannot lower the pace of a shared queue. A lower observed delay can be applied only after the previously successful maximum expires from the decay window.
  • Successful values are deduplicated for urlfrontier.robots.delay.decay.secs. A failed RPC self-retries only when a higher value arrived while the call was in flight; otherwise its value is kept as an ambiguous floor for the decay window (the server may have applied it before the response was lost) and the next queue signal reasserts at least that value.
  • 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.robots.delay.max.secs: 86400
urlfrontier.robots.delay.decay.secs: 1800

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.

Deployment model

Keyed setDelay/blockQueueUntil calls are not propagated across URLFrontier nodes: in 2.5 the distributed service ignores local=false for these RPCs (crawler-commons/url-frontier#146, confirmed upstream; fix in progress). Consequences here:

  • With multiple configured urlfrontier.address entries the bolt warns (rate-limit blocks from Adaptive back-off for rate-limited hosts via the queue stream #1984 stay best-effort on the connected node, unchanged behaviour) and fails fast only when robots pacing is enabled, since silent partial pacing would violate robots.txt. A single endpoint through the urlfrontier.host/urlfrontier.port fallback stays valid.
  • A cluster behind a single load-balanced address cannot be detected and has the same limitation.
  • The restriction is lifted once the upstream fix is merged and released, the dependency here is bumped, and a distributed test covers it — not merely when the upstream PR lands.
  • Separately, the frontier's per-queue politeness gate in getURLs is not atomic (getURLs: per-queue politeness gate is not atomic under concurrent requests crawler-commons/url-frontier#147): with several concurrent getURLs clients (multiple Spout tasks or topologies) a queue can be served more than once inside the delay window. This is not limited to the transition to a new delay; it can recur on every concurrently aligned refill. Documented; a single Spout task per frontier is recommended when pacing must be strict.

Tests

Coverage includes both fetcher implementations, fractional delay rounding, stale and colliding metadata, opt-in and startup validation (including the multi-address warn/fail split), asynchronous max-wins serialization, failed and stale callbacks, the ambiguous floor, deduplication and the decay window for lower values, 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 60 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? (title kept descriptive; the issue is referenced in the first line and in the commits)
  • 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 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 modules, with the Testcontainers integration tests)
  • 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
…nt only

The delay signal is now conservative: the maximum observed value wins
and a lower delay applies only after the previous maximum expires from
the decay window. The cap and dedupe window move to dedicated keys
(urlfrontier.robots.delay.max.secs / .decay.secs) instead of reusing the
back-off ones. A failed RPC no longer self-retries without a higher
pending value: its value is kept as an ambiguous floor for the decay
window - the server may have applied it before the response was lost -
so the next signal is not deduplicated and reasserts at least that
value instead of lowering a possibly applied delay.

Keyed setDelay/blockQueueUntil are not propagated across URLFrontier
nodes (crawler-commons/url-frontier#146): multiple configured addresses
now log a warning bolt-wide and fail fast only when robots pacing is
enabled; the single host/port fallback stays valid. Javadoc, README and
the asciidoc reference document the dedicated keys, the
connected-node-only semantics and the non-atomic getURLs politeness
gate under concurrent clients. QueueRegulatorBoltTest folds the two
blind waits into one.
@dpol1

dpol1 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Done in e2cda7a: pacing now coalesces to the max observed delay (a failed RPC keeps its value as a floor for the decay window, so a lower signal can't undercut it), the cap and dedupe window moved to dedicated urlfrontier.robots.delay.* keys, and multiple frontier addresses fail fast only when robots pacing is enabled (just a warning otherwise, matching the #1984 behaviour). While double-checking the concurrent path I also found the getURLs politeness gate itself is racy under concurrent clients — filed as crawler-commons/url-frontier#147 and documented here rather than enforced. PR body updated, marking as ready.

@dpol1
dpol1 marked this pull request as ready for review July 20, 2026 13:03
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