Distributed Web Crawler Architecture Deep Dive: Frontier, Politeness, Dedupe and Storage
How does a production web crawler actually work inside? This page opens each component of the Atlas Discovery reference design: the domain model and URL state machine, a conservative URL canonicalizer, robots.txt evaluation under RFC 9309, the robots-aware atomic claim with leases and fence tokens, the safe fetch and egress pipeline, parsing, three-layer deduplication and Bloom filter sizing, conditional recrawl scoring, the JavaScript render lane, the outbox write path and fenced regional ownership. Four pseudocode mechanisms carry sample inputs, outputs and failure behavior.
Component Map and Responsibilities
This page opens each component of the Atlas Discovery crawler and shows how it works inside: data shapes, algorithms, state transitions and the places where one component's guarantee ends and the next one begins. It assumes you know what a crawler is; each new term is defined when first used.
Atlas Discovery in one paragraph. Atlas crawls public pages from three fictional publishers (news-a, docs-b, store-c at .example.test) to feed a search index. A URL is an address; an origin is scheme://host:port and is the unit of politeness; the frontier is the durable set of discovered URLs waiting to be fetched; a lease is a time-limited claim by a worker, carrying a fence token that lets storage reject stale writers; robots.txt is the publisher's published crawling preference. The tracked page throughout is U-104, news-a.example.test/articles/chip-supply. Assumed scale: 10 million successful fetches per day, about 116 per second.
| Component | Owns | Does not own |
|---|---|---|
| URL normalizer / scope guard | Identity of a URL; whether it is in scope | Whether it may be fetched |
| Policy service (robots, suppression) | Allow/deny verdicts with policy version | Rate limiting |
| Frontier | Durable URL state, priority, leases, fences | Network I/O |
| Origin scheduler / buckets | When an origin may be contacted next | What the page contains |
| Fetcher + egress gate | Connection, redirects, limits, SSRF defense | Parsing |
| Parser | Text, links, metadata, language | Fetch decisions |
| Dedupe | URL, content and near-duplicate identity | Storage layout |
| Storage + outbox | Bodies, documents, events | Scheduling |
| Recrawl scorer | Next-visit time | Politeness (it only proposes; buckets decide) |
| Render lane | Executing scripts for chosen pages | Skipping the policy checks |
Domain Model and Data Layout
The tables below are logical, not a mandated schema. They show which fields exist, why, and which constraint protects which invariant.
| Entity | Key fields | Constraint that matters |
|---|---|---|
urls | url_id, normalized_url, raw_discovered_url, origin_key, state, priority, next_fetch_at, attempt_count, lease_owner, lease_expires_at, fence, etag, last_modified, last_status, content_hash, policy_version, discovered_from | Unique on normalized_url: first-seen is a database decision |
urls (continued) | depth, lease_id, last_http_status, body_hash, last_changed_at, last_success_at, render_mode (raw or rendered) | body_hash changes only when content changes; last_changed_at is set only then |
origins | origin_key, next_eligible_at, max_in_flight, in_flight, backoff_until, robots_version, owner_region, owner_token | next_eligible_at and in_flight change only in the claim transaction |
robots_cache | origin_key, fetched_at, expires_at, status_class, parsed_rules, version | Version is stored on every verdict |
documents | content_hash, body_ref, text_ref, language, fetched_at, fetch_id | One row per distinct content, referenced by many URLs |
url_document | url_id, content_hash, first_seen_at, last_seen_at, provenance | Many URLs may point to one document |
outbox | event_id, type, document_ref, created_at, published_at | Written in the same transaction as the document row |
suppressions | scope (origin/host/URL), reason, created_at, verified_by | Checked before every fetch and after redirects |
| Field | Why it exists | Value for U-104 after its first fetch |
|---|---|---|
raw_url / normalized_url | Audit versus identity | https://news-a.example.test/articles/chip-supply?utm_source=x#top / https://news-a.example.test/articles/chip-supply |
origin_key | Politeness grouping and shard key | https://news-a.example.test:443 |
discovered_from, depth | Provenance and scope depth limit | S-01, depth 1 |
priority, state | Scheduling and the state machine | 0.72, FETCHED |
next_fetch_at | Next recrawl due time | 2026-03-02T09:40Z |
attempt_count | Retry budget | 1 |
lease_id, lease_fence, lease_expires_at | Ownership and stale-write rejection | L-9913, 7, expired |
robots_policy_version | Which rules justified the fetch | 3 |
last_http_status, etag, last_modified | Conditional recrawl inputs | 200, "a41f", Tue 09:12 GMT |
body_hash, last_changed_at, last_success_at | True change detection, freshness metric | sha256:9c1e…, 09:12, 09:40 |
render_mode | Which lane produced the text | raw |
Two design choices deserve a reason. First, keeping raw_discovered_url beside normalized_url makes normalization reversible and auditable. Second, url_document separates the address from the content, because the same text can live at several addresses and one address can serve different text over time.
The state machine
The spec-level terminal states map as follows: FAILED covers retry-exhausted URLs (the retryable form is LEASED → DEFERRED with next_fetch_at in the future), BLOCKED and QUARANTINED are terminal until a policy or parser change, and SUPPRESSED is terminal until an opt-out is withdrawn.
| From | To | Trigger |
|---|---|---|
| DISCOVERED | ELIGIBLE | Passed identity, scope, policy and priority admission |
| DISCOVERED | BLOCKED | Robots/suppression/scope verdict, with reason code |
| ELIGIBLE | LEASED | Claim transaction (fence incremented) |
| LEASED | FETCHED | Commit with a matching fence |
| LEASED | ELIGIBLE | Lease expired with no valid commit; attempt_count kept |
| LEASED | DEFERRED | 429/503; next_fetch_at set from Retry-After or backoff |
| LEASED | FAILED | Retry budget exhausted |
| LEASED | QUARANTINED | Limit violated or suspicious content type |
URL Canonicalization in Detail
The normalizer produces one identity for provably equal addresses and refuses to merge anything uncertain. The goal is stable identity with auditability, not the shortest key.
| Before | After | Why |
|---|---|---|
HTTP://News-A.EXAMPLE.test:80/articles/chip-supply#top | http://news-a.example.test/articles/chip-supply | Scheme and host are case-insensitive; :80 is http's default; fragments never reach the server |
https://news-a.example.test/a/b/../chip-supply | https://news-a.example.test/a/chip-supply | Dot segments resolved |
https://news-a.example.test/articles/chip-supply?utm_source=x&utm_medium=y | …/chip-supply | Both parameters are on news-a's tracking list |
https://news-a.example.test/a?b=2&a=1 | …/a?a=1&b=2 | Sort parameters only for origins whose query order is known not to matter |
https://docs-b.example.test/Guide/Setup | …/Guide/Setup (unchanged) | Path case is significant on many servers; merging with `/guide/setup` would be wrong |
https://store-c.example.test/products?id=7 and ?id=8 | Both unchanged and distinct | The value is the identity; unknown parameters are preserved; merging would be wrong |
function canonicalize(raw, base, originPolicy):
u = parse(resolve(raw, base)) # relative -> absolute
if u.scheme not in {"http","https"}: return REJECT("scheme")
u.scheme = lower(u.scheme); u.host = idna_to_ascii(lower(u.host))
if u.port == default_port(u.scheme): u.port = None
u.fragment = None
u.path = remove_dot_segments(percent_normalize(u.path))
if originPolicy.tracking_params:
u.query = drop(u.query, originPolicy.tracking_params)
if originPolicy.query_order_insensitive:
u.query = sort_params(u.query)
if len(str(u)) > 2048: return REJECT("too_long")
return ACCEPT(identity=str(u), raw=raw, rules_fired=trace)Trailing slashes, encoding, internationalised names and collisions
Trailing slash. /docs and /docs/ are different resources by the URL rules, and many servers redirect one to the other. Atlas does not strip slashes blindly. It records the redirect when it sees one and learns the origin's preferred form. Percent-encoding. %7e and ~ are equivalent (unreserved characters), so the normalizer decodes unreserved bytes and upper-cases the hex digits of the rest, but it never decodes %2F, because a slash inside a segment is not a path separator. Internationalised hosts. bücher.example.test becomes its ASCII (punycode) form before comparison, and mixed-script lookalike hosts are flagged for review rather than merged. Collisions. Two raw URLs that normalize to the same identity are a *collision*; the system keeps both raw spellings in discovered_from provenance, and an operator can list the collisions a rule produced. That list is how F02 is caught: a rule that suddenly creates thousands of collisions on one origin is suspicious.
Redirect and canonical relationships. A redirect is the server's statement that a fetch of A returned B, and it is evaluated as a new candidate (the fetch pipeline chapter). A canonical link is the page's claim about its preferred address and is only a hint. Neither rewrites the identity of A: the frontier keeps A, records A → B or A ~ B as an edge, and decides to merge only through repeated redirects confirming it or a matching content hash.
Failure behavior: a parser error rejects with a reason and never guesses. Internationalised hostnames are converted to their ASCII form so that visually similar names are compared reliably. rel=canonical is stored as a hint and may later add a *candidate* merge, but only content comparison (chapter 8) turns a hint into a decision.
Robots.txt Parsing and Evaluation
The parser reduces a file into groups of rules for named user-agents. The evaluator finds the applicable group, then the best matching rule for a path.
User-agent: *
Disallow: /account/
Disallow: /cart/
User-agent: Atlas-Bot
Disallow: /account/
Allow: /account/help/
Disallow: /search| Path | Matching rules | Decision |
|---|---|---|
/account/orders | Disallow: /account/ (9 chars) | Blocked |
/account/help/faq | Disallow: /account/ (9), Allow: /account/help/ (14) | Longest wins: Allowed |
/products/lamp | none | Allowed (default) |
/search?q=lamp | Disallow: /search (7) | Blocked |
Wildcard * and a dollar-sign end anchor are supported in patterns. Atlas picks the most specific user-agent group and does not merge groups of different agents. Fetching the file is itself a fetch: redirects are bounded and every hop is checked. Status handling is deliberate. 2xx: parse and cache for up to 24 hours or less if the origin sends shorter cache headers. 4xx: the file is unavailable, which RFC 9309 says means no crawl restrictions, so crawl normally (do still honor everything else). 5xx or network error: unreachable, so treat the origin as disallowed temporarily, reuse the last good copy for a bounded time and back the origin off. An oversize file is truncated at a limit and parsed as far as read, which the document says is acceptable at around 500 KiB.
Every verdict records policy_version. At claim time and after each redirect the claim transaction compares the URL's verdict version against the current one; a mismatch triggers a recheck before any connection. A Crawl-delay line is a hint from outside the standard; Atlas treats it as one input into the origin's rate, never as an authority that can raise the rate above the operator's cap.
Frontier, Origin Buckets and the Atomic Claim
The frontier combines two structures: the table of URLs and, derived from it, a priority-ordered view of origins that are due. The claim is the only operation that moves a URL toward the network.
The scheduler keeps origins in a min-heap keyed by next_eligible_at. Popping gives the origin that became due first. An origin with in_flight == max_in_flight, an active backoff_until or an exhausted budget is skipped and re-inserted with a later key. Inside an origin, URLs are ordered by priority, then by age. Aging adds a bonus for time waiting so that a low-priority origin cannot starve.
function claim(worker, now): # one short transaction
for origin in due_origins(now): # min-heap order, caps applied
if origin.in_flight >= origin.max_in_flight: continue
if origin.backoff_until > now: continue
url = best_eligible_url(origin) # priority, then age
if url is None: continue
verdict = policy.check(url, origin) # robots + suppression
if verdict.version != url.policy_version:
mark(url, recheck); continue
if verdict.denied:
set_state(url, BLOCKED, verdict.reason); continue
url.state = LEASED; url.lease_owner = worker
url.fence += 1; url.lease_expires_at = now + LEASE
origin.in_flight += 1
origin.next_eligible_at = now + origin.min_gap
return Claim(url, token=url.fence)
return NONEAging and starvation caps. An origin's effective priority is base + age_weight × minutes_waiting, capped so aging cannot outweigh critical freshness work entirely. A hard starvation cap overrides everything: any origin with an eligible URL and no service for 30 minutes is moved to the front. Class quotas limit one class to a share of attempts while others have eligible work. Partitioning and host affinity. The frontier is partitioned by hash of origin_key, and each partition is served by schedulers that own whole origins, so an origin's bucket is only ever touched by one partition's claim transaction. Workers are stateless and may take work from any partition, but because they present the lease fence on commit, they need no affinity for correctness; affinity (keeping an origin's connections on one worker) only saves TLS handshakes.
LEASE is longer than the expected fetch time but not so long that a crashed worker delays recovery for minutes. A sweeper returns rows with lease_expires_at < now to ELIGIBLE and decrements in_flight. A worker finishing early renews nothing; one needing more time extends the lease with its fence token, and the extension fails if the fence changed.
The Fetch Pipeline and Egress Gate
The fetcher is a pipeline of small gates, each of which can stop the request before more expensive work begins.
| # | Gate | Rejects when | Reason code |
|---|---|---|---|
| 1 | Scheme | Not http/https | bad_scheme |
| 2 | Network destination | Any resolved IP in private, loopback, link-local, multicast, reserved or metadata ranges | blocked_network |
| 3 | Scope | Host not allowed, depth or budget exceeded | out_of_scope |
| 4 | Robots / suppression | Verdict is deny or version stale | robots_denied, suppressed |
| 5 | Origin bucket | Not eligible | (not rejected; deferred) |
| 6 | Connect + TLS | Cannot verify the certificate | tls_failed |
| 7 | Response limits | Decompressed size, type, time or header limits exceeded | oversize, bad_type, timeout |
Connect-time pinning means that the address the validator approved is the address the socket opens. Resolving the name a second time inside the HTTP library would let a rebinding attacker return a public address for validation and an internal one for the connection. IPv4-mapped IPv6 forms such as ::ffff:10.0.0.5 are unwrapped before checking. Redirect hops are capped at five and the sequence of normalized URLs is remembered to stop loops. Limits apply to bytes read after decompression: the reader aborts the stream at the cap rather than trusting Content-Length.
| Response | Class | Crawler action |
|---|---|---|
| 200 with HTML | Success | Parse, hash, commit |
| 304 | Unchanged (conditional request only) | Update last_success_at; no parse |
| 301/302/307/308 | Redirect | New candidate; all gates again; hop limit 5 |
| 404 / 410 | Gone | Terminal after one or two confirmations; 410 sooner; keep tombstone |
| 401 / 403 | Not available to us | Do not retry aggressively; never attempt to bypass |
| 429 | Slow down | Origin-wide backoff by Retry-After or backoff schedule |
| 500 / 502 / 503 / 504 | Retryable | Backoff with jitter, bounded attempts |
| Timeout, reset, DNS failure | Retryable network | Backoff; repeated DNS failure becomes a terminal reason |
Timeouts and cancellation. Three separate limits apply: connect (a few seconds), time to first byte, and a total deadline for the whole response. When the deadline passes the fetcher cancels the read, closes the socket and records timeout; a result arriving after cancellation is ignored. TLS and SNI. The client sends the original hostname in SNI and verifies the certificate against that name, *while connecting to the pinned validated IP*; connecting by IP without the name would fail verification, and verifying against the IP would be wrong. Soft 404. A page that returns 200 with text like 'not found' is detected by comparing its extracted text and title against a stored fingerprint of the origin's error page, and by a very short text with no outlinks; it is stored with soft_404=true and excluded from the index, but it still counts against the fetch budget. Transfer encodings. Chunked bodies are read incrementally, and Content-Encoding decoding happens in the same size-limited stream.
function fetch_and_commit(claim):
url, token = claim.url, claim.token
for attempt in 1..MAX_ATTEMPTS: # bounded, e.g. 3
try:
resp = egress.get(url, conditional=url.validators,
limits=LIMITS, deadline=now()+T_MAX)
except Retryable as e: # timeout, 503, reset
sleep(backoff(attempt, e.retry_after)); continue
except Rejected as e: # gates: terminal
return commit(url, token, state=e.state, reason=e.code)
body_ref = object_store.put_content_addressed(resp.body) # idempotent
return commit(url, token, resp, body_ref) # fence-checked
return commit(url, token, state=DEFERRED, reason="retries_exhausted")
function commit(url, token, ...):
BEGIN
if row(url).fence != token: ABORT -> StaleWriter # fenced out
update frontier row; write document metadata; write outbox row
COMMITParsing and Extraction
The parser turns bytes into a document and a list of candidate links. Its output is evidence; later stages decide what to do with it.
It detects the character encoding (response header, byte-order mark, a meta tag, then a statistical guess) and records which method won. It extracts the visible text (excluding scripts and styles), title, headings, language, rel=canonical, robots meta directives and links with their anchor text. Relative links resolve against the <base> element if present, otherwise the final response URL after redirects, which is why the fetcher stores both. A nofollow marking influences priority and policy rather than being silently ignored, and frames and image-map links are optional extraction targets. Parse failures (broken markup is common) degrade to best-effort text with a parse_quality flag; a parser exceeding its time or memory budget quarantines the document.
Extraction confidence. Each document carries parse_quality (clean, repaired, partial) and text_ratio (visible text bytes over body bytes). A low ratio with a script-heavy body is the signal that feeds the render-lane decision. Raw HTML versus rendered DOM. Links found only after scripts run are tagged from_render=true; they enter the frontier like any other candidate but with lower initial priority because they cost more to confirm. Document types. HTML and plain text are parsed; PDF and feeds go to dedicated extractors behind the same limits; images, video and archives are skipped by the type allowlist. Malformed markup is handled by the parser's error-recovery rules, not by regexes, and never aborts the batch.
Extraction yield is also an observation: if a URL class suddenly returns almost no text, either the page changed, the site moved content into scripts (chapter 10) or the fetcher is receiving a block page. The parse stage therefore reports words extracted and links found per origin.
Deduplication Mechanics
Three mechanisms answer three questions at three costs.
| Layer | Input | Question | Where | Error mode |
|---|---|---|---|---|
| URL | Normalized URL | Known address? | Unique key on urls | Over-merge if normalization is too aggressive |
| Content hash | Extracted text (after boilerplate trimming) | Same text? | documents.content_hash | Boilerplate differences hide equality; hash of raw bytes would see ad rotation as change |
| Near-duplicate | SimHash/MinHash fingerprint | Mostly the same? | Fingerprint index with a threshold | False merge of corrected vs outdated versions |
Content hashing needs a decision about *what* is hashed. Hashing raw HTML marks a page changed whenever an advertisement rotates. Atlas hashes normalized main text. When U-110 (/print) and U-104 produce the same text hash, both URLs reference one documents row and url_document records two provenance entries; the second URL is not dropped, so the discovery path and a possible canonical declaration are preserved.
Bloom filter sizing. A Bloom filter with *n* inserted items and false-positive rate *p* needs about m = −n·ln(p) ÷ (ln 2)² bits and (m ÷ n)·ln 2 hash functions. For one billion URLs at 1 percent that is roughly 9.6 billion bits, about 1.2 GB. Because a hit means 'maybe', the exact store confirms before any discard (diagram below). The filter's job is to remove most *lookups for new URLs*, never to remove URLs.
Recrawl, Conditional Requests and the Priority Score
Recrawl turns the crawler from a snapshot into a monitor. Each page's history decides when it is due.
A conditional request carries the last ETag as If-None-Match and/or the last Last-Modified value as If-Modified-Since. A 304 response has no body, and the stored document stays current. A 200 is parsed and hashed; the page counts as changed only if the content hash differs. Servers vary: some give weak ETags, some change an ETag per request, some ignore conditional headers. Atlas therefore tracks per-origin validator reliability (how often a 200 followed by an equal hash happened) and, past a threshold, stops trusting validators there.
function next_fetch_at(page, now, origin):
interval = clamp(page.est_change_interval, MIN, MAX) # from history
score = importance(page) / interval # how worthwhile
if origin.validator_reliable and page.changed_recently == False:
interval = min(interval * 1.5, MAX) # back off stable pages
jitter = random(0.9, 1.1)
return now + interval * jitterEstimating the change interval. For each page keep a smoothed estimate: after each recrawl, if the content hash changed, est = max(MIN, 0.5 × est) (come back sooner); if unchanged, est = min(MAX, 1.5 × est) (come back later). A page that changed three times in three visits converges to MIN in a few steps; a stable archive page reaches MAX in a few more. Importance. A score from inbound-link evidence, audience tier of the requesting tenant and whether downstream queries hit the page. Multi-tenant prioritization. Each tenant has a share of the recrawl budget; unused share flows to others, and a tenant cannot starve discovery because discovery has its own reserved fraction. Preventing storms. Intervals are jittered by ±10 percent, newly onboarded batches are spread over the interval using hash(url) mod interval as an offset, and recrawl enqueue is rate-limited per origin so a thousand pages becoming due at 09:00 do not become a burst. Staleness metrics. Freshness gap is now − last_changed_at_observed for pages known to have changed, reported as percentiles by priority class, plus the share of due pages that are overdue.
Budgeting: a fixed fraction of attempts is reserved for discovery, the rest is split into freshness classes. Recrawl candidates still pass through the origin bucket, so a very volatile site cannot override politeness by scoring highly.
The JavaScript Render Lane
Rendering is a separate service with its own contract: take a URL that has passed the same checks, return the post-script document or a failure.
Selection: for a page class, fetch raw HTML and rendered DOM for a sample and compare extracted words. If raw extraction recovers at least a chosen fraction (say 90 percent) of rendered text, render nothing. Otherwise the class is marked render-required. The render worker runs a headless browser in an isolated sandbox with no access to internal networks, a hard wall-clock limit, a memory cap and a cap on sub-requests. Every sub-request, including images, scripts and API calls the page makes, is routed through the same egress gate and counted against the same origin bucket; a page that makes forty requests uses forty units of politeness budget. If the lane is saturated, pages fall back to raw extraction and are marked render_skipped. Outputs are tagged rendered=true so downstream consumers can compare quality.
Storage, Outbox and Idempotent Consumers
The write path crosses four systems and no shared transaction. Correctness comes from the order of operations and from idempotency at each boundary.
| Step | System | Atomic with… | If a crash happens after this step |
|---|---|---|---|
| 1 | Object store put (content-addressed) | Nothing | Orphan body; sweeper removes unreferenced bodies after a grace period |
| 2 | DB transaction: frontier row, document, url_document, outbox row | All four together | Fully committed, or none of it |
| 3 | Relay publishes outbox row, marks published_at | Nothing | Event may be sent twice; consumer dedupes by event_id |
| 4 | Indexer applies event | Its own store | Safe to replay: update-by-id |
Reconciliation. A periodic worker compares three things: object-store keys with no referencing row older than a grace period (delete them), rows in FETCHED with no outbox event (insert the missing event), and index-ready counts against document counts per origin. Mismatches are alerts, not silent repairs, except for the two safe ones above. The index-ready event is written only after the document row exists, so no consumer ever sees an event for content that is not durable.
The outbox solves the dual-write problem: updating a database and publishing a message cannot be done atomically across two systems. Writing the event as a row in the same transaction guarantees an event exists if and only if the document row exists. The relay is at-least-once, so events are idempotent: the consumer stores processed event_ids or applies updates keyed by document id and content hash.
Regional Ownership and Fencing
Politeness is a property of the origin even when the crawler runs in several regions, so each origin's bucket needs one owner at a time.
Partition key and hot partitions. The partition key is origin_key, because the origin's bucket is the coordination boundary. Skew arises when a single origin holds a huge share of URLs: its rows are heavy, but its *rate* is still capped by politeness, so the load it puts on the scheduler is small; the real hot spots are storage size and sweeper scans. The fix is to sub-partition a giant origin's URL rows by hash(url) while keeping one bucket row for the origin, so claims still serialize on that single row. Region choice follows network distance and residency; failover reassigns ownership with a higher token. A control plane assigns each origin to a region with an ownership token that increases on every reassignment (41, then 42). The bucket accepts a claim only if the presented token equals the current one. During a partition the old owner may keep working until its lease expires, so a bounded window of double scheduling exists. Large origins may be split into pre-allocated shares (for example, 60 and 40 percent of the cap) so that both regions can run without per-request coordination; the shares sum to the cap. The guarantee is therefore: fenced ownership with *bounded* overshoot, not a perfectly synchronized global counter.
Policy Versioning and Suppression
Rules change: robots files are edited, publishers opt out, internal blocklists grow. The design records which version of which policy produced each decision.
policy_version is a monotonically increasing number per origin covering robots rules, suppressions and scope settings. A verdict is valid only for the version that produced it; the claim transaction rechecks if versions differ. Suppression (an opt-out or deletion request) outranks everything: it blocks the fetch, triggers deletion workflows and is itself retained as a record without the content it removed.
Observability Model and Reason Codes
A crawler without reason codes is unexplainable. Every state change in the frontier carries one.
| Signal | Healthy shape | What a deviation means |
|---|---|---|
| Request rate vs origin limit | At or below cap | Above cap: a bucket bug or ownership overlap |
| Share of 429/503 per origin | Low, short-lived | Persistent: cap too high or publisher complaint |
| Oldest eligible URL age | Seconds to minutes | Rising: scheduler or capacity problem |
| Blocked by robots / scope | Stable | Sudden jump: a robots change or a scope bug |
| Parse yield (words, links) | Stable by class | Drop: block page, script-rendered site or parser regression |
| 304 rate and changed-page yield | Stable | Falling 304 share: validators broken; low yield: wasted recrawls |
| Freshness gap by priority | Within target | Rising: recrawl budget or scheduling |
| Stage counts: fetched, parsed, stored, index-ready | Close together | A widening gap identifies the failing stage |
Operational formulas. Attempt rate = successes ÷ success ratio (115.74 ÷ 0.80 = 144.68 per second). Origin saturation = requests per second to an origin ÷ its cap; sustained values near 1.0 with rising 429s mean the cap is too high. Byte throughput = attempts × mean compressed bytes. Backlog age = now − next_fetch_at of the oldest eligible URL. Changed-page yield = changed pages ÷ recrawl attempts. Crawl coverage = distinct pages stored ÷ pages known to exist from sitemaps or samples. A falling 304 share with a stable changed-page yield means validators have degraded.
Security, Privacy and Compliance Mechanics
The crawler is an automated client with network reach and a store of third-party content, so it carries its own security and compliance duties.
| Risk | Mechanism |
|---|---|
| SSRF and DNS rebinding | Connect-time address validation, pinning, per-hop checks, network deny rules (fetch pipeline chapter) |
| Malicious scripts in the render lane | Isolated sandbox, no internal network, no credentials, per-page limits, throwaway profile per page |
| Data exfiltration | Render workers and fetchers have no access to internal services or secrets; egress is allowlisted to the public internet |
| Personal data in pages | Do not crawl authenticated or robots-excluded areas; filter obvious personal identifiers (emails, phone numbers) from the indexable text when policy requires; keep raw bodies for a short, bounded period |
| Deletion and opt-out | A verified request writes a suppression, blocks fetches immediately and drives deletion across frontier, documents, bodies and index entries |
| Access control | Separate roles: operators may pause or suppress an origin; only a privileged role may change rate caps or retention; consumers read documents but cannot read raw bodies |
| Auditability | Every block, suppression, policy version change and deletion is an append-only record with actor, time and reason |
A deletion request is processed as a small state machine: RECEIVED → VERIFIED → SUPPRESSED → DELETING → DELETED → AUDITED. Each step is idempotent and keyed by the suppression id, so a crash mid-deletion resumes rather than restarts. The suppression record outlives the deleted data, because it is what prevents the content from being fetched again. Identity of the requester is verified against the origin (for example, a token served at a known path) before any deletion happens, so a stranger cannot erase a publisher's pages from the index.
Correctness Invariants and the Tests Behind Them
Each invariant below is a statement that must always hold, with the test that would catch a violation.
| # | Invariant | Test that guards it |
|---|---|---|
| 1 | No connection opens before scheme, network, scope, robots and bucket checks pass; they repeat at every redirect | Unit test with a blocked URL and a redirect to a private address: zero sockets opened |
| 2 | An origin's request starts are never closer than its minimum gap, regardless of worker count | Two-claim race test at the same instant: exactly one succeeds |
| 3 | A commit succeeds only if its fence equals the row's current fence | Stale-token commit test returns stale |
| 4 | Every URL is in exactly one state; transitions outside the table are rejected | Property test over random transition sequences |
| 5 | First-seen of a normalized URL is decided only by the database unique key | Concurrent insert test: one winner |
| 6 | Normalization never merges distinguishable case, path or query-value identities | Table test with the six before/after examples |
| 7 | Robots: longest match wins, ties allow; 4xx means unavailable, 5xx or timeout means unreachable | Table-driven tests from the standard's examples |
| 8 | A Bloom 'maybe' is always verified before a URL is discarded | Test with a forced false positive: URL is still inserted |
| 9 | An outbox row exists if and only if its document row exists | Crash-injection test between each write step |
| 10 | A stale ownership token is rejected by the bucket | Token 41 versus 42 test |
| 11 | Retries honor Retry-After and never exceed the attempt cap | Fake-clock retry test |
| 12 | Suppressed origins are never fetched, and deletion is repeatable | Suppression plus replay test |
- 01Every URL has one state and every change has a reason code.
- 02The claim transaction is the single place where policy, fairness, rate and ownership meet.
- 03A fetch result is committed only with a matching fence; bodies are content-addressed so retries are harmless.
- 04Dedupe layers answer different questions and fail differently; a Bloom 'maybe' is always verified.
- 05Idempotency, not exactly-once delivery, makes the storage path safe.
- 06Regional ownership is fenced and bounded, not globally exact.