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:
parent
7f2e730a1c
commit
6ad65f59e1
@ -247,6 +247,10 @@ export default Alchemy.Stack(
|
||||
date: wrangler.compatibility_date,
|
||||
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: {
|
||||
enabled: wrangler.observability?.enabled ?? true,
|
||||
traces: { enabled: wrangler.observability?.traces?.enabled ?? false },
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
auditLinks,
|
||||
auditPages,
|
||||
} from "@/db/schema";
|
||||
import { getDatabaseProvider } from "@/db/provider";
|
||||
import { executeInBatches } from "@/db/runBatch";
|
||||
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
||||
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
|
||||
// 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 POSTGRES_LINK_INSERT_SIZE = 500;
|
||||
|
||||
async function createAudit(data: {
|
||||
id: string;
|
||||
@ -207,9 +209,20 @@ async function insertCrawledBatch(
|
||||
})),
|
||||
),
|
||||
);
|
||||
await executeInBatches(linkRows, (tx, row) =>
|
||||
tx.insert(auditLinks).values(row).onConflictDoNothing(),
|
||||
);
|
||||
if (getDatabaseProvider() === "postgres") {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { sha256Hex } from "@/server/lib/audit/ids";
|
||||
import { normalizeUrl } from "@/server/lib/audit/url-utils";
|
||||
|
||||
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
|
||||
@ -103,7 +104,10 @@ export async function crawlPage(
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
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(
|
||||
statusCode,
|
||||
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: {
|
||||
url: string;
|
||||
statusCode: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user