Skip to content

feat(plugin): crawl breadth — distinct URLs per bot per day (HLL); v0.27.0 - #65

Merged
harper-joseph merged 2 commits into
mainfrom
feat/crawl-breadth
Aug 5, 2026
Merged

feat(plugin): crawl breadth — distinct URLs per bot per day (HLL); v0.27.0#65
harper-joseph merged 2 commits into
mainfrom
feat/crawl-breadth

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

What

Crawl breadth: distinct URLs crawled per bot per UTC day, plus % -of-corpus coverage once divided by the Target count — the rollout metric that shows a crawler going deeper into the catalog after each phase flip, which plain request counters cannot express (distinct counts don't add).

Stacked on #64 (feat/bot-serve-metrics) — merge that first; GitHub will retarget this PR to main automatically.

How

HyperLogLog sketches (src/util/hll.js): p=14 → 16 KB register array, ~0.8% standard error, cyrb53 hash (imul-based — no BigInt, no allocation). The load-bearing property: merge = element-wise max = exact set union, byte-for-byte the sketch a single observer would have built — so per-thread/per-node shards decompose one logical global sketch losslessly, duplicates across threads/nodes collapse by construction, and merging never compounds the estimation error.

Recording (src/util/crawlStats.js, wired in bot_request.js behind the existing recordBots gate): per-thread in-memory sketch per bot per day. Hot-path cost is one number compare (day rollover), one Map lookup, one 53-bit string hash + one byte max — no allocation, no await, no storage touch.

Persistence: each worker merges its sketches into one row per (day, bot, node) (crawl_stats.CrawlSketch) on a timer — read-merge-write serialized by the existing cross-worker mutex (util/coordination.js), and cross-node there's no contention because the node is in the key. One row per node (not per thread) keeps the read side inside the admin console's query-cost rules: a week on a 4-node cluster is a few hundred 16 KB rows, not thousands. Day rollover persists the old day via setImmediate; a bounded retention sweep (default 90d) runs at rollover on one worker per node; a failed flush re-marks its bots dirty — sketches are cumulative and merging is idempotent, so whole-flush retry is safe. Loss model: a dying thread loses at most flushInterval of its own unmerged observations — an undercount, never a double count.

Read path: GET /prerender_admin/crawl-breadth?days=N (default 7, max 31) — behind the resource's super-user gate and withHeavySlot, day-indexed range read, capped with a truncated flag rather than presenting a short read as complete. Returns per-day { bots: [{ bot, distinctUrls, shards }], total } where total is the cross-bot union (not a sum). The table is deliberately not @exported — raw shards are only useful merged.

Config (config.crawlStats): enabled, flushInterval (5 min), retentionDays (90), maxBotsPerThread (64 — overflow bots share a visible ~overflow bucket so a UA-derivation flood can't mint unbounded 16 KB sketches).

Tests

  • test/hll.test.js — accuracy within 3% at 100 / 5k / 100k / 1M URLs (linear-counting and asymptotic ranges), duplicates never move the estimate, merge is byte-for-byte the union sketch, merge order irrelevant.
  • test/crawlStats.test.js — flush merges with the stored row (a second worker's registers survive), mutex taken, bot cap + overflow bucket, UTC rollover persists old day and starts clean, failed-flush retry, computeBreadth day grouping + cross-bot union.

Full suite: 356 pass, lint + format clean.

Version

v0.27.0 (0.25 claimed by #62/#63, 0.26 by #64).

Out of scope

Admin-console panel (stays unwired pending the node-local vs cluster aggregation decision), corpus-count denominator in the endpoint (a Target COUNT(*) is not a click-time query — dashboards divide separately), hydration-marker metric (browser package, next up).

🤖 Generated with Claude Code

@harper-joseph

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a crawl breadth metric to track distinct URLs crawled per bot per UTC day using HyperLogLog (HLL) sketches. It adds configuration options, integrates tracking into the bot request handler, exposes a new admin endpoint, and implements the HLL and persistence logic along with comprehensive tests. The review feedback highlights a critical runtime TypeError in the persistence logic due to trailing parentheses on a lock call, and recommends refactoring the admin endpoint to stream database rows instead of buffering them in memory to prevent event loop starvation and excessive memory usage.

Comment thread packages/plugin/src/util/crawlStats.js Outdated
estimate: estimateSketch(merged),
updatedAt: Date.now(),
});
})();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This trailing () will cause a runtime TypeError. The await on the withLock call resolves to the result of the locked operation (likely undefined), which is not a function. Removing the parentheses will fix this crash in the persistence logic.

Suggested change
})();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one isn't a crash — withLock(fn) in util/mutex.js returns a wrapped function (const withLock = (fn) => { return async (...args) => ... }), so the trailing () is what executes the locked section; removing it would mean the persist silently never runs (the flush tests in test/crawlStats.test.js exercise this path and pass). That said, the misread is fair — the repo's withLock idiom wraps reusable functions (cf. RenderQueue.claim), and an inline one-shot withLock(fn)() reads like an accidental call. Rewritten in 88a1a33 as an explicit lock()/try/finally unlock(), which is unambiguous.

Comment on lines +1061 to +1077
const rows = await Array.fromAsync(
databases.crawl_stats.CrawlSketch.search({
conditions: [{ attribute: 'day', comparator: 'greater_than_equal', value: since }],
select: ['day', 'bot', 'registers'],
limit: cap + 1, // one extra row = "truncated", never merged
})
);
const truncated = rows.length > cap;
if (truncated) rows.length = cap;
return json({
node: server.hostname,
days,
since,
shardsMerged: rows.length,
truncated,
breadth: computeBreadth(rows),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This implementation uses Array.fromAsync to buffer all database rows into memory before processing. With a cap of 4096 and each row's registers being 16KB, this could consume up to ~64MB of memory for a single admin request. This can impact a worker that also serves traffic and goes against the repository's general rule to prefer streaming for large datasets.

Let's refactor this to process the rows as a stream using for await...of, which will be much more memory-efficient. Additionally, to prevent event loop starvation when iterating over large database cursors, we should yield the event loop unconditionally (e.g., using setImmediate every 200 rows).

		const searcher = databases.crawl_stats.CrawlSketch.search({
			conditions: [{ attribute: 'day', comparator: 'greater_than_equal', value: since }],
			select: ['day', 'bot', 'registers'],
			limit: cap + 1, // one extra row = "truncated", never merged
		});

		const byDay = new Map();
		let rowCount = 0;
		for await (const row of searcher) {
			rowCount++;
			if (rowCount > cap) break;
			if (rowCount % 200 === 0) {
				await new Promise(resolve => setImmediate(resolve));
			}
			if (!row?.registers || !row.bot) continue;
			let bots = byDay.get(row.day);
			if (!bots) byDay.set(row.day, (bots = new Map()));
			let entry = bots.get(row.bot);
			if (!entry) bots.set(row.bot, (entry = { registers: createSketch(), shards: 0 }));
			mergeSketch(entry.registers, row.registers);
			entry.shards++;
		}

		const truncated = rowCount > cap;
		const shardsMerged = Math.min(rowCount, cap);

		const breadth = [...byDay]
			.sort(([a], [b]) => (a < b ? 1 : -1))
			.map(([forDay, bots]) => {
				const union = createSketch();
				const perBot = [...bots]
					.map(([bot, { registers, shards }]) => {
						mergeSketch(union, registers);
						return { bot, distinctUrls: estimateSketch(registers), shards };
					})
					.sort((a, b) => b.distinctUrls - a.distinctUrls);
				return { day: forDay, total: estimateSketch(union), bots: perBot };
			});

		return json({
			node: server.hostname,
			days,
			since,
			shardsMerged,
			truncated,
			breadth,
		});
References
  1. Use a streaming for await...of loop instead of Array.fromAsync when processing or counting a large number of database/index entries to avoid buffering all rows in memory, and periodically yield to the event loop (e.g., every 200 rows) to maintain responsiveness.
  2. When iterating over large database cursors or registries in Node.js, yield the event loop unconditionally based on the number of rows scanned to prevent event loop starvation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in 88a1a33. The route now for-awaits the cursor, merging each row into the per-(day, bot) accumulator and releasing it (resident set is one sketch per (day, bot), not cap × 16 KB of raw rows), and yields the event loop every 200 rows via the file's existing yieldNow. computeBreadth split into mergeBreadthRow + finalizeBreadth so the streaming route and the in-memory composition share one implementation; tests unchanged and green. Also swept the rest of the PR for the same buffering class: sweepExpired streams its cursor already and selects only id (limit 1000), and the other Array.fromAsync uses in this file are small-field, page-sized reads.

@harper-joseph

Copy link
Copy Markdown
Contributor Author

Addressed both review comments in 88a1a33: the crawl-breadth read path now streams the cursor with periodic event-loop yields instead of buffering up to 64 MB, and the persist section uses explicit lock/try/finally in place of the inline withLock(fn)() one-shot (which was correct — withLock returns a wrapped function — but unidiomatic enough to read as a bug; see the inline reply). 356 tests pass, lint clean.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a crawl breadth metric that tracks distinct URLs crawled per bot per UTC day using HyperLogLog (HLL) sketches. It adds configuration options, an admin endpoint, a GraphQL schema for CrawlSketch, and utility modules for HLL and crawl stats, complete with unit tests. The review feedback highlights two main improvements: correcting the YAML indentation in the README documentation so that recordUnmatched and bots are properly nested under analytics, and replacing Array.fromAsync with a streaming for await...of loop in PrerenderAdmin.js to prevent buffering large rows in memory.

Comment thread packages/plugin/README.md
Comment on lines +140 to 147
crawlStats: # crawl breadth: distinct URLs crawled per bot per UTC day (HyperLogLog, ~0.8% error)
enabled: true # also gated by analytics.enabled above; read via GET /prerender_admin/crawl-breadth?days=7
flushInterval: 300000 # ms between sketch persists (max observation loss if a worker dies)
retentionDays: 90 # sketch rows older than this are swept at day rollover
maxBotsPerThread: 64 # cap on per-thread sketches; overflow bots share one '~overflow' bucket
recordUnmatched: true # also record UAs that matched no configured bot (as 'other')
bots: # registry: which crawlers are tracked by name. { name, match } — match is a
- { name: Googlebot, match: googlebot } # case-insensitive UA substring; longer matches win.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation for recordUnmatched and bots appears to be incorrect. In src/config.js, these are properties of the analytics object. However, in this README example, they are shown at the top level, alongside crawlStats.

To avoid confusion, the example configuration in the README should accurately reflect the structure defined in the code. It should likely be structured as follows:

  analytics:
    enabled: true
    # ... other analytics properties
    recordUnmatched: true
    bots:
      - { name: Googlebot, match: googlebot }

  crawlStats:
    enabled: true
    # ... crawlStats properties

Comment on lines +1061 to +1067
const results = databases.crawl_stats.CrawlSketch.search({
conditions: [{ attribute: 'day', comparator: 'greater_than_equal', value: since }],
select: ['day', 'bot', 'registers'],
limit: cap + 1, // one extra row = "truncated", never merged
});

// Stream the cursor instead of buffering it: each 16 KB row merges into the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Array.fromAsync here buffers all rows from the database search into memory before processing. With a cap of 4096 rows and each row's registers being 16KB, this could lead to significant memory consumption (up to 64MB) on a worker node, potentially impacting performance.

A general rule in this repository advises against this pattern: "Use a streaming for await...of loop instead of Array.fromAsync ... to avoid buffering all rows in memory."

To improve memory efficiency, consider processing the rows as a stream using a for await...of loop. This would involve moving the aggregation logic from computeBreadth directly into crawlBreadthInner to build the byDay map as you iterate, thus avoiding the creation of a large intermediate rows array.

References
  1. Use a streaming for await...of loop instead of Array.fromAsync when processing or counting a large number of database/index entries (e.g., up to a cap) to avoid buffering all rows in memory.

@harper-joseph
harper-joseph changed the base branch from feat/bot-serve-metrics to main August 5, 2026 00:13
harper-joseph and others added 2 commits August 4, 2026 20:13
…ketches; v0.27.0

Answers the rollout question analytics counters cannot: how many DISTINCT
URLs each crawler covered per UTC day (distinct counts don't add, a
URL-dimensioned metric key space would be the whole corpus, and a
row-per-URL table would put a write on the bot read path).

- util/hll.js: HyperLogLog, p=14 (16 KB registers, ~0.8% error), cyrb53
  hash (no BigInt, no allocation). Merge = element-wise max = exact set
  union, so shards reassemble losslessly and merging never compounds error.
- util/crawlStats.js: per-thread sketch per bot per day; hot-path cost is
  one hash + one byte max behind the existing analytics gate. Flush merges
  into ONE row per (day, bot, node) via read-merge-write under the
  cross-worker mutex — keeps the read side to days × bots × nodes small
  rows. Day rollover persists the old day; retention sweep at rollover;
  failed flushes re-mark dirty (cumulative + idempotent = safe retry).
- schema: crawl_stats.CrawlSketch, day-indexed, not exported — reads go
  through GET /prerender_admin/crawl-breadth?days=N (super-user gate,
  withHeavySlot, capped + truncated flag per the console's query-cost
  rules), merging shards per bot and reporting the cross-bot union.
- config.crawlStats: enabled / flushInterval / retentionDays /
  maxBotsPerThread (overflow bots share a '~overflow' bucket).

Stacked on feat/bot-serve-metrics (#64). Tests: 356 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eview)

- crawlBreadthInner: for-await the search cursor, merging each 16 KB row
  into the per-(day,bot) accumulator and releasing it, yielding the event
  loop every 200 rows — resident set is one sketch per (day,bot), not up
  to cap × 16 KB of buffered rows. computeBreadth splits into
  mergeBreadthRow + finalizeBreadth (computeBreadth remains as the
  in-memory composition, tests unchanged).
- persist: explicit lock/try/finally instead of an inline withLock(fn)()
  one-shot — correct but unidiomatic (the repo's withLock wraps REUSABLE
  functions, cf. RenderQueue.claim), and it read like an accidental call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@harper-joseph
harper-joseph merged commit 90db760 into main Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant