Repository navigation
fix(platform): render a crawled page again only when it changed - #4439
Conversation
The crawler read `<loc>` out of a sitemap and nothing else, so every scan requested every page again — probe, browser render and the assets a render loads — and compared content hashes afterwards. A site that dated all 700 of its pages answered some 8,000 requests and two gigabytes a scan, every six hours, for pages its sitemap marked unchanged for months. A scheduled rescan now leaves a page alone when its stored text was read after the entry's `<lastmod>`. Every doubt still requests the page: an entry without a readable date, a date that moved, a date the sitemap dropped, a failed last read, text stored but not indexed yet. A date alone covers its whole day in any time zone, a read within the hour after the date does not count, and a page is read again once its last read is older than seven days, whatever its date says. "Scan now" requests every page; "Resume scanning" and the scans that follow a saved embedding model stay ordinary ones. The date lives in `public_web.website_urls.sitemap_lastmod` (knowledge-db migration 13), and each discovery replaces what the one before recorded.
The crawler's one way to learn whether a page had changed was to do everything again: fetch it, render it in a browser with every image, script and stylesheet the render loads, and compare content hashes afterwards. A 700-page site scanned every six hours answered some 8,000 requests and two gigabytes a scan for pages unchanged in months. A scan now asks for each page once and opens the browser only for a page that changed. The request carries the validators of the page's last settled visit, and a 304 leaves the page as it is. A server that builds its pages on request has no modification time to give — no ETag, no Last-Modified, a new token in every response — so the text read out of the plain HTML is compared too. What a page is checked by is kept only when its plain HTML carried what the browser showed (nine tenths of its words); a page built by its JavaScript is rendered on every scan, as before. "Scan now" asks the same way as a scheduled scan. This replaces the first approach on this branch, which skipped pages by their sitemap `<lastmod>` and read every page again weekly: a sitemap dates a listing page by its own last edit, not by what it lists, and a clock-based re-read is the load the change is there to remove.
Two tests of a rendered page let the store report changed text, which sent the scan into the indexer and its lookup of the knowledge database: green beside a developer's `.env`, red in CI. The row now already stores what the browser shows, and the tests assert the indexer is never asked. Also stops exporting the coverage floor, which only its own module reads (Knip).
Independent review: PR #4439Verdict: FINDINGS CODEChange detection is a conditional GET using stored DATAMigration 13 is additive/idempotent ( REL-MANUAL304 and same-text probes leave indexed content untouched; changed HTML is rendered and re-indexed; failed probes/renders leave the page due and record an error. Robots meta/header checks still run on the probe path. Fetch and render timeouts are classified and retained. Finding: the coverage classifier can treat a page as server-rendered when only 90% of rendered words are present in plain HTML; a later JS change in the uncovered portion is skipped as unchanged (see below). PERFORMANCE / RESOURCESThe optimization removes browser work for unchanged pages and bounds probe body reads by the render-size cap. Hashing is linear in the bounded response; validator strings are capped at 512 chars. Storage adds three nullable columns per URL and does not grow per scan. SECURITYThe existing safe-fetch path is retained: HTTPS, host allowlist, redirect revalidation, private-address checks, response-size and timeout limits. Conditional headers contain only origin-provided validator values (trimmed/capped) and introduce no new egress or SSRF path. TESTSThe new unit/integration coverage exercises 304, same-text token churn, changed text, JS-only pages, robots meta withdrawal, charset handling, render settlement, and scan claims. Those tests would fail against the old always-render behavior. Missing regression: a page with 90% plain-text coverage whose JS changes a visible 10% segment, and a relative-link change with unchanged visible text. Main checks run 37482586587 passed; no local test was run under the optional guard. Finding
Related PROpen PR #4445 reworks the same crawl/indexing area (vector storage and UI, LOCALE and A11Y are not applicable: this PR changes crawler/backend/migration behavior and documentation only, with no user-facing UI controls or locale keys. |
Apply the three corrections from the independent fact review of #4447 (website scan probing versus the browser fetch, the knowledge database container and its migration order, and the #4448 entry), and add the changes merged after the reviewed range: the two-factor grace anchor (#4437) with its migration note, sharp 0.35.5 for GHSA-wq5f-xc86-pv6w (#4450) with the pinned sandbox runtime image note, and the All projects archived badge (#4416). Add known issues for #4438, #4439 and #4448 from their post-merge reviews. Independent review: ACCEPT at 32d07ec (PR comment 6024188366).
Keep crawl change-check validators in new v2 columns (knowledge-db migration 14) and invalidate them with a trigger whenever legacy validators or page content are written, so the unchanged-page fast path can no longer skip a real change. Legacy provenance is not backfilled. Adds change-check and integration regressions. Independent review: ACCEPT at 7781964 (PR comment 6025882356). Refs #4439
Summary
The crawler's one way to learn whether a page had changed was to do everything again: fetch it, render it in a browser with every image, script and stylesheet the render loads, and compare content hashes afterwards. A 700-page site scanned every six hours answered about 8,000 requests and 2 GiB per scan for pages unchanged in months.
A scan now asks for each page once and opens the browser only for a page that changed.
304 Not Modifiedto the validators of the last visit (ETag,Last-Modified)Why the text and not a date. A server that builds its pages on request has no modification time to give. On the site this was measured on, a
HEADreturns noETag, noLast-Modifiedand noContent-Length, a conditionalGETis answered200with the whole page, and two responses for one page differ in their bytes (a token, a nonce) while the text read out of them is identical. A static file on the same host carries both validators. The sitemap is no better witness: it dates its blog index 2023 while that page lists articles from this month.Which pages can be judged by one request. When a page is rendered, the text of its plain HTML is compared with what the browser showed. If the plain HTML carried at least nine tenths of the words, the page is server-rendered and keeps its validators and the hash of its plain text; otherwise it keeps nothing and is rendered every time. Measured on real pages:
Scan now runs the same scan as the schedule; there is no full mode.
What is stored
Knowledge-db migration 13 adds
public_web.website_urls.probe_hashand puts the baseline'setagandlast_modifiedback to work. The two went unused when the render lane arrived, so whatever they still hold is cleared once: a value written before then may belong to a JavaScript shell. The three columns are written only when a page is stored and indexed, and cleared when its content is purged.Rollout
The first scan after the upgrade still renders every HTML page: no page has a plain-text hash yet. From the second scan on, unchanged pages cost one request. On the measured site that is about 700 requests of compressed HTML (55 to 91 KB each) in place of about 8,000 requests and 2 GiB.
Replaces the first approach on this branch
The first commit skipped pages by their sitemap
<lastmod>and read every page again weekly. It is gone: a sitemap dates a listing page by its own last edit, not by what it lists, and a clock-based re-read is the load this change removes.Not in this PR
Checklist
bun run check: every task of this PR's own passes locally on the merged tree (format, conflict markers, links,lint:manual, platform lint, platform typecheck, 82,556 platform tests, docs suite). Three tasks fail on this machine for reasons outside the diff and pass in CI:@tale/cli#test(a stale local install:tools/cli/node_modules/@inquirer/coreresolvessignal-exitto the hoisted 3.x),@tale/platform#test:ui(one tasks search test under load; 12 of 12 alone),@tale/sandbox-runtime-daemon#test(timeouts that vary run to run beside a running dev stack).bun run lint:sastpasses (Opengrep): 0 findings.docs/{en,de,fr}/platform/knowledge/crawling.md.Test plan
bunx vitest --run --project server backend/core/knowledge lib/knowledgeinservices/platform: the conditional request, both ways a page is found unchanged, the gates an unchanged page still passes (alias,X-Robots-Tag, robots meta tag), the probe handed to the render batch, what a rendered page keeps to be checked by, and the coverage measure ([KNOW-R16]).ITEST_LANES=checkFiles,checkWebsitesCrawl bun run backend:integrationagainst atale-dbimage built from this branch: 48/48. The threewebsites change checklanes run a fixture site whose server answers conditional requests and counts what it sent:completedwithout a render session, which the harness could not open;