Bound audit crawl resource usage (#401)

Cap crawled HTML bodies at 2 MiB before Cheerio parsing, bulk-insert
audit links on Postgres instead of per-row Hyperdrive round trips, and
raise the Worker CPU ceiling to the 5-minute paid max for link-heavy
crawl steps.
This commit is contained in:
Ben Senescu 2026-07-18 23:49:30 -04:00 committed by GitHub
parent 7f2e730a1c
commit 6ad65f59e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 57 additions and 4 deletions

View File

@ -247,6 +247,10 @@ export default Alchemy.Stack(
date: wrangler.compatibility_date, date: wrangler.compatibility_date,
flags: wrangler.compatibility_flags, flags: wrangler.compatibility_flags,
}, },
// Site audits parse and persist batches of HTML inside Workflow steps.
// Paid Workers permit up to five minutes; keep headroom for unusually
// link-heavy sites after bounding page bodies and bulk-writing links.
limits: { cpuMs: 300_000 },
observability: { observability: {
enabled: wrangler.observability?.enabled ?? true, enabled: wrangler.observability?.enabled ?? true,
traces: { enabled: wrangler.observability?.traces?.enabled ?? false }, traces: { enabled: wrangler.observability?.traces?.enabled ?? false },

View File

@ -12,6 +12,7 @@ import {
auditLinks, auditLinks,
auditPages, auditPages,
} from "@/db/schema"; } from "@/db/schema";
import { getDatabaseProvider } from "@/db/provider";
import { executeInBatches } from "@/db/runBatch"; import { executeInBatches } from "@/db/runBatch";
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues"; import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
import { deterministicAuditRowId } from "@/server/lib/audit/ids"; import { deterministicAuditRowId } from "@/server/lib/audit/ids";
@ -28,6 +29,7 @@ import type {
// checks. Mega-menu/footer-heavy sites can carry 1000+ links per page; cap // checks. Mega-menu/footer-heavy sites can carry 1000+ links per page; cap
// what we store so a 10k-page crawl can't write tens of millions of link rows. // what we store so a 10k-page crawl can't write tens of millions of link rows.
const MAX_STORED_LINKS_PER_PAGE = 500; const MAX_STORED_LINKS_PER_PAGE = 500;
const POSTGRES_LINK_INSERT_SIZE = 500;
async function createAudit(data: { async function createAudit(data: {
id: string; id: string;
@ -207,9 +209,20 @@ async function insertCrawledBatch(
})), })),
), ),
); );
await executeInBatches(linkRows, (tx, row) => if (getDatabaseProvider() === "postgres") {
tx.insert(auditLinks).values(row).onConflictDoNothing(), // A Postgres transaction executes runBatch statements sequentially. Bulk
); // values avoid thousands of Hyperdrive round trips on link-heavy pages.
for (let i = 0; i < linkRows.length; i += POSTGRES_LINK_INSERT_SIZE) {
await db
.insert(auditLinks)
.values(linkRows.slice(i, i + POSTGRES_LINK_INSERT_SIZE))
.onConflictDoNothing();
}
} else {
await executeInBatches(linkRows, (tx, row) =>
tx.insert(auditLinks).values(row).onConflictDoNothing(),
);
}
await insertIssues(auditId, issues); await insertIssues(auditId, issues);
} }

View File

@ -6,6 +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;
/** /**
* Markers of a bot-mitigation challenge page. We classify these honestly as * Markers of a bot-mitigation challenge page. We classify these honestly as
@ -103,7 +104,10 @@ 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");
const body = isHtml ? await response.text() : ""; // Large pages make Cheerio disproportionately expensive and can exhaust a
// crawl step's CPU or isolate memory. The first 2 MiB still contains the
// SEO metadata and navigation needed by the audit in normal documents.
const body = isHtml ? await readTextUpTo(response, MAX_HTML_BYTES) : "";
const fetchClass = classifyFetch( const fetchClass = classifyFetch(
statusCode, statusCode,
response.headers, response.headers,
@ -198,6 +202,38 @@ export async function crawlPage(
} }
} }
async function readTextUpTo(response: Response, maxBytes: number) {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parts: string[] = [];
let bytesRead = 0;
try {
while (bytesRead < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
const remaining = maxBytes - bytesRead;
const chunk =
value.byteLength > remaining ? value.subarray(0, remaining) : value;
bytesRead += chunk.byteLength;
parts.push(decoder.decode(chunk, { stream: true }));
if (bytesRead >= maxBytes) {
await reader.cancel();
break;
}
}
} finally {
reader.releaseLock();
}
parts.push(decoder.decode());
return parts.join("");
}
function emptyPageResult(input: { function emptyPageResult(input: {
url: string; url: string;
statusCode: number; statusCode: number;