Skip to content

fix(scraper): keep crawling while one page is slow - #520

Open
arabold wants to merge 2 commits into
mainfrom
fix/490-slow-page-stalls-crawl
Open

arabold wants to merge 2 commits into
mainfrom
fix/490-slow-page-stalls-crawl

Conversation

@arabold

@arabold arabold commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Fixes #490, which was reopened after 3.2.0.

What the reporter sees

After 3.2.0 the crawl of https://www.brendangregg.com still seems to stop, at Guess/guess.qbas when **/blog/** is excluded and at Guess/guess.ps when it is not.

I reproduced this on 3.2.1 with both the CLI and the web UI (with the UI's default exclude patterns). The crawl never actually hangs. It always finishes (621/621 processed, 440 indexed), but it freezes for about 7 seconds right after guess.qbas.

Why it freezes

Three files in that directory always answer HTTP 500, because Apache tries to run them as CGI scripts: guess.py, guess.pl and guess.pl.txt. The fetcher treats 500 as retryable, so each one takes 4 attempts with 1 + 2 + 4 seconds of backoff in between.

The crawl loop processed pages in fixed batches of maxConcurrency (3) and waited for the whole batch before starting the next one. So while one URL was waiting to retry, the other two slots did nothing and the whole crawl stood still.

The Jobs page shows the last finished URL as "Processing …". In the directory listing, guess.py sits between guess.ps and guess.qbas, so the page that looks stuck depends on how the batch of 3 lines up. Excluding the blog or not changes that alignment, which matches the report exactly. The same batch barrier was also behind part of the original "freezes for a long time" report, when a large image download held up its batch.

The change

The loop in BaseScraperStrategy.scrape() now runs a sliding window. When an item finishes, the next queued item starts right away. A URL waiting to retry now only holds up its own slot.

Two things stay the same on purpose:

  • Crawl order. Links found by a page are added to the queue in the order pages were taken off it, not in the order they finish. The queue stays breadth-first, so every URL is still first offered at its shortest depth, and timing does not change which pages are crawled or which pages fit under maxPages.
  • The maxPages limit. Items that are still running count against the remaining budget, so at most maxPages - pagesIndexed items run at once and the limit is never overshot. This is the same guarantee the batch size gave before.

processBatch is split into processQueueItem (one item to its outcome) and admitToQueue (deduplication and the queue totals). Most of the diff in BaseScraperStrategy.ts is the re-indentation of that body, so reviewing with whitespace hidden is easier. The two WebScraperStrategy tests that called processBatch directly now call processQueueItem.

Tests

Four new tests in BaseScraperStrategy.test.ts:

  • A slow page no longer blocks the pages queued behind it. This test fails on main.
  • Each URL keeps its shortest depth when pages finish out of order. main already passes this. It guards the admission order: I checked that it fails if links are added in finish order, which the existing breadth-first test does not catch.
  • A slot freed by a page that stored nothing is refilled without indexing past maxPages.
  • The crawl does not finish while a page that reached maxPages is still being stored. This was found in review: the first version of the loop stopped as soon as the count reached the limit, so scrape() could resolve with a write still pending. The loop now runs until nothing is left running, and reaching maxPages only stops new items from starting.

Lint and a cold-cache typecheck are clean, and all 2329 tests pass locally.

Live check

I ran the same web UI crawl of brendangregg.com before and after the change:

before after
pages processed 621 / 621 621 / 621
pages indexed 440 440 (identical URL set)
gap after guess.qbas 7 s none, all of Guess/ stores within one second
total time 159 s 143 s

After the change, the longest time the Jobs page went without updating was 3.1 seconds, on a slide page that has to be rendered in a browser.

Not changed here

  • The Jobs page label "Processing …" names a page that has already finished. That is what made an innocent page look stuck. It is a small separate UI change.
  • Whether a 500 should be retried three times is a policy question. Retries now cost only one slot, so I left the policy as it is.

🤖 Generated with Claude Code

The crawl processed pages in fixed batches of maxConcurrency and waited for
the whole batch before starting the next one. One slow item therefore idled
every other slot. On brendangregg.com, Guess/guess.py, guess.pl and
guess.pl.txt always answer HTTP 500 (Apache runs them as CGI), and each is
retried with 1+2+4 s of backoff, so the crawl stood still for about 7 s at a
time. The Jobs page shows the last finished URL, which is why the reporter
saw it "stop" at guess.qbas or guess.ps depending on how the batch lined up.

The loop now runs a sliding window: a slot is refilled as soon as its item
finishes. Discovered links are still admitted in dequeue order, not in
completion order, so the queue stays breadth-first, every URL is still first
offered at its shortest depth, and the crawl order does not depend on timing.
Running items count against the remaining maxPages budget, so the limit is
never overshot.

processBatch is split into processQueueItem (one item to its outcome) and
admitToQueue (deduplication and queue totals). Most of the diff is the
re-indentation of that body.

Fixes #490
Copilot AI balanced review requested due to automatic review settings September 27, 2026 23:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The crawl can resolve before all in-flight progress callbacks and storage operations finish.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Replaces fixed-batch scraping with a sliding concurrency window while preserving crawl order and page limits.

Changes:

  • Splits item processing from ordered queue admission.
  • Adds concurrency, breadth-first ordering, and limit tests.
  • Updates direct strategy tests for the new method.
File Description
BaseScraperStrategy.ts Implements sliding-window crawling.
BaseScraperStrategy.test.ts Adds concurrency regression coverage.
WebScraperStrategy.test.ts Updates internal method calls.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

let nextSeq = 0;
let nextToAdmit = 0;

while (this.pagesIndexed < maxPages) {
An item counts its page before its progress callback stores it. When the
last pages that fit under maxPages ran concurrently, one could reach the
limit while another was still storing, and the loop exited on the count
alone, so scrape() resolved with a write still pending. Fixed batches never
had this problem because Promise.all waited for every member.

The loop now runs until nothing is left running. Reaching maxPages only
stops new items from starting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checking indexing

2 participants