Cap site-audit crawl memory: byte-budgeted window, 1 MiB read cap, persist backpressure (#445)

This commit is contained in:
Ben Senescu 2026-08-01 13:22:26 -04:00 committed by GitHub
parent acd28749c8
commit 145324138e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 103 additions and 50 deletions

View File

@ -51,7 +51,7 @@ function page(
describe("adjustCrawlWindow", () => { describe("adjustCrawlWindow", () => {
it("keeps the window on an empty batch", () => { it("keeps the window on an empty batch", () => {
expect(adjustCrawlWindow(25, [])).toBe(25); expect(adjustCrawlWindow(10, [])).toBe(10);
}); });
it("halves the window when a third of the batch is troubled", () => { it("halves the window when a third of the batch is troubled", () => {
@ -74,27 +74,32 @@ describe("adjustCrawlWindow", () => {
it("grows on a clean, fast batch up to the cap", () => { it("grows on a clean, fast batch up to the cap", () => {
const recent = Array.from({ length: 25 }, () => page("ok", 400)); const recent = Array.from({ length: 25 }, () => page("ok", 400));
expect(adjustCrawlWindow(25, recent)).toBe(30); expect(adjustCrawlWindow(10, recent)).toBe(15);
expect(adjustCrawlWindow(40, recent)).toBe(40); expect(adjustCrawlWindow(20, recent)).toBe(20);
}); });
it("treats heavy pages as trouble even when they respond fast", () => { it("caps the window so heavy pages stay inside the byte budget", () => {
// 1 MiB average pages: 16 MiB budget / 1 MiB = window of 16.
const recent = Array.from({ length: 25 }, () => const recent = Array.from({ length: 25 }, () =>
page("ok", 300, 2 * 1024 * 1024), page("ok", 300, 1024 * 1024),
); );
expect(adjustCrawlWindow(25, recent)).toBe(12); expect(adjustCrawlWindow(20, recent)).toBe(16);
}); });
it("does not grow when some pages are heavy", () => { it("keeps the byte bound at the minimum window even for huge pages", () => {
const recent = [ const recent = Array.from({ length: 25 }, () =>
...Array.from({ length: 4 }, () => page("ok", 300, 1024 * 1024)), page("ok", 300, 4 * 1024 * 1024),
...Array.from({ length: 21 }, () => page("ok", 300)), );
]; expect(adjustCrawlWindow(20, recent)).toBe(5);
expect(adjustCrawlWindow(25, recent)).toBe(25); });
it("lets small pages use the full window cap", () => {
const recent = Array.from({ length: 25 }, () => page("ok", 400, 10_000));
expect(adjustCrawlWindow(20, recent)).toBe(20);
}); });
it("holds steady on a clean but slow batch", () => { it("holds steady on a clean but slow batch", () => {
const recent = Array.from({ length: 25 }, () => page("ok", 5_000)); const recent = Array.from({ length: 25 }, () => page("ok", 5_000));
expect(adjustCrawlWindow(25, recent)).toBe(25); expect(adjustCrawlWindow(15, recent)).toBe(15);
}); });
}); });

View File

@ -7,22 +7,28 @@ import type { CrawledPageResult } from "@/server/lib/audit/types";
* slowly (politeness toward struggling or defensive sites) and grows when * slowly (politeness toward struggling or defensive sites) and grows when
* the site answers fast. * the site answers fast.
*/ */
export const INITIAL_CRAWL_WINDOW = 25; export const INITIAL_CRAWL_WINDOW = 10;
const MIN_WINDOW = 5; const MIN_WINDOW = 5;
const MAX_WINDOW = 40; const MAX_WINDOW = 20;
const SLOW_RESPONSE_MS = 10_000; const SLOW_RESPONSE_MS = 10_000;
const FAST_RESPONSE_MS = 1_500; const FAST_RESPONSE_MS = 1_500;
/** /**
* Pages at/above this HTML size count as trouble: each in-flight page * Total HTML the in-flight window may buffer at once. Each in-flight page
* buffers its body, so a wide window on a heavy-page site is memory * holds its body (decoded to a UTF-16 string, roughly doubling it), and the
* pressure the response time can't see (it's measured at headers). * workflow shares its isolate's 128 MB memory limit with the rest of the
* worker production audits died with exceededMemory when a fast site let
* the window grow unchecked.
*/ */
const HEAVY_PAGE_BYTES = 1024 * 1024; const IN_FLIGHT_HTML_BUDGET_BYTES = 16 * 1024 * 1024;
/** Floor for the observed page size so tiny-page sites can't void the bound. */
const MIN_ASSUMED_PAGE_BYTES = 64 * 1024;
/** /**
* Adapt the window to the last persisted sub-batch. Shrinks on trouble * Adapt the window to the last persisted sub-batch. Shrinks on trouble
* (errors, blocks, very slow responses, heavy bodies), grows only on a * (errors, blocks, very slow responses), grows only on a clean and mostly
* clean and mostly-fast batch. * fast batch, and is always capped so the batch's average page size times
* the window stays inside the in-flight byte budget.
*/ */
export function adjustCrawlWindow( export function adjustCrawlWindow(
windowSize: number, windowSize: number,
@ -32,19 +38,29 @@ export function adjustCrawlWindow(
const troubled = recent.filter( const troubled = recent.filter(
(page) => (page) =>
page.fetchClass !== "ok" || page.fetchClass !== "ok" ||
(page.responseTimeMs ?? 0) >= SLOW_RESPONSE_MS || (page.responseTimeMs ?? 0) >= SLOW_RESPONSE_MS,
page.htmlBytes >= HEAVY_PAGE_BYTES,
).length; ).length;
let next = windowSize;
if (troubled * 3 >= recent.length) { if (troubled * 3 >= recent.length) {
return Math.max(MIN_WINDOW, Math.floor(windowSize / 2)); next = Math.max(MIN_WINDOW, Math.floor(windowSize / 2));
} } else {
const fast = recent.filter( const fast = recent.filter(
(page) => (page) =>
page.fetchClass === "ok" && page.fetchClass === "ok" &&
(page.responseTimeMs ?? Infinity) <= FAST_RESPONSE_MS, (page.responseTimeMs ?? Infinity) <= FAST_RESPONSE_MS,
).length; ).length;
if (troubled === 0 && fast * 2 >= recent.length) { if (troubled === 0 && fast * 2 >= recent.length) {
return Math.min(MAX_WINDOW, windowSize + 5); next = Math.min(MAX_WINDOW, windowSize + 5);
} }
return windowSize; }
const avgPageBytes = Math.max(
recent.reduce((sum, page) => sum + page.htmlBytes, 0) / recent.length,
MIN_ASSUMED_PAGE_BYTES,
);
const byteBound = Math.max(
MIN_WINDOW,
Math.floor(IN_FLIGHT_HTML_BUDGET_BYTES / avgPageBytes),
);
return Math.min(next, byteBound);
} }

View File

@ -6,7 +6,7 @@ import { sha256Hex } from "@/server/lib/audit/ids";
import { normalizeUrl } from "@/server/lib/audit/url-utils"; import { normalizeUrl } from "@/server/lib/audit/url-utils";
const CRAWL_USER_AGENT = "OpenSEO-Audit/1.0"; const CRAWL_USER_AGENT = "OpenSEO-Audit/1.0";
const MAX_HTML_BYTES = 2 * 1024 * 1024; const MAX_HTML_BYTES = 1024 * 1024;
/** /**
* Markers of a bot-mitigation challenge page. We classify these honestly as * Markers of a bot-mitigation challenge page. We classify these honestly as
@ -104,7 +104,7 @@ export async function crawlPage(
const contentType = response.headers.get("content-type") ?? ""; const contentType = response.headers.get("content-type") ?? "";
const isHtml = contentType.includes("text/html"); const isHtml = contentType.includes("text/html");
// Cap what we read: the first 2 MiB still contains the SEO metadata and // Cap what we read: the first 1 MiB still contains the SEO metadata and
// navigation needed by the audit in normal documents. // navigation needed by the audit in normal documents.
const body = isHtml ? await readTextUpTo(response, MAX_HTML_BYTES) : ""; const body = isHtml ? await readTextUpTo(response, MAX_HTML_BYTES) : "";
const fetchClass = classifyFetch( const fetchClass = classifyFetch(
@ -124,6 +124,9 @@ export async function crawlPage(
headerCanonicalUrl, headerCanonicalUrl,
crawlDepth, crawlDepth,
inSitemap, inSitemap,
// The body was still fetched and buffered; report its size so the
// crawl window's byte budget sees blocked/error pages too.
htmlBytes: body.length,
}); });
} }
@ -244,6 +247,7 @@ function emptyPageResult(input: {
headerCanonicalUrl: string | null; headerCanonicalUrl: string | null;
crawlDepth: number | null; crawlDepth: number | null;
inSitemap: boolean; inSitemap: boolean;
htmlBytes?: number;
}): CrawledPageResult { }): CrawledPageResult {
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -270,7 +274,7 @@ function emptyPageResult(input: {
wordCount: 0, wordCount: 0,
contentHash: null, contentHash: null,
isHtml: false, isHtml: false,
htmlBytes: 0, htmlBytes: input.htmlBytes ?? 0,
imagesTotal: 0, imagesTotal: 0,
imagesMissingAlt: 0, imagesMissingAlt: 0,
images: [], images: [],

View File

@ -34,6 +34,13 @@ const CHUNK_TARGET_PAGES = 200;
const CHUNK_SOFT_DEADLINE_MS = 90_000; const CHUNK_SOFT_DEADLINE_MS = 90_000;
/** Crawled pages are persisted in sub-batches of this size. */ /** Crawled pages are persisted in sub-batches of this size. */
const PERSIST_BATCH_SIZE = 25; const PERSIST_BATCH_SIZE = 25;
/**
* Stop launching new fetches while more than this many persist sub-batches
* are waiting: persistence is sequential, so when the DB falls behind a fast
* site, unpersisted page results would otherwise pile up in memory without
* bound.
*/
const MAX_QUEUED_PERSIST_BATCHES = 2;
/** /**
* Mega-menu/footer-heavy sites can carry 1000+ links per page; cap what we * Mega-menu/footer-heavy sites can carry 1000+ links per page; cap what we
@ -146,13 +153,16 @@ async function runCrawlChunk(
// Persistence runs concurrently with fetching (pipelined) but sequentially // Persistence runs concurrently with fetching (pipelined) but sequentially
// with itself, so DB write pressure stays bounded at one batch at a time. // with itself, so DB write pressure stays bounded at one batch at a time.
let persistChain: Promise<unknown> = Promise.resolve(); let persistChain: Promise<unknown> = Promise.resolve();
let queuedPersists = 0;
const flush = () => { const flush = () => {
if (batch.length === 0) return; if (batch.length === 0) return;
const pages = batch; const pages = batch;
batch = []; batch = [];
windowSize = adjustCrawlWindow(windowSize, pages); windowSize = adjustCrawlWindow(windowSize, pages);
persistChain = persistChain.then(() => queuedPersists += 1;
persistChain = persistChain
.then(() =>
persistCrawledPages({ persistCrawledPages({
auditId, auditId,
workflowInstanceId, workflowInstanceId,
@ -163,7 +173,10 @@ async function runCrawlChunk(
depthByUrl, depthByUrl,
maxPages, maxPages,
}), }),
); )
.finally(() => {
queuedPersists -= 1;
});
}; };
const launch = (entry: ClaimedUrl) => { const launch = (entry: ClaimedUrl) => {
@ -182,14 +195,29 @@ async function runCrawlChunk(
while (true) { while (true) {
while ( while (
inFlight.size < windowSize && inFlight.size < windowSize &&
queuedPersists <= MAX_QUEUED_PERSIST_BATCHES &&
nextIndex < claimed.length && nextIndex < claimed.length &&
Date.now() < deadlineAt Date.now() < deadlineAt
) { ) {
launch(claimed[nextIndex]); launch(claimed[nextIndex]);
nextIndex += 1; nextIndex += 1;
} }
if (inFlight.size === 0) break; if (inFlight.size > 0) {
await Promise.race(inFlight); await Promise.race(inFlight);
continue;
}
// Nothing in flight. If launches are only paused by persistence
// backpressure, wait for the queue to drain and resume; otherwise the
// chunk is done (leases exhausted or soft deadline hit).
if (
queuedPersists > MAX_QUEUED_PERSIST_BATCHES &&
nextIndex < claimed.length &&
Date.now() < deadlineAt
) {
await persistChain;
continue;
}
break;
} }
flush(); flush();
await persistChain; await persistChain;