Repository navigation
crawl: put the frontier on disk - #95
Merged
Merged
Conversation
Frontier.seen was a map[string]struct{} and the queue was a slice, which
works right up to the size the frontier is actually for. The gao seed set
is around 280M URLs and a Go map with 280M string keys is tens of
gigabytes of live heap.
The state is a SQLite file now, with modernc.org/sqlite so there is still
no cgo. One table for the frontier keyed on a 16 byte URL hash, one for
per host politeness, and a staging table in front of the frontier.
Three things make it fast enough to drive a crawl.
Admissions are staged and merged in key order. Inserting discovered URLs
straight into the frontier writes to a random page of a B-tree far larger
than any page cache, and it collapses: 4M rows went in at under 3k a
second and still slowing, against 170k a second for the first 200k. The
staging table is written in rowid order, so an admission is an append,
and the merge sorts by url_hash so it sweeps the frontier once instead of
jumping around it. The merge threshold scales with the queue, so the
number of sweeps over a crawl stays small.
Pops are claimed in batches of 512 and served from memory, so the pop
rate is a memory operation with one query amortised over hundreds of
them. Completions and politeness clocks are buffered the same way and
written in key order.
A claim is a state on the row, which is what makes a restart correct.
Rows a dead run left claimed go back to pending on open, rows marked done
stay done, and staged rows that never merged get merged.
The seen check is an exact cache, not a Bloom filter. A Bloom filter in
front of a seen check reports new URLs as already known at its false
positive rate, and the frontier drops them: sized at the usual one
percent it silently lost 52 of 50000 URLs in a test run. The cache is
memory bounded by forgetting cold keys instead, and a forgotten key costs
one wasted row in the next merge, where the primary key drops it.
Measured on 10M URLs, 2M hosts, on one laptop disk: admitted at 38k a
second overall and 125k for the first 2M, popped at 9.2k a second with no
decay, 368 MB of heap and 675 MB RSS, 1.18 GB on disk. Memory is bounded
by the seen cache and the page cache rather than by the frontier, so it
is flat in the number of URLs.
The 280M admission was not run. It needs around 33 GB of database and
this machine has 23 GB free.
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #51.
Frontier.seenwas amap[string]struct{}and the queue was a slice. That works right up to the size the frontier is actually for: the gao seed set is around 280M URLs, and a Go map with 280M string keys is tens of gigabytes of live heap and a garbage collector that never finishes.The state is a SQLite file now,
modernc.org/sqliteso there is still no cgo. One table for the frontier keyed on a 16 byte URL hash, one for per host politeness, and a staging table in front of the frontier.The part that took the work
The obvious version of this is slow in a way that does not show up until it matters. Inserting discovered URLs straight into the frontier writes to a random page of a B-tree that is far larger than any page cache, and the admit rate falls off a cliff as the table grows.
Sorting each batch by key first helps a lot and is still not enough, because every batch sweeps the whole tree regardless of how few rows it carries.
So admissions go to a staging table, which is a rowid table written in increasing rowid order, and an append to the right hand edge of a B-tree costs the same at four million rows as at four hundred. They are merged into the frontier with one
INSERT OR IGNORE ... ORDER BY url_hash, which walks the frontier left to right once and reads and dirties each leaf page once. The merge threshold scales with what is already queued, so the number of sweeps over a crawl stays small instead of growing with it.The same locality argument applies to the other two write paths, so completions and politeness clocks are buffered and written in key order too.
Pops claim 512 rows in one query and serve them from memory, so the pop rate is a memory operation with a query amortised over hundreds of it. A claimed URL whose host is inside its politeness delay is written back with
next_atset to when the host is next eligible, rather than rescanned.Why there is no Bloom filter
The issue asks for a Bloom filter in front of the seen check, and I did write one first. It is the wrong structure for this position and the test run says so: a Bloom filter's error is a false positive, which in front of a seen check means reporting a brand new URL as already known, and the frontier then drops it. Sized at the conventional one percent it silently lost 52 of 50000 URLs.
One page in a hundred missing from a crawl, with nothing downstream able to tell, is not a trade a data structure should make on the operator's behalf. So the cache is exact for what it holds and forgets the coldest keys instead of lying about them. A forgotten key costs one wasted row in the next merge, where the primary key drops it, and nothing else. Memory is bounded the same way a filter would have bounded it, by a configured size.
Restart
A claim is a state on the row. Rows a dead run left claimed go back to pending on open, because a claim that outlived its process is a URL that was fetched or was not and only one of those readings is safe. Rows marked done stay done, which is what stops a restart refetching. Staged rows that never merged are merged by the next flush.
Completions are buffered up to
SyncEvery, so a clean stop refetches nothing and a kill refetches at most one claim batch. That buffering is worth about four times the pop rate.SyncEvery: 1gives back the exactness for callers that want it, andSync()is there for a caller that has just been told to stop.Numbers
10M URLs across 2M hosts, one laptop disk, default config except a 4M entry seen cache:
Pop does not decay, which is the property that matters, and every one of the 10M URLs came back out. At 200k the same code does 170k/s admit and 42k/s pop in 14 MB of heap.
Reproduce with
CCRAWL_SCALE_URLS=10000000 go test ./ccrawl -run TestFrontierScale -v -timeout 2h.Done when
TestFrontierResumesWithoutRefetching,TestFrontierReclaimsClaimsLeftInFlightandTestFrontierDedupsAcrossRestart. Exact across a clean stop, bounded bySyncEveryacross a kill.Scope
Library only. Nothing calls
NewFrontieroutside the tests yet, which is why the redesign was free.ccrawl crawl runis #54 and comes last on purpose.Gates:
gofmt,go vet,go test -race ./... -count=1,golangci-lint runclean,scripts/docs-drift.shclean. No CLI surface changed, so there is nothing for the reference to drift from.