Repository navigation
feat(plugin): negative cache expiry: midnight and a replicated table; v0.107.0 - #254
harper-joseph wants to merge 1 commit into
Conversation
… v0.107.0 render.negative.expiry: 'midnight' answers a stored 404 until the next local midnight after the origin last confirmed it, with no background re-checks. render.negative.replicate keeps one copy for the cluster in a new replicated table, SharedNegativePage, instead of one per node. Midnight rows are reclaimed over the following day (per-key spread) so the cleanup is not one wave after midnight. A row found live after its window ended is counted as negative_cache expired-live rather than timed as served_wrong. An unresolvable expiryTimezone fails closed and is flagged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new 'expiry: midnight' policy and a cluster-wide replication option ('replicate') for the negative cache in the prerender plugin. The 'midnight' policy allows stored 404/410 responses to remain valid until the next midnight in a specified timezone, avoiding background re-checks. The 'replicate' option stores negative cache entries in a new replicated 'SharedNegativePage' table rather than node-local tables, reducing origin requests across clusters. Additionally, a reclaim spread mechanism is introduced to distribute database deletions throughout the day. Review feedback suggests improving the 'isTimeZone' helper in 'packages/plugin/src/config.js' to explicitly verify that the timezone parameter is a non-empty string, preventing 'undefined' or invalid values from silently passing validation by falling back to the system timezone.
| const isTimeZone = (zone) => { | ||
| try { | ||
| new Intl.DateTimeFormat('en-US', { timeZone: zone }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; |
There was a problem hiding this comment.
The isTimeZone helper returns true if zone is undefined because Intl.DateTimeFormat defaults to the system timezone when timeZone is omitted or undefined. To prevent invalid or missing timezone configurations from silently passing validation, explicitly verify that zone is a non-empty string before checking it with Intl.
const isTimeZone = (zone) => {
if (typeof zone !== 'string' || !zone) {
return false;
}
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone });
return true;
} catch {
return false;
}
};References
- Validation layers should explicitly check for invalid values (such as
nullorundefined) rather than relying on preceding layers (like merge layers) to filter them out, ensuring the validator remains robust and self-contained.
Adds two options to the negative cache (
render.negative), for an origin that wants each dead URL asked about once a day:expiry: 'midnight'(withexpiryTimezone): a stored 404 answers until the next local midnight after the origin last confirmed it. No background re-checks.replicate: true: one copy for the cluster in a new replicated table,SharedNegativePage, instead of one per node inNegativePage.Both default off, so a deployment that sets neither behaves as before.
Why
An origin asked for 404s to be held until its nightly change. A replay of one week of a 4-node production deployment's dead-URL requests (~790k a day) compared policies:
The stale figures are estimates. Successful requests aren't logged, so they use each URL's prior request rate.
What changes
getNextTimeOfDay('00:00', expiryTimezone, checkedAt), then expired; there is no revalidate window. The boundary is computed at read time fromcheckedAt, so switching applies to rows already stored.getNextTimeOfDaygains an optionalnowMs(backward compatible);rawExpiresAtnow passes itsnowMsthrough.@expiresAtis its boundary plusfnv1a32(key) % 1 day. Every row stored on one day shares one boundary, so without the spread Harper's cleanup would delete a whole day of blob rows (~270k per node on the measured deployment) in one pass after midnight. Serving still stops at the boundary.SharedNegativePagelives in the existingnegative_cachedatabase. That database already has a replicated anchor, so this adds no new replication connections, and it stays isolated frompage_cacheandraw_cachecopies. Each mode reads only its own table, so switching needs no migration.negative_cache expired-livecount: a row whose window had ended, found live by the next proxied request. It is counted and not timed asserved_wrong, because the page may have come back after the row stopped answering. Under midnight expiry this is the stale-404 signal;recheck-liveno longer fires.expiryTimezonefails closed: nothing answers and stores count aswrite-failed, rather than throwing on the request path. Config flags it asrender.negative.expiryTimezone. ThelifeMswarnings no longer fire under midnight expiry.Capacity check on the target cluster (measured before building)
raw_cachealready replicates ~834k/day of the same size (+32%)Origin 404 fetches in the first hour after midnight rise ~42% (peak 6.3/s → 6.9/s). Crawlers' re-requests spread across the hour, so there is no spike. The main thing to watch after enabling is memory: swap at the post-midnight write wave.
Test plan
node --testinpackages/plugin: 2,106 pass. New tests cover midnight freshness, body age, expiry spread, the bad-timezone path, answering with no re-check,replicatetable routing and a replicated drop,expired-live, schema parity between the two tables, the config warnings, andgetNextTimeOfDaywith an explicit now across a DST change.npm run lintandnpm run format:checkare clean.SharedNegativePagealongside the node-localNegativePagein one database, and reclaim of spread@expiresAtrows.Rollout
render.negative.expiry: midnight,expiryTimezone, andreplicate: true. Either can be flipped at runtime once the table exists.negative_cache expired-live, the stores, and swap around midnight.Follow-ups (not in this PR)
expired-liveyet.🤖 Generated with Claude Code