import type { WorkflowStep } from "cloudflare:workers"; import type { RobotsResult } from "@/server/lib/audit/discovery"; import type { CrawledPageResult } from "@/server/lib/audit/types"; import { isSameOrigin } from "@/server/lib/audit/url-utils"; import { isCrawlableUrl } from "@/server/lib/audit/url-policy"; import { deterministicAuditRowId } from "@/server/lib/audit/ids"; import { runPageReporters } from "@/server/lib/audit/issues/page-reporters"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { getAuditScratchpad, type ClaimedUrl, type FrontierStats, type ScratchpadLinkRow, } from "@/server/features/audit/AuditScratchpad"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { adjustCrawlWindow, INITIAL_CRAWL_WINDOW, } from "@/server/lib/audit/crawl-window"; import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers"; import { pgStep } from "@/server/workflows/pgStep"; import { CRAWL_CHUNK_STEP } from "@/server/workflows/auditStepConfigs"; /** * The crawl runs in chunks: each chunk is one durable step that leases up to * CHUNK_TARGET_PAGES URLs from the scratchpad DO, crawls them with a rolling * concurrency window, and persists results incrementally (full page rows to * the app DB, links/mirror/frontier updates to the DO). Step returns carry * only counters, so step state stays tiny regardless of site size and the * workflow heap stays O(one batch). */ const CHUNK_TARGET_PAGES = 200; /** Stop launching new fetches after this long; leftover leases are released. */ const CHUNK_SOFT_DEADLINE_MS = 90_000; /** Crawled pages are persisted in sub-batches of this size. */ 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 * record so a 10k-page crawl can't produce tens of millions of link rows. */ const MAX_STORED_LINKS_PER_PAGE = 500; /** * Cap newly discovered URLs sent to the scratchpad per persist sub-batch. * Serialized RPC messages are limited to 32 MiB; a crawler-trap page family * (faceted nav, calendars) can emit tens of thousands of unique URLs per * page. Dropped URLs are usually re-discovered from later pages, and a site * generating this many is past maxPages anyway. */ const MAX_DISCOVERED_PER_BATCH = 20_000; const MAX_PROGRESS_TITLE_CHARS = 300; function shouldQueueCrawlLink( link: string, origin: string, robots: RobotsResult, ): boolean { return ( isSameOrigin(link, origin) && isCrawlableUrl(link) && robots.isAllowed(link) ); } type CrawlPhaseParams = { auditId: string; workflowInstanceId: string; origin: string; maxPages: number; robots: RobotsResult; /** Frontier size after discovery seeding (from the discover-urls step). */ seededCount: number; }; export type CrawlPhaseResult = { pagesCrawled: number; /** True when the frontier was exhausted before hitting maxPages. */ completed: boolean; }; export async function runCrawlPhase( step: WorkflowStep, params: CrawlPhaseParams, ): Promise { let chunkNo = 0; let attemptedTotal = 0; let pending = params.seededCount; let zeroProgressChunks = 0; while (pending > 0 && attemptedTotal < params.maxPages) { chunkNo += 1; const result = await pgStep( step, `crawl-chunk-${chunkNo}`, CRAWL_CHUNK_STEP, () => runCrawlChunk({ ...params, chunkNo, attemptedBefore: attemptedTotal, }), ); // Apply the chunk's counters even when it did no new work (a retried // chunk whose earlier attempt persisted everything reports 0 attempts // with up-to-date scratchpad totals) — finalize must not see stale ones. attemptedTotal = result.attempted; pending = result.pending; // One zero-attempt chunk is normal (retry of a completed chunk number); // two in a row means the frontier is unservable — stop with what we // have instead of spinning forever. zeroProgressChunks = result.attemptedInChunk === 0 ? zeroProgressChunks + 1 : 0; if (zeroProgressChunks >= 2) break; } return { pagesCrawled: attemptedTotal, completed: pending === 0 }; } async function runCrawlChunk( input: CrawlPhaseParams & { chunkNo: number; attemptedBefore: number }, ): Promise<{ attemptedInChunk: number; attempted: number; pending: number }> { const { auditId, workflowInstanceId, origin, maxPages, robots, chunkNo } = input; const scratchpad = getAuditScratchpad(auditId); const claimLimit = Math.min( CHUNK_TARGET_PAGES, maxPages - input.attemptedBefore, ); const claimed = await scratchpad.claimChunk(chunkNo, claimLimit); if (claimed.length === 0) { const stats = await scratchpad.getStats(); return { attemptedInChunk: 0, attempted: stats.attempted, pending: stats.pending, }; } const depthByUrl = new Map(claimed.map((entry) => [entry.url, entry.depth])); const deadlineAt = Date.now() + CHUNK_SOFT_DEADLINE_MS; let windowSize = INITIAL_CRAWL_WINDOW; let nextIndex = 0; let attemptedInChunk = 0; const inFlight = new Set>(); let batch: CrawledPageResult[] = []; // Persistence runs concurrently with fetching (pipelined) but sequentially // with itself, so DB write pressure stays bounded at one batch at a time. let persistChain: Promise = Promise.resolve(); let queuedPersists = 0; const flush = () => { if (batch.length === 0) return; const pages = batch; batch = []; windowSize = adjustCrawlWindow(windowSize, pages); queuedPersists += 1; persistChain = persistChain .then(() => persistCrawledPages({ auditId, workflowInstanceId, origin, robots, scratchpad, pages, depthByUrl, maxPages, }), ) .finally(() => { queuedPersists -= 1; }); }; const launch = (entry: ClaimedUrl) => { const promise = crawlPage(entry.url, entry.depth, entry.inSitemap) .then((page) => { attemptedInChunk += 1; batch.push(page); if (batch.length >= PERSIST_BATCH_SIZE) flush(); }) .finally(() => { inFlight.delete(promise); }); inFlight.add(promise); }; while (true) { while ( inFlight.size < windowSize && nextIndex < claimed.length && Date.now() < deadlineAt ) { // queuedPersists changes when persistChain settles. Keep it out of the // loop condition because the type-aware linter cannot see that async // mutation and flags the otherwise valid backpressure check. if (queuedPersists > MAX_QUEUED_PERSIST_BATCHES) break; launch(claimed[nextIndex]); nextIndex += 1; } if (inFlight.size > 0) { 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(); await persistChain; // Leases we never launched (soft deadline) go back to the queue. const unattempted = claimed.slice(nextIndex).map((entry) => entry.url); if (unattempted.length > 0) { await scratchpad.releaseUrls(unattempted); } // Progress counters are written per persisted sub-batch (in // persistCrawledPages), so a chunk that dies mid-way underreports by at // most one sub-batch, not a whole chunk. const stats = await scratchpad.getStats(); return { attemptedInChunk, attempted: stats.attempted, pending: stats.pending, }; } async function persistCrawledPages(input: { auditId: string; workflowInstanceId: string; origin: string; robots: RobotsResult; scratchpad: ReturnType; pages: CrawledPageResult[]; depthByUrl: Map; maxPages: number; }): Promise { const { auditId, origin, robots, scratchpad, pages, depthByUrl } = input; // Deterministic ids keep every write idempotent across step retries. for (const page of pages) { page.id = await deterministicAuditRowId(auditId, page.url); } const issues = pages.flatMap((page) => runPageReporters(page)); await AuditRepository.insertCrawledBatch(auditId, pages, issues); const links: ScratchpadLinkRow[] = []; const discovered = new Map(); for (const page of pages) { const pageDepth = depthByUrl.get(page.url) ?? null; const childDepth = pageDepth === null ? null : pageDepth + 1; let storedForPage = 0; for (const link of page.links) { if (!link.isInternal) continue; if (storedForPage < MAX_STORED_LINKS_PER_PAGE) { storedForPage += 1; links.push({ sourcePageId: page.id, sourceUrl: page.url, targetUrl: link.targetUrl, anchor: link.anchor, isNofollow: link.isNofollow, }); } if ( discovered.size < MAX_DISCOVERED_PER_BATCH && !discovered.has(link.targetUrl) && shouldQueueCrawlLink(link.targetUrl, origin, robots) ) { discovered.set(link.targetUrl, childDepth); } } // Redirect targets continue the same navigation path: same depth. if ( page.redirectUrl && !discovered.has(page.redirectUrl) && shouldQueueCrawlLink(page.redirectUrl, origin, robots) ) { discovered.set(page.redirectUrl, pageDepth); } } const stats = await scratchpad.recordBatch({ crawledUrls: pages.map((page) => page.url), pages: pages.map((page) => ({ pageId: page.id, url: page.url, statusCode: page.statusCode, fetchClass: page.fetchClass, redirectUrl: page.redirectUrl, })), links, discovered: Array.from(discovered, ([url, depth]) => ({ url, depth })), }); await AuditRepository.updateAuditProgress(auditId, input.workflowInstanceId, { pagesCrawled: stats.attempted, pagesTotal: Math.min(stats.seen, input.maxPages), }); await AuditProgressKV.pushCrawledUrls( auditId, pages.map((page) => ({ url: page.url, statusCode: page.statusCode, title: page.title.slice(0, MAX_PROGRESS_TITLE_CHARS), crawledAt: Date.now(), })), ); return stats; }