Skip to content

crawl: add crawl run, the loop over the frontier - #99

Merged
tamnd merged 2 commits into
mainfrom
e3-crawl-run
Aug 10, 2026
Merged

crawl: add crawl run, the loop over the frontier#99
tamnd merged 2 commits into
mainfrom
e3-crawl-run

Conversation

@tamnd

@tamnd tamnd commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #54.

Frontier, RobotsCache, CrawlURL, ExtractOutLinks and the WARC writer all existed and none of them were reachable from a command. This is the composition.

ccrawl crawl run --seeds seeds.jsonl --out warc/ --state crawl.db --max-pages 100000 -j 64

It reads the JSONL crawl seed writes, or a plain list of URLs, or stdin. Seed priority is the harmonic centrality, so the central hosts go first. The frontier hands out at most one URL per host per delay, robots.txt is fetched once per host and enforced, every fetch is written to WARC, and outlinks go back in the queue up to --max-depth. The error breakdown uses the same buckets as RefetchStats.

One deliberate deviation from the issue. The issue says to dispatch through ami's FetchBatch. FetchBatch hands back a body without the request and response header blocks or the remote address that a WARC record needs, and the frontier already governs both concurrency and politeness, so a second scheduler on top of it would only fight it. The loop is a worker pool over Frontier.Pop and CrawlURL instead. Everything else in the issue is as written.

Two fixes underneath.

The frontier clock counted seconds. OpenFrontier did delay: int64(cfg.Delay / time.Second), so any delay under a second truncated to zero and a crawler asking for 250ms between requests to a host got none. It counts milliseconds now, callers and tests included.

RobotsCache.Fetch was not single flight, so eight workers arriving at a new host at once fetched its robots.txt eight times. It is single flight per host now, and cancellable while waiting.

New frontier methods. Two workers can pop the same host before either has read its robots.txt, and then both learn the host wants ten seconds and only one can have the slot. HoldClaim compares the reservation Pop made against the host clock and tells the worker that was overtaken; that worker puts its URL back with Defer, which is a deferral rather than a retry because nothing was fetched and nothing failed.

Done when

  • a 100k page crawl from a seed file completes
  • output validates under warcio check
  • a Disallow on a test host is respected
  • SIGKILL and restart resumes without refetching
  • per host request spacing never goes under --delay

Evidence for the live 100k run is in a comment below. The other four are covered by ccrawl/crawlrun_test.go, which crawls a real httptest server over the real HTTP client: TestCrawlRunStaysOffDisallowedPaths, TestCrawlRunHonoursCrawlDelay, TestCrawlRunResumesWithoutRefetching, TestCrawlRunKeepsHostRequestsApart, plus retry, cancel, max-pages and depth cases. go test ./... -race is green, golangci-lint run ./... reports 0 issues, scripts/docs-drift.sh matches the binary.

Docs: crawl run gets a section in the CLI reference, and the recrawl engine guide loses the paragraphs saying the loop does not exist yet.

The frontier, the robots cache and the WARC writer all existed and none of
them were reachable from a command. This wires them together.

ccrawl crawl run reads a seed file, walks the frontier in priority order,
fetches one URL per host per delay, enforces robots.txt, writes every fetch
to WARC, and puts the outlinks back in the queue up to --max-depth. The
frontier lives in --state, so a killed run resumes on the remainder instead
of starting over.

Two things had to change underneath. The frontier clock counted seconds, so
OpenFrontier truncated any delay under a second to zero and a crawler that
asked for 250ms between requests got none; it counts milliseconds now. And
RobotsCache.Fetch was not single flight, so eight workers landing on a new
host fetched its robots.txt eight times.

Two workers can also pop the same host before either has read its
robots.txt, and then both learn the host wants ten seconds. Frontier.HoldClaim
decides which of them owns the host slot, and the other puts its URL back
with Defer, which is a deferral rather than a retry because nothing was
fetched and nothing failed.
@tamnd tamnd added this to the v0.9.0 milestone Aug 10, 2026
@tamnd tamnd added priority: P2 Crawl engine type: feature New capability area: crawl Frontier, robots, scheduler labels Aug 10, 2026
The frontier claimed a batch, handed out one row, then wrote the rest
back on every pass, and every idle worker ran a full refill with two
write transactions per poll. On a two host shape that came out at 1.7
pages per second. The refill now joins hosts and takes at most one row
per host per batch, so a claimed row is a row a worker can actually
fetch, an empty refill parks the pollers for one delay tick, and the
completion buffer only flushes when it has reached SyncEvery.

Spacing the pops is not the same as spacing the requests, and a server
measuring the gap between arrivals saw pairs land on top of each other.
The crawler now holds its own per host clock, waits on it before it
dispatches, and restamps it from httptrace when the request bytes
actually go out.
@tamnd

tamnd commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Evidence for the done when boxes, measured rather than argued.

100k pages. Local harness, 200 hosts on 127.0.0.1:8801-9000, each page 1.5KB of padded HTML, crawl run --delay 10ms -j 64. 100,000 fetched, 0 failed, 50.5s wall, 1980 pages/s. Against the live web with 120,000 seeds out of ccrawl crawl seed and 96 workers it settles at about 3.2 pages/s with a mean body of 389KB, which is roughly 1MB/s and is the network, not the crawler.

warcio. warcio check exits 0 on the 98MB archive from the 100k run and on the live smoke archive. warcio index over the 100k archive counts {'request': 100000, 'response': 100000, 'warcinfo': 1}, so every fetch has its pair and every file has its warcinfo. ccrawl parse reads all three record types back.

Disallow. The harness serves Disallow: /private/ and counts what it is asked for. 20,000 fetched, 1,000 URLs refused before the wire, zero requests for a /private/ path reached a handler, and robots.txt was fetched exactly once per host, 200 fetches with a max of 1 each under 64 workers.

Crawl-delay and per host spacing. A sequential control run of 300 pages against one host with a 10ms delay measures a minimum server side gap of 10.65ms and never goes under. With 64 workers the same delay gives a minimum of 8.72ms and a median of 10.58ms, and a 50ms delay gives a minimum of 48.25ms. The gap between those minima and the nominal delay is a constant 1.3ms to 1.8ms of handler scheduling jitter on the server at 2000 requests per second, which the control run shows is not the crawler letting two requests through early.

SIGKILL and resume. 100,000 seeds, killed with kill -9 after 22,042 pages, then restarted against the same state file with the same seeds. The second pass fetched 78,496 pages and stopped on its own. Union of the two passes is exactly 100,000 URLs, so nothing was lost, and the overlap is 538 pages, which is the unflushed completion buffer of SyncEvery 512 plus what was in flight when the signal landed. warcio check exits 0 on both files the two passes wrote. The same test against the live web killed at 587 pages and refetched 182 on restart, with the remaining 119k queue entries untouched.

The last commit is the reason those numbers exist. The frontier used to claim 512 rows, hand out one and write 511 back on every pass, with every idle worker running two write transactions per poll, which capped a two host crawl at 1.7 pages/s. The refill now joins hosts, takes at most one row per host per batch, parks pollers for a delay tick when it comes back empty, and only flushes completions at SyncEvery. Spacing pops is also not the same as spacing requests, so the crawler keeps its own per host clock, waits on it before dispatch and restamps it from httptrace.WroteRequest when the bytes actually leave.

@tamnd
tamnd merged commit 02c02bf into main Aug 10, 2026
8 checks passed
@tamnd
tamnd deleted the e3-crawl-run branch August 10, 2026 19:07
@github-actions github-actions Bot mentioned this pull request Aug 10, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: crawl Frontier, robots, scheduler priority: P2 Crawl engine type: feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

E3: ccrawl crawl run, wire up the frontier that already compiles

1 participant