Researchcrawler engineering
Back to writing

Crawler engineering

The crawl frontier needs a state machine

A crawler needs a durable account of ownership, attempts and evidence before it needs a faster queue.

Monochrome generative point study “Lorenz Lantern,” a mathematical form rendered in black and white.

A worker times out, a replacement takes its URL, and the first worker eventually returns. Both responses may be worth keeping. Only one worker should advance the frontier. I would make the writer check the current assignment when it commits: a queue can deliver work without settling who owns the result.

The writer has to settle that disagreement. Withdrawing a lease in the scheduler is ineffective if storage still accepts the former owner’s update. Durable states make the crawl recoverable only when the component changing the current result also checks who is entitled to change it.

Method & limits

The worker race is a design scenario. Sources checked October 25, 2026: the Chubby sequencer discussion and SQLite transaction, UPSERT, and WAL documentation. The prior July 19 cutoff applies to the architecture’s other guidance. The pinned Atlas tests are private implementation references described by the author; readers cannot independently inspect them here, and no fresh execution is reported. No lease protocol, fault injection, backup procedure, throughput result or search-engine scheduler was implemented or measured.

Expiry does not stop the old worker

Lease expiry is permission to reclaim work, not evidence that the former worker stopped. Give each assignment a monotonically increasing generation and carry it with every result. The writer must compare that generation with the current owner in the same authoritative transaction that accepts the frontier transition. Checking ownership earlier, then writing later, leaves a race in which ownership changes between the two operations.

Burrows’s Chubby paper gives the client a sequencer describing its acquired lock and makes the recipient check validity. A once-valid lock can’t authorize a delayed request forever. I borrow that recipient-side check for the local crawler design.

An obsolete worker can append an attempt and response without changing the replacement’s current state. Named states then explain whether the URL is waiting for capacity, paused by policy, retryable, or terminal. The current-state row is a projection whose transition history should make it reconstructible.

Crawl frontier / recoveryOne job, two attempts

Expiry permits reassignment. A generation check fences the write.

  1. Assign A · generation n

    The frontier records the assignment; A begins work.

  2. Lease expires; assign B · n + 1

    A may still be running. The frontier now requires the newer generation.

  3. A’s response arrives after reassignment.
    Returned generationn
    Frontier requiresn + 1

Retain the response.Keep the attempt’s evidence, including a late result.

Do not advance current state.The old generation no longer owns the assignment.

A sequence from this section’s authored frontier architecture, not a recorded crawl. Lease expiry cannot stop an already running worker. The current generation controls state advancement; retaining a response and accepting its state change are separate decisions.
Minimal crawl-frontier transition contract
FromEventToRequired evidence
discoveredscope acceptedadmitteddiscovery edge and comparison key
admittedorigin slot assignedscheduledpriority, earliest fetch time, policy version
scheduledworker lease acquiredfetchingworker, lease expiry, attempt ID
fetchingresponse capturedfetchedstatus, headers, body reference, timings
fetchingtransient failureretryableerror class, retry count, next eligible time
any pre-fetch statepolicy deniessuppressedrule, policy source, evaluation time
fetched or retryablecompletion rule metterminalterminal reason and artifact set
Transition event

The event stores both the decision and the inputs needed to review it later.

type FrontierTransition = {
  runId: string;
  fetchKey: string;
  attemptId: string;
  from: FrontierState;
  event: string;
  to: FrontierState;
  occurredAt: string;
  policyVersion: string;
  evidenceRef?: string;
  reason: string;
};

The same address is a policy decision

Keep the observed reference, resolved request URL, and comparison key. RFC 3986 permits lowercasing scheme and host, removing dot segments, and normalizing percent-encoded unreserved characters. Deleting queries, folding path case, or equating trailing slashes needs a content-specific policy.

A documented normalizer can classify a parameter as tracking-only, content-bearing, order-sensitive or unknown. Preserve the original href so a later correction can revise the comparison key without erasing the reference the page actually emitted. The normalizer version belongs in the identity key. Fragments usually do not create another HTTP fetch, but belong on discovery edges for in-page navigation.

Admission also needs named rejection states for unsupported schemes, user information, malformed ports and out-of-scope hosts. Silent deletion makes later coverage impossible to reconcile. The run should be able to explain why an observed reference never became a job.

URL identity also determines where discovery links lead. The internal-link graph method tests crawlable edges, reachable components, and contextual relevance.

Capacity belongs to the destination origin

A global FIFO lets a deep site occupy the workers. Give each scheme-host-port origin its own eligible queue, concurrency bound and earliest dispatch time. The global scheduler chooses an origin fairly; the local scheduler chooses a URL. Transport failures, 429s and repeated 5xxs can slow one origin without freezing every other host.

A job admitted for one host may redirect to another with different scope, robots rules and available capacity. Check each hop and preserve its response. An HTTP client following the chain invisibly can bypass the scheduler the application thought it had built.

Robots retrieval needs its status, expiry, parser version and policy result. RFC 9309 defines access-failure and cache behavior; a stricter operator policy belongs alongside the protocol result as an explicit override. Backoff should retain its reason, bounded exponential delay and jitter, with applicable Retry-After honored within local safety limits.

The run also needs a finite contract before discovery starts. Calendars and facet permutations can generate more addresses than the content justifies. Bound URL count, depth and pattern cardinality, then separately bound response bytes, redirects, rendering and wall time. A suppressed pattern needs an example, reason and count so “finished” never conceals “stopped by budget.”

The executable robots.txt wildcard cases test permission for each path. Scheduling allowed fetches and retrying failures come next.

Acknowledgment follows the commit

For the local SQLite design, I prefer immutable worker result messages and one serialized writer. First persist the artifact, then use a short transaction to validate duplicate-payload equality, check current lease generation and commit the accepted attempt and projection. Acknowledgment follows that commit. The network fetch and browser work stay outside it. SQLite transactions.

A crash after commit but before acknowledgment causes redelivery. The attempt key identifies the logical submission; compare the payload hash too, and quarantine conflicting reuse of the ID. The SQL’s WHERE clause only prevents an older accepted sequence replacing a newer one. The lease-generation check must still authorize the frontier transition, under SQLite’s UPSERT semantics.

Persisting the response first can leave an unreferenced blob after a failed database commit, but committing first can leave an accepted result pointing to missing bytes, so I would accept the orphaned blob and reconcile it only after a grace period protects work still in flight. The transaction commits its immutable reference without waiting for HTTP, browser work, or storage calls.

INSERT OR REPLACE can delete and recreate a row, affecting foreign keys and erasing first observations. Named conflict targets make the allowed collision visible. A selected update guarded by attempt sequence prevents an older message from moving the current projection backward, while lease generation remains the authority check for the frontier transition.

Before the transactionPersist the artifactNetwork and filesystem work stays outside the writer lock.
One durable resultBEGIN → COMMIT
  • Attempt state
  • Response metadata
  • Artifact references
  • Observations
  • Edges
  • Frontier transitions
Only after commitAcknowledge
After an interruptionRedeliver safelyCheck identity and payload before accepting a redelivery.
Persist the artifact before committing its immutable reference and result. Storage or commit failures remain recoverable.
Recommended crawl-storage grains
View as a table
TableOne row representsUniqueness anchor
crawl_runOne bounded execution contractrun_id
url_identityOne comparison key under one normalizernormalizer_version + fetch_key
discovery_edgeOne observed source-to-reference relationrun + source artifact + node + observed ref
fetch_attemptOne request attemptattempt_id or run + URL + sequence
artifactOne immutable stored payloadstorage namespace + content hash
rule_evaluationOne rule version on one input setrun + URL + rule + version + input hash
Append and project

Illustrative append/projection SQL. Validate duplicate payload equality and current lease authority first; the sequence guard prevents older accepted attempts from moving this projection backward.

BEGIN IMMEDIATE;

INSERT INTO fetch_attempt (attempt_id, run_id, url_id, sequence, status, artifact_id)
VALUES (:attempt_id, :run_id, :url_id, :sequence, :status, :artifact_id)
ON CONFLICT(attempt_id) DO NOTHING;

INSERT INTO url_current (run_id, url_id, sequence, status, attempt_id)
VALUES (:run_id, :url_id, :sequence, :status, :attempt_id)
ON CONFLICT(run_id, url_id) DO UPDATE SET
  sequence = excluded.sequence,
  status = excluded.status,
  attempt_id = excluded.attempt_id
WHERE excluded.sequence > url_current.sequence;

COMMIT;

For an assistant that changes saved state, tool-call timeout recovery follows a write through a lost response, its saved receipt, and a newer edit.

WAL still has one writer

WAL lets readers hold snapshots while a writer appends commits. There is still one writer at a time. I would keep transactions short and busy retries bounded, then use queue age to signal when collection outruns storage.

Long readers hold back checkpoints and can grow the WAL. Close snapshots promptly, paginate exports against a deliberate snapshot, and monitor checkpoint progress. SQLite’s shared-memory design requires participants on the same host. For independent hosts sharing a network filesystem, put write authority behind a service or use a server database.

SQLite documents that synchronous=NORMAL in WAL mode can lose recent committed transactions after power loss, while FULL syncs the WAL at commit. The artifact store needs a compatible persistence guarantee: a database reference cannot rescue an unsynced file. A process-crash test and a power-loss test exercise different promises.

SQLite’s WAL advisory describes a rare WAL-reset corruption issue fixed in SQLite 3.51.3 and later, with backports including 3.44.6 and 3.50.7. Concurrent writing and checkpointing across connections are relevant conditions. Inventory the embedded library used by the application and check the official advisory: the system sqlite3 command can belong to another runtime.

The count belongs to URL identities, not attempts

One admitted URL can have a timeout, an expired lease and a successful retry. That is one covered identity with several attempts, and the history must survive. At its cutoff, the run must reconcile terminal results with work that remains explicitly unresolved. Idle workers say nothing about whether that reconciliation is complete.

Atlas’s incremental-crawl tests cover response reuse after 304, reuse after an unchanged 200, and re-parsing when an artifact version changes. Unchanged content can need a new interpretation, so both the response and its parser or rule version belong in the record.

Interruption tests should target the actual boundaries: before artifact storage, before commit, after commit but before acknowledgment, and during export. A duplicate delivery should leave one logical result; a restore should reproduce run counts and key report queries. Integrity checks and foreign-key checks belong to the archive contract, and a live WAL backup must use a supported mechanism rather than copy only the main file.

A bounded single-worker crawl can keep pending work and its dispositions in one transactional process. It needs no distributed lease service while recovery cannot leave old and new assignments running together. Once those assignments can overlap, the generation check protects the writer from a late return. That concurrency boundary is the reason to add the protocol.

In the recovery design, the stalled worker may finish after its replacement has committed. Its response can still join the attempt history, but it cannot become the current result. Redelivery of an already accepted message should recover the same logical submission; conflicting bytes under that ID require investigation. These are the distinct outcomes that interruption tests around storage, commit and acknowledgment need to exercise.

The frontier owes an account of every admitted URL under the run’s limits. Workers can finish in any order. I would add concurrency only when the writer can preserve one current owner and a recoverable history, regardless of which late message arrives last.

Sources

  1. RFC 3986: Uniform Resource Identifier generic syntaxVerified Jul 19, 2026
  2. RFC 9309: Robots Exclusion ProtocolVerified Jul 19, 2026
  3. Google Search: URL structure best practicesVerified Jul 19, 2026
  4. Google Crawling Infrastructure: crawl budget managementVerified Jul 19, 2026
  5. RFC 9110: HTTP SemanticsVerified Jul 19, 2026
  6. SQLite transaction documentationVerified Jul 19, 2026
  7. SQLite UPSERT documentationVerified Jul 19, 2026
  8. SQLite write-ahead logging documentationVerified Jul 19, 2026
  9. SQLite atomic commit documentationVerified Jul 19, 2026
  10. SQLite PRAGMA integrity_checkVerified Jul 19, 2026
  11. Incremental crawl regression tests — private reference

    Private implementation reference, pinned to revision ef3bad25ea0f43f7b4a854fa56c74af5a50fc8db. The code is not publicly available. This is an author-reported implementation account, not independent verification or a fresh test-suite result. 304 reuse, unchanged-body reuse, and artifact-version invalidation.

  12. Mike Burrows — The Chubby lock service for loosely-coupled distributed systemsUSENIX OSDI · 2006Verified Oct 25, 2026

    Sections 2.1 and 2.4 explain acquisition counts and recipient-checked sequencers for delayed requests. This is a design comparison, not evidence of an Atlas implementation.

Read next