Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ rest: true # required for the @export-ed table REST endpoints
enabled: true # record bot analytics at all: bot_request (ingress volume by host/bot/device),
# bot_serve (outcome by source/cache-status/bot — origin offload + cache hit rate), and
# page_age (ms since the served page rendered — freshness at serve, cache hits only)

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.
Comment on lines +140 to 147

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

Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender",
"version": "0.26.0",
"version": "0.27.0",
"type": "module",
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
"license": "Apache-2.0",
Expand Down
13 changes: 13 additions & 0 deletions packages/plugin/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,19 @@ const defaultConfig = () => ({
{ name: 'Sitebulb', match: 'sitebulb' },
],
},

// Crawl breadth: distinct URLs crawled per bot per UTC day, via per-thread HyperLogLog
// sketches flushed to crawl_stats.CrawlSketch (util/crawlStats.js). Read merged through
// GET /prerender_admin/crawl-breadth. Recording is additionally gated by the analytics
// gate above (no bot name → nothing to attribute a sketch to).
crawlStats: {
enabled: true,
flushInterval: 5 * MINUTE, // per-thread sketch persistence cadence (max data loss on a crash)
retentionDays: 90, // sketch rows older than this are swept at day rollover
// Sketches are 16 KB each; this caps a UA-derivation flood from minting unbounded
// per-thread sketches. Overflow bots share one '~overflow' bucket for the day.
maxBotsPerThread: 64,
},
});

// The live config object. Mutated in place by applyOptions so existing imports
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin/src/http_handlers/bot_request.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fetchOriginResource } from '../util/upstream.js';
import { PrerenderedPage } from '../resources/PrerenderedPage.js';
import { resolveServingPolicy, pollForFreshRender } from '../util/renderNow.js';
import { currentMinuteMs } from '../util/time.js';
import { recordCrawl } from '../util/crawlStats.js';
import { deliverResource } from './response.js';

export async function handleBotRequest(request) {
Expand All @@ -29,6 +30,9 @@ export async function handleBotRequest(request) {
const recordBots = config.analytics.enabled && (request.botName !== 'other' || config.analytics.recordUnmatched);
if (recordBots) {
server.recordAnalytics(true, 'bot_request', url.hostname, request.botName, deviceType);
// Crawl breadth (distinct URLs per bot per day): one hash + one byte max into a
// per-thread HLL sketch — see util/crawlStats.js for the cost/loss model.
recordCrawl(request.botName, cacheUrl);
}

// Debug/observability info surfaced as x-harper-* response headers (only when the
Expand Down
49 changes: 49 additions & 0 deletions packages/plugin/src/resources/PrerenderAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* GET /prerender_admin/pages ?prefix&cursor&limit super_user
* GET /prerender_admin/page-content ?cacheKey (text/plain) super_user
* GET /prerender_admin/unrouted this worker's unrouted tally super_user
* GET /prerender_admin/crawl-breadth ?days (default 7, max 31) super_user
* POST /prerender_admin/explain { url, deviceType } super_user
* POST /prerender_admin/schedule { cacheKey } -> local row super_user
* POST /prerender_admin/queue { scope, paused } super_user
Expand Down Expand Up @@ -64,6 +65,7 @@ import { fetchScheduleFromPeer } from '../util/peer.js';
import { getLastReconcile, isReconcileRunning, runReconcileOnce } from '../util/reconcile.js';
import { getBacklogSnapshotState, runBacklogSnapshotOnce } from '../util/backlogSnapshot.js';
import { peekUnroutedReport } from '../util/unrouted.js';
import { mergeBreadthRow, finalizeBreadth } from '../util/crawlStats.js';
import { decode } from '../util/contentEncoding.js';
import { RenderQueue } from './RenderQueue.js';
import { QueueState } from './QueueState.js';
Expand Down Expand Up @@ -358,6 +360,8 @@ export class PrerenderAdmin extends Resource {
interval: config.ingress.report.interval,
report: peekUnroutedReport(),
});
case 'crawl-breadth':
return PrerenderAdmin.crawlBreadth(target);
default:
return json({ error: `Unknown route: ${route}` }, 404);
}
Expand Down Expand Up @@ -1041,6 +1045,51 @@ export class PrerenderAdmin extends Resource {
* would be unindexed table filters, and the client filters the fetched page instead,
* labelled as exactly that. `content` is never selected.
*/
// Crawl breadth: distinct URLs crawled per bot per UTC day, from the merged CrawlSketch
// node rows (util/crawlStats.js). Query cost: one day-indexed range read of
// days × bots-with-traffic × nodes 16 KB rows (a week on a 4-node cluster is a few
// hundred rows), capped below and reported truncated rather than presented as complete.
// Never touches the render queue or the page cache.
static crawlBreadth(target) {
return withHeavySlot(() => this.crawlBreadthInner(target));
}

static async crawlBreadthInner(target) {
const days = Math.min(Math.max(1, Number(target?.get?.('days')) || 7), 31);
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const cap = 4096;
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
Comment on lines +1061 to +1067

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.

// accumulator and is released, so the resident set is one sketch per (day, bot) —
// not up to cap × 16 KB of raw rows. Yield the event loop periodically; this worker
// also serves bot traffic.
const byDay = new Map();
let shardsMerged = 0;
let truncated = false;
for await (const row of results) {
if (shardsMerged === cap) {
truncated = true;
break;
}
mergeBreadthRow(byDay, row);
if (++shardsMerged % 200 === 0) await yieldNow();
}

return json({
node: server.hostname,
days,
since,
shardsMerged,
truncated,
breadth: finalizeBreadth(byDay),
});
}

static listPages(target) {
return withHeavySlot(() => this.listPagesInner(target));
}
Expand Down
20 changes: 20 additions & 0 deletions packages/plugin/src/schemas/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,23 @@ type PrerenderedPage @table(database: "page_cache") @export {
type SharedBuffer @table(database: "coordination", replicate: false) {
key: Any @primaryKey
}

# One HyperLogLog register array per (UTC day, bot, node) — this node's shard of that day's
# global distinct-URL sketch for that crawler (util/crawlStats.js). Workers merge their
# thread sketches into the node row under the cross-worker mutex; readers merge a day's
# node rows by element-wise max, which reassembles the exact union. Rows replicate
# (default) so any one node can answer for the cluster. Deliberately NOT @export: the raw
# 16 KB shards are only useful merged — reads go through GET /prerender_admin/crawl-breadth,
# behind that resource's super-user gate. Own database: writes are low-frequency and share
# no transaction with any other table.
type CrawlSketch @table(database: "crawl_stats") {
id: String @primaryKey # `${day}|${bot}|${node}`
day: String @indexed
bot: String
node: String
registers: Bytes
# This NODE's own estimate, stored for eyeballing a raw row. Never sum these —
# distinct counts don't add; merge the node rows and estimate the union.
estimate: Float
updatedAt: Date
}
211 changes: 211 additions & 0 deletions packages/plugin/src/util/crawlStats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Crawl breadth: distinct URLs crawled per bot per UTC day, via per-thread HyperLogLog
* sketches (util/hll.js — see there for why a sketch and why per-thread shards merge into
* an exact global union).
*
* Shape: each worker thread keeps one in-memory sketch per bot for the current day and, on
* a timer, merges it into this node's `crawl_stats.CrawlSketch` row (`day|bot|node`) —
* read-merge-write under the cross-worker mutex (util/coordination.js), so concurrent
* workers never lose each other's registers. One row per node (not per thread) keeps the
* read side inside the admin console's query-cost rules: a week is days × bots × nodes
* small rows, not × threads. Cross-node there is no contention at all — the node is in the
* key. The read side (PrerenderAdmin's crawl-breadth route) merges a day's rows per bot.
*
* Hot-path cost (recordCrawl): one number compare for day rollover, one Map lookup, one
* 53-bit string hash + one byte max. No allocation, no await, no storage touch. Everything
* async (flush, retention sweep) happens on the timer or via setImmediate.
*
* Loss model: a thread dying loses at most flushInterval worth of that thread's unmerged
* observations — an undercount, never a double count. Thread sketches are cumulative for
* the day and merging is idempotent, so a failed flush is simply retried whole next cycle.
* Rows replicate (table default), so any single node can answer for the cluster.
*/

import { config } from '../config.js';
import { getMutex } from './coordination.js';
import { createSketch, addToSketch, mergeSketch, estimateSketch } from './hll.js';

// When a thread sees more distinct bot names than the cap in one day (a UA-derivation
// flood — registry bots can't exceed it), the overflow shares this bucket. Distinct from
// the registry's 'other' so a capped day is visible as such rather than blending in.
export const OVERFLOW_BUCKET = '~overflow';

let sketches = new Map(); // botName -> Uint8Array registers
const dirty = new Set(); // botNames with unflushed observations
let day = null; // 'YYYY-MM-DD' (UTC) the current sketches belong to
let dayEndMs = 0; // rollover boundary, so the hot path compares one number
let flushTimer = null;

const utcDayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
const table = () => databases.crawl_stats.CrawlSketch;

/**
* Observe one crawled URL. Called on the bot serving path (behind the analytics gate) —
* everything above about hot-path cost is about this function. Synchronous by design.
*/
export function recordCrawl(botName, url) {
if (!config.crawlStats.enabled) return;

const now = Date.now();
if (now >= dayEndMs) rollover(now);

let sketch = sketches.get(botName);
if (!sketch) {
if (sketches.size >= config.crawlStats.maxBotsPerThread) {
botName = OVERFLOW_BUCKET;
sketch = sketches.get(botName);
}
if (!sketch) {
sketch = createSketch();
sketches.set(botName, sketch);
}
}
addToSketch(sketch, url);
dirty.add(botName);

// Lazily started so only threads that actually serve bot traffic run a timer.
if (!flushTimer) {
flushTimer = setInterval(() => flushSketches().catch((e) => logger.error(e)), config.crawlStats.flushInterval);
flushTimer.unref?.();
}
}

// Close out the old day and start the new one. The old sketches are captured and persisted
// off the hot path via setImmediate; the maps are swapped synchronously so no observation
// lands in the wrong day. Also the retention hook: one worker per node sweeps expired rows.
function rollover(now) {
const previous = day ? { day, sketches, dirty: [...dirty] } : null;
day = utcDayOf(now);
dayEndMs = Date.parse(day) + 24 * 60 * 60 * 1000;
sketches = new Map();
dirty.clear();

if (previous?.dirty.length) {
setImmediate(() => persist(previous.day, previous.sketches, previous.dirty).catch((e) => logger.error(e)));
}
if (previous && server.workerIndex === 0) {
setImmediate(() => sweepExpired().catch((e) => logger.error(e)));
}
}

/** Persist this thread's dirty sketches for the current day. Exported for tests. */
export async function flushSketches() {
if (!dirty.size) return;
const bots = [...dirty];
dirty.clear();
try {
await persist(day, sketches, bots);
} catch (e) {
// Sketches are cumulative for the day and merging is idempotent, so retrying the
// whole flush next cycle is safe — re-mark rather than wait for new traffic.
for (const bot of bots) dirty.add(bot);
throw e;
}
}

async function persist(forDay, forSketches, bots) {
const CrawlSketch = table();
for (const bot of bots) {
const mine = forSketches.get(bot);
if (!mine) continue;
// Read-merge-write of this node's row, serialized against the other workers by the
// cross-worker mutex. The row is this node's own (node is in the key), so the read
// is local — no cross-node fetch to time out on. Explicit lock/finally rather than
// `withLock` because this is a one-shot section — the repo's withLock idiom wraps a
// REUSABLE function (see RenderQueue.claim), and an inline `withLock(fn)()` reads
// like an accidental double call.
const id = `${forDay}|${bot}|${server.hostname}`;
const mutex = getMutex(`crawlSketch/${bot}`);
await mutex.lock();
try {
const existing = await CrawlSketch.get(id);
// Merge into a copy: merging the row INTO the thread sketch would fold other
// workers' registers into thread-local state, and a later failed write would
// then re-contribute them as if they were this thread's own.
const merged = new Uint8Array(mine);
if (existing?.registers) mergeSketch(merged, existing.registers);
// `estimate` is this NODE's count, stored for eyeballing a raw row — the real
// per-day number requires merging all nodes first (distinct counts don't add).
await CrawlSketch.put(id, {
day: forDay,
bot,
node: server.hostname,
registers: merged,
estimate: estimateSketch(merged),
updatedAt: Date.now(),
});
} finally {
mutex.unlock();
}
}
}

// Delete rows past retention. Runs once per day-rollover on one worker per node; deletes
// are idempotent so concurrent nodes sweeping the same replicated rows is harmless. Bounded
// per pass — anything left over is caught the next day.
async function sweepExpired() {
const CrawlSketch = table();
const cutoff = utcDayOf(Date.now() - config.crawlStats.retentionDays * 24 * 60 * 60 * 1000);
const expired = await CrawlSketch.search({
conditions: [{ attribute: 'day', comparator: 'less_than', value: cutoff }],
select: ['id'],
limit: 1000,
});
for await (const row of expired) {
await CrawlSketch.delete(row.id);
}
}

/**
* Streaming accumulator for the admin crawl-breadth route: fold one raw sketch row into
* `byDay` (a Map of day -> Map of bot -> { registers, shards }). Split this way so the
* route can `for await` a search cursor row by row — merging each 16 KB row into the
* accumulator and releasing it — instead of buffering the whole result set; the resident
* set is one sketch per (day, bot), not one per row.
*/
export function mergeBreadthRow(byDay, row) {
if (!row?.registers || !row.bot) return;
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++;
}

/**
* Turn the accumulator into per-day breadth estimates:
* [{ day, total, bots: [{ bot, distinctUrls, shards }] }] sorted by day desc, where
* `total` is the distinct-URL count of the UNION across bots (not a sum).
*/
export function finalizeBreadth(byDay) {
return [...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 };
});
}

/** Convenience over the two halves above, for in-memory row sets (and tests). */
export function computeBreadth(rows) {
const byDay = new Map();
for (const row of rows) mergeBreadthRow(byDay, row);
return finalizeBreadth(byDay);
}

/** Test hook: reset module state between tests. */
export function resetCrawlStats() {
sketches = new Map();
dirty.clear();
day = null;
dayEndMs = 0;
if (flushTimer) clearInterval(flushTimer);
flushTimer = null;
}
Loading