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,
|
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 },
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user