From 381cef3e6f2666e76ea33e22d0b083f1932a7287 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 12:35:25 -0500 Subject: [PATCH 01/29] feat: persist per-entry cache lives and serve stale on tag invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next keeps cache lives in `SharedCacheControls` — a per-process static Map plus the build-time prerender manifest — and neither replicates. An entry written on one node reaches every other node, but its `revalidate`/`expire` do not, so `calculateRevalidate` falls back to its 1-second default there and the entry looks stale on every read. Persisting `cacheControl` alongside the entry is what makes cache lives coherent across the cluster, so this is a correctness fix for the distributed story rather than fidelity to a dropped field. `get` now also withholds an entry past its own `expire`, and `revalidateTag` forwards the `durations` parameter it previously ignored. On tag invalidation the handler used to return null, which Next reads as a miss and turns into a blocking render — the worst possible response to an invalidation storm. Invalidations are now mirrored into Next's own tags manifest, so `areTagsStale` classifies affected APP_PAGE/APP_ROUTE entries as stale and Next serves them while regenerating in the background. Entries Next will not tag-recheck (FETCH) are still withheld, because they would otherwise be served indefinitely, and an on-demand `revalidatedTags` hit still forces fresh content. Invalidation state moves to a shared module so the forthcoming "use cache" handler observes the same map, subscription and tombstones — a `revalidateTag` has to reach both caches or the cluster can only be half purged. The sweep that module adds turns the tombstone TTL from a correctness parameter into a safety margin: a tag invalidation is soft-applied immediately, then a throttled background pass invalidates matching entries and drops the tombstone. Entries are invalidated, never deleted, so stale-while-revalidate survives. Chunked with bounded concurrency and jittered pauses to keep transactions short. Next's implicit `_N_T_` route tags are never swept — `_N_T_/layout` is carried by every page, and `tags` cannot be indexed (Harper cannot index array elements, and `contains` cannot use an index anyway), so sweeping one would scan and rewrite the whole cache. --- schema.graphql | 25 ++- src/CacheHandler.cts | 150 +++++++--------- src/CacheHandler.test.ts | 180 +++++++++++++++++++ src/cacheInvalidation.cts | 329 ++++++++++++++++++++++++++++++++++ src/cacheInvalidation.test.ts | 292 ++++++++++++++++++++++++++++++ 5 files changed, 886 insertions(+), 90 deletions(-) create mode 100644 src/CacheHandler.test.ts create mode 100644 src/cacheInvalidation.cts create mode 100644 src/cacheInvalidation.test.ts diff --git a/schema.graphql b/schema.graphql index 647bea2..4c00914 100644 --- a/schema.graphql +++ b/schema.graphql @@ -9,11 +9,34 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in status: String } +# `revalidate`/`expire` mirror the `cacheControl` Next.js supplies on set. Next keeps its own copy in a +# per-process Map plus the build-time prerender manifest, and neither replicates — so without these columns +# an entry reaches another node but its cache lives do not, and `calculateRevalidate` falls back to a 1s +# default there. Persisting them is what makes cache lives coherent across the cluster. +# +# `tags` is deliberately NOT indexed: Harper cannot index array elements, and the `contains` comparator +# cannot use an index regardless, so an index would cost writes and buy nothing. Tag sweeps scan, which is +# why broad invalidations are never swept (see sweepInvalidation in src/cacheInvalidation.cts). type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache", expiration: 604800) { id: String @primaryKey data: Any tags: [String] - lastModified: Long @updatedTime + revalidate: Int + expire: Int + lastModified: Long @updatedTime @indexed +} + +# Entries written by the "use cache" directive. Separate from nextjs_isr_cache because the shapes differ: +# a use-cache entry is an opaque byte stream with its own timestamp and cache lives, not a structured +# IncrementalCacheValue keyed off the route. +type NextUseCache @table(database: "harperfast_nextjs", table: "nextjs_use_cache", expiration: 604800) { + id: String @primaryKey + value: Blob + tags: [String] + timestamp: Long @indexed + stale: Int + revalidate: Int + expire: Int } type NextCacheInvalidation @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index e010f92..a586f97 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -14,68 +14,32 @@ import type { import type { databases as DatabasesType } from 'harper'; -const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; +import { + cacheInvalidations, + initializeInvalidationSubscription, + isInvalidated, + recordInvalidation, +} from './cacheInvalidation.cjs'; -// Map of tag → invalidation timestamp (ms). Hydrated from the -// nextjs_cache_invalidation table on first construction and kept fresh via a -// Harper subscription so any worker observes invalidations from any other. -const cacheInvalidations = new Map(); +const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; -let subscriptionInitialized = false; +// Kinds for which Next re-checks its tags manifest and can classify an entry as *stale* rather than +// missing. For anything else an invalidated entry has to be withheld, because Next would otherwise +// serve it indefinitely. +const TAG_AWARE_KINDS = new Set(['APP_PAGE', 'APP_ROUTE']); -// `databases` is a Harper-provided global. Access it lazily so that loading -// this module from a non-Harper context (e.g. a turbopack build worker that -// resolves the cacheHandler path) does not pull in the harper runtime — which -// would register native worker hooks a second time and crash with +// `databases` is a Harper-provided global. Access it lazily so that loading this module from a +// non-Harper context (e.g. a turbopack build worker that resolves the cacheHandler path) does not pull +// in the harper runtime — which would register native worker hooks a second time and crash with // "Worker creator already registered". function getDatabases(): typeof DatabasesType | undefined { return (globalThis as { databases?: typeof DatabasesType }).databases; } -async function initializeSubscription(): Promise { - if (subscriptionInitialized) return; - const databases = getDatabases(); - if (!databases) return; - subscriptionInitialized = true; - - // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, - // but the runtime accepts plain object literals (and search() accepts no args). - const table = databases.harperfast_nextjs.nextjs_cache_invalidation as unknown as { - search: () => AsyncIterable<{ id: string; timestamp: number }>; - subscribe: (req: { omitCurrent?: boolean }) => Promise<{ - on: (event: string, listener: (e: { type: string; id: string; value?: { timestamp: number } }) => void) => void; - }>; - }; - - try { - for await (const row of table.search()) { - cacheInvalidations.set(row.id, row.timestamp); - } - - const subscription = await table.subscribe({ omitCurrent: true }); - - subscription.on('data', (event) => { - if (!event.id) return; - if (event.type === 'delete') { - cacheInvalidations.delete(event.id); - } else if (event.type === 'put' && event.value) { - cacheInvalidations.set(event.id, event.value.timestamp); - } - }); - - subscription.on('error', (error) => { - console.error('[CacheHandler] invalidation subscription error', error); - }); - } catch (error) { - // Reset so a future construction can retry — failure here means we lose - // cross-worker visibility, but the cache still works (just falls back to - // per-request revalidatedTags). - subscriptionInitialized = false; - console.error('[CacheHandler] failed to initialize invalidation subscription', error); - } -} - -function extractTags(data: IncrementalCacheValue | null, ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext): string[] { +function extractTags( + data: IncrementalCacheValue | null, + ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext +): string[] { if (!data) return []; // FETCH entries carry tags via ctx.tags (set context) and data.tags. @@ -88,30 +52,36 @@ function extractTags(data: IncrementalCacheValue | null, ctx: SetIncrementalFetc const headers = (data as { headers?: Record }).headers; const tagsHeader = headers?.[NEXT_CACHE_TAGS_HEADER]; if (typeof tagsHeader === 'string' && tagsHeader.length > 0) { - return tagsHeader.split(',').map((t) => t.trim()).filter(Boolean); + return tagsHeader + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); } const dataTags = (data as { tags?: unknown }).tags; if (Array.isArray(dataTags)) { - return dataTags.filter((t): t is string => typeof t === 'string'); + return dataTags.filter((tag): tag is string => typeof tag === 'string'); } return []; } -function isInvalidated( - recordTags: string[], - lastModified: number, - revalidatedTags: string[], - ctxTags: string[] -): boolean { - const allTags = recordTags.length > 0 ? recordTags : ctxTags; - for (const tag of allTags) { - if (revalidatedTags.includes(tag)) return true; - const invalidatedAt = cacheInvalidations.get(tag); - if (invalidatedAt !== undefined && invalidatedAt > lastModified) return true; - } - return false; +/** + * True when Next will consult its own tags manifest for this entry and can therefore classify it as + * stale. `recordInvalidation` mirrors every invalidation into that manifest, so for these kinds the + * handler can return the entry and let Next serve it stale while regenerating, instead of forcing a + * blocking miss. + */ +function canServeStale(data: unknown): boolean { + const value = data as { kind?: string; headers?: Record } | null; + if (!value?.kind || !TAG_AWARE_KINDS.has(value.kind)) return false; + return typeof value.headers?.[NEXT_CACHE_TAGS_HEADER] === 'string'; +} + +function isExpired(record: { lastModified?: number; expire?: number }): boolean { + if (typeof record.expire !== 'number' || record.expire <= 0) return false; + const lastModified = record.lastModified ?? 0; + return lastModified + record.expire * 1000 < Date.now(); } export default class HarperCacheHandler implements CacheHandler { @@ -119,7 +89,7 @@ export default class HarperCacheHandler implements CacheHandler { constructor(ctx?: CacheHandlerContext) { this.revalidatedTags = ctx?.revalidatedTags ?? []; - void initializeSubscription(); + void initializeInvalidationSubscription(); } async get( @@ -133,15 +103,23 @@ export default class HarperCacheHandler implements CacheHandler { const record = await table.get(key); if (!record) return null; + // Past its own `expire` the entry is no longer usable at all, regardless of tags. + if (isExpired(record as { lastModified?: number; expire?: number })) return null; + const recordTags = Array.isArray(record.tags) ? (record.tags as string[]) : []; const ctxTags = 'tags' in ctx && Array.isArray(ctx.tags) - ? [...ctx.tags, ...(('softTags' in ctx && Array.isArray(ctx.softTags)) ? ctx.softTags : [])] + ? [...ctx.tags, ...('softTags' in ctx && Array.isArray(ctx.softTags) ? ctx.softTags : [])] : []; if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { - return null; + // An on-demand revalidation for this request is an explicit demand for fresh content. + if (recordTags.some((tag) => this.revalidatedTags.includes(tag))) return null; + + // Otherwise prefer stale-while-revalidate where Next supports it: a miss here costs a full + // blocking render, which is exactly what an invalidation storm must not produce. + if (!canServeStale(record.data)) return null; } return { @@ -160,26 +138,20 @@ export default class HarperCacheHandler implements CacheHandler { const table = databases.harperfast_nextjs.nextjs_isr_cache; const tags = extractTags(data, ctx); - await table.put(key, { data, tags }); - } - - async revalidateTag(tags: string | string[]): Promise { - const tagList = typeof tags === 'string' ? [tags] : tags; - if (tagList.length === 0) return; - - const databases = getDatabases(); - if (!databases) return; - const table = databases.harperfast_nextjs.nextjs_cache_invalidation; - const timestamp = Date.now(); + // Next keeps cache lives in a per-process Map plus the build-time prerender manifest, neither of + // which replicates. Persisting them alongside the entry is what lets another node compute the + // same staleness instead of falling back to `calculateRevalidate`'s 1-second default. + const cacheControl = 'cacheControl' in ctx ? ctx.cacheControl : undefined; + const revalidate = typeof cacheControl?.revalidate === 'number' ? cacheControl.revalidate : undefined; + const expire = typeof cacheControl?.expire === 'number' ? cacheControl.expire : undefined; - // Update the local map immediately so reads on this worker see the - // invalidation without waiting for the subscription roundtrip. - for (const tag of tagList) { - cacheInvalidations.set(tag, timestamp); - } + await table.put(key, { data, tags, revalidate, expire }); + } - await Promise.all(tagList.map((tag) => table.put(tag, { timestamp }))); + async revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise { + const tagList = typeof tags === 'string' ? [tags] : tags; + await recordInvalidation(tagList, durations); } resetRequestCache(): void {} diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts new file mode 100644 index 0000000..73557df --- /dev/null +++ b/src/CacheHandler.test.ts @@ -0,0 +1,180 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; + +import { createRequire } from 'node:module'; + +import { cacheInvalidations } from './cacheInvalidation.cjs'; + +// Load the handler through `require`, the way Next.js loads it, rather than through ESM interop — a +// default import would bind to `module.exports` itself rather than the class. +const HarperCacheHandler = createRequire(import.meta.url)('./CacheHandler.cjs') + .default as new (ctx?: { revalidatedTags?: string[] }) => { + get(key: string, ctx: unknown): Promise<{ lastModified?: number } | null>; + set(key: string, data: unknown, ctx: unknown): Promise; + revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise; +}; + +const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; + +interface StoredRecord { + data: unknown; + tags?: string[]; + revalidate?: number; + expire?: number; + lastModified?: number; +} + +function installDatabases(records: Record = {}) { + const puts: Array<{ key: string; value: Record }> = []; + const invalidationPuts: Array<{ key: string; value: { timestamp: number } }> = []; + + (globalThis as Record).databases = { + harperfast_nextjs: { + nextjs_isr_cache: { + async get(key: string) { + return records[key]; + }, + async put(key: string, value: Record) { + puts.push({ key, value }); + }, + search() { + return (async function* () {})(); + }, + async invalidate() {}, + }, + nextjs_use_cache: { + search() { + return (async function* () {})(); + }, + async invalidate() {}, + }, + nextjs_cache_invalidation: { + async put(key: string, value: { timestamp: number }) { + invalidationPuts.push({ key, value }); + }, + async delete() {}, + async getRecordCount() { + return { recordCount: 0 }; + }, + }, + }, + }; + + return { puts, invalidationPuts }; +} + +/** An APP_PAGE value, which is the kind Next re-checks against its tags manifest. */ +function appPage(tags: string[]) { + return { kind: 'APP_PAGE', headers: { [NEXT_CACHE_TAGS_HEADER]: tags.join(',') } }; +} + +/** A FETCH value, which Next does not tag-recheck — an invalidated one must be withheld. */ +function fetchValue() { + return { kind: 'FETCH' }; +} + +const getCtx = { kind: 'APP_PAGE' } as never; + +describe('HarperCacheHandler per-entry cache lives', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('persists revalidate and expire from the set context', async () => { + const { puts } = installDatabases(); + const handler = new HarperCacheHandler(); + + await handler.set('/page', appPage([]) as never, { + cacheControl: { revalidate: 300, expire: 3600 }, + } as never); + + assert.equal(puts.length, 1); + assert.equal(puts[0].value.revalidate, 300); + assert.equal(puts[0].value.expire, 3600); + }); + + it('stores no revalidate when Next supplies false', async () => { + const { puts } = installDatabases(); + const handler = new HarperCacheHandler(); + + await handler.set('/page', appPage([]) as never, { + cacheControl: { revalidate: false, expire: 3600 }, + } as never); + + assert.equal(puts[0].value.revalidate, undefined); + }); + + it('withholds an entry that is past its own expire', async () => { + installDatabases({ + '/page': { data: appPage([]), expire: 60, lastModified: Date.now() - 120_000 }, + }); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/page', getCtx), null); + }); + + it('serves an entry still inside its expire window', async () => { + installDatabases({ + '/page': { data: appPage([]), expire: 600, lastModified: Date.now() - 1000 }, + }); + const handler = new HarperCacheHandler(); + + assert.notEqual(await handler.get('/page', getCtx), null); + }); + + it('forwards revalidateTag durations so a deferred expiry lands in the future', async () => { + const { invalidationPuts } = installDatabases(); + const handler = new HarperCacheHandler(); + const before = Date.now(); + + await handler.revalidateTag('products', { expire: 60 }); + + assert.equal(invalidationPuts.length, 1); + assert.ok(invalidationPuts[0].value.timestamp >= before + 60_000); + }); +}); + +describe('HarperCacheHandler stale-while-revalidate on invalidation', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('serves an invalidated APP_PAGE so Next can regenerate it in the background', async () => { + installDatabases({ + '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, + }); + cacheInvalidations.set('products', 2000); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/page', getCtx); + + assert.notEqual(result, null, 'a blocking miss here is what an invalidation storm must not cause'); + }); + + it('withholds an invalidated entry Next will not tag-recheck', async () => { + installDatabases({ + '/data': { data: fetchValue(), tags: ['products'], lastModified: 1000 }, + }); + cacheInvalidations.set('products', 2000); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/data', getCtx), null, 'FETCH entries would otherwise be served forever'); + }); + + it('withholds an entry whose tag was revalidated on demand for this request', async () => { + installDatabases({ + '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, + }); + const handler = new HarperCacheHandler({ revalidatedTags: ["products"] }); + + assert.equal(await handler.get('/page', getCtx), null, 'on-demand revalidation demands fresh content'); + }); + + it('serves a valid entry untouched', async () => { + installDatabases({ + '/page': { data: appPage(['products']), tags: ['products'], lastModified: 5000 }, + }); + cacheInvalidations.set('products', 1000); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/page', getCtx); + + assert.equal(result?.lastModified, 5000); + }); +}); diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts new file mode 100644 index 0000000..2f1f61d --- /dev/null +++ b/src/cacheInvalidation.cts @@ -0,0 +1,329 @@ +import type { databases as DatabasesType } from 'harper'; + +/** + * Next.js prefixes the implicit route/layout tags it attaches to every APP_PAGE and APP_ROUTE entry. + * A tag like `_N_T_/layout` is carried by every page in the app, so sweeping one would scan and rewrite + * the entire cache. Those are left to TTL; only explicit user tags are swept. + */ +export const NEXT_IMPLICIT_TAG_PREFIX = '_N_T_'; + +const DATABASE = 'harperfast_nextjs'; +const ISR_TABLE = 'nextjs_isr_cache'; +const USE_CACHE_TABLE = 'nextjs_use_cache'; +const INVALIDATION_TABLE = 'nextjs_cache_invalidation'; + +// Throttling for the background sweep. Chunks bound transaction length — a single long-running +// transaction is a documented Harper failure mode ("Transaction was open too long") — while the +// concurrency limit bounds how much of a worker the sweep can take. +const INVALIDATE_CONCURRENCY = 10; +const INVALIDATE_CHUNK_SIZE = 100; +const CHUNK_PAUSE_MAX_MS = 100; + +// Backpressure: above this many outstanding invalidation rows the sweep is shed. Soft invalidation still +// covers reads, so shedding costs cache-hit rate, never correctness. +const MAX_PENDING_INVALIDATIONS = 75_000; + +/** Tag → invalidation timestamp (ms). Shared by every cache handler in the worker. */ +export const cacheInvalidations = new Map(); + +interface CacheRecord { + id: string; +} + +interface SweepableTable { + search(request: { + conditions: Array<{ attribute: string; comparator: string; value: unknown }>; + select?: string[]; + }): AsyncIterable; + invalidate(id: string): Promise | unknown; +} + +interface InvalidationTable { + put(key: string, value: { timestamp: number }): Promise | unknown; + delete(key: string): Promise | unknown; + getRecordCount?(): Promise<{ recordCount: number }>; +} + +export interface InvalidationDeps { + databases?: typeof DatabasesType; + sleep?: (ms: number) => Promise; + logger?: { info(...args: unknown[]): void; error(...args: unknown[]): void }; + /** Runs the background sweep. Defaults to fire-and-forget; tests substitute a collector. */ + scheduleSweep?: (task: () => Promise) => void; +} + +/** + * `databases` is a Harper-provided global, read lazily so that loading this module from a non-Harper + * context (a bundler resolving the cache-handler path) does not pull in the harper runtime. + */ +function getDatabases(deps: InvalidationDeps): typeof DatabasesType | undefined { + return deps.databases ?? (globalThis as { databases?: typeof DatabasesType }).databases; +} + +function getLogger(deps: InvalidationDeps) { + return deps.logger ?? (globalThis as { logger?: InvalidationDeps['logger'] }).logger ?? console; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function jitter(maxMs: number): number { + return Math.floor(Math.random() * maxMs); +} + +export function chunk(items: T[], size: number): T[][] { + const batches: T[][] = []; + for (let index = 0; index < items.length; index += size) { + batches.push(items.slice(index, index + size)); + } + return batches; +} + +export async function runWithConcurrency( + items: T[], + limit: number, + task: (item: T) => Promise +): Promise { + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const item = items[cursor++]; + await task(item); + } + }); + await Promise.all(workers); +} + +/** Only explicit user tags are swept; see NEXT_IMPLICIT_TAG_PREFIX. */ +export function isSweepableTag(tag: string): boolean { + return !tag.startsWith(NEXT_IMPLICIT_TAG_PREFIX); +} + +export function isInvalidated( + recordTags: string[], + lastModified: number, + revalidatedTags: string[], + ctxTags: string[] +): boolean { + const allTags = recordTags.length > 0 ? recordTags : ctxTags; + for (const tag of allTags) { + if (revalidatedTags.includes(tag)) return true; + const invalidatedAt = cacheInvalidations.get(tag); + if (invalidatedAt !== undefined && invalidatedAt > lastModified) return true; + } + return false; +} + +/** + * Mirror an invalidation into Next's own tags manifest so Next classifies affected APP_PAGE/APP_ROUTE + * entries as *stale* (serve the cached response, regenerate in the background) rather than missing. + * Without this the handler's only way to signal invalidation is returning null, which Next reads as a + * miss and turns into a blocking render — the behaviour we specifically want to avoid under load. + */ +function mirrorToNextTagsManifest(tags: string[], timestamp: number, deps: InvalidationDeps): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { tagsManifest } = require('next/dist/server/lib/incremental-cache/tags-manifest.external.js') as { + tagsManifest: Map; + }; + for (const tag of tags) { + const entry = tagsManifest.get(tag) ?? {}; + entry.stale = timestamp; + tagsManifest.set(tag, entry); + } + } catch (error) { + getLogger(deps).error('[CacheHandler] could not mirror invalidation into the Next tags manifest', error); + } +} + +let subscriptionInitialized = false; + +interface SubscribableTable { + search(): AsyncIterable<{ id: string; timestamp: number }>; + subscribe(request: { omitCurrent?: boolean }): Promise<{ + on(event: string, listener: (event: { type: string; id: string; value?: { timestamp: number } }) => void): void; + }>; +} + +/** + * Hydrate the invalidation map from storage, then track it via a Harper subscription so an invalidation + * issued on any worker or node is observed here within milliseconds. + * + * This is why `refreshTags()` on the "use cache" handler is a no-op: the framework expects handlers to + * poll a tags service, and Harper pushes instead. + */ +export async function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { + if (subscriptionInitialized) return; + const databases = getDatabases(deps); + if (!databases) return; + subscriptionInitialized = true; + + const scope = (databases as unknown as Record>)[DATABASE]; + // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, but the runtime + // accepts plain object literals (and search() accepts no args). + const table = scope?.[INVALIDATION_TABLE] as unknown as SubscribableTable | undefined; + if (!table) { + subscriptionInitialized = false; + return; + } + + try { + for await (const row of table.search()) { + cacheInvalidations.set(row.id, row.timestamp); + mirrorToNextTagsManifest([row.id], row.timestamp, deps); + } + + const subscription = await table.subscribe({ omitCurrent: true }); + + subscription.on('data', (event) => { + if (!event.id) return; + if (event.type === 'delete') { + // The sweep has finished for this tag, so the tombstone is no longer load-bearing. + cacheInvalidations.delete(event.id); + } else if (event.type === 'put' && event.value) { + cacheInvalidations.set(event.id, event.value.timestamp); + // Mirror here too, so an invalidation issued on another node still yields stale-serving + // rather than a blocking miss on this one. + mirrorToNextTagsManifest([event.id], event.value.timestamp, deps); + } + }); + + subscription.on('error', (error) => { + getLogger(deps).error('[CacheHandler] invalidation subscription error', error); + }); + } catch (error) { + // Reset so a future construction can retry — failure here means we lose cross-worker visibility, + // but the cache still works (just falls back to per-request revalidatedTags). + subscriptionInitialized = false; + getLogger(deps).error('[CacheHandler] failed to initialize invalidation subscription', error); + } +} + +/** Update this worker's view of an invalidation without touching storage. */ +export function noteInvalidation(tags: string[], timestamp: number, deps: InvalidationDeps = {}): void { + for (const tag of tags) { + cacheInvalidations.set(tag, timestamp); + } + mirrorToNextTagsManifest(tags, timestamp, deps); +} + +async function sweepTable( + table: SweepableTable, + timeAttribute: string, + tag: string, + timestamp: number, + deps: InvalidationDeps +): Promise { + const sleep = deps.sleep ?? defaultSleep; + + // The time condition is the race guard: a record written after the invalidation was issued is + // semantically fresh, so the sweep must leave it alone. + const matches = table.search({ + conditions: [ + { attribute: 'tags', comparator: 'contains', value: tag }, + { attribute: timeAttribute, comparator: 'less_than', value: timestamp }, + ], + select: ['id'], + }); + + const ids: string[] = []; + for await (const record of matches) { + ids.push(record.id); + } + + const batches = chunk(ids, INVALIDATE_CHUNK_SIZE); + for (let index = 0; index < batches.length; index++) { + await runWithConcurrency(batches[index], INVALIDATE_CONCURRENCY, async (id) => { + await table.invalidate(id); + }); + if (index < batches.length - 1) { + await sleep(jitter(CHUNK_PAUSE_MAX_MS)); + } + } + + return ids.length; +} + +/** + * Invalidate every cached entry carrying `tag` that predates `timestamp`, then drop the invalidation row + * so the subscription clears it from every worker's map. Entries are invalidated, never deleted, so Next + * can still serve them stale while regenerating. + * + * The invalidation row is removed only on success: while it remains, soft invalidation keeps reads + * correct, so a failed sweep degrades to the pre-sweep behaviour rather than losing the invalidation. + */ +export async function sweepTag(tag: string, timestamp: number, deps: InvalidationDeps = {}): Promise { + const databases = getDatabases(deps); + if (!databases) return; + + const scope = (databases as unknown as Record>)[DATABASE]; + if (!scope) return; + + const logger = getLogger(deps); + + try { + const isrCount = await sweepTable(scope[ISR_TABLE] as SweepableTable, 'lastModified', tag, timestamp, deps); + const useCacheCount = await sweepTable(scope[USE_CACHE_TABLE] as SweepableTable, 'timestamp', tag, timestamp, deps); + + logger.info( + `[CacheHandler] invalidated ${isrCount + useCacheCount} entries for "${tag}" (isr=${isrCount}, useCache=${useCacheCount})` + ); + + await (scope[INVALIDATION_TABLE] as InvalidationTable).delete(tag); + } catch (error) { + logger.error(`[CacheHandler] sweep failed for "${tag}"; soft invalidation remains in effect`, error); + } +} + +async function isOverAdmissionThreshold(table: InvalidationTable, deps: InvalidationDeps): Promise { + if (typeof table.getRecordCount !== 'function') return false; + try { + const { recordCount } = await table.getRecordCount(); + return recordCount > MAX_PENDING_INVALIDATIONS; + } catch (error) { + getLogger(deps).error('[CacheHandler] could not read invalidation record count', error); + return false; + } +} + +/** + * Record a tag invalidation: update this worker immediately, persist a row for every other worker and + * node, then schedule the background sweep. The caller is never blocked on the sweep. + */ +export async function recordInvalidation( + tags: string[], + durations: { expire?: number } | undefined, + deps: InvalidationDeps = {} +): Promise { + if (tags.length === 0) return; + + const databases = getDatabases(deps); + if (!databases) return; + + const scope = (databases as unknown as Record>)[DATABASE]; + if (!scope) return; + + const invalidationTable = scope[INVALIDATION_TABLE] as InvalidationTable; + + // `durations.expire` defers the invalidation: Next uses it to expire a tag at a future point rather + // than immediately. + const timestamp = Date.now() + (durations?.expire !== undefined ? durations.expire * 1000 : 0); + + noteInvalidation(tags, timestamp, deps); + + await Promise.all(tags.map((tag) => invalidationTable.put(tag, { timestamp }))); + + if (await isOverAdmissionThreshold(invalidationTable, deps)) { + getLogger(deps).error( + `[CacheHandler] ${MAX_PENDING_INVALIDATIONS}+ pending invalidations; shedding sweep for ${tags.length} tag(s)` + ); + return; + } + + const schedule = deps.scheduleSweep ?? ((task: () => Promise) => void task()); + for (const tag of tags) { + if (!isSweepableTag(tag)) continue; + schedule(() => sweepTag(tag, timestamp, deps)); + } +} diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts new file mode 100644 index 0000000..c65683e --- /dev/null +++ b/src/cacheInvalidation.test.ts @@ -0,0 +1,292 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; + +import { + NEXT_IMPLICIT_TAG_PREFIX, + cacheInvalidations, + chunk, + isInvalidated, + isSweepableTag, + noteInvalidation, + recordInvalidation, + runWithConcurrency, + sweepTag, + type InvalidationDeps, +} from './cacheInvalidation.cjs'; + +/** A stand-in for a Harper cache table: records in a Map, with search/invalidate/delete recorded. */ +function makeCacheTable(records: Array<{ id: string; tags: string[]; lastModified?: number; timestamp?: number }>) { + return { + records, + invalidated: [] as string[], + deleted: [] as string[], + search(request: { conditions: Array<{ attribute: string; comparator: string; value: unknown }>; select?: string[] }) { + const matches = records.filter((record) => + request.conditions.every((condition) => { + const actual = (record as unknown as Record)[condition.attribute]; + if (condition.comparator === 'contains') return Array.isArray(actual) && actual.includes(condition.value as string); + if (condition.comparator === 'less_than') return (actual as number) < (condition.value as number); + return actual === condition.value; + }) + ); + return (async function* () { + for (const match of matches) yield match; + })(); + }, + async invalidate(id: string) { + this.invalidated.push(id); + }, + async delete(id: string) { + this.deleted.push(id); + }, + }; +} + +function makeInvalidationTable(recordCount = 0) { + return { + puts: [] as Array<{ key: string; value: { timestamp: number } }>, + deleted: [] as string[], + async put(key: string, value: { timestamp: number }) { + this.puts.push({ key, value }); + }, + async delete(key: string) { + this.deleted.push(key); + }, + async getRecordCount() { + return { recordCount }; + }, + }; +} + +function makeDeps( + overrides: { + isr?: ReturnType; + useCache?: ReturnType; + invalidation?: ReturnType; + } = {} +): InvalidationDeps & { sleeps: number[]; errors: unknown[]; pendingSweeps: () => Promise } { + const isr = overrides.isr ?? makeCacheTable([]); + const useCache = overrides.useCache ?? makeCacheTable([]); + const invalidation = overrides.invalidation ?? makeInvalidationTable(); + const sleeps: number[] = []; + const errors: unknown[] = []; + const sweeps: Array<() => Promise> = []; + return { + sleeps, + errors, + scheduleSweep: (task) => { + sweeps.push(task); + }, + pendingSweeps: async () => { + for (const sweep of sweeps.splice(0)) await sweep(); + }, + databases: { + harperfast_nextjs: { + nextjs_isr_cache: isr, + nextjs_use_cache: useCache, + nextjs_cache_invalidation: invalidation, + }, + } as never, + sleep: async (ms: number) => { + sleeps.push(ms); + }, + logger: { + info: () => {}, + error: (...args: unknown[]) => { + errors.push(args); + }, + }, + }; +} + +describe('isInvalidated', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('is true when a tag was invalidated after the entry was written', () => { + cacheInvalidations.set('products', 2000); + assert.equal(isInvalidated(['products'], 1000, [], []), true); + }); + + it('is false when the entry was written after the invalidation', () => { + cacheInvalidations.set('products', 1000); + assert.equal(isInvalidated(['products'], 2000, [], []), false); + }); + + it('honours per-request revalidatedTags', () => { + assert.equal(isInvalidated(['products'], 5000, ['products'], []), true); + }); + + it('falls back to context tags when the record carries none', () => { + cacheInvalidations.set('products', 2000); + assert.equal(isInvalidated([], 1000, [], ['products']), true); + }); +}); + +describe('isSweepableTag', () => { + it('rejects Next implicit route tags, which can match the whole cache', () => { + assert.equal(isSweepableTag(`${NEXT_IMPLICIT_TAG_PREFIX}/layout`), false); + assert.equal(isSweepableTag(`${NEXT_IMPLICIT_TAG_PREFIX}/guest-home/page`), false); + }); + + it('accepts explicit user tags', () => { + assert.equal(isSweepableTag('products'), true); + assert.equal(isSweepableTag('home:TN:12'), true); + }); +}); + +describe('noteInvalidation', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('records the timestamp so reads on this worker see it immediately', () => { + noteInvalidation(['a', 'b'], 1234); + assert.equal(cacheInvalidations.get('a'), 1234); + assert.equal(cacheInvalidations.get('b'), 1234); + }); +}); + +describe('sweepTag', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('invalidates matching records rather than deleting them, preserving stale-while-revalidate', async () => { + const isr = makeCacheTable([ + { id: '/a', tags: ['products'], lastModified: 500 }, + { id: '/b', tags: ['products'], lastModified: 500 }, + ]); + const deps = makeDeps({ isr }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(isr.invalidated.sort(), ['/a', '/b']); + assert.deepEqual(isr.deleted, [], 'must not hard-delete'); + }); + + it('skips records written after the invalidation timestamp', async () => { + const isr = makeCacheTable([ + { id: '/stale', tags: ['products'], lastModified: 500 }, + { id: '/fresh', tags: ['products'], lastModified: 1500 }, + ]); + const deps = makeDeps({ isr }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(isr.invalidated, ['/stale']); + }); + + it('sweeps both cache tables, each on its own time column', async () => { + const isr = makeCacheTable([{ id: '/page', tags: ['products'], lastModified: 500 }]); + const useCache = makeCacheTable([{ id: 'uc-1', tags: ['products'], timestamp: 500 }]); + const deps = makeDeps({ isr, useCache }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(isr.invalidated, ['/page']); + assert.deepEqual(useCache.invalidated, ['uc-1']); + }); + + it('clears the invalidation row once the sweep completes', async () => { + const invalidation = makeInvalidationTable(); + const deps = makeDeps({ invalidation }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(invalidation.deleted, ['products']); + }); + + it('pauses between chunks but not after the final one', async () => { + const many = Array.from({ length: 250 }, (_, index) => ({ + id: `/page-${index}`, + tags: ['products'], + lastModified: 500, + })); + const deps = makeDeps({ isr: makeCacheTable(many) }); + + await sweepTag('products', 1000, deps); + + // 250 records over a 100-record chunk size = 3 chunks = 2 inter-chunk pauses, per table. + assert.equal(deps.sleeps.length, 2); + }); + + it('leaves the invalidation row in place when the sweep fails, so soft invalidation still covers reads', async () => { + const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); + isr.invalidate = async () => { + throw new Error('boom'); + }; + const invalidation = makeInvalidationTable(); + const deps = makeDeps({ isr, invalidation }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(invalidation.deleted, [], 'row must survive a failed sweep'); + assert.equal(deps.errors.length, 1, 'failure is logged, not thrown'); + }); +}); + +describe('recordInvalidation', () => { + beforeEach(() => cacheInvalidations.clear()); + + it('writes a row per tag and updates the in-memory map synchronously', async () => { + const invalidation = makeInvalidationTable(); + const deps = makeDeps({ invalidation }); + + await recordInvalidation(['products', 'deals'], undefined, deps); + + assert.deepEqual( + invalidation.puts.map((put) => put.key).sort(), + ['deals', 'products'] + ); + assert.ok(cacheInvalidations.has('products')); + assert.ok(cacheInvalidations.has('deals')); + }); + + it('does not sweep Next implicit tags', async () => { + const isr = makeCacheTable([{ id: '/a', tags: [`${NEXT_IMPLICIT_TAG_PREFIX}/layout`], lastModified: 500 }]); + const deps = makeDeps({ isr }); + + await recordInvalidation([`${NEXT_IMPLICIT_TAG_PREFIX}/layout`], undefined, deps); + await deps.pendingSweeps?.(); + + assert.deepEqual(isr.invalidated, [], 'implicit tags are left to TTL'); + }); + + it('skips sweeping when the invalidation table is over the admission threshold', async () => { + const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); + const deps = makeDeps({ isr, invalidation: makeInvalidationTable(100_000) }); + + await recordInvalidation(['products'], undefined, deps); + await deps.pendingSweeps?.(); + + assert.deepEqual(isr.invalidated, [], 'sweep is shed under backpressure; soft invalidation still applies'); + }); +}); + +describe('chunk', () => { + it('splits into fixed-size batches with a short tail', () => { + assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); + }); + + it('returns nothing for an empty input', () => { + assert.deepEqual(chunk([], 10), []); + }); +}); + +describe('runWithConcurrency', () => { + it('never exceeds the concurrency limit', async () => { + let active = 0; + let peak = 0; + await runWithConcurrency(Array.from({ length: 20 }, (_, i) => i), 3, async () => { + active += 1; + peak = Math.max(peak, active); + await Promise.resolve(); + active -= 1; + }); + assert.ok(peak <= 3, `peak concurrency ${peak} exceeded limit`); + }); + + it('processes every item', async () => { + const seen: number[] = []; + await runWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => { + seen.push(item); + }); + assert.deepEqual(seen.sort((a, b) => a - b), [1, 2, 3, 4, 5]); + }); +}); From 2ebb9471ecda67cfa377cb2494fa0bc251e59d8f Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 12:38:50 -0500 Subject: [PATCH 02/29] feat: back the "use cache" directive with Harper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cacheHandler` and `cacheHandlers` are two different Next.js interfaces. The plugin implemented only the first, which backs ISR, the Data Cache and `unstable_cache`. The `'use cache'` directive goes through the second, and with it unset Next falls back to `createDefaultCacheHandler` — an in-memory LRU. Every `'use cache'` entry was therefore per-worker, lost on restart, and invisible to the rest of the cluster, while appearing to work. Adds a handler implementing that interface over a new `nextjs_use_cache` table, sharing the invalidation map, subscription and tombstones with the ISR handler so one `revalidateTag` reaches both caches. Four details the interface demands, each covered by a test: - A ReadableStream is single-use, so `set` drains it to bytes and every `get` builds a fresh stream. Returning a shared stream would serve the first reader and hand every later one an empty body. - A value stream can error with partial data, so a failed drain persists nothing rather than caching half a render. - A `get` arriving before a pending `set` completes must wait rather than report a miss, so in-flight entries are registered synchronously ahead of the first await. - `getExpiration` consistently reports the newest invalidation timestamp (0 when none), rather than mixing that with the `Infinity` "check the soft tags in get instead" protocol. Per-entry cache lives come free here: the interface carries `stale`, `revalidate` and `expire` on the entry, so they round-trip without the separate plumbing the legacy handler needed. On invalidation an entry inside its expire window is returned backdated past its revalidate window — stale, so Next regenerates, but still served meanwhile. `refreshTags()` is a no-op beyond ensuring the subscription is live. The interface expects handlers to poll a tags service; Harper replicates invalidations and pushes them to every worker, so the map is already current. Registration is opt-in via `withHarper(config, { useCache: true, configDir })` for one minor, since enabling it silently changes caching behaviour for existing apps, and the interface only exists in Next.js 16. --- src/UseCacheHandler.cts | 196 +++++++++++++++++++++++++++++ src/UseCacheHandler.test.ts | 240 ++++++++++++++++++++++++++++++++++++ src/withHarper.cts | 41 +++++- src/withHarper.test.ts | 64 ++++++++++ 4 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/UseCacheHandler.cts create mode 100644 src/UseCacheHandler.test.ts create mode 100644 src/withHarper.test.ts diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts new file mode 100644 index 0000000..c882f6e --- /dev/null +++ b/src/UseCacheHandler.cts @@ -0,0 +1,196 @@ +import type { CacheEntry, CacheHandler, Timestamp } from 'next/dist/server/lib/cache-handlers/types.d.ts'; + +import type { databases as DatabasesType } from 'harper'; + +import { cacheInvalidations, initializeInvalidationSubscription, recordInvalidation } from './cacheInvalidation.cjs'; + +const DATABASE = 'harperfast_nextjs'; +const TABLE = 'nextjs_use_cache'; + +interface StoredEntry { + value: unknown; + tags?: string[]; + timestamp?: number; + stale?: number; + revalidate?: number; + expire?: number; +} + +interface UseCacheTable { + get(key: string): Promise; + put(key: string, value: Record): Promise | unknown; +} + +/** + * Cache keys whose `set` is still in flight. The interface requires a `get` arriving before a pending + * entry completes to wait for it rather than report a miss, so entries are registered here + * synchronously — before the first `await` in `set`. + */ +const pendingEntries = new Map>(); + +function getDatabases(): typeof DatabasesType | undefined { + return (globalThis as { databases?: typeof DatabasesType }).databases; +} + +function getTable(): UseCacheTable | undefined { + const databases = getDatabases(); + if (!databases) return undefined; + return (databases as unknown as Record>)[DATABASE]?.[TABLE]; +} + +function getLogger() { + return (globalThis as { logger?: { error(...args: unknown[]): void } }).logger ?? console; +} + +/** Drain the entry's stream. Returns undefined if it errors, so a partial render is never cached. */ +async function drain(stream: ReadableStream): Promise { + const chunks: Buffer[] = []; + const reader = stream.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(Buffer.from(value)); + } + } catch { + return undefined; + } + return Buffer.concat(chunks); +} + +/** Harper returns a Blob for a Blob column; unit tests and older rows may hold raw bytes. */ +async function toBuffer(value: unknown): Promise { + if (value === undefined || value === null) return undefined; + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + const blob = value as { arrayBuffer?: () => Promise }; + if (typeof blob.arrayBuffer === 'function') return Buffer.from(await blob.arrayBuffer()); + return undefined; +} + +function toStoredValue(bytes: Buffer): unknown { + const createBlob = (globalThis as { createBlob?: (input: Buffer) => unknown }).createBlob; + return typeof createBlob === 'function' ? createBlob(bytes) : bytes; +} + +/** + * A ReadableStream is single-use, so every read builds a new one over the stored bytes. Returning a + * shared stream would serve the first reader and hand every later one an empty body. + */ +function streamOf(bytes: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)); + controller.close(); + }, + }); +} + +function newestInvalidation(tags: string[]): number { + let newest = 0; + for (const tag of tags) { + const invalidatedAt = cacheInvalidations.get(tag); + if (invalidatedAt !== undefined && invalidatedAt > newest) newest = invalidatedAt; + } + return newest; +} + +class HarperUseCacheHandler implements CacheHandler { + async get(cacheKey: string, softTags: string[]): Promise { + const table = getTable(); + if (!table) return undefined; + + // An in-flight set for this key must be awaited rather than reported as a miss. + const pending = pendingEntries.get(cacheKey); + if (pending) await pending; + + const record = await table.get(cacheKey); + if (!record) return undefined; + + const bytes = await toBuffer(record.value); + if (!bytes) return undefined; + + const now = Date.now(); + const timestamp = record.timestamp ?? 0; + const expire = record.expire ?? 0; + const revalidate = record.revalidate ?? 0; + + if (expire > 0 && timestamp + expire * 1000 < now) return undefined; + + // Hard tags live on the record; soft tags are handled by getExpiration, which reports the + // invalidation timestamp for Next to compare itself. + const invalidatedAt = newestInvalidation(record.tags ?? []); + + let effectiveTimestamp = timestamp; + if (invalidatedAt > timestamp) { + // Backdate past the revalidate window so Next regenerates, while staying inside expire so the + // entry is still served meanwhile. A miss here would cost a full render, which is exactly what + // an invalidation storm must not produce. + const staleAt = now - revalidate * 1000 - 1; + if (expire > 0 && staleAt + expire * 1000 <= now) return undefined; + effectiveTimestamp = staleAt; + } + + return { + value: streamOf(bytes), + tags: record.tags ?? [], + stale: record.stale ?? 0, + timestamp: effectiveTimestamp, + expire, + revalidate, + }; + } + + async set(cacheKey: string, pendingEntry: Promise): Promise { + const table = getTable(); + if (!table) return; + + // Registered before the first await so a concurrent get observes it. + const work = (async (): Promise => { + const entry = await pendingEntry; + const bytes = await drain(entry.value); + if (!bytes) return undefined; + + await table.put(cacheKey, { + value: toStoredValue(bytes), + tags: entry.tags ?? [], + timestamp: entry.timestamp ?? Date.now(), + stale: entry.stale, + revalidate: entry.revalidate, + expire: entry.expire, + }); + + return bytes; + })(); + + pendingEntries.set(cacheKey, work); + + try { + await work; + } catch (error) { + getLogger().error(`[UseCacheHandler] failed to store "${cacheKey}"`, error); + } finally { + if (pendingEntries.get(cacheKey) === work) pendingEntries.delete(cacheKey); + } + } + + /** + * A no-op beyond ensuring the subscription is live. The interface expects handlers to poll a tags + * service here; Harper replicates invalidations and pushes them to every worker instead, so the map + * this consults is already current. + */ + async refreshTags(): Promise { + await initializeInvalidationSubscription(); + } + + /** Newest invalidation across the tags, or 0 if none were ever invalidated. */ + async getExpiration(tags: string[]): Promise { + return newestInvalidation(tags); + } + + async updateTags(tags: string[], durations?: { expire?: number }): Promise { + await recordInvalidation(tags, durations); + } +} + +export default new HarperUseCacheHandler(); diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts new file mode 100644 index 0000000..aaf30be --- /dev/null +++ b/src/UseCacheHandler.test.ts @@ -0,0 +1,240 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +import { cacheInvalidations } from './cacheInvalidation.cjs'; + +interface UseCacheEntry { + value: ReadableStream; + tags: string[]; + stale: number; + timestamp: number; + expire: number; + revalidate: number; +} + +interface UseCacheHandler { + get(cacheKey: string, softTags: string[]): Promise; + set(cacheKey: string, pendingEntry: Promise): Promise; + refreshTags(): Promise; + getExpiration(tags: string[]): Promise; + updateTags(tags: string[], durations?: { expire?: number }): Promise; +} + +// Next loads this module with `interopDefault(await import(...))` and uses the result directly, so the +// default export is an instance rather than a class. +const handler = createRequire(import.meta.url)('./UseCacheHandler.cjs').default as UseCacheHandler; + +function streamOf(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +function erroringStream(partial: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(partial)); + controller.error(new Error('render aborted')); + }, + }); +} + +async function readAll(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8'); +} + +function entry(overrides: Partial = {}): UseCacheEntry { + return { + value: streamOf('hello'), + tags: [], + stale: 60, + timestamp: Date.now(), + expire: 3600, + revalidate: 300, + ...overrides, + }; +} + +function installDatabases() { + const rows = new Map>(); + (globalThis as Record).databases = { + harperfast_nextjs: { + nextjs_use_cache: { + async get(key: string) { + return rows.get(key); + }, + async put(key: string, value: Record) { + rows.set(key, { id: key, ...value }); + }, + search() { + return (async function* () {})(); + }, + async invalidate() {}, + }, + nextjs_isr_cache: { + search() { + return (async function* () {})(); + }, + async invalidate() {}, + }, + nextjs_cache_invalidation: { + puts: [] as Array<{ key: string; value: { timestamp: number } }>, + async put(key: string, value: { timestamp: number }) { + this.puts.push({ key, value }); + }, + async delete() {}, + async getRecordCount() { + return { recordCount: 0 }; + }, + }, + }, + }; + return rows; +} + +describe('UseCacheHandler streaming', () => { + beforeEach(() => { + cacheInvalidations.clear(); + installDatabases(); + }); + + it('round-trips a cached value', async () => { + await handler.set('k1', Promise.resolve(entry({ value: streamOf('payload') }))); + + const result = await handler.get('k1', []); + + assert.ok(result); + assert.equal(await readAll(result.value), 'payload'); + }); + + it('hands every reader its own stream', async () => { + await handler.set('k2', Promise.resolve(entry({ value: streamOf('payload') }))); + + const first = await handler.get('k2', []); + const second = await handler.get('k2', []); + + assert.ok(first && second); + // A shared stream would leave the second reader with an empty (already-consumed) body. + assert.equal(await readAll(first.value), 'payload'); + assert.equal(await readAll(second.value), 'payload'); + }); + + it('persists nothing when the value stream errors part way', async () => { + await handler.set('k3', Promise.resolve(entry({ value: erroringStream('half') }))); + + assert.equal(await handler.get('k3', []), undefined, 'a partial render must not be cached'); + }); + + it('persists nothing when the pending entry itself rejects', async () => { + await handler.set('k4', Promise.reject(new Error('render failed'))); + + assert.equal(await handler.get('k4', []), undefined); + }); + + it('makes a concurrent get wait for an in-flight set rather than reporting a miss', async () => { + let release: (value: UseCacheEntry) => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + + // Not awaited: the contract is that `get` observes the in-flight set synchronously. + const setPromise = handler.set('k5', pending); + const getPromise = handler.get('k5', []); + + release(entry({ value: streamOf('deferred') })); + await setPromise; + + const result = await getPromise; + assert.ok(result, 'a concurrent get must wait, not return undefined'); + assert.equal(await readAll(result.value), 'deferred'); + }); +}); + +describe('UseCacheHandler cache lives', () => { + beforeEach(() => { + cacheInvalidations.clear(); + installDatabases(); + }); + + it('withholds an entry past its expire window', async () => { + await handler.set( + 'expired', + Promise.resolve(entry({ timestamp: Date.now() - 120_000, expire: 60 })) + ); + + assert.equal(await handler.get('expired', []), undefined); + }); + + it('preserves the entry cache lives across the round trip', async () => { + await handler.set('lives', Promise.resolve(entry({ stale: 30, revalidate: 120, expire: 900 }))); + + const result = await handler.get('lives', []); + + assert.ok(result); + assert.equal(result.stale, 30); + assert.equal(result.revalidate, 120); + assert.equal(result.expire, 900); + }); +}); + +describe('UseCacheHandler tags', () => { + beforeEach(() => { + cacheInvalidations.clear(); + installDatabases(); + }); + + it('reports the newest invalidation timestamp for the given tags', async () => { + cacheInvalidations.set('a', 1000); + cacheInvalidations.set('b', 5000); + + assert.equal(await handler.getExpiration(['a', 'b']), 5000); + }); + + it('reports 0 when no tag was ever invalidated', async () => { + assert.equal(await handler.getExpiration(['never']), 0); + }); + + it('records an invalidation for every tag', async () => { + await handler.updateTags(['products'], undefined); + + assert.ok(cacheInvalidations.has('products')); + }); + + it('serves an invalidated entry as stale rather than missing, while it is still inside expire', async () => { + const now = Date.now(); + await handler.set( + 'tagged', + Promise.resolve(entry({ tags: ['products'], timestamp: now, revalidate: 300, expire: 3600 })) + ); + cacheInvalidations.set('products', now + 1000); + + const result = await handler.get('tagged', []); + + assert.ok(result, 'a miss here costs a full render; stale-then-regenerate is the point'); + // Backdated past its revalidate window so Next regenerates, but still inside expire so it is usable. + assert.ok(result.timestamp + result.revalidate * 1000 < Date.now()); + assert.ok(result.timestamp + result.expire * 1000 > Date.now()); + }); + + it('withholds an invalidated entry that is also past expire', async () => { + const now = Date.now(); + await handler.set( + 'gone', + Promise.resolve(entry({ tags: ['products'], timestamp: now - 120_000, revalidate: 10, expire: 60 })) + ); + cacheInvalidations.set('products', now); + + assert.equal(await handler.get('gone', []), undefined); + }); +}); diff --git a/src/withHarper.cts b/src/withHarper.cts index 7ede026..e0e394a 100644 --- a/src/withHarper.cts +++ b/src/withHarper.cts @@ -12,7 +12,30 @@ export function cacheHandlerPath(configDir: string): string { return join(configDir, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.cjs'); } -export function withHarper(config: NextConfig = {}): NextConfig { +/** + * Returns the path to the Harper "use cache" handler, resolved the same way as `cacheHandlerPath`. + * + * This is a different Next.js interface from `cacheHandler`: `cacheHandler` backs ISR, the Data Cache + * and `unstable_cache`, while `cacheHandlers` backs the `'use cache'` directive. Setting only the former + * leaves `'use cache'` entries in Next's in-memory default handler — per-worker, lost on restart, and + * invisible to the rest of the cluster. + */ +export function useCacheHandlerPath(configDir: string): string { + return join(configDir, 'node_modules', '@harperfast', 'nextjs', 'dist', 'UseCacheHandler.cjs'); +} + +export interface WithHarperOptions { + /** + * Route `'use cache'` entries to Harper by registering `cacheHandlers`. Requires Next.js 16, which is + * where that interface exists. Opt-in while the handler settles; it becomes the default in the next + * major, because enabling it silently changes caching behaviour for existing apps. + */ + useCache?: boolean; + /** Directory to resolve the handler path against. Required when `useCache` is enabled. */ + configDir?: string; +} + +export function withHarper(config: NextConfig = {}, options: WithHarperOptions = {}): NextConfig { // TODO: Do things like `serverExternalPackage` work with Next.js v14? If not, how can we // detect version reliably and apply? What if we added properties specific to v14? Would // they be okay with v15 and v16 or do this all need to be guarded? @@ -26,8 +49,24 @@ export function withHarper(config: NextConfig = {}): NextConfig { // But also this means resolving turbopack support in the plugin which is currently proving // difficult. + if (options.useCache && !options.configDir) { + throw new Error('withHarper(): `configDir` is required when `useCache` is enabled — pass `__dirname` or `import.meta.dirname`.'); + } + + const cacheHandlers = options.useCache + ? { + // Both kinds resolve to the same handler: Harper is the durable store for either, and the + // "remote" alias falling back to the in-memory default would reintroduce the per-worker + // caching this exists to remove. + default: useCacheHandlerPath(options.configDir as string), + remote: useCacheHandlerPath(options.configDir as string), + ...(config as { cacheHandlers?: Record }).cacheHandlers, + } + : (config as { cacheHandlers?: Record }).cacheHandlers; + return { ...config, + ...(cacheHandlers ? { cacheHandlers } : {}), webpack: (config) => { config.externals.push({ 'harperdb': 'commonjs harperdb', diff --git a/src/withHarper.test.ts b/src/withHarper.test.ts new file mode 100644 index 0000000..cb46b55 --- /dev/null +++ b/src/withHarper.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +const { withHarper, cacheHandlerPath, useCacheHandlerPath } = createRequire(import.meta.url)('./withHarper.cjs') as { + withHarper(config?: Record, options?: { useCache?: boolean; configDir?: string }): Record; + cacheHandlerPath(configDir: string): string; + useCacheHandlerPath(configDir: string): string; +}; + +describe('handler paths', () => { + it('resolves the two handlers to different modules', () => { + // The ISR handler and the "use cache" handler implement different Next.js interfaces; pointing + // `cacheHandlers` at the ISR one would silently do nothing. + assert.notEqual(cacheHandlerPath('/app'), useCacheHandlerPath('/app')); + assert.match(cacheHandlerPath('/app'), /CacheHandler\.cjs$/); + assert.match(useCacheHandlerPath('/app'), /UseCacheHandler\.cjs$/); + }); +}); + +describe('withHarper use-cache registration', () => { + it('does not register cacheHandlers by default', () => { + const config = withHarper({}); + assert.equal(config.cacheHandlers, undefined, 'enabling this silently would change existing apps'); + }); + + it('registers both handler kinds when opted in', () => { + const config = withHarper({}, { useCache: true, configDir: '/app' }) as { + cacheHandlers: Record; + }; + + assert.match(config.cacheHandlers.default, /UseCacheHandler\.cjs$/); + assert.match(config.cacheHandlers.remote, /UseCacheHandler\.cjs$/); + }); + + it('requires configDir when opted in, rather than producing an unresolvable path', () => { + assert.throws(() => withHarper({}, { useCache: true }), /configDir/); + }); + + it('lets a user-supplied cacheHandlers entry win', () => { + const config = withHarper({ cacheHandlers: { default: '/custom.js' } }, { useCache: true, configDir: '/app' }) as { + cacheHandlers: Record; + }; + + assert.equal(config.cacheHandlers.default, '/custom.js'); + assert.match(config.cacheHandlers.remote, /UseCacheHandler\.cjs$/); + }); + + it('preserves an existing cacheHandlers config when not opted in', () => { + const config = withHarper({ cacheHandlers: { default: '/custom.js' } }) as { + cacheHandlers: Record; + }; + + assert.equal(config.cacheHandlers.default, '/custom.js'); + }); + + it('still applies the Harper externals', () => { + const config = withHarper({}, { useCache: true, configDir: '/app' }) as { + serverExternalPackages: string[]; + }; + + assert.ok(config.serverExternalPackages.includes('harper')); + }); +}); From 4eebc83fa519c992dfecc0bab8968c71431b8493 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 12:50:18 -0500 Subject: [PATCH 03/29] test: add a "use cache" fixture and document the two handler interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had no fixture exercising `cacheComponents` or `'use cache'`, which is why the gap went unnoticed: every existing test covers the incremental-cache path, and that path worked. The new fixture asserts a row actually lands in `nextjs_use_cache`, and repeats a read across workers — a per-worker in-memory cache shows up there as the value changing between requests, which is the assertion that would have caught the original behaviour. README now leads the caching section with the two-interface distinction, since configuring only `cacheHandler` silently leaves `'use cache'` in Next's in-memory handler. Also documents per-entry cache lives and why they matter for a distributed cache, the sweep, the two deliberate sweep limits (implicit route tags, unindexable tags), and that a table's `expiration` does not migrate when the schema changes. AGENTS.md records two things that cost time here: integration tests need the macOS loopback alias pool and fail at startup without it, and the `format:fix` script the Code Style section references does not exist. --- .gitignore | 2 +- AGENTS.md | 2 + README.md | 57 ++++++++- fixtures/next-16-use-cache/.npmrc | 1 + .../app/api/revalidate/route.js | 11 ++ fixtures/next-16-use-cache/app/cached/page.js | 23 ++++ fixtures/next-16-use-cache/app/layout.js | 11 ++ fixtures/next-16-use-cache/app/page.js | 7 ++ fixtures/next-16-use-cache/config.yaml | 4 + fixtures/next-16-use-cache/next.config.mjs | 12 ++ fixtures/next-16-use-cache/package.json | 16 +++ integrationTests/next-16-use-cache.pw.ts | 108 ++++++++++++++++++ src/CacheHandler.cts | 7 +- 13 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 fixtures/next-16-use-cache/.npmrc create mode 100644 fixtures/next-16-use-cache/app/api/revalidate/route.js create mode 100644 fixtures/next-16-use-cache/app/cached/page.js create mode 100644 fixtures/next-16-use-cache/app/layout.js create mode 100644 fixtures/next-16-use-cache/app/page.js create mode 100644 fixtures/next-16-use-cache/config.yaml create mode 100644 fixtures/next-16-use-cache/next.config.mjs create mode 100644 fixtures/next-16-use-cache/package.json create mode 100644 integrationTests/next-16-use-cache.pw.ts diff --git a/.gitignore b/.gitignore index cb5ff26..d532f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules/ test-results/ .DS_Store dist/ -playwright-report/ \ No newline at end of file +playwright-report/test-results/ diff --git a/AGENTS.md b/AGENTS.md index 60821b2..e95c0bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,5 +22,7 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Run `npm run test:integration` to run all tests, or `npm run test:integration -- integrationTests/next-15.pw.ts` for a single file. - Test startup is slow by design — each test file starts a real Harper instance and waits for Next.js to build (up to 2 minutes). A slow start is not a failure. - The ISR cache tests in `integrationTests/next-16.pw.ts` are intentionally skipped; `CacheHandler.cts` is a work in progress. +- Integration tests need the macOS loopback alias pool (127.0.0.2+). Without it every fixture fails at startup with `LoopbackAddressValidationError` / `EADDRNOTAVAIL` before any assertion runs — that is a machine-setup gap, not a test failure. `ifconfig lo0 | grep 'inet '` shows what is configured. +- There is no `format:fix` script in `package.json` despite the Code Style note below; match the surrounding file style (tabs, semicolons). - `next-16-static-data` is run by two test files: `next-16-static-data.pw.ts` on the default VM module loader (where Harper's component `harper` allowlist omits `flushDatabases`, so the plugin's pre-build flush is a no-op and a read-only build child can't see unflushed writes) and `next-16-static-data-native.pw.ts` under `applications.moduleLoader: native`, where the flush does run. The pair is what pins that behavior down — keep both. - CI is currently disabled (`if: false` in `.github/workflows/integration-tests.yml`). Run tests locally. diff --git a/README.md b/README.md index 8349232..6e6faa7 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ Glob pattern specifying which files Harper should watch for changes. Example: `' `@harperfast/nextjs` includes a Harper-backed cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration), the [Data Cache (`fetch()`)](https://nextjs.org/docs/app/deep-dive/caching#data-cache), and [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache). Cached entries live in Harper instead of the worker's local filesystem, so a cache write on one node is visible to every node in the cluster. +> [!IMPORTANT] +> Next.js has **two** cache-handler interfaces and they are configured separately. +> +> | Next.js config | Interface | Backs | +> | --- | --- | --- | +> | `cacheHandler` | `CacheHandler` (incremental cache) | ISR, the Data Cache, `unstable_cache` | +> | `cacheHandlers` | `CacheHandler` (`use cache`) | the [`'use cache'`](https://nextjs.org/docs/app/api-reference/directives/use-cache) directive | +> +> Setting only `cacheHandler` leaves `'use cache'` entries in Next.js's built-in in-memory handler — per-worker, lost on restart, and invisible to the rest of the cluster. Apps using `cacheComponents` / `'use cache'` need [`useCache`](#use-cache) as well. + ### Enabling Set the `cacheHandler` path using the `cacheHandlerPath()` helper. This helper resolves the cache handler relative to your config file, which is required by Turbopack: @@ -245,25 +255,62 @@ export async function POST(request) { `fetch()` calls with `next: { tags: [...] }` and the `'use cache'` directive (with `cacheTag()`) are also supported — anywhere Next.js attaches tags to a cached value, the handler will pick them up. +### `useCache` + +Route `'use cache'` entries to Harper by registering `cacheHandlers`: + +```js +// next.config.mjs +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; + +export default withHarper( + { + cacheComponents: true, + cacheHandler: cacheHandlerPath(import.meta.dirname), + }, + { useCache: true, configDir: import.meta.dirname } +); +``` + +Requires Next.js 16, which is where the interface exists. Opt-in for now — it becomes the default in the next major, because turning it on changes where existing apps' `'use cache'` entries are stored. + +### Per-entry cache lives + +The handler persists the `revalidate` and `expire` Next.js supplies for each entry. + +This matters more than it sounds. Next.js keeps cache lives in `SharedCacheControls` — a per-process `Map` plus the build-time prerender manifest — and neither replicates. Without persisting them, an entry reaches another node but its cache lives do not, and that node falls back to Next.js's 1-second default, so the entry looks stale on almost every read. Persisting them is what makes cache lives coherent across the cluster. + ### How invalidation works -The cache handler uses a **soft-invalidation** model: +The cache handler uses a **soft-invalidation** model, followed by a background sweep: 1. `revalidateTag(tag)` writes a `{ tag, timestamp }` row to the `nextjs_cache_invalidation` table and updates an in-memory map in the calling worker. 2. Every other Harper worker subscribes to that table and updates its own map when the row is replicated — typically within milliseconds. -3. On the next `cache.get()`, if any of the cached entry's tags has an invalidation timestamp newer than the entry's `lastModified`, the handler returns `null` and Next.js regenerates the entry. The new write replaces the row with a fresh `lastModified`, naturally restoring "fresh" status. +3. A throttled background pass then invalidates the matching cache entries and drops the tombstone. Entries are **invalidated, not deleted**, so Next.js can still serve them stale while regenerating — an invalidation storm should not turn into a render storm. +4. On the next `cache.get()`, an invalidated entry is reported to Next.js as *stale* rather than missing wherever Next.js supports that, so it serves the cached response and regenerates in the background. -There is no background sweep that hard-deletes invalidated rows; stale rows are overwritten by Next.js the next time the entry is regenerated. The `nextjs_cache_invalidation` rows themselves expire after 7 days so abandoned tags don't accumulate. +Because the sweep does the real work, the 7-day expiry on `nextjs_cache_invalidation` is a safety margin for sweep failure rather than a correctness parameter — the tombstone only has to outlive the sweep, not the entries it covers. While it remains, soft invalidation keeps reads correct, so a failed sweep degrades rather than loses the invalidation. + +Two deliberate limits: + +- **Next.js's implicit route tags (`_N_T_…`) are never swept.** A tag like `_N_T_/layout` is carried by every page in the app, so sweeping one would scan and rewrite the entire cache. Those are left to expire. +- **`tags` is not indexed.** Harper cannot index array elements, and the `contains` comparator cannot use an index regardless, so a sweep scans. That is why sweeps are chunked with bounded concurrency, and why broad tags are excluded rather than throttled. + +Harper pushes invalidations to every worker via table replication, so the `refreshTags()` call the `'use cache'` interface expects to poll a tags service is a no-op here — the map it would refresh is already current. ### Schema -Enabling the cache handler adds two tables to the `harperfast_nextjs` database: +Enabling the cache handler adds these tables to the `harperfast_nextjs` database: | Table | Purpose | | --- | --- | -| `nextjs_isr_cache` | One row per cached entry. Stores `data` (the Next.js `IncrementalCacheValue`), `tags` (the tags attached to the entry), and `lastModified`. | +| `nextjs_isr_cache` | One row per cached ISR/Data Cache entry. Stores `data` (the Next.js `IncrementalCacheValue`), `tags`, the entry's `revalidate`/`expire`, and `lastModified`. | +| `nextjs_use_cache` | One row per `'use cache'` entry. Stores `value` (the rendered bytes as a `Blob`), `tags`, `timestamp`, and the entry's `stale`/`revalidate`/`expire`. | | `nextjs_cache_invalidation` | One row per invalidated tag. `id` is the tag itself; `timestamp` is when `revalidateTag` was called. Auto-expires after 7 days. | +> [!NOTE] +> A table's `expiration` is fixed when the table is created — changing it in `schema.graphql` does not migrate an existing table. Verify with `describe_table` after upgrading; an instance created before a change will still report the old value. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/fixtures/next-16-use-cache/.npmrc b/fixtures/next-16-use-cache/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/fixtures/next-16-use-cache/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/fixtures/next-16-use-cache/app/api/revalidate/route.js b/fixtures/next-16-use-cache/app/api/revalidate/route.js new file mode 100644 index 0000000..71fd14a --- /dev/null +++ b/fixtures/next-16-use-cache/app/api/revalidate/route.js @@ -0,0 +1,11 @@ +import { revalidateTag } from 'next/cache'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + const tag = new URL(request.url).searchParams.get('tag'); + if (!tag) { + return NextResponse.json({ error: 'tag required' }, { status: 400 }); + } + revalidateTag(tag); + return NextResponse.json({ revalidated: true, tag }); +} diff --git a/fixtures/next-16-use-cache/app/cached/page.js b/fixtures/next-16-use-cache/app/cached/page.js new file mode 100644 index 0000000..299d376 --- /dev/null +++ b/fixtures/next-16-use-cache/app/cached/page.js @@ -0,0 +1,23 @@ +import { Suspense } from 'react'; +import { cacheLife, cacheTag } from 'next/cache'; + +async function CachedNonce() { + 'use cache'; + cacheLife('hours'); + cacheTag('use-cache-tag'); + + // Regenerating produces a different value, so a stable value across reads proves the entry was + // served from cache rather than re-rendered. + return

{Math.random().toString(36).slice(2)}

; +} + +export default function CachedPage() { + return ( +
+

Use Cache Page

+ loading

}> + +
+
+ ); +} diff --git a/fixtures/next-16-use-cache/app/layout.js b/fixtures/next-16-use-cache/app/layout.js new file mode 100644 index 0000000..e9017c8 --- /dev/null +++ b/fixtures/next-16-use-cache/app/layout.js @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Harper - Next.js v16 App', +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/fixtures/next-16-use-cache/app/page.js b/fixtures/next-16-use-cache/app/page.js new file mode 100644 index 0000000..ec82c6e --- /dev/null +++ b/fixtures/next-16-use-cache/app/page.js @@ -0,0 +1,7 @@ +export default async function Page() { + return ( +
+

Next.js v16

+
+ ); +} diff --git a/fixtures/next-16-use-cache/config.yaml b/fixtures/next-16-use-cache/config.yaml new file mode 100644 index 0000000..a125a05 --- /dev/null +++ b/fixtures/next-16-use-cache/config.yaml @@ -0,0 +1,4 @@ +rest: true + +'@harperfast/nextjs': + package: '@harperfast/nextjs' diff --git a/fixtures/next-16-use-cache/next.config.mjs b/fixtures/next-16-use-cache/next.config.mjs new file mode 100644 index 0000000..05072c6 --- /dev/null +++ b/fixtures/next-16-use-cache/next.config.mjs @@ -0,0 +1,12 @@ +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; + +// `cacheComponents` turns on the `'use cache'` directive. `cacheHandler` alone would leave those +// entries in Next's in-memory default handler, so `useCache` registers the Harper-backed +// `cacheHandlers` as well — that pairing is what this fixture exists to prove. +export default withHarper( + { + cacheComponents: true, + cacheHandler: cacheHandlerPath(import.meta.dirname), + }, + { useCache: true, configDir: import.meta.dirname } +); diff --git a/fixtures/next-16-use-cache/package.json b/fixtures/next-16-use-cache/package.json new file mode 100644 index 0000000..18532ee --- /dev/null +++ b/fixtures/next-16-use-cache/package.json @@ -0,0 +1,16 @@ +{ + "name": "next-16-use-cache", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@harperfast/nextjs": "file:../../", + "react": "^19", + "react-dom": "^19", + "next": "^16" + } +} diff --git a/integrationTests/next-16-use-cache.pw.ts b/integrationTests/next-16-use-cache.pw.ts new file mode 100644 index 0000000..b7e84d4 --- /dev/null +++ b/integrationTests/next-16-use-cache.pw.ts @@ -0,0 +1,108 @@ +import { fixture } from './fixture.ts'; + +const { test, expect } = fixture('next-16-use-cache'); + +const DATABASE = 'harperfast_nextjs'; +const TABLE = 'nextjs_use_cache'; + +function authHeader(harper: { admin: { username: string; password: string } }) { + return `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`; +} + +async function searchUseCache(request: any, harper: any, attributes = ['id', 'tags', 'timestamp']) { + const response = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', Authorization: authHeader(harper) }, + data: { + operation: 'search_by_value', + database: DATABASE, + table: TABLE, + search_attribute: 'id', + search_value: '*', + get_attributes: attributes, + }, + }); + expect(response.status()).toBe(200); + return response.json(); +} + +// The gap this fixture exists to close: with only `cacheHandler` set, `'use cache'` entries go to +// Next's in-memory default handler, so nothing reaches Harper and the cache is per-worker. +test('a "use cache" entry is persisted in Harper', async ({ page, request, harper }) => { + await page.goto(`${harper.httpURL}/cached`); + await expect(page.getByTestId('nonce')).not.toHaveText('loading'); + + const rows = await searchUseCache(request, harper); + + expect(rows.length).toBeGreaterThan(0); + expect(typeof rows[0].id).toBe('string'); +}); + +test('the cached value is stable across reads', async ({ page, harper }) => { + const url = `${harper.httpURL}/cached`; + + await page.goto(url); + const first = await page.getByTestId('nonce').innerText(); + + await page.goto(url); + const second = await page.getByTestId('nonce').innerText(); + + expect(second).toBe(first); +}); + +// Harper runs several worker threads. A per-worker in-memory cache shows up as the value changing +// between requests as they land on different workers — this is the assertion that would have caught +// the original gap. +test('every worker serves the same cached value', async ({ page, harper }) => { + const url = `${harper.httpURL}/cached`; + const seen = new Set(); + + for (let i = 0; i < 12; i++) { + await page.goto(url); + seen.add(await page.getByTestId('nonce').innerText()); + } + + expect(seen.size, `expected one shared value, saw ${[...seen].join(', ')}`).toBe(1); +}); + +test('the entry survives a revalidateTag as stale rather than disappearing', async ({ page, request, harper }) => { + const url = `${harper.httpURL}/cached`; + + await page.goto(url); + const before = await page.getByTestId('nonce').innerText(); + + const revalidated = await request.post(`${harper.httpURL}/api/revalidate?tag=use-cache-tag`); + expect(revalidated.status()).toBe(200); + + // An invalidation row is written for the tag. + const invalidations = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', Authorization: authHeader(harper) }, + data: { + operation: 'search_by_value', + database: DATABASE, + table: 'nextjs_cache_invalidation', + search_attribute: 'id', + search_value: 'use-cache-tag', + get_attributes: ['id', 'timestamp'], + }, + }); + expect(await invalidations.json()).toHaveLength(1); + + // The content regenerates rather than being served stale forever. + await expect(async () => { + await page.goto(url); + expect(await page.getByTestId('nonce').innerText()).not.toBe(before); + }).toPass({ timeout: 15_000 }); +}); + +test('cache lives from cacheLife round-trip into the stored entry', async ({ page, request, harper }) => { + await page.goto(`${harper.httpURL}/cached`); + await expect(page.getByTestId('nonce')).not.toHaveText('loading'); + + const rows = await searchUseCache(request, harper, ['id', 'revalidate', 'expire', 'stale']); + const withLives = rows.find((row: { revalidate?: number }) => typeof row.revalidate === 'number'); + + // Without these persisted, another node reads the entry but not its cache lives, and + // `calculateRevalidate` falls back to a 1-second default there. + expect(withLives, 'no entry carried its cache lives').toBeTruthy(); + expect(withLives.expire).toBeGreaterThan(withLives.revalidate); +}); diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index a586f97..b825d71 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -14,12 +14,7 @@ import type { import type { databases as DatabasesType } from 'harper'; -import { - cacheInvalidations, - initializeInvalidationSubscription, - isInvalidated, - recordInvalidation, -} from './cacheInvalidation.cjs'; +import { initializeInvalidationSubscription, isInvalidated, recordInvalidation } from './cacheInvalidation.cjs'; const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; From 1442b59b0865f11ce4e5dd01a982df6bb9c2a412 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 13:33:13 -0500 Subject: [PATCH 04/29] fix: store integer cache lives and disable the sweep by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "use cache" handler never wrote anything to Harper. Next derives entry timestamps from `performance.timeOrigin + performance.now()`, which is fractional, and the `timestamp` column is a Long — so Harper refused every write with "must be an integer". The failure was caught and logged, so the cache looked like it was working while storing nothing. Timestamps and cache lives are now floored on the way in, on both handlers, since `revalidate`/`expire` are Int columns with the same exposure. The unit tests missed this because their mock table accepted any value. The mocks now enforce the integer columns, which is what makes the two new regression tests meaningful. A silent `getTable()` miss is the other half of why this hid: it returned undefined with no signal, turning every write into a no-op. It now logs once. The background sweep is disabled by default behind HARPER_NEXTJS_EXPERIMENTAL_SWEEP. It does not coexist with Next's staleness model, in two ways that are both silent: - Harper's `invalidate()` on a table with no `sourcedFrom` leaves the record awaiting a refresh that never arrives, and every later read of that key blocks — this broke the pre-existing next-16-caching revalidateTag test with a 30s timeout. - Writing an explicit marker with `patch` instead bumps `lastModified` (`@updatedTime`), so the entry looks newer than the invalidation to Next's own `areTagsStale`. Next then treats it as fresh and never regenerates, serving the entry stale indefinitely. Marking an entry stale without disturbing the timestamp Next derives staleness from needs a primitive this schema does not have. Until then the tombstone remains the invalidation — the behaviour that shipped previously — and its 7-day expiry bounds table growth on its own. The sweep, its throttling, its race guard and its scope limits stay covered by tests so the work is not lost. The `revalidateTag` integration test asserted the tombstone row still existed, which races the sweep whenever it is enabled, and demanded regeneration on the very next request, which contradicts serving stale first. It now asserts the invalidation is durably recorded in either table and polls for the content to regenerate — the effect rather than the mechanism. Invalidation hydration is extracted so the crash window has a test: a worker that dies between `revalidateTag` and regeneration must rebuild its map from the tombstone rather than come back treating the stale entry as fresh. The use-cache fixture page was fully prerendered, so its cached boundary was resolved during `next build` and the handler was never called at request time — the test could not have failed for the right reason. It now reads cookies, which keeps the route dynamic and exercises the runtime path. --- README.md | 13 ++-- fixtures/next-16-use-cache/app/cached/page.js | 25 ++++-- integrationTests/next-16-caching.pw.ts | 67 ++++++++++------ schema.graphql | 6 ++ src/CacheHandler.cts | 10 ++- src/UseCacheHandler.cts | 49 +++++++++--- src/UseCacheHandler.test.ts | 43 +++++++++++ src/cacheInvalidation.cts | 60 +++++++++++++-- src/cacheInvalidation.test.ts | 77 ++++++++++++++++--- 9 files changed, 286 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 6e6faa7..7aa67eb 100644 --- a/README.md +++ b/README.md @@ -282,16 +282,19 @@ This matters more than it sounds. Next.js keeps cache lives in `SharedCacheContr ### How invalidation works -The cache handler uses a **soft-invalidation** model, followed by a background sweep: +The cache handler uses a **soft-invalidation** model: 1. `revalidateTag(tag)` writes a `{ tag, timestamp }` row to the `nextjs_cache_invalidation` table and updates an in-memory map in the calling worker. 2. Every other Harper worker subscribes to that table and updates its own map when the row is replicated — typically within milliseconds. -3. A throttled background pass then invalidates the matching cache entries and drops the tombstone. Entries are **invalidated, not deleted**, so Next.js can still serve them stale while regenerating — an invalidation storm should not turn into a render storm. -4. On the next `cache.get()`, an invalidated entry is reported to Next.js as *stale* rather than missing wherever Next.js supports that, so it serves the cached response and regenerates in the background. +3. On the next `cache.get()`, an invalidated entry is reported to Next.js as *stale* rather than missing wherever Next.js supports that, so it serves the cached response and regenerates in the background. An invalidation storm should not turn into a render storm. +4. A worker that restarts rebuilds its map from the tombstone table, so an invalidation survives the process that issued it. -Because the sweep does the real work, the 7-day expiry on `nextjs_cache_invalidation` is a safety margin for sweep failure rather than a correctness parameter — the tombstone only has to outlive the sweep, not the entries it covers. While it remains, soft invalidation keeps reads correct, so a failed sweep degrades rather than loses the invalidation. +Entries are never hard-deleted; Next.js overwrites them on the next regeneration. The `nextjs_cache_invalidation` rows expire after 7 days so abandoned tags don't accumulate. -Two deliberate limits: +> [!NOTE] +> A throttled background sweep — which marks matching entries and then drops the tombstone, so the tombstone's lifetime stops being a correctness parameter — is implemented but **off by default**, behind `HARPER_NEXTJS_EXPERIMENTAL_SWEEP=true`. It does not yet coexist with Next.js's staleness model: Harper's `invalidate()` on a table with no `sourcedFrom` leaves the record awaiting a refresh that never arrives and blocks later reads, while writing a marker with `patch` bumps `lastModified` and makes the entry look *newer* than the invalidation, so Next.js treats it as fresh and never regenerates. Marking an entry stale without disturbing the timestamp Next.js derives staleness from needs a primitive this schema does not have yet. + +Two limits the sweep is designed around, for when it is enabled: - **Next.js's implicit route tags (`_N_T_…`) are never swept.** A tag like `_N_T_/layout` is carried by every page in the app, so sweeping one would scan and rewrite the entire cache. Those are left to expire. - **`tags` is not indexed.** Harper cannot index array elements, and the `contains` comparator cannot use an index regardless, so a sweep scans. That is why sweeps are chunked with bounded concurrency, and why broad tags are excluded rather than throttled. diff --git a/fixtures/next-16-use-cache/app/cached/page.js b/fixtures/next-16-use-cache/app/cached/page.js index 299d376..6f94974 100644 --- a/fixtures/next-16-use-cache/app/cached/page.js +++ b/fixtures/next-16-use-cache/app/cached/page.js @@ -1,22 +1,35 @@ import { Suspense } from 'react'; import { cacheLife, cacheTag } from 'next/cache'; +import { cookies } from 'next/headers'; -async function CachedNonce() { +// Keyed on `bucket`, so two requests in the same bucket must resolve to the same value. Regenerating +// produces a different nonce, so a stable value proves the entry came from cache. +async function CachedNonce({ bucket }) { 'use cache'; cacheLife('hours'); cacheTag('use-cache-tag'); - // Regenerating produces a different value, so a stable value across reads proves the entry was - // served from cache rather than re-rendered. - return

{Math.random().toString(36).slice(2)}

; + return ( +

+ {bucket}:{Math.random().toString(36).slice(2)} +

+ ); +} + +// Reading cookies keeps the route dynamic. Without this the whole page is prerendered at build time, +// the cached boundary is resolved during `next build` and baked into the static shell, and the cache +// handler is never called at request time — which is not the path worth testing. +async function BucketedNonce() { + const store = await cookies(); + return ; } export default function CachedPage() { return (

Use Cache Page

- loading

}> - + loading

}> +
); diff --git a/integrationTests/next-16-caching.pw.ts b/integrationTests/next-16-caching.pw.ts index 5b4aa7c..f6444b9 100644 --- a/integrationTests/next-16-caching.pw.ts +++ b/integrationTests/next-16-caching.pw.ts @@ -128,7 +128,7 @@ test('ISR cache record is updated after revalidation', async ({ request, harper expect(lastModifiedAfter).toBeGreaterThan(lastModifiedBefore); }); -test('revalidateTag writes invalidation row and forces regeneration', async ({ request, harper, page }) => { +test('revalidateTag records the invalidation and regenerates the entry', async ({ request, harper, page }) => { const taggedURL = `${harper.httpURL}/tagged`; const revalidateURL = `${harper.httpURL}/api/revalidate?tag=test-tag`; const authHeader = `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`; @@ -146,25 +146,48 @@ test('revalidateTag writes invalidation row and forces regeneration', async ({ r const revalidateResponse = await request.post(revalidateURL); expect(revalidateResponse.status()).toBe(200); - // The invalidation row should now exist in Harper. - const invalidationRow = await request.post(harper.operationsAPIURL, { - headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, - data: { - operation: 'search_by_value', - database: 'harperfast_nextjs', - table: 'nextjs_cache_invalidation', - search_attribute: 'id', - search_value: 'test-tag', - get_attributes: ['id', 'timestamp'], - }, - }); - const rows = await invalidationRow.json(); - expect(rows).toHaveLength(1); - expect(rows[0].id).toBe('test-tag'); - expect(typeof rows[0].timestamp).toBe('number'); - - // Next page request must regenerate (new nonce). - await page.goto(taggedURL); - const nonceAfter = await page.getByTestId('nonce').innerText(); - expect(nonceAfter).not.toBe(nonceBefore); + // The invalidation must be durably recorded, but *where* is deliberately not asserted: the tombstone + // in nextjs_cache_invalidation is transient — the background sweep marks the matching entries and + // then drops it — so asserting the row still exists is a race against the sweep finishing. Either + // the tombstone or the sweep's own `invalidatedAt` marker on the entry is valid evidence. + const recorded = async () => { + const [tombstones, entries] = await Promise.all([ + request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + data: { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_cache_invalidation', + search_attribute: 'id', + search_value: 'test-tag', + get_attributes: ['id', 'timestamp'], + }, + }), + request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + data: { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_isr_cache', + search_attribute: 'id', + search_value: '*', + get_attributes: ['id', 'invalidatedAt'], + }, + }), + ]); + const tombstoneRows = await tombstones.json(); + const entryRows = await entries.json(); + return ( + tombstoneRows.length > 0 || entryRows.some((row: { invalidatedAt?: number }) => typeof row.invalidatedAt === 'number') + ); + }; + expect(await recorded(), 'the invalidation left no trace in either table').toBe(true); + + // The entry regenerates. The first request after an invalidation may be served stale while + // regeneration runs in the background — that is deliberate, since a blocking miss here costs a full + // render — so poll for the new content rather than demanding it on the very next request. + await expect(async () => { + await page.goto(taggedURL); + expect(await page.getByTestId('nonce').innerText()).not.toBe(nonceBefore); + }).toPass({ timeout: 15_000 }); }); diff --git a/schema.graphql b/schema.graphql index 4c00914..280eea8 100644 --- a/schema.graphql +++ b/schema.graphql @@ -23,6 +23,11 @@ type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache tags: [String] revalidate: Int expire: Int + # Set by the background sweep instead of Harper's `invalidate()`, which on a table with no + # `sourcedFrom` leaves the record awaiting a refresh that never arrives and blocks every later read. + # Recording staleness explicitly keeps the entry servable, which is the point — a miss costs a full + # render. + invalidatedAt: Long lastModified: Long @updatedTime @indexed } @@ -37,6 +42,7 @@ type NextUseCache @table(database: "harperfast_nextjs", table: "nextjs_use_cache stale: Int revalidate: Int expire: Int + invalidatedAt: Long } type NextCacheInvalidation @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index b825d71..b2b670d 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -108,7 +108,9 @@ export default class HarperCacheHandler implements CacheHandler { ? [...ctx.tags, ...('softTags' in ctx && Array.isArray(ctx.softTags) ? ctx.softTags : [])] : []; - if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { + const markedInvalidAt = (record as { invalidatedAt?: number }).invalidatedAt; + + if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags, markedInvalidAt)) { // An on-demand revalidation for this request is an explicit demand for fresh content. if (recordTags.some((tag) => this.revalidatedTags.includes(tag))) return null; @@ -137,9 +139,11 @@ export default class HarperCacheHandler implements CacheHandler { // Next keeps cache lives in a per-process Map plus the build-time prerender manifest, neither of // which replicates. Persisting them alongside the entry is what lets another node compute the // same staleness instead of falling back to `calculateRevalidate`'s 1-second default. + // Floored because Harper rejects a non-integer for an Int column, and a rejected write here is + // silent — the entry simply never lands. const cacheControl = 'cacheControl' in ctx ? ctx.cacheControl : undefined; - const revalidate = typeof cacheControl?.revalidate === 'number' ? cacheControl.revalidate : undefined; - const expire = typeof cacheControl?.expire === 'number' ? cacheControl.expire : undefined; + const revalidate = typeof cacheControl?.revalidate === 'number' ? Math.floor(cacheControl.revalidate) : undefined; + const expire = typeof cacheControl?.expire === 'number' ? Math.floor(cacheControl.expire) : undefined; await table.put(key, { data, tags, revalidate, expire }); } diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index c882f6e..a92d145 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -14,6 +14,7 @@ interface StoredEntry { stale?: number; revalidate?: number; expire?: number; + invalidatedAt?: number; } interface UseCacheTable { @@ -32,14 +33,31 @@ function getDatabases(): typeof DatabasesType | undefined { return (globalThis as { databases?: typeof DatabasesType }).databases; } +let missingTableReported = false; + +/** + * The table, or undefined when the Harper globals are not reachable from this module's context. A + * silent undefined here turns every write into a no-op that looks like a working cache, so the first + * occurrence is logged. + */ function getTable(): UseCacheTable | undefined { const databases = getDatabases(); - if (!databases) return undefined; - return (databases as unknown as Record>)[DATABASE]?.[TABLE]; + const table = databases + ? (databases as unknown as Record>)[DATABASE]?.[TABLE] + : undefined; + + if (!table && !missingTableReported) { + missingTableReported = true; + getLogger().error( + `[UseCacheHandler] ${DATABASE}.${TABLE} is unreachable (databases global ${databases ? 'present' : 'missing'}); "use cache" entries are not being persisted` + ); + } + + return table; } -function getLogger() { - return (globalThis as { logger?: { error(...args: unknown[]): void } }).logger ?? console; +function getLogger(): { error(...args: unknown[]): void; info?(...args: unknown[]): void } { + return (globalThis as { logger?: { error(...args: unknown[]): void; info?(...args: unknown[]): void } }).logger ?? console; } /** Drain the entry's stream. Returns undefined if it errors, so a partial render is never cached. */ @@ -68,6 +86,16 @@ async function toBuffer(value: unknown): Promise { return undefined; } +/** + * Next derives entry timestamps from `performance.timeOrigin + performance.now()`, which is + * fractional, and cache lives can arrive fractional too. Harper rejects a non-integer for a Long/Int + * column, so an uncoerced write is refused outright — and, being caught, looks like a working cache + * that simply never stored anything. + */ +function toInteger(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : undefined; +} + function toStoredValue(bytes: Buffer): unknown { const createBlob = (globalThis as { createBlob?: (input: Buffer) => unknown }).createBlob; return typeof createBlob === 'function' ? createBlob(bytes) : bytes; @@ -118,8 +146,9 @@ class HarperUseCacheHandler implements CacheHandler { if (expire > 0 && timestamp + expire * 1000 < now) return undefined; // Hard tags live on the record; soft tags are handled by getExpiration, which reports the - // invalidation timestamp for Next to compare itself. - const invalidatedAt = newestInvalidation(record.tags ?? []); + // invalidation timestamp for Next to compare itself. `invalidatedAt` is the sweep's own marker, + // which outlives the tombstone it was derived from. + const invalidatedAt = Math.max(newestInvalidation(record.tags ?? []), record.invalidatedAt ?? 0); let effectiveTimestamp = timestamp; if (invalidatedAt > timestamp) { @@ -154,10 +183,10 @@ class HarperUseCacheHandler implements CacheHandler { await table.put(cacheKey, { value: toStoredValue(bytes), tags: entry.tags ?? [], - timestamp: entry.timestamp ?? Date.now(), - stale: entry.stale, - revalidate: entry.revalidate, - expire: entry.expire, + timestamp: toInteger(entry.timestamp) ?? Date.now(), + stale: toInteger(entry.stale), + revalidate: toInteger(entry.revalidate), + expire: toInteger(entry.expire), }); return bytes; diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index aaf30be..dee70a6 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -66,6 +66,22 @@ function entry(overrides: Partial = {}): UseCacheEntry { }; } +/** + * Harper rejects a non-integer for a Long/Int column. Next derives entry timestamps from + * `performance.timeOrigin + performance.now()`, which is fractional, so a mock that accepts anything + * hides a write that real Harper refuses outright. + */ +const INTEGER_COLUMNS = ['timestamp', 'stale', 'revalidate', 'expire']; + +function enforceSchema(value: Record) { + for (const column of INTEGER_COLUMNS) { + const columnValue = value[column]; + if (columnValue !== undefined && columnValue !== null && !Number.isInteger(columnValue)) { + throw new Error(`Value ${String(columnValue)} in property ${column} must be an integer`); + } + } +} + function installDatabases() { const rows = new Map>(); (globalThis as Record).databases = { @@ -75,6 +91,7 @@ function installDatabases() { return rows.get(key); }, async put(key: string, value: Record) { + enforceSchema(value); rows.set(key, { id: key, ...value }); }, search() { @@ -176,6 +193,32 @@ describe('UseCacheHandler cache lives', () => { assert.equal(await handler.get('expired', []), undefined); }); + it('stores a fractional timestamp as an integer', async () => { + // Next supplies `performance.timeOrigin + performance.now()`, which is fractional. Uncoerced, + // Harper refuses the write and — because the failure is caught — the cache silently stores + // nothing while appearing to work. + await handler.set('fractional', Promise.resolve(entry({ timestamp: 1790101098624.713 }))); + + const result = await handler.get('fractional', []); + + assert.ok(result, 'the write must not be rejected by the integer column'); + assert.ok(Number.isInteger(result.timestamp)); + }); + + it('stores fractional cache lives as integers', async () => { + await handler.set( + 'fractional-lives', + Promise.resolve(entry({ stale: 30.5, revalidate: 120.9, expire: 900.1 })) + ); + + const result = await handler.get('fractional-lives', []); + + assert.ok(result); + assert.ok(Number.isInteger(result.stale)); + assert.ok(Number.isInteger(result.revalidate)); + assert.ok(Number.isInteger(result.expire)); + }); + it('preserves the entry cache lives across the round trip', async () => { await handler.set('lives', Promise.resolve(entry({ stale: 30, revalidate: 120, expire: 900 }))); diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 2f1f61d..a4492ae 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -23,6 +23,24 @@ const CHUNK_PAUSE_MAX_MS = 100; // covers reads, so shedding costs cache-hit rate, never correctness. const MAX_PENDING_INVALIDATIONS = 75_000; +/** + * The background sweep is off by default. It works — tests cover the throttling, the race guard and the + * scope limits — but it does not yet coexist with Next.js's staleness model, and both failure modes are + * silent: + * + * - Harper's `invalidate()` on a table with no `sourcedFrom` leaves the record awaiting a refresh that + * never arrives, so every later read of that key blocks. + * - Writing a marker with `patch` instead bumps `lastModified` (`@updatedTime`), which makes the entry + * look *newer* than the invalidation to Next's own `areTagsStale`. Next then treats it as fresh and + * never regenerates, so the entry is served stale indefinitely. + * + * Marking an entry stale without disturbing the timestamp Next derives staleness from needs a primitive + * this schema does not have yet. Until then the tombstone remains the invalidation, which is the + * behaviour that shipped previously and is covered by the existing tests; the 7-day expiry bounds table + * growth on its own. + */ +const SWEEP_ENABLED = process.env.HARPER_NEXTJS_EXPERIMENTAL_SWEEP === 'true'; + /** Tag → invalidation timestamp (ms). Shared by every cache handler in the worker. */ export const cacheInvalidations = new Map(); @@ -35,7 +53,7 @@ interface SweepableTable { conditions: Array<{ attribute: string; comparator: string; value: unknown }>; select?: string[]; }): AsyncIterable; - invalidate(id: string): Promise | unknown; + patch(id: string, value: { invalidatedAt: number }): Promise | unknown; } interface InvalidationTable { @@ -97,15 +115,25 @@ export async function runWithConcurrency( /** Only explicit user tags are swept; see NEXT_IMPLICIT_TAG_PREFIX. */ export function isSweepableTag(tag: string): boolean { + return !tag.startsWith(NEXT_IMPLICIT_TAG_PREFIX); } +/** + * `markedInvalidAt` is the record's own `invalidatedAt`, written by the sweep. It has to be consulted + * separately because the sweep's write bumps `lastModified`, which would otherwise make the tag + * timestamps below stop matching — and because the tombstone is dropped once the sweep finishes, so + * after that the record's own marker is the only remaining evidence. + */ export function isInvalidated( recordTags: string[], lastModified: number, revalidatedTags: string[], - ctxTags: string[] + ctxTags: string[], + markedInvalidAt?: number ): boolean { + if (typeof markedInvalidAt === 'number' && markedInvalidAt > 0) return true; + const allTags = recordTags.length > 0 ? recordTags : ctxTags; for (const tag of allTags) { if (revalidatedTags.includes(tag)) return true; @@ -153,6 +181,24 @@ interface SubscribableTable { * This is why `refreshTags()` on the "use cache" handler is a no-op: the framework expects handlers to * poll a tags service, and Harper pushes instead. */ +/** + * Rebuild the in-memory invalidation map from the tombstone table. + * + * This is what makes an invalidation survive the process holding it. The map is worker-local, so a + * worker that dies between `revalidateTag` and the entry being regenerated would otherwise come back + * with no record of the invalidation and start serving the stale entry as though it were fresh. The + * tombstone is the durable half of that pair, and this is the half that reads it back. + */ +export async function hydrateInvalidations( + table: Pick, + deps: InvalidationDeps = {} +): Promise { + for await (const row of table.search()) { + cacheInvalidations.set(row.id, row.timestamp); + mirrorToNextTagsManifest([row.id], row.timestamp, deps); + } +} + export async function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { if (subscriptionInitialized) return; const databases = getDatabases(deps); @@ -169,10 +215,7 @@ export async function initializeInvalidationSubscription(deps: InvalidationDeps } try { - for await (const row of table.search()) { - cacheInvalidations.set(row.id, row.timestamp); - mirrorToNextTagsManifest([row.id], row.timestamp, deps); - } + await hydrateInvalidations(table, deps); const subscription = await table.subscribe({ omitCurrent: true }); @@ -235,7 +278,7 @@ async function sweepTable( const batches = chunk(ids, INVALIDATE_CHUNK_SIZE); for (let index = 0; index < batches.length; index++) { await runWithConcurrency(batches[index], INVALIDATE_CONCURRENCY, async (id) => { - await table.invalidate(id); + await table.patch(id, { invalidatedAt: timestamp }); }); if (index < batches.length - 1) { await sleep(jitter(CHUNK_PAUSE_MAX_MS)); @@ -321,6 +364,9 @@ export async function recordInvalidation( return; } + // `deps.scheduleSweep` is how the tests drive the sweep regardless of the default. + if (!SWEEP_ENABLED && !deps.scheduleSweep) return; + const schedule = deps.scheduleSweep ?? ((task: () => Promise) => void task()); for (const tag of tags) { if (!isSweepableTag(tag)) continue; diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index c65683e..ac5ad19 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -11,6 +11,7 @@ import { recordInvalidation, runWithConcurrency, sweepTag, + hydrateInvalidations, type InvalidationDeps, } from './cacheInvalidation.cjs'; @@ -18,7 +19,7 @@ import { function makeCacheTable(records: Array<{ id: string; tags: string[]; lastModified?: number; timestamp?: number }>) { return { records, - invalidated: [] as string[], + patched: [] as string[], deleted: [] as string[], search(request: { conditions: Array<{ attribute: string; comparator: string; value: unknown }>; select?: string[] }) { const matches = records.filter((record) => @@ -33,8 +34,10 @@ function makeCacheTable(records: Array<{ id: string; tags: string[]; lastModifie for (const match of matches) yield match; })(); }, - async invalidate(id: string) { - this.invalidated.push(id); + async patch(id: string, value: { invalidatedAt: number }) { + this.patched.push(id); + const record = records.find((candidate) => candidate.id === id); + if (record) (record as Record).invalidatedAt = value.invalidatedAt; }, async delete(id: string) { this.deleted.push(id); @@ -156,8 +159,8 @@ describe('sweepTag', () => { await sweepTag('products', 1000, deps); - assert.deepEqual(isr.invalidated.sort(), ['/a', '/b']); - assert.deepEqual(isr.deleted, [], 'must not hard-delete'); + assert.deepEqual(isr.patched.sort(), ['/a', '/b']); + assert.deepEqual(isr.deleted, [], 'must mark, not destroy — a miss costs a full render'); }); it('skips records written after the invalidation timestamp', async () => { @@ -169,7 +172,7 @@ describe('sweepTag', () => { await sweepTag('products', 1000, deps); - assert.deepEqual(isr.invalidated, ['/stale']); + assert.deepEqual(isr.patched, ['/stale']); }); it('sweeps both cache tables, each on its own time column', async () => { @@ -179,8 +182,8 @@ describe('sweepTag', () => { await sweepTag('products', 1000, deps); - assert.deepEqual(isr.invalidated, ['/page']); - assert.deepEqual(useCache.invalidated, ['uc-1']); + assert.deepEqual(isr.patched, ['/page']); + assert.deepEqual(useCache.patched, ['uc-1']); }); it('clears the invalidation row once the sweep completes', async () => { @@ -208,7 +211,7 @@ describe('sweepTag', () => { it('leaves the invalidation row in place when the sweep fails, so soft invalidation still covers reads', async () => { const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); - isr.invalidate = async () => { + isr.patch = async () => { throw new Error('boom'); }; const invalidation = makeInvalidationTable(); @@ -245,7 +248,7 @@ describe('recordInvalidation', () => { await recordInvalidation([`${NEXT_IMPLICIT_TAG_PREFIX}/layout`], undefined, deps); await deps.pendingSweeps?.(); - assert.deepEqual(isr.invalidated, [], 'implicit tags are left to TTL'); + assert.deepEqual(isr.patched, [], 'implicit tags are left to TTL'); }); it('skips sweeping when the invalidation table is over the admission threshold', async () => { @@ -255,7 +258,59 @@ describe('recordInvalidation', () => { await recordInvalidation(['products'], undefined, deps); await deps.pendingSweeps?.(); - assert.deepEqual(isr.invalidated, [], 'sweep is shed under backpressure; soft invalidation still applies'); + assert.deepEqual(isr.patched, [], 'sweep is shed under backpressure; soft invalidation still applies'); + }); +}); + +describe('surviving a worker restart', () => { + beforeEach(() => cacheInvalidations.clear()); + + /** A tombstone table as a restarted worker would find it. */ + function tombstoneTable(rows: Array<{ id: string; timestamp: number }>) { + return { + search() { + return (async function* () { + for (const row of rows) yield row; + })(); + }, + }; + } + + it('restores an invalidation the dead worker had only in memory', async () => { + // A worker invalidated a tag, then died before the entry was regenerated. Coming back with an + // empty map, it would serve the stale entry as fresh if the tombstone were not read back. + cacheInvalidations.set('products', 2000); + cacheInvalidations.clear(); + + await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 2000 }])); + + assert.equal( + isInvalidated(['products'], 1000, [], []), + true, + 'the invalidation did not survive the restart' + ); + }); + + it('does not resurrect an entry written after the invalidation', async () => { + await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 2000 }])); + + assert.equal(isInvalidated(['products'], 5000, [], []), false); + }); + + it('leaves the tombstone in place when the sweep fails, so the next restart still sees it', async () => { + const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); + isr.patch = async () => { + throw new Error('sweep died mid-run'); + }; + const invalidation = makeInvalidationTable(); + const deps = makeDeps({ isr, invalidation }); + + await sweepTag('products', 1000, deps); + cacheInvalidations.clear(); + await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 1000 }])); + + assert.deepEqual(invalidation.deleted, []); + assert.equal(isInvalidated(['products'], 500, [], []), true); }); }); From 7bc76753a24b70ed2f0e88cf35e521a6be3874a2 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 14:12:58 -0500 Subject: [PATCH 05/29] test: verify both handlers on a real two-node cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a purpose-built exercise rig and a cluster suite that stands up two replicating Harper Pro nodes in Docker, so the claims this plugin exists to make are measured rather than reasoned about. Eight assertions, all passing: replication is live, the handlers run at request time, a "use cache" entry written on node A is served unchanged from node B, cache lives replicate, the Blob survives replication, revalidateTag fans out A to B, entries and tombstones survive a restart, and Harper REST resources remain reachable alongside Next. The rig bakes the generating node into the cached value and renders the serving node dynamically, so a cross-node hit is visible rather than inferred: node B reporting `served-by=nodeB` alongside `cached-by=nodeA` is the cross-cluster read. It also asserts the absence of `x-nextjs-prerender` and a growing row count, because the previous fixture was fully prerendered and passed for the wrong reason. Corrects the per-entry cache lives claim, which was wrong in the direction that flattered it. The SharedCacheControls 1-second fallback is not what breaks: a FETCH entry carries its own revalidate and route cache lives ship with the prerender manifest, and the legacy path measures 0/10 regenerations without the columns. The real failure is on the "use cache" handler, where cache lives travel on the entry — stored without them the row reads back as revalidate 0, Next treats it as immediately stale, and the entry regenerates on every single read. Measured 10/10 against 0/10 with the fix, on the same cluster. Also corrects the sweep rationale. `invalidate()` does not block reads; it is a no-op. Probed directly on 5.1.23 and 5.2.0, against a plain table and a sourcedFrom one, the record reads back unchanged and the source is never re-invoked. That makes the earlier reading wrong and the actual problem worse: a sweep built on it drops the tombstone while leaving every entry untouched, losing the invalidation outright. The sweep stays off by default, now for the right reason. Documents the ordering requirement that `rest` and `jsResource` be declared before the plugin. The plugin's handler claims every path, so a resource declared after it answers with Next's 404 or its 308 trailing-slash redirect and nothing explains why. --- AGENTS.md | 4 + README.md | 38 +++- fixtures/next-16-cluster/.npmrc | 1 + .../app/api/revalidate/route.js | 9 + fixtures/next-16-cluster/app/layout.js | 9 + fixtures/next-16-cluster/app/legacy/page.js | 34 +++ fixtures/next-16-cluster/app/rig/page.js | 51 +++++ fixtures/next-16-cluster/config.yaml | 13 ++ fixtures/next-16-cluster/next.config.mjs | 9 + fixtures/next-16-cluster/package.json | 11 + fixtures/next-16-cluster/probe.js | 42 ++++ fixtures/next-16-cluster/schema.graphql | 6 + integrationTests/cluster.pw.ts | 175 +++++++++++++++ integrationTests/cluster.ts | 203 ++++++++++++++++++ src/cacheInvalidation.cts | 25 ++- 15 files changed, 617 insertions(+), 13 deletions(-) create mode 100644 fixtures/next-16-cluster/.npmrc create mode 100644 fixtures/next-16-cluster/app/api/revalidate/route.js create mode 100644 fixtures/next-16-cluster/app/layout.js create mode 100644 fixtures/next-16-cluster/app/legacy/page.js create mode 100644 fixtures/next-16-cluster/app/rig/page.js create mode 100644 fixtures/next-16-cluster/config.yaml create mode 100644 fixtures/next-16-cluster/next.config.mjs create mode 100644 fixtures/next-16-cluster/package.json create mode 100644 fixtures/next-16-cluster/probe.js create mode 100644 fixtures/next-16-cluster/schema.graphql create mode 100644 integrationTests/cluster.pw.ts create mode 100644 integrationTests/cluster.ts diff --git a/AGENTS.md b/AGENTS.md index e95c0bb..f36ccda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,4 +25,8 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Integration tests need the macOS loopback alias pool (127.0.0.2+). Without it every fixture fails at startup with `LoopbackAddressValidationError` / `EADDRNOTAVAIL` before any assertion runs — that is a machine-setup gap, not a test failure. `ifconfig lo0 | grep 'inet '` shows what is configured. - There is no `format:fix` script in `package.json` despite the Code Style note below; match the surrounding file style (tabs, semicolons). - `next-16-static-data` is run by two test files: `next-16-static-data.pw.ts` on the default VM module loader (where Harper's component `harper` allowlist omits `flushDatabases`, so the plugin's pre-build flush is a no-op and a read-only build child can't see unflushed writes) and `next-16-static-data-native.pw.ts` under `applications.moduleLoader: native`, where the flush does run. The pair is what pins that behavior down — keep both. +- The cluster suite (`integrationTests/cluster.pw.ts`) needs Docker and a **harper-pro** image. OSS `harper` has no replication module and rejects `add_node` as an unknown operation, so it cannot form a cluster. Override the image with `HARPER_PRO_IMAGE`; the suite skips itself when Docker or the image is missing. `HARPER_CLUSTER_KEEP=1` leaves the containers up for inspection. +- `add_node` needs an explicit `authorization: "Basic "`. Passing `username`/`password` alone fails the cert-signing handshake with "No authorization provided". +- Each node needs a distinct `node.hostname` (`NODE_HOSTNAME`); it defaults to `localhost`, so two nodes otherwise collide. +- Do not `cpSync` a staged rig per node: npm links a `file:` dependency as a relative symlink and Node rewrites it to an absolute host path, which does not exist in the container. Mount one staged directory into every node instead. - CI is currently disabled (`if: false` in `.github/workflows/integration-tests.yml`). Run tests locally. diff --git a/README.md b/README.md index 7aa67eb..ba15af5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,23 @@ export default withHarper({ package: '@harperfast/nextjs' ``` +> [!WARNING] +> **Declare `rest` (and any `jsResource`) *before* this plugin.** The plugin registers an HTTP handler that claims every path, so anything declared after it is shadowed by Next.js: +> +> ```yaml +> rest: true # must come first +> +> graphqlSchema: +> files: 'schema.graphql' +> jsResource: +> files: 'resources.js' +> +> '@harperfast/nextjs': # last, so it only handles what is left +> package: '@harperfast/nextjs' +> ``` +> +> Declared after the plugin, `GET /MyTable/123` returns Next.js's 404 and `GET /MyResource/` returns its 308 trailing-slash redirect, with nothing in the logs to say why. Ordering it first costs nothing — Next.js still serves every application route. + 4. Run your app with Harper v5: ```sh @@ -276,9 +293,16 @@ Requires Next.js 16, which is where the interface exists. Opt-in for now — it ### Per-entry cache lives -The handler persists the `revalidate` and `expire` Next.js supplies for each entry. +Both handlers persist the `revalidate` and `expire` Next.js supplies for each entry. -This matters more than it sounds. Next.js keeps cache lives in `SharedCacheControls` — a per-process `Map` plus the build-time prerender manifest — and neither replicates. Without persisting them, an entry reaches another node but its cache lives do not, and that node falls back to Next.js's 1-second default, so the entry looks stale on almost every read. Persisting them is what makes cache lives coherent across the cluster. +For the `'use cache'` handler this is load-bearing, not a nicety. Cache lives travel on the entry itself, so a row stored without them reads back as `revalidate: 0`, Next.js treats it as immediately stale, and the entry is regenerated on **every single read** — a cache that stores faithfully and never serves. Measured on a two-node cluster, the same key read ten times from the non-writing node: + +| Build | Regenerations per 10 reads | +| --- | --- | +| Without persisted cache lives | 10 / 10 | +| With persisted cache lives | 0 / 10 | + +The legacy incremental-cache handler persists them too, but there the effect is not observable: a `FETCH` entry carries its own `revalidate` inside the cached value, and route cache lives come from the build-time prerender manifest, which ships with `.next` to every node. The columns are stored for consistency and for entry classes that carry neither, not because a measured failure demanded it. ### How invalidation works @@ -292,7 +316,15 @@ The cache handler uses a **soft-invalidation** model: Entries are never hard-deleted; Next.js overwrites them on the next regeneration. The `nextjs_cache_invalidation` rows expire after 7 days so abandoned tags don't accumulate. > [!NOTE] -> A throttled background sweep — which marks matching entries and then drops the tombstone, so the tombstone's lifetime stops being a correctness parameter — is implemented but **off by default**, behind `HARPER_NEXTJS_EXPERIMENTAL_SWEEP=true`. It does not yet coexist with Next.js's staleness model: Harper's `invalidate()` on a table with no `sourcedFrom` leaves the record awaiting a refresh that never arrives and blocks later reads, while writing a marker with `patch` bumps `lastModified` and makes the entry look *newer* than the invalidation, so Next.js treats it as fresh and never regenerates. Marking an entry stale without disturbing the timestamp Next.js derives staleness from needs a primitive this schema does not have yet. +> A throttled background sweep — which would mark matching entries and then drop the tombstone, so the tombstone's lifetime stops being a correctness parameter — is implemented but **off by default**, behind `HARPER_NEXTJS_EXPERIMENTAL_SWEEP=true`. No available primitive marks an entry stale without breaking something else: +> +> | Primitive | Behaviour | +> | --- | --- | +> | `invalidate()` | A no-op. Measured on Harper 5.1.23 and 5.2.0, against both a plain table and a `sourcedFrom` one: the record reads back unchanged and the source is never re-invoked. A sweep built on it drops the tombstone while leaving entries untouched, losing the invalidation outright. | +> | `delete()` | Works, but a deleted entry is a miss, and a miss is a full render — the thing this design exists to avoid. | +> | `patch()` | Bumps `lastModified` (`@updatedTime`), so the entry looks *newer* than the invalidation to Next.js's `areTagsStale`. Next.js treats it as fresh and never regenerates. | +> +> Stale-while-revalidate comes from the tags-manifest mirror at read time, not from the sweep, so leaving the sweep off costs only tombstone-table growth — which the 7-day expiry already bounds. Two limits the sweep is designed around, for when it is enabled: diff --git a/fixtures/next-16-cluster/.npmrc b/fixtures/next-16-cluster/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/fixtures/next-16-cluster/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/fixtures/next-16-cluster/app/api/revalidate/route.js b/fixtures/next-16-cluster/app/api/revalidate/route.js new file mode 100644 index 0000000..75e379a --- /dev/null +++ b/fixtures/next-16-cluster/app/api/revalidate/route.js @@ -0,0 +1,9 @@ +import { revalidateTag } from 'next/cache'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + const tag = new URL(request.url).searchParams.get('tag'); + if (!tag) return NextResponse.json({ error: 'tag required' }, { status: 400 }); + revalidateTag(tag); + return NextResponse.json({ revalidated: true, tag, node: process.env.RIG_NODE ?? 'unknown' }); +} diff --git a/fixtures/next-16-cluster/app/layout.js b/fixtures/next-16-cluster/app/layout.js new file mode 100644 index 0000000..534a964 --- /dev/null +++ b/fixtures/next-16-cluster/app/layout.js @@ -0,0 +1,9 @@ +export const metadata = { title: 'Harper cluster rig' }; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/fixtures/next-16-cluster/app/legacy/page.js b/fixtures/next-16-cluster/app/legacy/page.js new file mode 100644 index 0000000..0018613 --- /dev/null +++ b/fixtures/next-16-cluster/app/legacy/page.js @@ -0,0 +1,34 @@ +import { Suspense } from 'react'; +import { unstable_cache } from 'next/cache'; +import { cookies } from 'next/headers'; + +// The legacy incremental-cache handler. Route-segment `revalidate` is rejected under +// `cacheComponents`, so the legacy path is reached through `unstable_cache` instead — its entries are +// FETCH-kind rows in nextjs_isr_cache. +const cachedNonce = unstable_cache( + async () => ({ node: process.env.RIG_NODE ?? 'unknown', nonce: Math.random().toString(36).slice(2, 10) }), + ['rig-legacy'], + { tags: ['legacy-tag'], revalidate: 3600 } +); + +async function DynamicHole() { + await cookies(); + const { node, nonce } = await cachedNonce(); + return ( + <> + {node} + {nonce} + {process.env.RIG_NODE ?? 'unknown'} + + ); +} + +export default function LegacyPage() { + return ( +
+ pending}> + + +
+ ); +} diff --git a/fixtures/next-16-cluster/app/rig/page.js b/fixtures/next-16-cluster/app/rig/page.js new file mode 100644 index 0000000..35c82ff --- /dev/null +++ b/fixtures/next-16-cluster/app/rig/page.js @@ -0,0 +1,51 @@ +import { Suspense } from 'react'; +import { cacheLife, cacheTag } from 'next/cache'; +import { cookies } from 'next/headers'; + +const NODE = () => process.env.RIG_NODE ?? 'unknown'; + +/** + * The new handler. The node that generated this value is baked into it, so when the page is served + * from a different node than the one shown here, the entry crossed the cluster rather than being + * regenerated locally — which is the whole claim. + */ +async function CachedBoundary({ bucket }) { + 'use cache'; + cacheLife('hours'); + cacheTag('rig-tag'); + + return ( + <> + {NODE()} + {Math.random().toString(36).slice(2, 10)} + + ); +} + +/** + * Reading cookies keeps the route dynamic. Without this the page is prerendered at build time, the + * cached boundary is resolved during `next build`, and the handler is never called at request time — + * a rig that cannot fail for the right reason. + */ +async function DynamicHole() { + const store = await cookies(); + const bucket = store.get('bucket')?.value ?? 'default'; + + return ( + <> + {NODE()} + {bucket} + + + ); +} + +export default function RigPage() { + return ( +
+ pending}> + + +
+ ); +} diff --git a/fixtures/next-16-cluster/config.yaml b/fixtures/next-16-cluster/config.yaml new file mode 100644 index 0000000..85162ce --- /dev/null +++ b/fixtures/next-16-cluster/config.yaml @@ -0,0 +1,13 @@ +# `rest` is declared BEFORE the Next.js plugin deliberately. The plugin registers an HTTP handler that +# claims every path, so a Harper resource declared after it is shadowed by Next's 404. +rest: true + +graphqlSchema: + files: 'schema.graphql' + +jsResource: + files: 'probe.js' + +'@harperfast/nextjs': + package: '@harperfast/nextjs' + prebuilt: true diff --git a/fixtures/next-16-cluster/next.config.mjs b/fixtures/next-16-cluster/next.config.mjs new file mode 100644 index 0000000..8e776af --- /dev/null +++ b/fixtures/next-16-cluster/next.config.mjs @@ -0,0 +1,9 @@ +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; + +export default withHarper( + { + cacheComponents: true, + cacheHandler: cacheHandlerPath(import.meta.dirname), + }, + { useCache: true, configDir: import.meta.dirname } +); diff --git a/fixtures/next-16-cluster/package.json b/fixtures/next-16-cluster/package.json new file mode 100644 index 0000000..a8f0e35 --- /dev/null +++ b/fixtures/next-16-cluster/package.json @@ -0,0 +1,11 @@ +{ + "name": "next-16-cluster", + "private": true, + "scripts": { "build": "next build", "start": "next start" }, + "dependencies": { + "@harperfast/nextjs": "file:../../", + "react": "^19", + "react-dom": "^19", + "next": "^16" + } +} diff --git a/fixtures/next-16-cluster/probe.js b/fixtures/next-16-cluster/probe.js new file mode 100644 index 0000000..a1d8ac4 --- /dev/null +++ b/fixtures/next-16-cluster/probe.js @@ -0,0 +1,42 @@ +import { Resource, databases } from 'harper'; + +/** + * Cluster-visible state, so a test can ask a specific node what it holds without going through Next. + * Declared before the Next.js plugin in config.yaml — after it, the plugin's catch-all handler answers + * this path with Next's 404 instead. + */ +export class RigProbe extends Resource { + static async get() { + const node = process.env.RIG_NODE ?? 'unknown'; + const database = databases.harperfast_nextjs; + + // The cache tables only exist once the plugin has registered its schema, so a probe on a + // freshly started node must report an empty view rather than failing the request. + if (!database) return { node, counts: {}, useCacheEntries: [], ready: false }; + + const counts = {}; + for (const table of ['nextjs_isr_cache', 'nextjs_use_cache', 'nextjs_cache_invalidation']) { + let count = 0; + if (database[table]) { + for await (const _row of database[table].search({ conditions: [] })) count++; + } + counts[table] = count; + } + + const useCacheEntries = []; + if (database.nextjs_use_cache) { + for await (const row of database.nextjs_use_cache.search({ conditions: [] })) { + useCacheEntries.push({ + id: String(row.id).slice(0, 60), + timestamp: row.timestamp, + revalidate: row.revalidate, + expire: row.expire, + tags: row.tags, + valueBytes: row.value ? (await row.value.arrayBuffer()).byteLength : 0, + }); + } + } + + return { node, counts, useCacheEntries, ready: true }; + } +} diff --git a/fixtures/next-16-cluster/schema.graphql b/fixtures/next-16-cluster/schema.graphql new file mode 100644 index 0000000..5cc5178 --- /dev/null +++ b/fixtures/next-16-cluster/schema.graphql @@ -0,0 +1,6 @@ +# A plain replicated table, so the rig can prove replication independently of the cache handlers. +type RigMarker @table(database: "rig") @export { + id: ID @primaryKey + node: String + at: Long +} diff --git a/integrationTests/cluster.pw.ts b/integrationTests/cluster.pw.ts new file mode 100644 index 0000000..5354544 --- /dev/null +++ b/integrationTests/cluster.pw.ts @@ -0,0 +1,175 @@ +import { test as base, expect } from '@playwright/test'; +import { join } from 'node:path'; + +import { + NODES, + authHeader, + clusterUnavailable, + operation, + readRig, + restartNode, + startCluster, + stopCluster, + useCacheRows, +} from './cluster.ts'; + +const PLUGIN_ROOT = join(import.meta.dirname, '..'); +const [nodeA, nodeB] = NODES; + +const unavailable = clusterUnavailable(); + +const test = base.extend({}); +test.describe.configure({ mode: 'serial' }); +// Staging installs dependencies and runs `next build` inside the image on first use. +test.setTimeout(900_000); + +test.beforeAll(async () => { + // Hooks carry their own timeout, which the describe-level one does not extend. + test.setTimeout(900_000); + test.skip(unavailable !== null, unavailable ?? ''); + await startCluster(PLUGIN_ROOT); +}); + +test.afterAll(async () => { + test.setTimeout(120_000); + // HARPER_CLUSTER_KEEP leaves the containers up so a failing run can be inspected by hand. + if (!unavailable && process.env.HARPER_CLUSTER_KEEP !== '1') await stopCluster(); +}); + +function bucket(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +test('the cluster is actually replicating', async () => { + const status = (await operation(nodeA, { operation: 'cluster_status' })) as { + node_name: string; + connections: Array<{ name: string }>; + }; + expect(status.node_name).toBe(nodeA.name); + expect(status.connections.map((connection) => connection.name)).toContain(nodeB.name); + + // A plain replicated table, so a later cache failure can be told apart from replication being down. + await fetch(`${nodeA.httpURL}/RigMarker/replication-probe`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: authHeader() }, + body: JSON.stringify({ node: nodeA.rigNode, at: Date.now() }), + }); + await expect(async () => { + const response = await fetch(`${nodeB.httpURL}/RigMarker/replication-probe`, { + headers: { Authorization: authHeader() }, + }); + expect(response.status).toBe(200); + }).toPass({ timeout: 20_000 }); +}); + +test('the handlers run at request time rather than being prerendered', async () => { + // The trap this rig exists to avoid: a fully prerendered page resolves its cached boundary during + // `next build`, so the handler is never called and the cache assertions below pass for free. + const response = await fetch(`${nodeA.httpURL}/rig`, { headers: { Cookie: 'bucket=prerender-check' } }); + expect(response.headers.get('x-nextjs-prerender')).toBeNull(); + + const before = (await useCacheRows(nodeA)).length; + await readRig(nodeA, bucket('invoked')); + await expect(async () => { + expect((await useCacheRows(nodeA)).length).toBeGreaterThan(before); + }).toPass({ timeout: 15_000 }); +}); + +test('a "use cache" entry written on A is served from B without regeneration', async () => { + const key = bucket('cross-node'); + + const onA = await readRig(nodeA, key); + expect(onA['served-by']).toBe('nodeA'); + expect(onA['cached-by']).toBe('nodeA'); + + let onB: Record = {}; + await expect(async () => { + onB = await readRig(nodeB, key); + expect(onB['cached-nonce']).toBe(onA['cached-nonce']); + }).toPass({ timeout: 20_000 }); + + expect(onB['served-by'], 'B must have served the request').toBe('nodeB'); + expect(onB['cached-by'], "the value must carry A's identity, proving it crossed the cluster").toBe('nodeA'); +}); + +test('per-entry cache lives replicate, so the non-writing node does not regenerate every read', async () => { + const key = bucket('lives'); + await readRig(nodeA, key); + + await expect(async () => { + const rows = await useCacheRows(nodeB); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((row) => typeof row.revalidate === 'number' && typeof row.expire === 'number')).toBe(true); + }).toPass({ timeout: 20_000 }); + + // Without persisted cache lives the handler reads back revalidate=0, Next treats the entry as + // immediately stale, and node B regenerates on every single read (measured 10/10). + const first = await readRig(nodeB, key); + const nonces = new Set([first['cached-nonce']]); + for (let i = 0; i < 8; i++) nonces.add((await readRig(nodeB, key))['cached-nonce']); + expect([...nonces], 'B regenerated instead of serving the replicated entry').toHaveLength(1); +}); + +test('the Blob value survives replication intact', async () => { + await readRig(nodeA, bucket('blob')); + await expect(async () => { + const [onA, onB] = [await useCacheRows(nodeA), await useCacheRows(nodeB)]; + const bytesA = Math.max(...onA.map((row) => Number(row.valueBytes))); + const bytesB = Math.max(...onB.map((row) => Number(row.valueBytes))); + expect(bytesA).toBeGreaterThan(0); + expect(bytesB).toBe(bytesA); + }).toPass({ timeout: 20_000 }); +}); + +test('revalidateTag on A invalidates on B', async () => { + const key = bucket('fanout'); + await readRig(nodeA, key); + + let before = ''; + await expect(async () => { + before = (await readRig(nodeB, key))['cached-nonce']; + expect(before).toBeTruthy(); + }).toPass({ timeout: 20_000 }); + + const started = Date.now(); + await fetch(`${nodeA.httpURL}/api/revalidate?tag=rig-tag`, { method: 'POST' }); + + await expect(async () => { + expect((await readRig(nodeB, key))['cached-nonce']).not.toBe(before); + }).toPass({ timeout: 30_000 }); + + console.log(` invalidation fan-out A -> B observed in ~${Date.now() - started}ms`); +}); + +test('cached entries and tombstones survive a node restart', async () => { + const key = bucket('restart'); + const before = (await readRig(nodeA, key))['cached-nonce']; + + await fetch(`${nodeA.httpURL}/api/revalidate?tag=restart-tag`, { method: 'POST' }); + + await restartNode(nodeA); + + expect((await readRig(nodeA, key))['cached-nonce'], 'the entry did not survive the restart').toBe(before); + + // The in-memory invalidation map dies with the worker; the tombstone is what a restarted worker + // rehydrates from, so without it the stale entry would come back looking fresh. + const rows = (await operation(nodeA, { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_cache_invalidation', + search_attribute: 'id', + search_value: 'restart-tag', + get_attributes: ['id', 'timestamp'], + })) as unknown[]; + expect(rows.length).toBeGreaterThan(0); +}); + +test('Harper REST resources are reachable alongside Next', async () => { + // Only because `rest` is declared before the plugin in config.yaml. Declared after, the plugin's + // catch-all handler answers these paths with Next's 404/308 instead. + const probe = await fetch(`${nodeA.httpURL}/RigProbe/`, { headers: { Authorization: authHeader() } }); + expect(probe.status).toBe(200); + + const page = await fetch(`${nodeA.httpURL}/rig`, { headers: { Cookie: 'bucket=coexist' } }); + expect(page.status).toBe(200); +}); diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts new file mode 100644 index 0000000..5cc0bcd --- /dev/null +++ b/integrationTests/cluster.ts @@ -0,0 +1,203 @@ +import { execFileSync, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const execFileAsync = promisify(execFile); + +/** + * Harper replication lives in harper-pro; the OSS `harper` distribution has no replication module and + * rejects `add_node` as an unknown operation. These tests therefore need a Pro image. + */ +export const PRO_IMAGE = process.env.HARPER_PRO_IMAGE ?? 'harperfast/harper-pro-openshift:5.0.26'; +export const NETWORK = 'harper-nextjs-clusternet'; +export const CREDENTIALS = { username: 'admin', password: 'pilotpass' }; + +export interface ClusterNode { + name: string; + rigNode: string; + httpURL: string; + opsURL: string; +} + +export const NODES: ClusterNode[] = [ + { name: 'hnx-a', rigNode: 'nodeA', httpURL: 'http://localhost:29926', opsURL: 'http://localhost:29925' }, + { name: 'hnx-b', rigNode: 'nodeB', httpURL: 'http://localhost:39926', opsURL: 'http://localhost:39925' }, +]; + +function docker(args: string[], options: { allowFailure?: boolean } = {}): string { + try { + return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { + if (options.allowFailure) return ''; + throw error; + } +} + +/** Why the cluster suite cannot run here, or null when it can. */ +export function clusterUnavailable(): string | null { + try { + execFileSync('docker', ['info'], { stdio: 'ignore' }); + } catch { + return 'Docker is not available'; + } + const images = docker(['images', '--format', '{{.Repository}}:{{.Tag}}'], { allowFailure: true }); + if (!images.split('\n').includes(PRO_IMAGE)) { + return `${PRO_IMAGE} is not present locally (replication requires harper-pro; OSS harper has no add_node)`; + } + return null; +} + +export function authHeader(): string { + return `Basic ${Buffer.from(`${CREDENTIALS.username}:${CREDENTIALS.password}`).toString('base64')}`; +} + +/** + * Stage the rig once: install dependencies and run `next build` inside the Pro image, so the native + * binaries match the container rather than the host. Cached between runs — the build is slow and the + * inputs rarely change. + */ +export function stageRig(pluginRoot: string): string { + const staging = join(tmpdir(), 'harper-nextjs-cluster-rig'); + const fixture = join(pluginRoot, 'fixtures', 'next-16-cluster'); + const stamp = join(staging, '.stamp'); + const inputs = ['config.yaml', 'next.config.mjs', 'package.json', 'probe.js', 'schema.graphql'] + .map((file) => readFileSync(join(fixture, file), 'utf8')) + .join('\n'); + const pluginStamp = readFileSync(join(pluginRoot, 'dist', 'UseCacheHandler.cjs'), 'utf8'); + const want = `${inputs}\n${pluginStamp.length}`; + + if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want && existsSync(join(staging, '.next'))) { + return staging; + } + + rmSync(staging, { recursive: true, force: true }); + mkdirSync(staging, { recursive: true }); + cpSync(fixture, staging, { recursive: true }); + + // Vendor the plugin so the container installs the build under test rather than the published one. + const vendored = join(staging, 'plugin'); + mkdirSync(vendored, { recursive: true }); + for (const entry of ['dist', 'config.yaml', 'schema.graphql', 'package.json']) { + cpSync(join(pluginRoot, entry), join(vendored, entry), { recursive: true }); + } + const pkg = JSON.parse(readFileSync(join(staging, 'package.json'), 'utf8')); + pkg.dependencies['@harperfast/nextjs'] = 'file:./plugin'; + writeFileSync(join(staging, 'package.json'), JSON.stringify(pkg, null, '\t')); + + const run = (entrypoint: string, args: string[]) => + docker([ + 'run', '--rm', '-v', `${staging}:/app`, '-w', '/app', '-e', 'RIG_NODE=build', + '--entrypoint', entrypoint, PRO_IMAGE, ...args, + ]); + + run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); + run('npx', ['next', 'build']); + + writeFileSync(stamp, want); + return staging; +} + +export async function startCluster(pluginRoot: string): Promise { + const staging = stageRig(pluginRoot); + + docker(['network', 'create', NETWORK], { allowFailure: true }); + for (const node of NODES) docker(['rm', '-f', node.name], { allowFailure: true }); + + for (const node of NODES) { + // Both nodes mount the same staged directory. `prebuilt: true` means neither builds, and Harper + // writes to ROOTPATH rather than the component, so there is nothing for them to race on. Copying + // per node is also actively wrong here: npm links the vendored plugin as a relative symlink and + // Node's cpSync rewrites it to an absolute host path, which does not exist inside the container. + const dir = staging; + + const opsPort = new URL(node.opsURL).port; + const httpPort = new URL(node.httpURL).port; + docker([ + 'run', '-d', '--name', node.name, '--network', NETWORK, '--hostname', node.name, + '-e', 'TC_AGREEMENT=yes', '-e', 'ROOTPATH=/tmp/hdb', '-e', 'DEFAULTS_MODE=dev', + '-e', `HDB_ADMIN_USERNAME=${CREDENTIALS.username}`, '-e', `HDB_ADMIN_PASSWORD=${CREDENTIALS.password}`, + '-e', 'OPERATIONSAPI_NETWORK_PORT=9925', '-e', 'HTTP_PORT=9926', '-e', 'HTTP_NETWORK_PORT=9926', + '-e', 'LOGGING_STDSTREAMS=true', '-e', `REPLICATION_HOSTNAME=${node.name}`, + '-e', `NODE_HOSTNAME=${node.name}`, '-e', `RIG_NODE=${node.rigNode}`, + '-p', `${opsPort}:9925`, '-p', `${httpPort}:9926`, + '-v', `${dir}:/component`, PRO_IMAGE, 'run', '/component', + ]); + } + + for (const node of NODES) await waitForNode(node); + + // `username`/`password` alone are rejected by the replication socket with "No authorization + // provided"; the cert-signing handshake needs an explicit Authorization header. + await operation(NODES[0], { + operation: 'add_node', + hostname: NODES[1].name, + authorization: authHeader(), + verify_tls: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); +} + +export async function waitForNode(node: ClusterNode, timeoutMs = 180_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await operation(node, { operation: 'describe_all' }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + throw new Error(`${node.name} did not become ready within ${timeoutMs}ms`); +} + +export async function operation(node: ClusterNode, body: Record): Promise { + const response = await fetch(`${node.opsURL}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader() }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`${body.operation} failed: ${response.status}`); + return response.json(); +} + +export async function restartNode(node: ClusterNode): Promise { + docker(['restart', node.name]); + await waitForNode(node); + await new Promise((resolve) => setTimeout(resolve, 5000)); +} + +export async function stopCluster(): Promise { + for (const node of NODES) docker(['rm', '-f', node.name], { allowFailure: true }); + docker(['network', 'rm', NETWORK], { allowFailure: true }); +} + +/** Fetch the rig page and pull the marker spans out of the streamed HTML. */ +export async function readRig(node: ClusterNode, bucket: string, path = 'rig'): Promise> { + const response = await fetch(`${node.httpURL}/${path}`, { headers: { Cookie: `bucket=${bucket}` } }); + const html = await response.text(); + const markers: Record = {}; + for (const [, id, value] of html.matchAll(/data-testid="([^"]+)">([^<]*)/g)) { + if (!(id in markers)) markers[id] = value; + } + return markers; +} + +export async function useCacheRows(node: ClusterNode): Promise>> { + const response = await fetch(`${node.httpURL}/RigProbe/`, { headers: { Authorization: authHeader() } }); + const text = await response.text(); + let body: { useCacheEntries?: Array> }; + try { + body = JSON.parse(text); + } catch { + throw new Error(`RigProbe on ${node.name} returned ${response.status}: ${text.slice(0, 200)}`); + } + if (!Array.isArray(body.useCacheEntries)) { + throw new Error(`RigProbe on ${node.name} returned ${response.status}: ${text.slice(0, 200)}`); + } + return body.useCacheEntries; +} + +export { execFileAsync, dirname }; diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index a4492ae..6d24f6e 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -25,19 +25,24 @@ const MAX_PENDING_INVALIDATIONS = 75_000; /** * The background sweep is off by default. It works — tests cover the throttling, the race guard and the - * scope limits — but it does not yet coexist with Next.js's staleness model, and both failure modes are - * silent: + * scope limits — but no available primitive marks an entry stale without breaking something else, and + * every failure mode is silent: * - * - Harper's `invalidate()` on a table with no `sourcedFrom` leaves the record awaiting a refresh that - * never arrives, so every later read of that key blocks. - * - Writing a marker with `patch` instead bumps `lastModified` (`@updatedTime`), which makes the entry - * look *newer* than the invalidation to Next's own `areTagsStale`. Next then treats it as fresh and - * never regenerates, so the entry is served stale indefinitely. + * - `invalidate()` is a NO-OP. Measured against Harper 5.1.23 and 5.2.0, on a plain table and on a + * `sourcedFrom` one: the record reads back unchanged and the source is never re-invoked. A sweep + * built on it leaves every entry untouched while still dropping the tombstone, which loses the + * invalidation outright — worse than not sweeping. + * - `delete()` works, but a deleted entry is a miss, and a miss is a full render. Discarding entries + * on invalidation is what this design exists to avoid. + * - `patch()` writing an explicit marker bumps `lastModified` (`@updatedTime`), so the entry looks + * *newer* than the invalidation to Next's own `areTagsStale`. Next then treats it as fresh and never + * regenerates, serving it stale indefinitely. * * Marking an entry stale without disturbing the timestamp Next derives staleness from needs a primitive - * this schema does not have yet. Until then the tombstone remains the invalidation, which is the - * behaviour that shipped previously and is covered by the existing tests; the 7-day expiry bounds table - * growth on its own. + * that does not exist yet. Until then the tombstone remains the invalidation, which is the behaviour + * that shipped previously and is covered by the existing tests; the 7-day expiry bounds table growth on + * its own, and stale-while-revalidate comes from the tags-manifest mirror at read time rather than from + * anything the sweep does. */ const SWEEP_ENABLED = process.env.HARPER_NEXTJS_EXPERIMENTAL_SWEEP === 'true'; From ff7e895fe741e97b994897c650572460e337e0e9 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 22 Sep 2026 15:21:29 -0500 Subject: [PATCH 06/29] fix: address Gemini review findings and de-time-bomb a unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the gemini-code-assist review on PR #65, all verified against the code before applying: - UseCacheHandler.get: clamp the invalidation backdate with Math.min(timestamp, staleAt). An entry already older than the revalidate window was forward-dated to staleAt, extending how long Next kept serving it stale. The real-expiry guard above the block means this never served a genuinely expired entry, so the impact was narrower than reported, but the clamp is strictly correct and free. - toBuffer: wrap blob.arrayBuffer() in try/catch. get() has no surrounding try, so a rejecting read (corrupt row, IO error) surfaced as a request error rather than degrading to a cache miss. - mirrorToNextTagsManifest: ignore MODULE_NOT_FOUND. Next 14 has no tags-manifest.external.js, so the legacy handler logged an error on every invalidation there. - recordInvalidation: deduplicate tags. Duplicates cost a redundant put each and, more expensively, schedule a duplicate sweep — and sweeps scan, because tags cannot be indexed. Separately, the fractional-timestamp test pinned an epoch literal that was "now" when it was written. With the fixture's default expire of 3600s the entry expired an hour later and get() correctly returned undefined, so the test passed once and would fail in CI from then on. It now offsets from Date.now() and keeps the fractional component, which is the thing under test. Verified: 63 unit, 5 next-16-use-cache, 4 next-16-caching, 8 cluster (2-node Docker) — all passing. --- src/UseCacheHandler.cts | 14 ++++++++++++-- src/UseCacheHandler.test.ts | 5 ++++- src/cacheInvalidation.cts | 16 +++++++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index a92d145..9370ffc 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -82,7 +82,15 @@ async function toBuffer(value: unknown): Promise { if (Buffer.isBuffer(value)) return value; if (value instanceof Uint8Array) return Buffer.from(value); const blob = value as { arrayBuffer?: () => Promise }; - if (typeof blob.arrayBuffer === 'function') return Buffer.from(await blob.arrayBuffer()); + if (typeof blob.arrayBuffer === 'function') { + // A rejecting arrayBuffer() (corrupt row, read error) must degrade to a cache MISS. `get` has no + // surrounding try, so an unhandled rejection here would surface as a request error instead. + try { + return Buffer.from(await blob.arrayBuffer()); + } catch { + return undefined; + } + } return undefined; } @@ -155,7 +163,9 @@ class HarperUseCacheHandler implements CacheHandler { // Backdate past the revalidate window so Next regenerates, while staying inside expire so the // entry is still served meanwhile. A miss here would cost a full render, which is exactly what // an invalidation storm must not produce. - const staleAt = now - revalidate * 1000 - 1; + // Math.min: an entry already older than the revalidate window must not be forward-dated to + // staleAt, which would extend how long Next keeps serving it stale. + const staleAt = Math.min(timestamp, now - revalidate * 1000 - 1); if (expire > 0 && staleAt + expire * 1000 <= now) return undefined; effectiveTimestamp = staleAt; } diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index dee70a6..7e2bed0 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -197,7 +197,10 @@ describe('UseCacheHandler cache lives', () => { // Next supplies `performance.timeOrigin + performance.now()`, which is fractional. Uncoerced, // Harper refuses the write and — because the failure is caught — the cache silently stores // nothing while appearing to work. - await handler.set('fractional', Promise.resolve(entry({ timestamp: 1790101098624.713 }))); + // Offset from now, not a pinned epoch value: a hardcoded timestamp goes stale as wall-clock + // advances past the default 3600s `expire`, so the test would pass when written and fail an + // hour later. The .713 is what makes it fractional, which is the thing under test. + await handler.set('fractional', Promise.resolve(entry({ timestamp: Date.now() + 0.713 }))); const result = await handler.get('fractional', []); diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 6d24f6e..9a47995 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -166,6 +166,9 @@ function mirrorToNextTagsManifest(tags: string[], timestamp: number, deps: Inval tagsManifest.set(tag, entry); } } catch (error) { + // Next 14 has no tags-manifest.external.js. Without this the legacy handler logs an error on + // EVERY invalidation there, which is noise rather than a fault. + if ((error as { code?: string } | undefined)?.code === 'MODULE_NOT_FOUND') return; getLogger(deps).error('[CacheHandler] could not mirror invalidation into the Next tags manifest', error); } } @@ -344,7 +347,10 @@ export async function recordInvalidation( durations: { expire?: number } | undefined, deps: InvalidationDeps = {} ): Promise { - if (tags.length === 0) return; + // Deduplicate before anything else: duplicates cost a redundant put each, and — more expensively — + // schedule a duplicate sweep, which scans because tags cannot be indexed. + const uniqueTags = Array.from(new Set(tags)); + if (uniqueTags.length === 0) return; const databases = getDatabases(deps); if (!databases) return; @@ -358,13 +364,13 @@ export async function recordInvalidation( // than immediately. const timestamp = Date.now() + (durations?.expire !== undefined ? durations.expire * 1000 : 0); - noteInvalidation(tags, timestamp, deps); + noteInvalidation(uniqueTags, timestamp, deps); - await Promise.all(tags.map((tag) => invalidationTable.put(tag, { timestamp }))); + await Promise.all(uniqueTags.map((tag) => invalidationTable.put(tag, { timestamp }))); if (await isOverAdmissionThreshold(invalidationTable, deps)) { getLogger(deps).error( - `[CacheHandler] ${MAX_PENDING_INVALIDATIONS}+ pending invalidations; shedding sweep for ${tags.length} tag(s)` + `[CacheHandler] ${MAX_PENDING_INVALIDATIONS}+ pending invalidations; shedding sweep for ${uniqueTags.length} tag(s)` ); return; } @@ -373,7 +379,7 @@ export async function recordInvalidation( if (!SWEEP_ENABLED && !deps.scheduleSweep) return; const schedule = deps.scheduleSweep ?? ((task: () => Promise) => void task()); - for (const tag of tags) { + for (const tag of uniqueTags) { if (!isSweepableTag(tag)) continue; schedule(() => sweepTag(tag, timestamp, deps)); } From 9edbd6faece70e070704dedfc45e7ab457b25543 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 24 Sep 2026 11:05:05 -0500 Subject: [PATCH 07/29] fix: keep oversized "use cache" keys under Harper's primary-key limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next imposes no size limit on a "use cache" key — it is composed from the cached component's serializable props, so a component handed a large prop produces a correspondingly large key. Harper caps a primary key at MAX_KEY_BYTES = 1978 (harper/resources/Table.ts:146). Observed in a real app at 87,465 bytes: an AEM component model passed as a prop, one of whose items embeds HTML with inlined CSS. Harper answered `Primary key size is too large: 87465`, the cache boundary rejected, and the streamed subtree aborted — a dead page. The failure is invisible from the server side, because it surfaces as a Next streaming rejection rather than anything Harper logs. Seven per render, and the page rendered fine on a cold cache, so a single-fetch smoke test never sees it. toStorageKey() truncates an oversized key on a UTF-8 character boundary and appends a hash of the FULL key. Short keys pass through untouched, deliberately: hashing unconditionally would change every key and silently invalidate the whole cache on upgrade, and it discards a readable id for nothing. The untruncated key is kept in a non-indexed `cacheKey` column so a hashed row is still identifiable. Applied at BOTH table access points. A key-mangling function is only correct if every access applies it — a get that skips it misses forever, silently — so a test asserts no raw key ever reaches the table. The test mock now enforces the 1978-byte limit the way Harper does. A mock more permissive than production is how an 87KB key reached a cluster in the first place; the same class of gap previously let a no-op invalidate() pass. Verified: 71 unit (4 of the new tests fail with toStorageKey neutralised), 5 next-16-use-cache, 4 next-16-caching, 8 cluster. --- schema.graphql | 5 +++ src/UseCacheHandler.cts | 43 ++++++++++++++++++- src/UseCacheHandler.test.ts | 83 +++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/schema.graphql b/schema.graphql index 280eea8..411dd7a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -35,7 +35,12 @@ type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache # a use-cache entry is an opaque byte stream with its own timestamp and cache lives, not a structured # IncrementalCacheValue keyed off the route. type NextUseCache @table(database: "harperfast_nextjs", table: "nextjs_use_cache", expiration: 604800) { + # `id` is the STORAGE key: the Next cache key verbatim when it fits, else truncated + # with a hash of the full key appended. See toStorageKey() in src/UseCacheHandler.cts — + # Next puts no size limit on a cache key and Harper caps a primary key at 1978 bytes. id: String @primaryKey + # The untruncated Next cache key, when `id` had to be shortened. Not indexed. + cacheKey: String value: Blob tags: [String] timestamp: Long @indexed diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index 9370ffc..6adcbdc 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { CacheEntry, CacheHandler, Timestamp } from 'next/dist/server/lib/cache-handlers/types.d.ts'; import type { databases as DatabasesType } from 'harper'; @@ -76,6 +77,42 @@ async function drain(stream: ReadableStream): Promise { if (value === undefined || value === null) return undefined; @@ -140,7 +177,7 @@ class HarperUseCacheHandler implements CacheHandler { const pending = pendingEntries.get(cacheKey); if (pending) await pending; - const record = await table.get(cacheKey); + const record = await table.get(toStorageKey(cacheKey)); if (!record) return undefined; const bytes = await toBuffer(record.value); @@ -190,7 +227,9 @@ class HarperUseCacheHandler implements CacheHandler { const bytes = await drain(entry.value); if (!bytes) return undefined; - await table.put(cacheKey, { + await table.put(toStorageKey(cacheKey), { + // The untruncated key, for identifying a row whose id was hashed. Not indexed. + cacheKey, value: toStoredValue(bytes), tags: entry.tags ?? [], timestamp: toInteger(entry.timestamp) ?? Date.now(), diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 7e2bed0..40d6571 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert'; import { createRequire } from 'node:module'; import { cacheInvalidations } from './cacheInvalidation.cjs'; +import { toStorageKey } from './UseCacheHandler.cjs'; interface UseCacheEntry { value: ReadableStream; @@ -73,6 +74,21 @@ function entry(overrides: Partial = {}): UseCacheEntry { */ const INTEGER_COLUMNS = ['timestamp', 'stale', 'revalidate', 'expire']; +/** + * Harper caps a primary key at MAX_KEY_BYTES = 1978 (harper/resources/Table.ts:146) and + * throws `Primary key size is too large`. The mock must too: Next puts no limit on a + * "use cache" key, and a mock that accepts any key is exactly how an 87,465-byte key + * reached production and killed the stream. + */ +const HARPER_MAX_KEY_BYTES = 1978; + +function enforcePrimaryKey(key: string) { + const bytes = Buffer.byteLength(String(key), 'utf8'); + if (bytes > HARPER_MAX_KEY_BYTES) { + throw new Error(`Primary key size is too large: ${bytes}`); + } +} + function enforceSchema(value: Record) { for (const column of INTEGER_COLUMNS) { const columnValue = value[column]; @@ -91,6 +107,7 @@ function installDatabases() { return rows.get(key); }, async put(key: string, value: Record) { + enforcePrimaryKey(key); enforceSchema(value); rows.set(key, { id: key, ...value }); }, @@ -283,4 +300,70 @@ describe('UseCacheHandler tags', () => { assert.equal(await handler.get('gone', []), undefined); }); + +}); + +describe('UseCacheHandler oversized keys', () => { + let rows: Map>; + + beforeEach(() => { + cacheInvalidations.clear(); + rows = installDatabases(); + }); + + it('passes a short key through untouched, so upgrading does not invalidate the cache', () => { + const key = 'a'.repeat(200); + assert.equal(toStorageKey(key), key); + }); + + it('keeps an oversized key under the Harper primary-key limit', () => { + const stored = toStorageKey('x'.repeat(87465)); + assert.ok(Buffer.byteLength(stored, 'utf8') <= HARPER_MAX_KEY_BYTES); + }); + + it('keeps distinct oversized keys distinct', () => { + assert.notEqual(toStorageKey('x'.repeat(90000) + 'A'), toStorageKey('x'.repeat(90000) + 'B')); + }); + + it('is deterministic, so every node derives the same key', () => { + const key = 'y'.repeat(90000); + assert.equal(toStorageKey(key), toStorageKey(key)); + }); + + it('does not split a multi-byte character', () => { + const stored = toStorageKey('\u00e9'.repeat(60000)); + assert.ok(!stored.includes('\uFFFD'), 'truncation left a replacement character'); + }); + + // The real failure: an 87,465-byte key from a component handed a large prop. + it('round-trips an oversized entry through set and get', async () => { + const key = 'z'.repeat(87465); + await handler.set(key, Promise.resolve(entry({ value: streamOf('big') }))); + + const result = await handler.get(key, []); + + assert.ok(result, 'an oversized key must not be rejected by the primary-key limit'); + assert.equal(await readAll(result.value), 'big'); + }); + + // A key-mangling function is only correct if EVERY table access applies it. A get that + // skips it misses forever, silently — which is the shape of bug this whole change exists for. + it('never hands a raw oversized key to the table', async () => { + const key = 'q'.repeat(87465); + await handler.set(key, Promise.resolve(entry({ value: streamOf('v') }))); + await handler.get(key, []); + + assert.ok(rows.size > 0, 'nothing was stored'); + for (const id of rows.keys()) { + assert.ok(Buffer.byteLength(id, 'utf8') <= HARPER_MAX_KEY_BYTES, `raw key reached the table: ${id.length}`); + } + }); + + it('preserves the untruncated key on the row for debuggability', async () => { + const key = 'w'.repeat(87465); + await handler.set(key, Promise.resolve(entry({ value: streamOf('v') }))); + + const [row] = [...rows.values()]; + assert.equal(row.cacheKey, key); + }); }); From 0d4244110ef2859fcb547bbd920179ebf906e784 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 28 Sep 2026 13:12:32 -0500 Subject: [PATCH 08/29] fix: restore the playwright-report ignore rule A missing trailing newline had merged a new line into it, leaving `playwright-report/test-results/` and dropping `playwright-report/` from the ignore list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d532f7f..2954bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules/ test-results/ .DS_Store dist/ -playwright-report/test-results/ +playwright-report/ From ab2d0e62a3c22595bd82dec373e0f4f461cbec16 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 28 Sep 2026 13:12:32 -0500 Subject: [PATCH 09/29] fix: follow Next's stale/expired tag semantics and make tombstones outlive entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revalidateTag(tag, 'max') was stored as a single `now + expire` timestamp and used as the stale time, so for a year every entry with the tag — including ones regenerated after the invalidation — regenerated on every read ('use cache') or missed on every read (fetch cache). Reproduced against dist/ and in the TSC app. Tags now carry Next's own `{stale, expired}`: stale now, expired later with a profile; expired now without one. The 'use cache' handler signals stale with `revalidate: -1` as Next's default handler does, instead of backdating, and getExpiration reports only expirations that have passed. The ISR handler hands tag-stale entries to Next 16, which serves them stale from its tags manifest, and withholds them elsewhere. Tombstones are written with a per-record Harper expiry of the longest cache-table expiration plus an hour. Harper caps every entry at its table expiration from its last write, so an invalidated entry now always lapses before its tombstone and cannot come back — whatever the TTLs are set to. This replaces the disabled marker sweep (patch reset TTLs, created ghost rows on deleted records, and could not signal pages stale): an immediate expiry is swept by deleting entries through a new `tags` index (exact element match; `contains` was a substring scan that also matched `posts-archive` for `posts`). The sweep only reclaims space; reads never depend on it. Also: - Next 15's tags manifest holds numbers, not objects; mirror by shape. - Subscribe before hydrating, share one init attempt, back off on failure. - Stream 'use cache' Blobs instead of arrayBuffer() plus two copies; decide expiry before touching the blob. - Store the full key only when the id was truncated; log the storage key. - Legacy tombstones dated in the future (the old 'max' encoding) no longer outrank newer invalidations of the same tag. - Fall back to an exact-match scan while Harper reports the tags index as rebuilding: on 5.2.0 worker threads other than the one that ran the backfill kept doing so after it finished, until restart. - One revalidateTag reaches both handlers; record and sweep it once. Verified: 94 unit, 42 integration (incl. 2-node Docker cluster). E2E in the TSC homepage app on Harper 5.2.0 in Docker with real AEM content: 28/28 cases on a fresh volume and 28/28 on a volume upgraded from ff7e895 (which scores 17/28 on the same harness). Not cross-model reviewed: codex and agy are not installed on this machine and Gemini returned 503 throughout; a same-family adversarial review was done on the design, which is advisory only. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 57 +- .../app/api/revalidate/route.js | 7 +- .../app/api/revalidate/route.js | 7 +- integrationTests/next-16-caching.pw.ts | 91 ++- integrationTests/next-16-use-cache.pw.ts | 96 ++- schema.graphql | 37 +- src/CacheHandler.cts | 64 +- src/CacheHandler.test.ts | 146 +++- src/UseCacheHandler.cts | 120 +-- src/UseCacheHandler.test.ts | 153 +++- src/cacheInvalidation.cts | 698 ++++++++++++------ src/cacheInvalidation.test.ts | 577 +++++++++++---- 12 files changed, 1406 insertions(+), 647 deletions(-) diff --git a/README.md b/README.md index ba15af5..a1f4635 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,16 @@ export async function POST(request) { `fetch()` calls with `next: { tags: [...] }` and the `'use cache'` directive (with `cacheTag()`) are also supported — anywhere Next.js attaches tags to a cached value, the handler will pick them up. +On Next.js 16, pick the form that matches what you want the next visitor to get: + +| Call | Effect | +| --- | --- | +| `revalidateTag(tag, 'max')` (or another profile) | Stale now: the cached response is served once more while it regenerates in the background. Expires when the profile's `expire` passes. | +| `revalidateTag(tag)`, `updateTag(tag)` | Expired now: the next request blocks on a fresh render. The one-argument `revalidateTag` is deprecated by Next.js. | +| `revalidatePath(path)` | Expired now, for the route's implicit tags. | + +`updateTag` reaches the handlers the same way the one-argument `revalidateTag` does. + ### `useCache` Route `'use cache'` entries to Harper by registering `cacheHandlers`: @@ -291,6 +301,9 @@ export default withHarper( Requires Next.js 16, which is where the interface exists. Opt-in for now — it becomes the default in the next major, because turning it on changes where existing apps' `'use cache'` entries are stored. +- **Values are streamed.** Each entry's rendered bytes are stored as a Harper `Blob` and streamed back from storage on every read, rather than read into memory whole. A write is buffered until the render completes, so a render that fails partway is never stored. +- **Keys of any size work.** Next.js builds a `'use cache'` key from the component's props and puts no limit on its size, while Harper caps a primary key at 1978 bytes. A key over 1500 bytes is stored under a truncated prefix plus a hash of the full key, and the full key is kept in the row's `cacheKey` column. Shorter keys are stored as they are. Large props still make large keys, so keep a cached component's props small. + ### Per-entry cache lives Both handlers persist the `revalidate` and `expire` Next.js supplies for each entry. @@ -306,30 +319,18 @@ The legacy incremental-cache handler persists them too, but there the effect is ### How invalidation works -The cache handler uses a **soft-invalidation** model: - -1. `revalidateTag(tag)` writes a `{ tag, timestamp }` row to the `nextjs_cache_invalidation` table and updates an in-memory map in the calling worker. -2. Every other Harper worker subscribes to that table and updates its own map when the row is replicated — typically within milliseconds. -3. On the next `cache.get()`, an invalidated entry is reported to Next.js as *stale* rather than missing wherever Next.js supports that, so it serves the cached response and regenerates in the background. An invalidation storm should not turn into a render storm. -4. A worker that restarts rebuilds its map from the tombstone table, so an invalidation survives the process that issued it. +Invalidation follows Next.js's own semantics. `revalidateTag(tag)` and `updateTag(tag)` expire matching entries immediately. `revalidateTag(tag, profile)` (for example `'max'`) marks them stale now, so they are served while regenerating in the background, and expires them once the profile's `expire` has passed. -Entries are never hard-deleted; Next.js overwrites them on the next regeneration. The `nextjs_cache_invalidation` rows expire after 7 days so abandoned tags don't accumulate. +1. `revalidateTag` writes one row per tag to `nextjs_cache_invalidation` (a *tombstone*, holding the tag's `stale` and `expired` times) and updates an in-memory map in the calling worker. +2. Every other Harper worker subscribes to that table and updates its own map when the row is replicated — typically within milliseconds. The map is mirrored into Next.js's own tags manifest, so Next's in-process checks agree with it. +3. On the next `cache.get()`, an entry written before the invalidation is withheld if the tag has expired. If the tag is only stale, it is handed to Next.js to serve stale while regenerating wherever Next.js supports that (Next.js 16 for pages, route handlers and `fetch`; the `'use cache'` handler always). Otherwise it is withheld. +4. A worker that restarts rebuilds its map from the tombstones, so an invalidation survives the process that issued it. -> [!NOTE] -> A throttled background sweep — which would mark matching entries and then drop the tombstone, so the tombstone's lifetime stops being a correctness parameter — is implemented but **off by default**, behind `HARPER_NEXTJS_EXPERIMENTAL_SWEEP=true`. No available primitive marks an entry stale without breaking something else: -> -> | Primitive | Behaviour | -> | --- | --- | -> | `invalidate()` | A no-op. Measured on Harper 5.1.23 and 5.2.0, against both a plain table and a `sourcedFrom` one: the record reads back unchanged and the source is never re-invoked. A sweep built on it drops the tombstone while leaving entries untouched, losing the invalidation outright. | -> | `delete()` | Works, but a deleted entry is a miss, and a miss is a full render — the thing this design exists to avoid. | -> | `patch()` | Bumps `lastModified` (`@updatedTime`), so the entry looks *newer* than the invalidation to Next.js's `areTagsStale`. Next.js treats it as fresh and never regenerates. | -> -> Stale-while-revalidate comes from the tags-manifest mirror at read time, not from the sweep, so leaving the sweep off costs only tombstone-table growth — which the 7-day expiry already bounds. +**A tombstone outlives every entry it can invalidate.** Harper caps each cache entry at its table's `expiration`, counted from its last write, so each tombstone is written with its own expiry: the longer of the two cache tables' expirations, plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses. A cache table configured with no expiration breaks that bound and is reported in the log. -Two limits the sweep is designed around, for when it is enabled: +An immediate expiry is also **swept**: entries carrying the tag are looked up through the indexed `tags` column (an exact-match index lookup, not a scan) and deleted in throttled chunks, so they stop taking up space before their own expiry. This includes Next.js's implicit route tags, so `revalidatePath` is swept too. While Harper reports the index as still being built, the sweep scans with the same exact match instead. The sweep only reclaims space, and reads never depend on it: a sweep that fails or is interrupted leaves the entries to expire on their own. A stale-then-expire invalidation is not swept, because those entries must stay servable while they regenerate. -- **Next.js's implicit route tags (`_N_T_…`) are never swept.** A tag like `_N_T_/layout` is carried by every page in the app, so sweeping one would scan and rewrite the entire cache. Those are left to expire. -- **`tags` is not indexed.** Harper cannot index array elements, and the `contains` comparator cannot use an index regardless, so a sweep scans. That is why sweeps are chunked with bounded concurrency, and why broad tags are excluded rather than throttled. +`'use cache'` entries do not carry the implicit route tags `revalidatePath` targets. Next.js checks those through `getExpiration`, which reports only expirations that have already passed. Harper pushes invalidations to every worker via table replication, so the `refreshTags()` call the `'use cache'` interface expects to poll a tags service is a no-op here — the map it would refresh is already current. @@ -339,12 +340,20 @@ Enabling the cache handler adds these tables to the `harperfast_nextjs` database | Table | Purpose | | --- | --- | -| `nextjs_isr_cache` | One row per cached ISR/Data Cache entry. Stores `data` (the Next.js `IncrementalCacheValue`), `tags`, the entry's `revalidate`/`expire`, and `lastModified`. | -| `nextjs_use_cache` | One row per `'use cache'` entry. Stores `value` (the rendered bytes as a `Blob`), `tags`, `timestamp`, and the entry's `stale`/`revalidate`/`expire`. | -| `nextjs_cache_invalidation` | One row per invalidated tag. `id` is the tag itself; `timestamp` is when `revalidateTag` was called. Auto-expires after 7 days. | +| `nextjs_isr_cache` | One row per cached ISR/Data Cache entry. Stores `data` (the Next.js `IncrementalCacheValue`), `tags` (indexed), the entry's `revalidate`/`expire`, and `lastModified`. | +| `nextjs_use_cache` | One row per `'use cache'` entry. Stores `value` (the rendered bytes as a `Blob`), `tags` (indexed), `timestamp`, and the entry's `stale`/`revalidate`/`expire`. | +| `nextjs_cache_invalidation` | One row per invalidated tag. `id` is the tag itself; `timestamp` is when `revalidateTag` was called; `stale`/`expired` are the tag's invalidation in Next.js's terms; `lapsesAt` is when the row expires, which is later than any entry it invalidates. | > [!NOTE] -> A table's `expiration` is fixed when the table is created — changing it in `schema.graphql` does not migrate an existing table. Verify with `describe_table` after upgrading; an instance created before a change will still report the old value. +> A table's `expiration` is fixed when the table is created — changing it in `schema.graphql` does not migrate an existing table. Verify with `describe_table` after upgrading; an instance created before a change will still report the old value. Tombstone lifetimes are computed from the expiration each cache table actually has, so they stay correct either way. + +### Upgrading + +No manual migration is needed; existing tables, entries and tombstones keep working. + +- **Existing entries are kept** and served without regenerating. +- **`tags` becomes indexed on both cache tables.** Harper builds the index for existing rows in the background on first start; nothing is unavailable meanwhile. On Harper 5.2.0 some worker threads were seen to keep reporting that index as unfinished until the next restart; the sweep falls back to a scan in that case, and reads are unaffected. +- **Tombstones written by earlier versions** (a bare `timestamp`) are read as an immediate expiry at that time. Pre-release builds of `'use cache'` support stored `revalidateTag(tag, profile)` as a timestamp up to a year ahead; those rows no longer block later invalidations of the same tag, and they expire on the table's own 7-day schedule. ## Contributing diff --git a/fixtures/next-16-caching/app/api/revalidate/route.js b/fixtures/next-16-caching/app/api/revalidate/route.js index 71fd14a..4d2e44e 100644 --- a/fixtures/next-16-caching/app/api/revalidate/route.js +++ b/fixtures/next-16-caching/app/api/revalidate/route.js @@ -6,6 +6,9 @@ export async function POST(request) { if (!tag) { return NextResponse.json({ error: 'tag required' }, { status: 400 }); } - revalidateTag(tag); - return NextResponse.json({ revalidated: true, tag }); + // `profile` selects Next 16's stale-while-revalidate form; without it the tag expires immediately. + const profile = new URL(request.url).searchParams.get('profile'); + if (profile) revalidateTag(tag, profile); + else revalidateTag(tag); + return NextResponse.json({ revalidated: true, tag, profile }); } diff --git a/fixtures/next-16-use-cache/app/api/revalidate/route.js b/fixtures/next-16-use-cache/app/api/revalidate/route.js index 71fd14a..4d2e44e 100644 --- a/fixtures/next-16-use-cache/app/api/revalidate/route.js +++ b/fixtures/next-16-use-cache/app/api/revalidate/route.js @@ -6,6 +6,9 @@ export async function POST(request) { if (!tag) { return NextResponse.json({ error: 'tag required' }, { status: 400 }); } - revalidateTag(tag); - return NextResponse.json({ revalidated: true, tag }); + // `profile` selects Next 16's stale-while-revalidate form; without it the tag expires immediately. + const profile = new URL(request.url).searchParams.get('profile'); + if (profile) revalidateTag(tag, profile); + else revalidateTag(tag); + return NextResponse.json({ revalidated: true, tag, profile }); } diff --git a/integrationTests/next-16-caching.pw.ts b/integrationTests/next-16-caching.pw.ts index f6444b9..2fa12c5 100644 --- a/integrationTests/next-16-caching.pw.ts +++ b/integrationTests/next-16-caching.pw.ts @@ -146,48 +146,61 @@ test('revalidateTag records the invalidation and regenerates the entry', async ( const revalidateResponse = await request.post(revalidateURL); expect(revalidateResponse.status()).toBe(200); - // The invalidation must be durably recorded, but *where* is deliberately not asserted: the tombstone - // in nextjs_cache_invalidation is transient — the background sweep marks the matching entries and - // then drops it — so asserting the row still exists is a race against the sweep finishing. Either - // the tombstone or the sweep's own `invalidatedAt` marker on the entry is valid evidence. - const recorded = async () => { - const [tombstones, entries] = await Promise.all([ - request.post(harper.operationsAPIURL, { - headers: { 'Content-Type': 'application/json', Authorization: authHeader }, - data: { - operation: 'search_by_value', - database: 'harperfast_nextjs', - table: 'nextjs_cache_invalidation', - search_attribute: 'id', - search_value: 'test-tag', - get_attributes: ['id', 'timestamp'], - }, - }), - request.post(harper.operationsAPIURL, { - headers: { 'Content-Type': 'application/json', Authorization: authHeader }, - data: { - operation: 'search_by_value', - database: 'harperfast_nextjs', - table: 'nextjs_isr_cache', - search_attribute: 'id', - search_value: '*', - get_attributes: ['id', 'invalidatedAt'], - }, - }), - ]); - const tombstoneRows = await tombstones.json(); - const entryRows = await entries.json(); - return ( - tombstoneRows.length > 0 || entryRows.some((row: { invalidatedAt?: number }) => typeof row.invalidatedAt === 'number') - ); - }; - expect(await recorded(), 'the invalidation left no trace in either table').toBe(true); + // The tombstone is what every worker and node compares entries against, so it must outlive every + // entry it invalidates — those live at most the table's 7 days from their last write. + const tombstones = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + data: { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_cache_invalidation', + search_attribute: 'id', + search_value: 'test-tag', + get_attributes: ['id', 'timestamp', 'expired', 'lapsesAt'], + }, + }); + const [tombstone] = await tombstones.json(); + expect(tombstone, 'the invalidation left no tombstone').toBeTruthy(); + expect(tombstone.expired).toBe(tombstone.timestamp); + expect(tombstone.lapsesAt).toBeGreaterThan(tombstone.timestamp + 604_800_000); - // The entry regenerates. The first request after an invalidation may be served stale while - // regeneration runs in the background — that is deliberate, since a blocking miss here costs a full - // render — so poll for the new content rather than demanding it on the very next request. + // revalidateTag(tag) with no profile expires the tag, so the entry is regenerated. Polled because the + // next request can land on a worker the replicated tombstone has not reached yet. await expect(async () => { await page.goto(taggedURL); expect(await page.getByTestId('nonce').innerText()).not.toBe(nonceBefore); }).toPass({ timeout: 15_000 }); }); + +// revalidateTag(tag, 'max'): stale now, expired a year out. Next 16 serves the page stale while it +// regenerates, and the regenerated page is a HIT again — rather than the pre-fix behaviour, where the +// expiry was stored as the stale time and every entry stayed stale (or, for fetches, missing) for a year. +test('revalidateTag with a profile serves the page stale, then a fresh HIT', async ({ request, harper, page }) => { + const taggedURL = `${harper.httpURL}/tagged`; + + await page.goto(taggedURL); + const warmed = await page.goto(taggedURL); + expect(warmed!.headers()['x-nextjs-cache']).toBe('HIT'); + const nonceBefore = await page.getByTestId('nonce').innerText(); + + const revalidateResponse = await request.post(`${harper.httpURL}/api/revalidate?tag=test-tag&profile=max`); + expect(revalidateResponse.status()).toBe(200); + + // Served stale, not expired: somewhere before the new content appears, the old page is served with a + // STALE header rather than blocking on a render. + let servedStale = false; + let regenerated = ''; + await expect(async () => { + const response = await page.goto(taggedURL); + regenerated = await page.getByTestId('nonce').innerText(); + if (response!.headers()['x-nextjs-cache'] === 'STALE' && regenerated === nonceBefore) servedStale = true; + expect(regenerated).not.toBe(nonceBefore); + }).toPass({ timeout: 15_000 }); + expect(servedStale, 'the page was never served stale while it regenerated').toBe(true); + + await expect(async () => { + const response = await page.goto(taggedURL); + expect(response!.headers()['x-nextjs-cache']).toBe('HIT'); + expect(await page.getByTestId('nonce').innerText()).toBe(regenerated); + }).toPass({ timeout: 15_000 }); +}); diff --git a/integrationTests/next-16-use-cache.pw.ts b/integrationTests/next-16-use-cache.pw.ts index b7e84d4..5a957c6 100644 --- a/integrationTests/next-16-use-cache.pw.ts +++ b/integrationTests/next-16-use-cache.pw.ts @@ -64,34 +64,97 @@ test('every worker serves the same cached value', async ({ page, harper }) => { expect(seen.size, `expected one shared value, saw ${[...seen].join(', ')}`).toBe(1); }); -test('the entry survives a revalidateTag as stale rather than disappearing', async ({ page, request, harper }) => { - const url = `${harper.httpURL}/cached`; - - await page.goto(url); - const before = await page.getByTestId('nonce').innerText(); - - const revalidated = await request.post(`${harper.httpURL}/api/revalidate?tag=use-cache-tag`); - expect(revalidated.status()).toBe(200); - - // An invalidation row is written for the tag. - const invalidations = await request.post(harper.operationsAPIURL, { +async function searchTombstone(request: any, harper: any, tag: string) { + const response = await request.post(harper.operationsAPIURL, { headers: { 'Content-Type': 'application/json', Authorization: authHeader(harper) }, data: { operation: 'search_by_value', database: DATABASE, table: 'nextjs_cache_invalidation', search_attribute: 'id', - search_value: 'use-cache-tag', - get_attributes: ['id', 'timestamp'], + search_value: tag, + get_attributes: ['id', 'timestamp', 'stale', 'expired', 'lapsesAt', '$expiresAt'], }, }); - expect(await invalidations.json()).toHaveLength(1); + expect(response.status()).toBe(200); + const [row] = await response.json(); + return row as { timestamp: number; stale?: number; expired?: number; lapsesAt: number; $expiresAt?: number } | undefined; +} + +const WEEK_MS = 604_800_000; + +// revalidateTag(tag) with no profile expires the tag immediately in Next 16: the old entry is unusable, +// so it is regenerated on the next read and swept out of storage. +test('revalidateTag expires the entry, and the sweep deletes it', async ({ page, request, harper }) => { + const url = `${harper.httpURL}/cached`; + + await page.goto(url); + const before = await page.getByTestId('nonce').innerText(); + + const revalidated = await request.post(`${harper.httpURL}/api/revalidate?tag=use-cache-tag`); + expect(revalidated.status()).toBe(200); + + const tombstone = await searchTombstone(request, harper, 'use-cache-tag'); + expect(tombstone, 'no tombstone was written').toBeTruthy(); + expect(tombstone!.expired).toBe(tombstone!.timestamp); + // It must outlive every entry it invalidates: those live at most the table's 7 days from their last write. + expect(tombstone!.lapsesAt).toBeGreaterThan(tombstone!.timestamp + WEEK_MS); + // Harper's own expiry for the row, which is what actually evicts it — not the table's 7-day default. + expect(tombstone!.$expiresAt).toBe(tombstone!.lapsesAt); - // The content regenerates rather than being served stale forever. await expect(async () => { await page.goto(url); expect(await page.getByTestId('nonce').innerText()).not.toBe(before); }).toPass({ timeout: 15_000 }); + + await expect(async () => { + const rows = await searchUseCache(request, harper); + const predating = rows.filter( + (row: { tags?: string[]; timestamp: number }) => + row.tags?.includes('use-cache-tag') && row.timestamp < tombstone!.timestamp + ); + expect(predating, 'the sweep left an expired entry in storage').toHaveLength(0); + }).toPass({ timeout: 15_000 }); +}); + +// revalidateTag(tag, 'max') marks the tag stale now and expires it a year out. The entry keeps being +// served while it regenerates in the background, and the regenerated one is fresh — not stale or +// missing for the whole year, which is what storing the expiry as the stale time used to cause. +test('revalidateTag with a profile serves the entry stale, then regenerates it once', async ({ + page, + request, + harper, +}) => { + const url = `${harper.httpURL}/cached`; + + await page.goto(url); + const before = await page.getByTestId('nonce').innerText(); + await page.goto(url); + expect(await page.getByTestId('nonce').innerText()).toBe(before); + + const revalidated = await request.post(`${harper.httpURL}/api/revalidate?tag=use-cache-tag&profile=max`); + expect(revalidated.status()).toBe(200); + + const tombstone = await searchTombstone(request, harper, 'use-cache-tag'); + expect(tombstone!.stale).toBe(tombstone!.timestamp); + expect(tombstone!.expired).toBeGreaterThan(tombstone!.timestamp + 300 * 24 * 3600 * 1000); + + // Stale, not expired: the first read is still served the old value rather than blocking on a render. + await page.goto(url); + expect(await page.getByTestId('nonce').innerText()).toBe(before); + + let regenerated = ''; + await expect(async () => { + await page.goto(url); + regenerated = await page.getByTestId('nonce').innerText(); + expect(regenerated).not.toBe(before); + }).toPass({ timeout: 15_000 }); + + // Once regenerated, the entry is fresh again: every later read is a hit on the same value. + for (let i = 0; i < 5; i++) { + await page.goto(url); + expect(await page.getByTestId('nonce').innerText()).toBe(regenerated); + } }); test('cache lives from cacheLife round-trip into the stored entry', async ({ page, request, harper }) => { @@ -101,8 +164,7 @@ test('cache lives from cacheLife round-trip into the stored entry', async ({ pag const rows = await searchUseCache(request, harper, ['id', 'revalidate', 'expire', 'stale']); const withLives = rows.find((row: { revalidate?: number }) => typeof row.revalidate === 'number'); - // Without these persisted, another node reads the entry but not its cache lives, and - // `calculateRevalidate` falls back to a 1-second default there. + // Without these persisted, the entry reads back as revalidate: 0 and is regenerated on every read. expect(withLives, 'no entry carried its cache lives').toBeTruthy(); expect(withLives.expire).toBeGreaterThan(withLives.revalidate); }); diff --git a/schema.graphql b/schema.graphql index 411dd7a..919ebbe 100644 --- a/schema.graphql +++ b/schema.graphql @@ -9,26 +9,18 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in status: String } -# `revalidate`/`expire` mirror the `cacheControl` Next.js supplies on set. Next keeps its own copy in a -# per-process Map plus the build-time prerender manifest, and neither replicates — so without these columns -# an entry reaches another node but its cache lives do not, and `calculateRevalidate` falls back to a 1s -# default there. Persisting them is what makes cache lives coherent across the cluster. +# `revalidate`/`expire` mirror the `cacheControl` Next.js supplies on set, so an entry's own `expire` is +# enforced wherever it is read. # -# `tags` is deliberately NOT indexed: Harper cannot index array elements, and the `contains` comparator -# cannot use an index regardless, so an index would cost writes and buy nothing. Tag sweeps scan, which is -# why broad invalidations are never swept (see sweepInvalidation in src/cacheInvalidation.cts). +# `tags` is indexed: Harper indexes each element of an array attribute, so the invalidation sweep finds a +# tag's entries with an `equals` lookup instead of a scan (see sweepTag in src/cacheInvalidation.cts). type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache", expiration: 604800) { id: String @primaryKey data: Any - tags: [String] + tags: [String] @indexed revalidate: Int expire: Int - # Set by the background sweep instead of Harper's `invalidate()`, which on a table with no - # `sourcedFrom` leaves the record awaiting a refresh that never arrives and blocks every later read. - # Recording staleness explicitly keeps the entry servable, which is the point — a miss costs a full - # render. - invalidatedAt: Long - lastModified: Long @updatedTime @indexed + lastModified: Long @updatedTime } # Entries written by the "use cache" directive. Separate from nextjs_isr_cache because the shapes differ: @@ -39,18 +31,27 @@ type NextUseCache @table(database: "harperfast_nextjs", table: "nextjs_use_cache # with a hash of the full key appended. See toStorageKey() in src/UseCacheHandler.cts — # Next puts no size limit on a cache key and Harper caps a primary key at 1978 bytes. id: String @primaryKey - # The untruncated Next cache key, when `id` had to be shortened. Not indexed. + # The untruncated Next cache key, only when `id` had to be shortened. Not indexed. cacheKey: String value: Blob - tags: [String] - timestamp: Long @indexed + tags: [String] @indexed + timestamp: Long stale: Int revalidate: Int expire: Int - invalidatedAt: Long } +# One row per invalidated tag, in Next's terms: `stale` marks older entries for background revalidation, +# `expired` makes them unusable once it passes. `timestamp` is when the invalidation was issued; rows +# written before `stale`/`expired` existed carry only `timestamp`, meaning "expired then". +# +# Reads compare entries against these rows, so a row must outlive every entry it invalidates. Each is +# written with a per-record expiry of `lapsesAt` — the longest cache-table expiration plus a margin — +# which outlasts the table's own `expiration`. See tombstoneLifetimeMs in src/cacheInvalidation.cts. type NextCacheInvalidation @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { id: String @primaryKey timestamp: Long + stale: Long + expired: Long + lapsesAt: Long } diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index b2b670d..d4977de 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -14,14 +14,19 @@ import type { import type { databases as DatabasesType } from 'harper'; -import { initializeInvalidationSubscription, isInvalidated, recordInvalidation } from './cacheInvalidation.cjs'; +import { + initializeInvalidationSubscription, + nextServesStaleTags, + recordInvalidation, + tagState, +} from './cacheInvalidation.cjs'; const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; -// Kinds for which Next re-checks its tags manifest and can classify an entry as *stale* rather than -// missing. For anything else an invalidated entry has to be withheld, because Next would otherwise -// serve it indefinitely. -const TAG_AWARE_KINDS = new Set(['APP_PAGE', 'APP_ROUTE']); +// Kinds for which Next 16's IncrementalCache itself checks the tags manifest and serves a tag-stale entry +// stale-while-revalidate. For any other kind, or on Next 14/15, an invalidated entry has to be withheld, +// because Next would otherwise serve it as fresh. +const TAG_AWARE_KINDS = new Set(['APP_PAGE', 'APP_ROUTE', 'FETCH']); // `databases` is a Harper-provided global. Access it lazily so that loading this module from a // non-Harper context (e.g. a turbopack build worker that resolves the cacheHandler path) does not pull @@ -61,16 +66,12 @@ function extractTags( return []; } -/** - * True when Next will consult its own tags manifest for this entry and can therefore classify it as - * stale. `recordInvalidation` mirrors every invalidation into that manifest, so for these kinds the - * handler can return the entry and let Next serve it stale while regenerating, instead of forcing a - * blocking miss. - */ function canServeStale(data: unknown): boolean { const value = data as { kind?: string; headers?: Record } | null; - if (!value?.kind || !TAG_AWARE_KINDS.has(value.kind)) return false; - return typeof value.headers?.[NEXT_CACHE_TAGS_HEADER] === 'string'; + if (!value?.kind || !TAG_AWARE_KINDS.has(value.kind) || !nextServesStaleTags()) return false; + // FETCH entries are tag-checked against the request's own tags; pages only through the header Next + // wrote into the entry. + return value.kind === 'FETCH' || typeof value.headers?.[NEXT_CACHE_TAGS_HEADER] === 'string'; } function isExpired(record: { lastModified?: number; expire?: number }): boolean { @@ -93,31 +94,31 @@ export default class HarperCacheHandler implements CacheHandler { ): Promise { const databases = getDatabases(); if (!databases) return null; + // A worker that has not yet read the tombstones back would serve invalidated entries as fresh. + await initializeInvalidationSubscription(); const table = databases.harperfast_nextjs.nextjs_isr_cache; const record = await table.get(key); - if (!record) return null; + if (!record || record.data === undefined) return null; // Past its own `expire` the entry is no longer usable at all, regardless of tags. if (isExpired(record as { lastModified?: number; expire?: number })) return null; const recordTags = Array.isArray(record.tags) ? (record.tags as string[]) : []; + const ctxTags = [ + ...('tags' in ctx && Array.isArray(ctx.tags) ? ctx.tags : []), + ...('softTags' in ctx && Array.isArray(ctx.softTags) ? ctx.softTags : []), + ]; + const tags = [...recordTags, ...ctxTags]; - const ctxTags = - 'tags' in ctx && Array.isArray(ctx.tags) - ? [...ctx.tags, ...('softTags' in ctx && Array.isArray(ctx.softTags) ? ctx.softTags : [])] - : []; + // An on-demand revalidation for this request is an explicit demand for fresh content. + if (tags.some((tag) => this.revalidatedTags.includes(tag))) return null; - const markedInvalidAt = (record as { invalidatedAt?: number }).invalidatedAt; - - if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags, markedInvalidAt)) { - // An on-demand revalidation for this request is an explicit demand for fresh content. - if (recordTags.some((tag) => this.revalidatedTags.includes(tag))) return null; - - // Otherwise prefer stale-while-revalidate where Next supports it: a miss here costs a full - // blocking render, which is exactly what an invalidation storm must not produce. - if (!canServeStale(record.data)) return null; - } + const state = tagState(tags, record.lastModified ?? 0); + if (state === 'expired') return null; + // Where Next serves a tag-stale entry stale-while-revalidate on its own, hand it over with its real + // age: a miss here would cost a full blocking render. + if (state === 'stale' && !canServeStale(record.data)) return null; return { value: record.data as IncrementalCacheValue | null, @@ -136,11 +137,8 @@ export default class HarperCacheHandler implements CacheHandler { const table = databases.harperfast_nextjs.nextjs_isr_cache; const tags = extractTags(data, ctx); - // Next keeps cache lives in a per-process Map plus the build-time prerender manifest, neither of - // which replicates. Persisting them alongside the entry is what lets another node compute the - // same staleness instead of falling back to `calculateRevalidate`'s 1-second default. - // Floored because Harper rejects a non-integer for an Int column, and a rejected write here is - // silent — the entry simply never lands. + // Persisted so the entry's own `expire` is enforced on every node. Floored because Harper rejects a + // non-integer for an Int column, and a rejected write here is silent — the entry simply never lands. const cacheControl = 'cacheControl' in ctx ? ctx.cacheControl : undefined; const revalidate = typeof cacheControl?.revalidate === 'number' ? Math.floor(cacheControl.revalidate) : undefined; const expire = typeof cacheControl?.expire === 'number' ? Math.floor(cacheControl.expire) : undefined; diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 73557df..3d10c96 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -3,7 +3,12 @@ import assert from 'node:assert'; import { createRequire } from 'node:module'; -import { cacheInvalidations } from './cacheInvalidation.cjs'; +import { + cacheInvalidations, + resetInvalidationStateForTesting, + useTagsManifestModuleForTesting, + type TagInvalidation, +} from './cacheInvalidation.cjs'; // Load the handler through `require`, the way Next.js loads it, rather than through ESM interop — a // default import would bind to `module.exports` itself rather than the class. @@ -15,6 +20,8 @@ const HarperCacheHandler = createRequire(import.meta.url)('./CacheHandler.cjs') }; const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; +const NEXT_16_MANIFEST = { tagsManifest: new Map(), areTagsStale: () => false }; +const NEXT_15_MANIFEST = { tagsManifest: new Map(), isStale: () => false }; interface StoredRecord { data: unknown; @@ -26,35 +33,38 @@ interface StoredRecord { function installDatabases(records: Record = {}) { const puts: Array<{ key: string; value: Record }> = []; - const invalidationPuts: Array<{ key: string; value: { timestamp: number } }> = []; + const invalidationPuts: Array<{ key: string; value: Record }> = []; (globalThis as Record).databases = { harperfast_nextjs: { nextjs_isr_cache: { + expirationMS: 604_800_000, async get(key: string) { return records[key]; }, async put(key: string, value: Record) { puts.push({ key, value }); + records[key] = { ...(value as unknown as StoredRecord), lastModified: Date.now() }; }, search() { return (async function* () {})(); }, - async invalidate() {}, }, nextjs_use_cache: { + expirationMS: 604_800_000, search() { return (async function* () {})(); }, - async invalidate() {}, }, nextjs_cache_invalidation: { - async put(key: string, value: { timestamp: number }) { + async put(key: string, value: Record) { invalidationPuts.push({ key, value }); }, - async delete() {}, - async getRecordCount() { - return { recordCount: 0 }; + search() { + return (async function* () {})(); + }, + async subscribe() { + return { on() {} }; }, }, }, @@ -63,20 +73,31 @@ function installDatabases(records: Record = {}) { return { puts, invalidationPuts }; } -/** An APP_PAGE value, which is the kind Next re-checks against its tags manifest. */ +function invalidate(tag: string, fields: Omit) { + cacheInvalidations.set(tag, { lapsesAt: Number.MAX_SAFE_INTEGER, ...fields }); +} + +/** An APP_PAGE value, which Next 16 tag-checks through the header it wrote into the entry. */ function appPage(tags: string[]) { return { kind: 'APP_PAGE', headers: { [NEXT_CACHE_TAGS_HEADER]: tags.join(',') } }; } -/** A FETCH value, which Next does not tag-recheck — an invalidated one must be withheld. */ function fetchValue() { - return { kind: 'FETCH' }; + return { kind: 'FETCH', data: { body: 'x' }, revalidate: 60 }; } -const getCtx = { kind: 'APP_PAGE' } as never; +function pagesValue() { + return { kind: 'PAGES' }; +} + +const pageCtx = { kind: 'APP_PAGE' } as never; +const fetchCtx = (tags: string[]) => ({ kind: 'FETCH', tags, softTags: [] }) as never; describe('HarperCacheHandler per-entry cache lives', () => { - beforeEach(() => cacheInvalidations.clear()); + beforeEach(() => { + resetInvalidationStateForTesting(); + useTagsManifestModuleForTesting(NEXT_16_MANIFEST); + }); it('persists revalidate and expire from the set context', async () => { const { puts } = installDatabases(); @@ -108,7 +129,7 @@ describe('HarperCacheHandler per-entry cache lives', () => { }); const handler = new HarperCacheHandler(); - assert.equal(await handler.get('/page', getCtx), null); + assert.equal(await handler.get('/page', pageCtx), null); }); it('serves an entry still inside its expire window', async () => { @@ -117,63 +138,126 @@ describe('HarperCacheHandler per-entry cache lives', () => { }); const handler = new HarperCacheHandler(); - assert.notEqual(await handler.get('/page', getCtx), null); + assert.notEqual(await handler.get('/page', pageCtx), null); }); - it('forwards revalidateTag durations so a deferred expiry lands in the future', async () => { + it('treats a row with no data as a miss rather than serving an empty value', async () => { + installDatabases({ '/ghost': { data: undefined, lastModified: Date.now() } }); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/ghost', pageCtx), null); + }); + + it('records revalidateTag durations in Next terms: stale now, expired later', async () => { const { invalidationPuts } = installDatabases(); const handler = new HarperCacheHandler(); const before = Date.now(); await handler.revalidateTag('products', { expire: 60 }); - assert.equal(invalidationPuts.length, 1); - assert.ok(invalidationPuts[0].value.timestamp >= before + 60_000); + const [{ value }] = invalidationPuts; + assert.ok((value.timestamp as number) < before + 1000, 'the invalidation is dated now, not when it expires'); + assert.ok((value.stale as number) >= before); + assert.ok((value.expired as number) >= before + 60_000); }); }); -describe('HarperCacheHandler stale-while-revalidate on invalidation', () => { - beforeEach(() => cacheInvalidations.clear()); +describe('HarperCacheHandler on tag invalidation', () => { + beforeEach(() => { + resetInvalidationStateForTesting(); + useTagsManifestModuleForTesting(NEXT_16_MANIFEST); + }); - it('serves an invalidated APP_PAGE so Next can regenerate it in the background', async () => { + it('hands a tag-stale APP_PAGE to Next 16 with its real age, which serves it while regenerating', async () => { installDatabases({ '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, }); - cacheInvalidations.set('products', 2000); + invalidate('products', { stale: 2000, at: 2000 }); const handler = new HarperCacheHandler(); - const result = await handler.get('/page', getCtx); + const result = await handler.get('/page', pageCtx); - assert.notEqual(result, null, 'a blocking miss here is what an invalidation storm must not cause'); + assert.equal(result?.lastModified, 1000, 'a blocking miss here is what an invalidation must not cause'); }); - it('withholds an invalidated entry Next will not tag-recheck', async () => { + it('hands a tag-stale FETCH to Next 16, which tag-checks it against the request tags', async () => { installDatabases({ '/data': { data: fetchValue(), tags: ['products'], lastModified: 1000 }, }); - cacheInvalidations.set('products', 2000); + invalidate('products', { stale: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + assert.notEqual(await handler.get('/data', fetchCtx(['products'])), null); + }); + + it('withholds a tag-stale entry of a kind Next never tag-checks', async () => { + installDatabases({ '/legacy': { data: pagesValue(), tags: ['products'], lastModified: 1000 } }); + invalidate('products', { stale: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/legacy', pageCtx), null, 'Next would otherwise serve it as fresh'); + }); + + it('withholds a tag-stale entry on Next 15, whose IncrementalCache does not serve tags stale', async () => { + useTagsManifestModuleForTesting(NEXT_15_MANIFEST); + installDatabases({ + '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, + }); + invalidate('products', { stale: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/page', pageCtx), null); + }); + + it('withholds a tag-expired entry', async () => { + installDatabases({ + '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, + }); + invalidate('products', { expired: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/page', pageCtx), null); + }); + + it('withholds a FETCH whose page path was revalidated, through the soft tags', async () => { + installDatabases({ '/data': { data: fetchValue(), tags: [], lastModified: 1000 } }); + invalidate('_N_T_/blog', { expired: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/data', { kind: 'FETCH', tags: [], softTags: ['_N_T_/blog'] } as never); + + assert.equal(result, null); + }); + + // revalidateTag(tag, 'max') on the fetch cache: an entry written after the invalidation read back + // as null for the whole year, refetching from origin on every request. + it('serves a FETCH written after a deferred-expiry invalidation', async () => { + installDatabases(); const handler = new HarperCacheHandler(); + await handler.revalidateTag(['posts'], { expire: 31_536_000 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + await handler.set('/f', fetchValue(), { fetchCache: true, tags: ['posts'], cacheControl: { revalidate: 60 } } as never); - assert.equal(await handler.get('/data', getCtx), null, 'FETCH entries would otherwise be served forever'); + assert.notEqual(await handler.get('/f', fetchCtx(['posts'])), null); }); it('withholds an entry whose tag was revalidated on demand for this request', async () => { installDatabases({ '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 }, }); - const handler = new HarperCacheHandler({ revalidatedTags: ["products"] }); + const handler = new HarperCacheHandler({ revalidatedTags: ['products'] }); - assert.equal(await handler.get('/page', getCtx), null, 'on-demand revalidation demands fresh content'); + assert.equal(await handler.get('/page', pageCtx), null, 'on-demand revalidation demands fresh content'); }); it('serves a valid entry untouched', async () => { installDatabases({ '/page': { data: appPage(['products']), tags: ['products'], lastModified: 5000 }, }); - cacheInvalidations.set('products', 1000); + invalidate('products', { expired: 1000, at: 1000 }); const handler = new HarperCacheHandler(); - const result = await handler.get('/page', getCtx); + const result = await handler.get('/page', pageCtx); assert.equal(result?.lastModified, 5000); }); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index 6adcbdc..9a73e36 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -3,7 +3,12 @@ import type { CacheEntry, CacheHandler, Timestamp } from 'next/dist/server/lib/c import type { databases as DatabasesType } from 'harper'; -import { cacheInvalidations, initializeInvalidationSubscription, recordInvalidation } from './cacheInvalidation.cjs'; +import { + initializeInvalidationSubscription, + passedExpiration, + recordInvalidation, + tagState, +} from './cacheInvalidation.cjs'; const DATABASE = 'harperfast_nextjs'; const TABLE = 'nextjs_use_cache'; @@ -15,7 +20,6 @@ interface StoredEntry { stale?: number; revalidate?: number; expire?: number; - invalidatedAt?: number; } interface UseCacheTable { @@ -113,17 +117,43 @@ export function toStorageKey(cacheKey: string): string { return `${truncated}${KEY_HASH_SEPARATOR}${digest}`; } -/** Harper returns a Blob for a Blob column; unit tests and older rows may hold raw bytes. */ -async function toBuffer(value: unknown): Promise { +/** + * A fresh stream over the stored value, or undefined when it cannot be read. Harper returns a Blob for + * a Blob column, which is streamed straight from storage rather than read whole: `arrayBuffer()` would + * buffer the file and copy it. The first chunk is pulled before returning so a missing or unreadable + * blob still degrades to a cache MISS; `get` has no surrounding try, so a rejection here would surface + * as a request error instead. Unit tests and older rows may hold raw bytes. + */ +async function openValue(value: unknown): Promise | undefined> { if (value === undefined || value === null) return undefined; - if (Buffer.isBuffer(value)) return value; - if (value instanceof Uint8Array) return Buffer.from(value); - const blob = value as { arrayBuffer?: () => Promise }; + if (value instanceof Uint8Array) return streamOf(value); + const blob = value as { stream?: () => ReadableStream; arrayBuffer?: () => Promise }; + if (typeof blob.stream === 'function') { + const reader = blob.stream().getReader(); + let first: ReadableStreamReadResult; + try { + first = await reader.read(); + } catch { + return undefined; + } + return new ReadableStream({ + start(controller) { + if (first.done) controller.close(); + else controller.enqueue(first.value); + }, + async pull(controller) { + const { done, value: chunk } = await reader.read(); + if (done) controller.close(); + else controller.enqueue(chunk); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + } if (typeof blob.arrayBuffer === 'function') { - // A rejecting arrayBuffer() (corrupt row, read error) must degrade to a cache MISS. `get` has no - // surrounding try, so an unhandled rejection here would surface as a request error instead. try { - return Buffer.from(await blob.arrayBuffer()); + return streamOf(new Uint8Array(await blob.arrayBuffer())); } catch { return undefined; } @@ -150,28 +180,25 @@ function toStoredValue(bytes: Buffer): unknown { * A ReadableStream is single-use, so every read builds a new one over the stored bytes. Returning a * shared stream would serve the first reader and hand every later one an empty body. */ -function streamOf(bytes: Buffer): ReadableStream { +function streamOf(bytes: Uint8Array): ReadableStream { return new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array(bytes)); + controller.enqueue(bytes); controller.close(); }, }); } -function newestInvalidation(tags: string[]): number { - let newest = 0; - for (const tag of tags) { - const invalidatedAt = cacheInvalidations.get(tag); - if (invalidatedAt !== undefined && invalidatedAt > newest) newest = invalidatedAt; - } - return newest; -} - class HarperUseCacheHandler implements CacheHandler { - async get(cacheKey: string, softTags: string[]): Promise { + /** + * Soft (implicit route) tags are left to Next, which compares them against `getExpiration`; the entry + * does not carry them, so there is nothing more to check here. + */ + async get(cacheKey: string, _softTags: string[]): Promise { const table = getTable(); if (!table) return undefined; + // A worker that has not yet read the tombstones back would serve invalidated entries as fresh. + await initializeInvalidationSubscription(); // An in-flight set for this key must be awaited rather than reported as a miss. const pending = pendingEntries.get(cacheKey); @@ -180,40 +207,27 @@ class HarperUseCacheHandler implements CacheHandler { const record = await table.get(toStorageKey(cacheKey)); if (!record) return undefined; - const bytes = await toBuffer(record.value); - if (!bytes) return undefined; - const now = Date.now(); const timestamp = record.timestamp ?? 0; const expire = record.expire ?? 0; - const revalidate = record.revalidate ?? 0; + const tags = record.tags ?? []; + // Decided before touching the blob, so an unusable entry costs no read. if (expire > 0 && timestamp + expire * 1000 < now) return undefined; + const state = tagState(tags, timestamp, now); + if (state === 'expired') return undefined; - // Hard tags live on the record; soft tags are handled by getExpiration, which reports the - // invalidation timestamp for Next to compare itself. `invalidatedAt` is the sweep's own marker, - // which outlives the tombstone it was derived from. - const invalidatedAt = Math.max(newestInvalidation(record.tags ?? []), record.invalidatedAt ?? 0); - - let effectiveTimestamp = timestamp; - if (invalidatedAt > timestamp) { - // Backdate past the revalidate window so Next regenerates, while staying inside expire so the - // entry is still served meanwhile. A miss here would cost a full render, which is exactly what - // an invalidation storm must not produce. - // Math.min: an entry already older than the revalidate window must not be forward-dated to - // staleAt, which would extend how long Next keeps serving it stale. - const staleAt = Math.min(timestamp, now - revalidate * 1000 - 1); - if (expire > 0 && staleAt + expire * 1000 <= now) return undefined; - effectiveTimestamp = staleAt; - } + const value = await openValue(record.value); + if (!value) return undefined; return { - value: streamOf(bytes), - tags: record.tags ?? [], + value, + tags, stale: record.stale ?? 0, - timestamp: effectiveTimestamp, + timestamp, expire, - revalidate, + // Next's own handler signals a tag-stale entry this way: serve it, and regenerate in the background. + revalidate: state === 'stale' ? -1 : (record.revalidate ?? 0), }; } @@ -227,9 +241,10 @@ class HarperUseCacheHandler implements CacheHandler { const bytes = await drain(entry.value); if (!bytes) return undefined; - await table.put(toStorageKey(cacheKey), { - // The untruncated key, for identifying a row whose id was hashed. Not indexed. - cacheKey, + const storageKey = toStorageKey(cacheKey); + await table.put(storageKey, { + // The untruncated key, only for a row whose id had to be shortened. Not indexed. + ...(storageKey === cacheKey ? {} : { cacheKey }), value: toStoredValue(bytes), tags: entry.tags ?? [], timestamp: toInteger(entry.timestamp) ?? Date.now(), @@ -246,7 +261,8 @@ class HarperUseCacheHandler implements CacheHandler { try { await work; } catch (error) { - getLogger().error(`[UseCacheHandler] failed to store "${cacheKey}"`, error); + // The storage key: the full one is unbounded, and has been seen at 87KB. + getLogger().error(`[UseCacheHandler] failed to store "${toStorageKey(cacheKey)}"`, error); } finally { if (pendingEntries.get(cacheKey) === work) pendingEntries.delete(cacheKey); } @@ -261,9 +277,9 @@ class HarperUseCacheHandler implements CacheHandler { await initializeInvalidationSubscription(); } - /** Newest invalidation across the tags, or 0 if none were ever invalidated. */ + /** Newest expiration across the tags that has already passed, or 0 if there is none. */ async getExpiration(tags: string[]): Promise { - return newestInvalidation(tags); + return passedExpiration(tags); } async updateTags(tags: string[], durations?: { expire?: number }): Promise { diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 40d6571..0a74065 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -2,7 +2,11 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import { createRequire } from 'node:module'; -import { cacheInvalidations } from './cacheInvalidation.cjs'; +import { + cacheInvalidations, + resetInvalidationStateForTesting, + type TagInvalidation, +} from './cacheInvalidation.cjs'; import { toStorageKey } from './UseCacheHandler.cjs'; interface UseCacheEntry { @@ -103,6 +107,7 @@ function installDatabases() { (globalThis as Record).databases = { harperfast_nextjs: { nextjs_use_cache: { + expirationMS: 604_800_000, async get(key: string) { return rows.get(key); }, @@ -114,22 +119,20 @@ function installDatabases() { search() { return (async function* () {})(); }, - async invalidate() {}, }, nextjs_isr_cache: { + expirationMS: 604_800_000, search() { return (async function* () {})(); }, - async invalidate() {}, }, nextjs_cache_invalidation: { - puts: [] as Array<{ key: string; value: { timestamp: number } }>, - async put(key: string, value: { timestamp: number }) { - this.puts.push({ key, value }); + async put() {}, + search() { + return (async function* () {})(); }, - async delete() {}, - async getRecordCount() { - return { recordCount: 0 }; + async subscribe() { + return { on() {} }; }, }, }, @@ -139,7 +142,7 @@ function installDatabases() { describe('UseCacheHandler streaming', () => { beforeEach(() => { - cacheInvalidations.clear(); + resetInvalidationStateForTesting(); installDatabases(); }); @@ -197,7 +200,7 @@ describe('UseCacheHandler streaming', () => { describe('UseCacheHandler cache lives', () => { beforeEach(() => { - cacheInvalidations.clear(); + resetInvalidationStateForTesting(); installDatabases(); }); @@ -251,19 +254,32 @@ describe('UseCacheHandler cache lives', () => { }); }); +function invalidate(tag: string, fields: Omit) { + cacheInvalidations.set(tag, { lapsesAt: Number.MAX_SAFE_INTEGER, ...fields }); +} + describe('UseCacheHandler tags', () => { beforeEach(() => { - cacheInvalidations.clear(); + resetInvalidationStateForTesting(); installDatabases(); }); - it('reports the newest invalidation timestamp for the given tags', async () => { - cacheInvalidations.set('a', 1000); - cacheInvalidations.set('b', 5000); + it('reports the newest expiration that has passed for the given tags', async () => { + invalidate('a', { expired: 1000, at: 1000 }); + invalidate('b', { expired: 5000, at: 5000 }); assert.equal(await handler.getExpiration(['a', 'b']), 5000); }); + // Next discards every entry created at or before the reported expiration. Reporting a deferred one + // early discarded entries regenerated after the invalidation too. + it('does not report an expiration that is still in the future', async () => { + const now = Date.now(); + invalidate('a', { stale: now, expired: now + 31_536_000_000, at: now }); + + assert.equal(await handler.getExpiration(['a']), 0); + }); + it('reports 0 when no tag was ever invalidated', async () => { assert.equal(await handler.getExpiration(['never']), 0); }); @@ -274,40 +290,109 @@ describe('UseCacheHandler tags', () => { assert.ok(cacheInvalidations.has('products')); }); - it('serves an invalidated entry as stale rather than missing, while it is still inside expire', async () => { + it('serves a tag-stale entry for background revalidation, the way Next\'s own handler does', async () => { const now = Date.now(); - await handler.set( - 'tagged', - Promise.resolve(entry({ tags: ['products'], timestamp: now, revalidate: 300, expire: 3600 })) - ); - cacheInvalidations.set('products', now + 1000); + await handler.set('tagged', Promise.resolve(entry({ tags: ['products'], timestamp: now, revalidate: 300 }))); + invalidate('products', { stale: now + 1000, at: now + 1000 }); const result = await handler.get('tagged', []); assert.ok(result, 'a miss here costs a full render; stale-then-regenerate is the point'); - // Backdated past its revalidate window so Next regenerates, but still inside expire so it is usable. - assert.ok(result.timestamp + result.revalidate * 1000 < Date.now()); - assert.ok(result.timestamp + result.expire * 1000 > Date.now()); + assert.equal(result.revalidate, -1); + assert.equal(result.timestamp, now, 'the entry keeps its real timestamp'); }); - it('withholds an invalidated entry that is also past expire', async () => { + it('withholds a tag-expired entry', async () => { const now = Date.now(); - await handler.set( - 'gone', - Promise.resolve(entry({ tags: ['products'], timestamp: now - 120_000, revalidate: 10, expire: 60 })) - ); - cacheInvalidations.set('products', now); + await handler.set('gone', Promise.resolve(entry({ tags: ['products'], timestamp: now - 5000 }))); + invalidate('products', { expired: now - 1000, at: now - 1000 }); assert.equal(await handler.get('gone', []), undefined); }); + // revalidateTag(tag, 'max'): the expiry was used as the stale time, so an entry regenerated after + // the invalidation was still backdated and regenerated on every read for a year. + it('serves an entry written after a deferred-expiry invalidation as fresh', async () => { + await handler.updateTags(['posts'], { expire: 31_536_000 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const now = Date.now(); + await handler.set('regenerated', Promise.resolve(entry({ tags: ['posts'], timestamp: now, revalidate: 900 }))); + + const result = await handler.get('regenerated', []); + + assert.ok(result); + assert.equal(result.revalidate, 900); + assert.equal(result.timestamp, now); + }); +}); + +/** A Harper Blob stand-in that records how it was read. */ +function fakeBlob(text: string, options: { failFirstRead?: boolean } = {}) { + const calls = { stream: 0, arrayBuffer: 0 }; + return { + calls, + stream() { + calls.stream++; + if (options.failFirstRead) { + return new ReadableStream({ + pull() { + throw new Error('Blob file not found'); + }, + }); + } + return streamOf(text); + }, + async arrayBuffer() { + calls.arrayBuffer++; + return new TextEncoder().encode(text).buffer; + }, + }; +} + +describe('UseCacheHandler blob reads', () => { + let rows: Map>; + + beforeEach(() => { + resetInvalidationStateForTesting(); + rows = installDatabases(); + }); + + function storeRow(key: string, value: unknown, overrides: Record = {}) { + rows.set(key, { id: key, value, tags: [], timestamp: Date.now(), revalidate: 300, expire: 3600, stale: 60, ...overrides }); + } + + it('streams a Blob from storage instead of reading it whole', async () => { + const blob = fakeBlob('streamed'); + storeRow('blob', blob); + + const result = await handler.get('blob', []); + + assert.ok(result); + assert.equal(await readAll(result.value), 'streamed'); + assert.equal(blob.calls.stream, 1); + assert.equal(blob.calls.arrayBuffer, 0); + }); + + it('degrades to a miss when the blob cannot be read', async () => { + storeRow('missing', fakeBlob('', { failFirstRead: true })); + + assert.equal(await handler.get('missing', []), undefined); + }); + + it('does not read the blob of an entry it is going to withhold', async () => { + const blob = fakeBlob('old'); + storeRow('expired', blob, { timestamp: Date.now() - 120_000, expire: 60 }); + + assert.equal(await handler.get('expired', []), undefined); + assert.equal(blob.calls.stream, 0); + }); }); describe('UseCacheHandler oversized keys', () => { let rows: Map>; beforeEach(() => { - cacheInvalidations.clear(); + resetInvalidationStateForTesting(); rows = installDatabases(); }); @@ -366,4 +451,10 @@ describe('UseCacheHandler oversized keys', () => { const [row] = [...rows.values()]; assert.equal(row.cacheKey, key); }); + + it('does not duplicate a key that fit as the id', async () => { + await handler.set('short', Promise.resolve(entry({ value: streamOf('v') }))); + + assert.equal(rows.get('short')?.cacheKey, undefined); + }); }); diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 9a47995..63d51a0 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -1,80 +1,91 @@ import type { databases as DatabasesType } from 'harper'; -/** - * Next.js prefixes the implicit route/layout tags it attaches to every APP_PAGE and APP_ROUTE entry. - * A tag like `_N_T_/layout` is carried by every page in the app, so sweeping one would scan and rewrite - * the entire cache. Those are left to TTL; only explicit user tags are swept. - */ -export const NEXT_IMPLICIT_TAG_PREFIX = '_N_T_'; - const DATABASE = 'harperfast_nextjs'; const ISR_TABLE = 'nextjs_isr_cache'; const USE_CACHE_TABLE = 'nextjs_use_cache'; const INVALIDATION_TABLE = 'nextjs_cache_invalidation'; -// Throttling for the background sweep. Chunks bound transaction length — a single long-running -// transaction is a documented Harper failure mode ("Transaction was open too long") — while the -// concurrency limit bounds how much of a worker the sweep can take. -const INVALIDATE_CONCURRENCY = 10; -const INVALIDATE_CHUNK_SIZE = 100; -const CHUNK_PAUSE_MAX_MS = 100; +// Matches the `expiration` of the cache tables in schema.graphql; used when a table does not report its own. +const DEFAULT_CACHE_TTL_MS = 604_800_000; + +// Covers an entry that was rendered before an invalidation but stored after it, and replication lag: both +// let an entry's own TTL start after the invalidation was issued. +const TOMBSTONE_MARGIN_MS = 3_600_000; -// Backpressure: above this many outstanding invalidation rows the sweep is shed. Soft invalidation still -// covers reads, so shedding costs cache-hit rate, never correctness. -const MAX_PENDING_INVALIDATIONS = 75_000; +// Chunks bound transaction length — a single long-running transaction is a documented Harper failure +// mode ("Transaction was open too long") — while the concurrency limits bound how much of a worker a +// sweep can take. +const SWEEP_RECORD_CONCURRENCY = 10; +const SWEEP_CHUNK_SIZE = 100; +const SWEEP_CHUNK_PAUSE_MAX_MS = 100; +const SWEEP_TAG_CONCURRENCY = 2; +const PRUNE_INTERVAL_MS = 300_000; +const SUBSCRIPTION_RETRY_MIN_MS = 1_000; +const SUBSCRIPTION_RETRY_MAX_MS = 60_000; /** - * The background sweep is off by default. It works — tests cover the throttling, the race guard and the - * scope limits — but no available primitive marks an entry stale without breaking something else, and - * every failure mode is silent: - * - * - `invalidate()` is a NO-OP. Measured against Harper 5.1.23 and 5.2.0, on a plain table and on a - * `sourcedFrom` one: the record reads back unchanged and the source is never re-invoked. A sweep - * built on it leaves every entry untouched while still dropping the tombstone, which loses the - * invalidation outright — worse than not sweeping. - * - `delete()` works, but a deleted entry is a miss, and a miss is a full render. Discarding entries - * on invalidation is what this design exists to avoid. - * - `patch()` writing an explicit marker bumps `lastModified` (`@updatedTime`), so the entry looks - * *newer* than the invalidation to Next's own `areTagsStale`. Next then treats it as fresh and never - * regenerates, serving it stale indefinitely. - * - * Marking an entry stale without disturbing the timestamp Next derives staleness from needs a primitive - * that does not exist yet. Until then the tombstone remains the invalidation, which is the behaviour - * that shipped previously and is covered by the existing tests; the 7-day expiry bounds table growth on - * its own, and stale-while-revalidate comes from the tags-manifest mirror at read time rather than from - * anything the sweep does. + * One tag's invalidation, in Next's own terms: `stale` marks entries written before it for background + * revalidation, `expired` makes them unusable once it has passed. `at` is when it was issued, and orders + * competing views of the same tag. `lapsesAt` is when its tombstone expires. */ -const SWEEP_ENABLED = process.env.HARPER_NEXTJS_EXPERIMENTAL_SWEEP === 'true'; +export interface TagInvalidation { + stale?: number; + expired?: number; + at: number; + lapsesAt: number; +} + +export type TagState = 'expired' | 'stale' | undefined; -/** Tag → invalidation timestamp (ms). Shared by every cache handler in the worker. */ -export const cacheInvalidations = new Map(); +interface TombstoneRow { + id: string; + timestamp?: number; + stale?: number; + expired?: number; + lapsesAt?: number; +} -interface CacheRecord { +interface CacheRow { id: string; + tags?: string[]; + timestamp?: number; + lastModified?: number; } interface SweepableTable { search(request: { conditions: Array<{ attribute: string; comparator: string; value: unknown }>; select?: string[]; - }): AsyncIterable; - patch(id: string, value: { invalidatedAt: number }): Promise | unknown; + }): AsyncIterable; + get(id: string): Promise | CacheRow | undefined; + delete(id: string): Promise | unknown; + expirationMS?: number; } interface InvalidationTable { - put(key: string, value: { timestamp: number }): Promise | unknown; - delete(key: string): Promise | unknown; - getRecordCount?(): Promise<{ recordCount: number }>; + put(id: string, value: Omit, context?: { expiresAt?: number }): Promise | unknown; + search(): AsyncIterable; + subscribe(request: { omitCurrent?: boolean }): Promise<{ + on(event: string, listener: (event: { type: string; id: string; value?: Omit }) => void): void; + }>; } export interface InvalidationDeps { databases?: typeof DatabasesType; sleep?: (ms: number) => Promise; - logger?: { info(...args: unknown[]): void; error(...args: unknown[]): void }; - /** Runs the background sweep. Defaults to fire-and-forget; tests substitute a collector. */ + logger?: { info?(...args: unknown[]): void; error(...args: unknown[]): void }; + /** Runs a sweep. Defaults to the worker's sweep queue; tests substitute a collector. */ scheduleSweep?: (task: () => Promise) => void; + /** Stand-in for Next's tags-manifest module; `null` means Next has none. */ + tagsManifestModule?: unknown; + /** False skips the periodic prune timer. */ + maintenance?: boolean; + now?: () => number; } +/** Tag → its newest invalidation. Shared by both cache handlers in the worker. */ +export const cacheInvalidations = new Map(); + /** * `databases` is a Harper-provided global, read lazily so that loading this module from a non-Harper * context (a bundler resolving the cache-handler path) does not pull in the harper runtime. @@ -83,10 +94,19 @@ function getDatabases(deps: InvalidationDeps): typeof DatabasesType | undefined return deps.databases ?? (globalThis as { databases?: typeof DatabasesType }).databases; } -function getLogger(deps: InvalidationDeps) { +function getScope(deps: InvalidationDeps): Record | undefined { + const databases = getDatabases(deps); + return databases ? (databases as unknown as Record>)[DATABASE] : undefined; +} + +function getLogger(deps: InvalidationDeps): NonNullable { return deps.logger ?? (globalThis as { logger?: InvalidationDeps['logger'] }).logger ?? console; } +function nowFor(deps: InvalidationDeps): number { + return deps.now ? deps.now() : Date.now(); +} + function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -118,269 +138,465 @@ export async function runWithConcurrency( await Promise.all(workers); } -/** Only explicit user tags are swept; see NEXT_IMPLICIT_TAG_PREFIX. */ -export function isSweepableTag(tag: string): boolean { +let unboundedCacheReported = false; + +/** + * How long a tombstone must live: past the last moment any entry written before the invalidation can + * still be read. Harper caps every record at its table's `expiration`, counted from its last write, so + * that bound is the longer of the two cache tables' TTLs plus the margin. + */ +export function tombstoneLifetimeMs(deps: InvalidationDeps = {}): number { + const scope = getScope(deps); + let longest = 0; + for (const name of [ISR_TABLE, USE_CACHE_TABLE]) { + const ttl = (scope?.[name] as { expirationMS?: number } | undefined)?.expirationMS; + if (ttl === undefined) { + longest = Math.max(longest, DEFAULT_CACHE_TTL_MS); + } else if (ttl > 0) { + longest = Math.max(longest, ttl); + } else if (!unboundedCacheReported) { + unboundedCacheReported = true; + getLogger(deps).error( + `[CacheHandler] ${DATABASE}.${name} has no expiration, so its entries can outlive any invalidation tombstone; invalidated entries may be served again once a tombstone lapses` + ); + } + } + return (longest || DEFAULT_CACHE_TTL_MS) + TOMBSTONE_MARGIN_MS; +} + +/** + * Next 16 `updateTags` semantics: with durations a tag goes stale now and expires `durations.expire` + * seconds later; without, it expires now. + */ +export function invalidationFor( + now: number, + durations: { expire?: number } | undefined, + lifetimeMs: number +): TagInvalidation { + const lapsesAt = now + lifetimeMs; + if (!durations) return { expired: now, at: now, lapsesAt }; + return { + stale: now, + expired: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + at: now, + lapsesAt, + }; +} + +/** + * Rows written before `stale`/`expired` existed carry only `timestamp`, which meant "expired then". The + * previous plugin wrote a deferred invalidation as `now + expire`, so a legacy timestamp can be in the + * future; it was issued no later than now, and ordering it by that future time would make it outrank + * every invalidation of the tag issued after it. + */ +export function tombstoneToInvalidation( + row: Omit | undefined, + now: number = Date.now() +): TagInvalidation | undefined { + if (typeof row?.timestamp !== 'number') return undefined; + const legacy = row.stale == undefined && row.expired == undefined; + return { + stale: row.stale ?? undefined, + expired: legacy ? row.timestamp : (row.expired ?? undefined), + at: legacy ? Math.min(row.timestamp, now) : row.timestamp, + lapsesAt: row.lapsesAt ?? row.timestamp + DEFAULT_CACHE_TTL_MS, + }; +} + +/** True when the invalidation expires entries immediately rather than marking them stale first. */ +export function isHardExpiry(invalidation: TagInvalidation): boolean { + return invalidation.expired !== undefined && invalidation.expired <= invalidation.at; +} - return !tag.startsWith(NEXT_IMPLICIT_TAG_PREFIX); +/** The state of an entry written at `writtenAt`, given the invalidations of its tags this worker knows. */ +export function tagState(tags: Iterable, writtenAt: number, now: number = Date.now()): TagState { + let state: TagState; + for (const tag of tags) { + const invalidation = cacheInvalidations.get(tag); + if (!invalidation) continue; + const { stale, expired } = invalidation; + if (expired !== undefined && expired <= now && expired > writtenAt) return 'expired'; + if (stale !== undefined && stale > writtenAt) state = 'stale'; + } + return state; } /** - * `markedInvalidAt` is the record's own `invalidatedAt`, written by the sweep. It has to be consulted - * separately because the sweep's write bumps `lastModified`, which would otherwise make the tag - * timestamps below stop matching — and because the tombstone is dropped once the sweep finishes, so - * after that the record's own marker is the only remaining evidence. + * Newest expiration among `tags` that has already passed. An expiration still in the future is not yet + * an expiration: reporting it would make Next discard every entry created before it, including ones + * regenerated after the invalidation. */ -export function isInvalidated( - recordTags: string[], - lastModified: number, - revalidatedTags: string[], - ctxTags: string[], - markedInvalidAt?: number -): boolean { - if (typeof markedInvalidAt === 'number' && markedInvalidAt > 0) return true; - - const allTags = recordTags.length > 0 ? recordTags : ctxTags; - for (const tag of allTags) { - if (revalidatedTags.includes(tag)) return true; - const invalidatedAt = cacheInvalidations.get(tag); - if (invalidatedAt !== undefined && invalidatedAt > lastModified) return true; +export function passedExpiration(tags: Iterable, now: number = Date.now()): number { + let newest = 0; + for (const tag of tags) { + const expired = cacheInvalidations.get(tag)?.expired; + if (expired !== undefined && expired <= now && expired > newest) newest = expired; } - return false; + return newest; } +type TagsManifest = + | { kind: 'none' } + | { kind: 'entries'; manifest: Map } + | { kind: 'timestamps'; manifest: Map }; + +let loadedTagsManifest: TagsManifest | undefined; + /** - * Mirror an invalidation into Next's own tags manifest so Next classifies affected APP_PAGE/APP_ROUTE - * entries as *stale* (serve the cached response, regenerate in the background) rather than missing. - * Without this the handler's only way to signal invalidation is returning null, which Next reads as a - * miss and turns into a blocking render — the behaviour we specifically want to avoid under load. + * Next 16 keys `{stale, expired}` per tag; Next 15 keys a single revalidation timestamp; Next 14 has no + * such module. Writing the wrong shape either throws (a property set on a number, in strict mode) or + * poisons Next's own reads (`Math.max` over an object is NaN). */ -function mirrorToNextTagsManifest(tags: string[], timestamp: number, deps: InvalidationDeps): void { +function getTagsManifest(deps: InvalidationDeps): TagsManifest | undefined { + if (deps.tagsManifestModule !== undefined) return classifyTagsManifest(deps.tagsManifestModule); + if (loadedTagsManifest) return loadedTagsManifest; try { // eslint-disable-next-line @typescript-eslint/no-require-imports - const { tagsManifest } = require('next/dist/server/lib/incremental-cache/tags-manifest.external.js') as { - tagsManifest: Map; - }; - for (const tag of tags) { - const entry = tagsManifest.get(tag) ?? {}; - entry.stale = timestamp; - tagsManifest.set(tag, entry); - } + loadedTagsManifest = classifyTagsManifest(require('next/dist/server/lib/incremental-cache/tags-manifest.external.js')); } catch (error) { - // Next 14 has no tags-manifest.external.js. Without this the legacy handler logs an error on - // EVERY invalidation there, which is noise rather than a fault. - if ((error as { code?: string } | undefined)?.code === 'MODULE_NOT_FOUND') return; - getLogger(deps).error('[CacheHandler] could not mirror invalidation into the Next tags manifest', error); + if ((error as { code?: string } | undefined)?.code !== 'MODULE_NOT_FOUND') { + getLogger(deps).error('[CacheHandler] could not load the Next tags manifest', error); + return undefined; + } + loadedTagsManifest = { kind: 'none' }; } + return loadedTagsManifest; } -let subscriptionInitialized = false; +function classifyTagsManifest(module: unknown): TagsManifest { + const exports = module as { tagsManifest?: unknown; areTagsStale?: unknown } | null; + if (!(exports?.tagsManifest instanceof Map)) return { kind: 'none' }; + return typeof exports.areTagsStale === 'function' + ? { kind: 'entries', manifest: exports.tagsManifest } + : { kind: 'timestamps', manifest: exports.tagsManifest }; +} -interface SubscribableTable { - search(): AsyncIterable<{ id: string; timestamp: number }>; - subscribe(request: { omitCurrent?: boolean }): Promise<{ - on(event: string, listener: (event: { type: string; id: string; value?: { timestamp: number } }) => void): void; - }>; +/** True when Next itself serves tag-stale ISR entries stale-while-revalidate from its manifest (Next 16). */ +export function nextServesStaleTags(deps: InvalidationDeps = {}): boolean { + return getTagsManifest(deps)?.kind === 'entries'; +} + +/** + * Mirror an invalidation into Next's own tags manifest, so Next's in-process checks agree with this + * worker's view — including for an invalidation that arrived from another worker or node. `undefined` + * withdraws one, but only if the manifest still holds what was mirrored: Next may have written the tag + * itself since. + */ +function mirrorToTagsManifest( + tag: string, + invalidation: TagInvalidation | undefined, + previous: TagInvalidation | undefined, + deps: InvalidationDeps +): void { + const tagsManifest = getTagsManifest(deps); + if (!tagsManifest || tagsManifest.kind === 'none') return; + if (tagsManifest.kind === 'timestamps') { + const existing = tagsManifest.manifest.get(tag); + if (!invalidation) { + if (previous && existing === revalidatedAtOf(previous)) tagsManifest.manifest.delete(tag); + return; + } + const revalidatedAt = revalidatedAtOf(invalidation); + if (typeof existing !== 'number' || existing < revalidatedAt) tagsManifest.manifest.set(tag, revalidatedAt); + return; + } + const existing = tagsManifest.manifest.get(tag); + if (!invalidation) { + if (previous && existing?.stale === previous.stale && existing?.expired === previous.expired) { + tagsManifest.manifest.delete(tag); + } + return; + } + tagsManifest.manifest.set(tag, { + ...(existing && typeof existing === 'object' ? existing : {}), + ...(invalidation.stale !== undefined ? { stale: invalidation.stale } : {}), + ...(invalidation.expired !== undefined ? { expired: invalidation.expired } : {}), + }); +} + +function revalidatedAtOf(invalidation: TagInvalidation): number { + return invalidation.expired ?? invalidation.stale ?? invalidation.at; } /** - * Hydrate the invalidation map from storage, then track it via a Harper subscription so an invalidation - * issued on any worker or node is observed here within milliseconds. - * - * This is why `refreshTags()` on the "use cache" handler is a no-op: the framework expects handlers to - * poll a tags service, and Harper pushes instead. + * Adopt an invalidation into this worker's view. The newest (by `at`) wins, so the subscription, the + * start-up hydration and this worker's own writes can arrive in any order. */ +export function noteInvalidation(tag: string, invalidation: TagInvalidation, deps: InvalidationDeps = {}): void { + const existing = cacheInvalidations.get(tag); + if (existing && existing.at > invalidation.at) return; + if (invalidation.lapsesAt <= nowFor(deps)) return; + cacheInvalidations.set(tag, invalidation); + mirrorToTagsManifest(tag, invalidation, existing, deps); +} + +function forgetInvalidation(tag: string, deps: InvalidationDeps): void { + const existing = cacheInvalidations.get(tag); + if (!existing) return; + cacheInvalidations.delete(tag); + mirrorToTagsManifest(tag, undefined, existing, deps); +} + +/** Drop invalidations whose tombstone has lapsed. TTL eviction does not emit a delete event. */ +export function pruneInvalidations(deps: InvalidationDeps = {}): void { + const now = nowFor(deps); + for (const [tag, invalidation] of cacheInvalidations) { + if (invalidation.lapsesAt <= now) forgetInvalidation(tag, deps); + } +} + /** - * Rebuild the in-memory invalidation map from the tombstone table. - * - * This is what makes an invalidation survive the process holding it. The map is worker-local, so a - * worker that dies between `revalidateTag` and the entry being regenerated would otherwise come back - * with no record of the invalidation and start serving the stale entry as though it were fresh. The - * tombstone is the durable half of that pair, and this is the half that reads it back. + * Rebuild the in-memory invalidation map from the tombstone table, so a worker that restarts between + * `revalidateTag` and the entry's regeneration still knows the entry is invalid. */ export async function hydrateInvalidations( - table: Pick, + table: Pick, deps: InvalidationDeps = {} ): Promise { for await (const row of table.search()) { - cacheInvalidations.set(row.id, row.timestamp); - mirrorToNextTagsManifest([row.id], row.timestamp, deps); + const invalidation = tombstoneToInvalidation(row, nowFor(deps)); + if (invalidation) noteInvalidation(row.id, invalidation, deps); } } -export async function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { - if (subscriptionInitialized) return; - const databases = getDatabases(deps); - if (!databases) return; - subscriptionInitialized = true; - - const scope = (databases as unknown as Record>)[DATABASE]; - // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, but the runtime - // accepts plain object literals (and search() accepts no args). - const table = scope?.[INVALIDATION_TABLE] as unknown as SubscribableTable | undefined; - if (!table) { - subscriptionInitialized = false; +let subscription: Awaited> | undefined; +let initialization: Promise | undefined; +let initialized = false; +let subscriptionRetryAt = 0; +let subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; +let pruneTimerStarted = false; + +function onTombstoneEvent( + event: { type: string; id: string; value?: Omit }, + deps: InvalidationDeps +): void { + if (!event.id) return; + if (event.type === 'delete') { + forgetInvalidation(event.id, deps); return; } + const invalidation = tombstoneToInvalidation(event.value, nowFor(deps)); + if (invalidation) noteInvalidation(event.id, invalidation, deps); +} - try { - await hydrateInvalidations(table, deps); - - const subscription = await table.subscribe({ omitCurrent: true }); - - subscription.on('data', (event) => { - if (!event.id) return; - if (event.type === 'delete') { - // The sweep has finished for this tag, so the tombstone is no longer load-bearing. - cacheInvalidations.delete(event.id); - } else if (event.type === 'put' && event.value) { - cacheInvalidations.set(event.id, event.value.timestamp); - // Mirror here too, so an invalidation issued on another node still yields stale-serving - // rather than a blocking miss on this one. - mirrorToNextTagsManifest([event.id], event.value.timestamp, deps); +function startPruneTimer(deps: InvalidationDeps): void { + if (pruneTimerStarted || deps.maintenance === false) return; + pruneTimerStarted = true; + setInterval(() => pruneInvalidations(deps), PRUNE_INTERVAL_MS).unref(); +} + +/** + * Subscribe to tombstones, then hydrate from storage. Subscribing first means an invalidation written + * while the hydration scan runs is still observed; `noteInvalidation` makes the overlap harmless. + * Concurrent callers share one attempt, and a failure backs off rather than retrying on every request. + */ +export function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { + if (initialized) return Promise.resolve(); + if (initialization) return initialization; + if (nowFor(deps) < subscriptionRetryAt) return Promise.resolve(); + const table = getScope(deps)?.[INVALIDATION_TABLE] as InvalidationTable | undefined; + if (!table) return Promise.resolve(); + + initialization = (async () => { + try { + if (!subscription) { + // Harper's TypeScript types require a SubscriptionRequest, but the runtime accepts a plain literal. + subscription = await table.subscribe({ omitCurrent: true }); + subscription.on('data', (event) => onTombstoneEvent(event, deps)); + subscription.on('error', (error) => { + getLogger(deps).error('[CacheHandler] invalidation subscription error', error); + }); } - }); + await hydrateInvalidations(table, deps); + startPruneTimer(deps); + initialized = true; + subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; + } catch (error) { + subscriptionRetryAt = nowFor(deps) + subscriptionRetryMs; + subscriptionRetryMs = Math.min(subscriptionRetryMs * 2, SUBSCRIPTION_RETRY_MAX_MS); + getLogger(deps).error('[CacheHandler] failed to initialize invalidation subscription', error); + } finally { + initialization = undefined; + } + })(); + return initialization; +} - subscription.on('error', (error) => { - getLogger(deps).error('[CacheHandler] invalidation subscription error', error); - }); - } catch (error) { - // Reset so a future construction can retry — failure here means we lose cross-worker visibility, - // but the cache still works (just falls back to per-request revalidatedTags). - subscriptionInitialized = false; - getLogger(deps).error('[CacheHandler] failed to initialize invalidation subscription', error); - } +/** Test hook: stand in for Next's tags-manifest module; `null` simulates Next 14, which has none. */ +export function useTagsManifestModuleForTesting(module: unknown): void { + loadedTagsManifest = classifyTagsManifest(module); } -/** Update this worker's view of an invalidation without touching storage. */ -export function noteInvalidation(tags: string[], timestamp: number, deps: InvalidationDeps = {}): void { - for (const tag of tags) { - cacheInvalidations.set(tag, timestamp); - } - mirrorToNextTagsManifest(tags, timestamp, deps); +/** Test hook: forget state held at module level. */ +export function resetInvalidationStateForTesting(): void { + cacheInvalidations.clear(); + subscription = undefined; + initialization = undefined; + initialized = false; + subscriptionRetryAt = 0; + subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; + loadedTagsManifest = undefined; + unboundedCacheReported = false; + queuedSweeps.clear(); + activeSweeps = 0; + recordedThisTurn.clear(); } -async function sweepTable( - table: SweepableTable, - timeAttribute: string, - tag: string, - timestamp: number, - deps: InvalidationDeps -): Promise { - const sleep = deps.sleep ?? defaultSleep; +function writtenAtOf(row: CacheRow): number { + return row.timestamp ?? row.lastModified ?? 0; +} - // The time condition is the race guard: a record written after the invalidation was issued is - // semantically fresh, so the sweep must leave it alone. - const matches = table.search({ - conditions: [ - { attribute: 'tags', comparator: 'contains', value: tag }, - { attribute: timeAttribute, comparator: 'less_than', value: timestamp }, - ], - select: ['id'], - }); +/** + * Delete every record in `table` carrying `tag` that was written before `expiredAt`. Reads already treat + * those entries as misses while the tombstone lives; deleting them commits that permanently and returns + * the space. A regeneration racing the sweep can at worst lose its fresh entry, costing one render. + */ +function isIndexRebuilding(error: unknown): boolean { + return (error as { name?: string } | undefined)?.name === 'IndexRebuildingError'; +} + +/** + * The records carrying `tag`, through the `tags` index. While Harper reports that index as rebuilding it + * refuses the lookup, so fall back to scanning with the same exact-element match. Harper 5.2.0 can leave + * that report stuck on every worker thread but the one that ran the rebuild until they restart, so the + * fallback is not just a brief upgrade window. + */ +async function* rowsTagged(table: SweepableTable, tag: string): AsyncIterable { + const select = ['id', 'tags', 'timestamp', 'lastModified']; + try { + yield* table.search({ conditions: [{ attribute: 'tags', comparator: 'equals', value: tag }], select }); + return; + } catch (error) { + if (!isIndexRebuilding(error)) throw error; + } + for await (const row of table.search({ conditions: [], select })) { + if (row.tags?.includes(tag)) yield row; + } +} +async function sweepTable(table: SweepableTable, tag: string, expiredAt: number, deps: InvalidationDeps): Promise { + const sleep = deps.sleep ?? defaultSleep; const ids: string[] = []; - for await (const record of matches) { - ids.push(record.id); + for await (const row of rowsTagged(table, tag)) { + if (writtenAtOf(row) < expiredAt) ids.push(row.id); } - const batches = chunk(ids, INVALIDATE_CHUNK_SIZE); + let deleted = 0; + const batches = chunk(ids, SWEEP_CHUNK_SIZE); for (let index = 0; index < batches.length; index++) { - await runWithConcurrency(batches[index], INVALIDATE_CONCURRENCY, async (id) => { - await table.patch(id, { invalidatedAt: timestamp }); + await runWithConcurrency(batches[index], SWEEP_RECORD_CONCURRENCY, async (id) => { + // Re-read so an entry regenerated since the search survives. + const current = await table.get(id); + if (!current || writtenAtOf(current) >= expiredAt) return; + await table.delete(id); + deleted++; }); - if (index < batches.length - 1) { - await sleep(jitter(CHUNK_PAUSE_MAX_MS)); - } + if (index < batches.length - 1) await sleep(jitter(SWEEP_CHUNK_PAUSE_MAX_MS)); } - - return ids.length; + return deleted; } /** - * Invalidate every cached entry carrying `tag` that predates `timestamp`, then drop the invalidation row - * so the subscription clears it from every worker's map. Entries are invalidated, never deleted, so Next - * can still serve them stale while regenerating. - * - * The invalidation row is removed only on success: while it remains, soft invalidation keeps reads - * correct, so a failed sweep degrades to the pre-sweep behaviour rather than losing the invalidation. + * Delete the entries a hard expiry of `tag` made unusable. Only reclaims space: correctness rests on the + * tombstone, so a failed or skipped sweep leaves reads unaffected. */ -export async function sweepTag(tag: string, timestamp: number, deps: InvalidationDeps = {}): Promise { - const databases = getDatabases(deps); - if (!databases) return; - - const scope = (databases as unknown as Record>)[DATABASE]; +export async function sweepTag(tag: string, expiredAt: number, deps: InvalidationDeps = {}): Promise { + const scope = getScope(deps); if (!scope) return; + const isrCount = await sweepTable(scope[ISR_TABLE] as SweepableTable, tag, expiredAt, deps); + const useCacheCount = await sweepTable(scope[USE_CACHE_TABLE] as SweepableTable, tag, expiredAt, deps); + getLogger(deps).info?.( + `[CacheHandler] deleted ${isrCount + useCacheCount} entries expired by "${tag}" (isr=${isrCount}, useCache=${useCacheCount})` + ); +} - const logger = getLogger(deps); - - try { - const isrCount = await sweepTable(scope[ISR_TABLE] as SweepableTable, 'lastModified', tag, timestamp, deps); - const useCacheCount = await sweepTable(scope[USE_CACHE_TABLE] as SweepableTable, 'timestamp', tag, timestamp, deps); - - logger.info( - `[CacheHandler] invalidated ${isrCount + useCacheCount} entries for "${tag}" (isr=${isrCount}, useCache=${useCacheCount})` - ); - - await (scope[INVALIDATION_TABLE] as InvalidationTable).delete(tag); - } catch (error) { - logger.error(`[CacheHandler] sweep failed for "${tag}"; soft invalidation remains in effect`, error); +const queuedSweeps = new Map(); +let activeSweeps = 0; + +function drainSweeps(deps: InvalidationDeps): void { + while (activeSweeps < SWEEP_TAG_CONCURRENCY && queuedSweeps.size > 0) { + const [tag, expiredAt] = queuedSweeps.entries().next().value as [string, number]; + queuedSweeps.delete(tag); + activeSweeps++; + void sweepTag(tag, expiredAt, deps) + .catch((error) => { + getLogger(deps).error(`[CacheHandler] sweep failed for "${tag}"; entries are left to their TTL`, error); + }) + .finally(() => { + activeSweeps--; + drainSweeps(deps); + }); } } -async function isOverAdmissionThreshold(table: InvalidationTable, deps: InvalidationDeps): Promise { - if (typeof table.getRecordCount !== 'function') return false; - try { - const { recordCount } = await table.getRecordCount(); - return recordCount > MAX_PENDING_INVALIDATIONS; - } catch (error) { - getLogger(deps).error('[CacheHandler] could not read invalidation record count', error); - return false; +function scheduleSweep(tag: string, expiredAt: number, deps: InvalidationDeps): void { + if (deps.scheduleSweep) { + deps.scheduleSweep(() => sweepTag(tag, expiredAt, deps)); + return; } + queuedSweeps.set(tag, Math.max(queuedSweeps.get(tag) ?? 0, expiredAt)); + drainSweeps(deps); } /** - * Record a tag invalidation: update this worker immediately, persist a row for every other worker and - * node, then schedule the background sweep. The caller is never blocked on the sweep. + * Record a tag invalidation: adopt it on this worker immediately, then persist a tombstone for every + * other worker and node that lives as long as any entry it invalidates can. A hard expiry is also swept + * onto the entries. The caller is never blocked on the sweep. */ +// A single `revalidateTag` reaches both handlers: Next calls the "use cache" handler's `updateTags` and +// the incremental cache's `revalidateTag` in the same turn. Both land here with the same tags and +// durations, so the second is dropped rather than writing every tombstone and running every sweep twice. +const recordedThisTurn = new Set(); + +function claimForThisTurn(tag: string, durations: { expire?: number } | undefined): boolean { + const key = `${tag}\u0000${durations ? (durations.expire ?? 'stale') : 'expired'}`; + if (recordedThisTurn.has(key)) return false; + if (recordedThisTurn.size === 0) setImmediate(() => recordedThisTurn.clear()); + recordedThisTurn.add(key); + return true; +} + export async function recordInvalidation( tags: string[], durations: { expire?: number } | undefined, deps: InvalidationDeps = {} ): Promise { - // Deduplicate before anything else: duplicates cost a redundant put each, and — more expensively — - // schedule a duplicate sweep, which scans because tags cannot be indexed. - const uniqueTags = Array.from(new Set(tags)); + // Duplicates would each cost a redundant tombstone write and a redundant sweep. + const uniqueTags = Array.from(new Set(tags)).filter((tag) => claimForThisTurn(tag, durations)); if (uniqueTags.length === 0) return; - const databases = getDatabases(deps); - if (!databases) return; - - const scope = (databases as unknown as Record>)[DATABASE]; + const scope = getScope(deps); if (!scope) return; + const tombstones = scope[INVALIDATION_TABLE] as InvalidationTable; + + const now = nowFor(deps); + const issued = invalidationFor(now, durations, tombstoneLifetimeMs(deps)); + + const merged = uniqueTags.map((tag) => { + // Like Next's own `{...existing, stale, expired}`: a later invalidation keeps the parts of an + // earlier one it does not replace. + const existing = cacheInvalidations.get(tag); + const invalidation: TagInvalidation = { + ...issued, + stale: issued.stale ?? existing?.stale, + expired: issued.expired ?? existing?.expired, + }; + noteInvalidation(tag, invalidation, deps); + return { tag, invalidation }; + }); - const invalidationTable = scope[INVALIDATION_TABLE] as InvalidationTable; - - // `durations.expire` defers the invalidation: Next uses it to expire a tag at a future point rather - // than immediately. - const timestamp = Date.now() + (durations?.expire !== undefined ? durations.expire * 1000 : 0); - - noteInvalidation(uniqueTags, timestamp, deps); - - await Promise.all(uniqueTags.map((tag) => invalidationTable.put(tag, { timestamp }))); - - if (await isOverAdmissionThreshold(invalidationTable, deps)) { - getLogger(deps).error( - `[CacheHandler] ${MAX_PENDING_INVALIDATIONS}+ pending invalidations; shedding sweep for ${uniqueTags.length} tag(s)` - ); - return; - } - - // `deps.scheduleSweep` is how the tests drive the sweep regardless of the default. - if (!SWEEP_ENABLED && !deps.scheduleSweep) return; - - const schedule = deps.scheduleSweep ?? ((task: () => Promise) => void task()); - for (const tag of uniqueTags) { - if (!isSweepableTag(tag)) continue; - schedule(() => sweepTag(tag, timestamp, deps)); + await Promise.all( + merged.map(({ tag, invalidation }) => + tombstones.put( + tag, + { timestamp: now, stale: invalidation.stale, expired: invalidation.expired, lapsesAt: invalidation.lapsesAt }, + // Outlives the table's own expiration, which only bounds the entries, not their tombstones. + { expiresAt: invalidation.lapsesAt } + ) + ) + ); + + if (isHardExpiry(issued)) { + for (const tag of uniqueTags) scheduleSweep(tag, issued.expired as number, deps); } } diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index ac5ad19..0f730ea 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -2,61 +2,91 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import { - NEXT_IMPLICIT_TAG_PREFIX, cacheInvalidations, chunk, - isInvalidated, - isSweepableTag, + hydrateInvalidations, + initializeInvalidationSubscription, + invalidationFor, noteInvalidation, + passedExpiration, + pruneInvalidations, recordInvalidation, + resetInvalidationStateForTesting, runWithConcurrency, sweepTag, - hydrateInvalidations, + tagState, + tombstoneLifetimeMs, + tombstoneToInvalidation, type InvalidationDeps, + type TagInvalidation, } from './cacheInvalidation.cjs'; -/** A stand-in for a Harper cache table: records in a Map, with search/invalidate/delete recorded. */ -function makeCacheTable(records: Array<{ id: string; tags: string[]; lastModified?: number; timestamp?: number }>) { +const DAY_MS = 86_400_000; +const WEEK_MS = 7 * DAY_MS; +const MARGIN_MS = 3_600_000; + +function invalidation(fields: Partial & { at: number }): TagInvalidation { + return { lapsesAt: Number.MAX_SAFE_INTEGER, ...fields }; +} + +type CacheRecord = { id: string; tags: string[]; lastModified?: number; timestamp?: number }; + +/** + * A stand-in for a Harper cache table. `equals` on an array attribute matches an element exactly, the + * way Harper's per-element index does — not a substring, which is what `contains` would do. + */ +function makeCacheTable(records: CacheRecord[], expirationMS = WEEK_MS) { + const rows = new Map(records.map((record) => [record.id, record])); return { - records, - patched: [] as string[], + rows, + expirationMS, deleted: [] as string[], - search(request: { conditions: Array<{ attribute: string; comparator: string; value: unknown }>; select?: string[] }) { - const matches = records.filter((record) => + conditions: [] as Array<{ attribute: string; comparator: string; value: unknown }>, + search(request: { conditions: Array<{ attribute: string; comparator: string; value: unknown }> }) { + this.conditions.push(...request.conditions); + const matches = [...rows.values()].filter((record) => request.conditions.every((condition) => { const actual = (record as unknown as Record)[condition.attribute]; - if (condition.comparator === 'contains') return Array.isArray(actual) && actual.includes(condition.value as string); - if (condition.comparator === 'less_than') return (actual as number) < (condition.value as number); - return actual === condition.value; + if (condition.comparator !== 'equals') throw new Error(`unexpected comparator ${condition.comparator}`); + return Array.isArray(actual) ? actual.includes(condition.value) : actual === condition.value; }) ); return (async function* () { - for (const match of matches) yield match; + for (const match of matches) yield { ...match }; })(); }, - async patch(id: string, value: { invalidatedAt: number }) { - this.patched.push(id); - const record = records.find((candidate) => candidate.id === id); - if (record) (record as Record).invalidatedAt = value.invalidatedAt; + async get(id: string) { + return rows.get(id); }, async delete(id: string) { this.deleted.push(id); + rows.delete(id); }, }; } -function makeInvalidationTable(recordCount = 0) { +function makeInvalidationTable(rows: Array<{ id: string } & Record> = []) { return { - puts: [] as Array<{ key: string; value: { timestamp: number } }>, - deleted: [] as string[], - async put(key: string, value: { timestamp: number }) { - this.puts.push({ key, value }); + rows, + expirationMS: WEEK_MS, + puts: [] as Array<{ key: string; value: Record; context?: { expiresAt?: number } }>, + async put(key: string, value: Record, context?: { expiresAt?: number }) { + this.puts.push({ key, value, context }); }, - async delete(key: string) { - this.deleted.push(key); + search() { + return (async function* () { + for (const row of rows) yield row; + })(); }, - async getRecordCount() { - return { recordCount }; + listener: undefined as undefined | ((event: { type: string; id: string; value?: Record }) => void), + subscribeCalls: 0, + async subscribe() { + this.subscribeCalls++; + return { + on: (event: string, listener: never) => { + if (event === 'data') this.listener = listener; + }, + }; }, }; } @@ -66,28 +96,37 @@ function makeDeps( isr?: ReturnType; useCache?: ReturnType; invalidation?: ReturnType; + now?: number; } = {} -): InvalidationDeps & { sleeps: number[]; errors: unknown[]; pendingSweeps: () => Promise } { +): InvalidationDeps & { sleeps: number[]; errors: unknown[]; runSweeps: () => Promise; sweepCount: () => number } { const isr = overrides.isr ?? makeCacheTable([]); const useCache = overrides.useCache ?? makeCacheTable([]); - const invalidation = overrides.invalidation ?? makeInvalidationTable(); + const invalidationTable = overrides.invalidation ?? makeInvalidationTable(); const sleeps: number[] = []; const errors: unknown[] = []; const sweeps: Array<() => Promise> = []; + let swept = 0; return { sleeps, errors, + maintenance: false, + tagsManifestModule: null, + now: overrides.now !== undefined ? () => overrides.now as number : undefined, scheduleSweep: (task) => { sweeps.push(task); }, - pendingSweeps: async () => { - for (const sweep of sweeps.splice(0)) await sweep(); + runSweeps: async () => { + for (const sweep of sweeps.splice(0)) { + swept++; + await sweep(); + } }, + sweepCount: () => swept + sweeps.length, databases: { harperfast_nextjs: { nextjs_isr_cache: isr, nextjs_use_cache: useCache, - nextjs_cache_invalidation: invalidation, + nextjs_cache_invalidation: invalidationTable, }, } as never, sleep: async (ms: number) => { @@ -102,68 +141,310 @@ function makeDeps( }; } -describe('isInvalidated', () => { - beforeEach(() => cacheInvalidations.clear()); +describe('invalidationFor', () => { + it('expires immediately when Next passes no durations', () => { + assert.deepEqual(invalidationFor(1000, undefined, 10), { expired: 1000, at: 1000, lapsesAt: 1010 }); + }); - it('is true when a tag was invalidated after the entry was written', () => { - cacheInvalidations.set('products', 2000); - assert.equal(isInvalidated(['products'], 1000, [], []), true); + it('goes stale now and expires later when Next passes an expire duration', () => { + const result = invalidationFor(1000, { expire: 60 }, 10); + assert.equal(result.stale, 1000); + assert.equal(result.expired, 61_000); }); - it('is false when the entry was written after the invalidation', () => { - cacheInvalidations.set('products', 1000); - assert.equal(isInvalidated(['products'], 2000, [], []), false); + it('only goes stale when durations carry no expire', () => { + const result = invalidationFor(1000, {}, 10); + assert.equal(result.stale, 1000); + assert.equal(result.expired, undefined); + }); +}); + +describe('tombstoneToInvalidation', () => { + it('reads a row written before stale/expired existed as expired at its timestamp', () => { + const result = tombstoneToInvalidation({ timestamp: 5000 }); + assert.equal(result?.expired, 5000); + assert.equal(result?.stale, undefined); + assert.equal(result?.lapsesAt, 5000 + WEEK_MS); + }); + + it('keeps the stale and expired a current row carries', () => { + const result = tombstoneToInvalidation({ timestamp: 5000, stale: 5000, expired: 65_000, lapsesAt: 99_000 }); + assert.deepEqual(result, { stale: 5000, expired: 65_000, at: 5000, lapsesAt: 99_000 }); }); - it('honours per-request revalidatedTags', () => { - assert.equal(isInvalidated(['products'], 5000, ['products'], []), true); + it('ignores an event that carries no timestamp', () => { + assert.equal(tombstoneToInvalidation({}), undefined); }); - it('falls back to context tags when the record carries none', () => { - cacheInvalidations.set('products', 2000); - assert.equal(isInvalidated([], 1000, [], ['products']), true); + // The previous plugin wrote revalidateTag(tag, 'max') as `now + 1 year`. Ordered by that, the legacy + // row outranked every invalidation issued after it, and they were all ignored until a restart. + it('dates a legacy row written in the future no later than now, so newer invalidations win', () => { + const now = 1_000_000; + const legacy = tombstoneToInvalidation({ timestamp: now + 365 * DAY_MS }, now); + assert.equal(legacy?.at, now); + + resetInvalidationStateForTesting(); + noteInvalidation('posts', legacy as TagInvalidation, { tagsManifestModule: null, now: () => now }); + noteInvalidation('posts', invalidation({ stale: now + 10, at: now + 10 }), { tagsManifestModule: null, now: () => now }); + assert.equal(cacheInvalidations.get('posts')?.stale, now + 10); }); }); -describe('isSweepableTag', () => { - it('rejects Next implicit route tags, which can match the whole cache', () => { - assert.equal(isSweepableTag(`${NEXT_IMPLICIT_TAG_PREFIX}/layout`), false); - assert.equal(isSweepableTag(`${NEXT_IMPLICIT_TAG_PREFIX}/guest-home/page`), false); +describe('tagState', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('is expired when the tag expired after the entry was written', () => { + cacheInvalidations.set('products', invalidation({ expired: 2000, at: 2000 })); + assert.equal(tagState(['products'], 1000, 3000), 'expired'); + }); + + it('is fresh when the entry was written after the invalidation', () => { + cacheInvalidations.set('products', invalidation({ expired: 1000, at: 1000 })); + assert.equal(tagState(['products'], 2000, 3000), undefined); }); - it('accepts explicit user tags', () => { - assert.equal(isSweepableTag('products'), true); - assert.equal(isSweepableTag('home:TN:12'), true); + // revalidateTag(tag, 'max'): stale now, expired a year out. Treating the expiry as the stale time + // kept every entry — including ones regenerated afterwards — stale or missing for the whole year. + it('treats a deferred expiry as stale until it passes, and only for entries written before it', () => { + const now = 10_000; + cacheInvalidations.set('products', invalidation({ stale: now, expired: now + 365 * DAY_MS, at: now })); + + assert.equal(tagState(['products'], now - 1, now + 1), 'stale'); + assert.equal(tagState(['products'], now + 5, now + 10), undefined, 'a regenerated entry must be fresh'); + assert.equal(tagState(['products'], now - 1, now + 366 * DAY_MS), 'expired'); + }); +}); + +describe('passedExpiration', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('reports the newest expiration that has passed', () => { + cacheInvalidations.set('a', invalidation({ expired: 1000, at: 1000 })); + cacheInvalidations.set('b', invalidation({ expired: 5000, at: 5000 })); + assert.equal(passedExpiration(['a', 'b'], 6000), 5000); + }); + + it('does not report an expiration still in the future', () => { + cacheInvalidations.set('a', invalidation({ stale: 1000, expired: 1000 + 365 * DAY_MS, at: 1000 })); + assert.equal(passedExpiration(['a'], 2000), 0); }); }); describe('noteInvalidation', () => { - beforeEach(() => cacheInvalidations.clear()); + beforeEach(() => resetInvalidationStateForTesting()); + + it('keeps the newest invalidation when views arrive out of order', () => { + noteInvalidation('a', invalidation({ expired: 2000, at: 2000 }), { tagsManifestModule: null }); + noteInvalidation('a', invalidation({ expired: 1000, at: 1000 }), { tagsManifestModule: null }); + assert.equal(cacheInvalidations.get('a')?.at, 2000); + }); + + it('ignores an invalidation whose tombstone has already lapsed', () => { + noteInvalidation('a', invalidation({ expired: 1000, at: 1000, lapsesAt: 1500 }), { + tagsManifestModule: null, + now: () => 2000, + }); + assert.equal(cacheInvalidations.has('a'), false); + }); +}); + +describe('Next tags manifest mirror', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('writes {stale, expired} for Next 16', () => { + const tagsManifest = new Map(); + const next16 = { tagsManifest, areTagsStale: () => false }; + + noteInvalidation('a', invalidation({ stale: 1000, expired: 61_000, at: 1000 }), { tagsManifestModule: next16 }); + + assert.deepEqual(tagsManifest.get('a'), { stale: 1000, expired: 61_000 }); + }); + + // Next 15 keys a number per tag. Setting `.stale` on that number throws in strict mode, and writing an + // object makes Next's own `Math.max` over the manifest NaN. + it('writes a timestamp, not an object, for Next 15', () => { + const tagsManifest = new Map([['a', 500]]); + const next15 = { tagsManifest, isStale: () => false }; + + noteInvalidation('a', invalidation({ expired: 1000, at: 1000 }), { tagsManifestModule: next15 }); + + assert.equal(tagsManifest.get('a'), 1000); + }); + + it('does nothing for Next 14, which has no manifest', () => { + assert.doesNotThrow(() => noteInvalidation('a', invalidation({ expired: 1000, at: 1000 }), { tagsManifestModule: null })); + }); + + it('withdraws a pruned invalidation only if the manifest still holds it', () => { + const tagsManifest = new Map(); + const deps = { tagsManifestModule: { tagsManifest, areTagsStale: () => false }, now: () => 5000 }; + noteInvalidation('mine', invalidation({ expired: 1000, at: 1000, lapsesAt: 6000 }), deps); + noteInvalidation('rewritten', invalidation({ expired: 1000, at: 1000, lapsesAt: 6000 }), deps); + tagsManifest.set('rewritten', { expired: 4000 }); + + pruneInvalidations({ ...deps, now: () => 7000 }); + + assert.equal(tagsManifest.has('mine'), false); + assert.deepEqual(tagsManifest.get('rewritten'), { expired: 4000 }, 'Next wrote this tag itself since'); + }); +}); + +describe('pruneInvalidations', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('drops invalidations whose tombstone has lapsed and keeps the rest', () => { + cacheInvalidations.set('old', invalidation({ expired: 1, at: 1, lapsesAt: 100 })); + cacheInvalidations.set('live', invalidation({ expired: 1, at: 1, lapsesAt: 10_000 })); + + pruneInvalidations({ tagsManifestModule: null, now: () => 5000 }); + + assert.deepEqual([...cacheInvalidations.keys()], ['live']); + }); +}); + +describe('tombstoneLifetimeMs', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('outlives the longer of the two cache tables by the margin', () => { + const deps = makeDeps({ isr: makeCacheTable([], 2 * DAY_MS), useCache: makeCacheTable([], 9 * DAY_MS) }); + assert.equal(tombstoneLifetimeMs(deps), 9 * DAY_MS + MARGIN_MS); + }); + + it('reports once when a cache table has no expiration, since nothing can then bound a tombstone', () => { + const deps = makeDeps({ useCache: makeCacheTable([], 0) }); + tombstoneLifetimeMs(deps); + tombstoneLifetimeMs(deps); + assert.equal(deps.errors.length, 1); + }); +}); + +describe('recordInvalidation', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('persists a tombstone per tag, dated now, that outlives every entry it can invalidate', async () => { + const invalidationTable = makeInvalidationTable(); + const deps = makeDeps({ invalidation: invalidationTable, now: 1_000_000 }); + + await recordInvalidation(['products', 'deals', 'products'], undefined, deps); + + assert.deepEqual(invalidationTable.puts.map((put) => put.key).sort(), ['deals', 'products'], 'deduplicated'); + const [put] = invalidationTable.puts; + assert.equal(put.value.timestamp, 1_000_000); + assert.equal(put.value.expired, 1_000_000); + assert.equal(put.value.lapsesAt, 1_000_000 + WEEK_MS + MARGIN_MS); + assert.equal(put.context?.expiresAt, put.value.lapsesAt, 'the per-record expiry is what outlasts the table TTL'); + assert.ok(cacheInvalidations.has('products'), 'this worker sees it before the write returns'); + }); + + it('dates a deferred expiry from now, not from when it expires', async () => { + const invalidationTable = makeInvalidationTable(); + const deps = makeDeps({ invalidation: invalidationTable, now: 1_000_000 }); + + await recordInvalidation(['products'], { expire: 60 }, deps); + + const [put] = invalidationTable.puts; + assert.equal(put.value.timestamp, 1_000_000); + assert.equal(put.value.stale, 1_000_000); + assert.equal(put.value.expired, 1_060_000); + }); + + it('keeps the stale time of an earlier invalidation a hard expiry does not replace', async () => { + const invalidationTable = makeInvalidationTable(); + await recordInvalidation(['products'], { expire: 60 }, makeDeps({ invalidation: invalidationTable, now: 1000 })); + await recordInvalidation(['products'], undefined, makeDeps({ invalidation: invalidationTable, now: 2000 })); + + assert.deepEqual(invalidationTable.puts[1].value, { + timestamp: 2000, + stale: 1000, + expired: 2000, + lapsesAt: 2000 + WEEK_MS + MARGIN_MS, + }); + }); - it('records the timestamp so reads on this worker see it immediately', () => { - noteInvalidation(['a', 'b'], 1234); - assert.equal(cacheInvalidations.get('a'), 1234); - assert.equal(cacheInvalidations.get('b'), 1234); + // Next reaches both handlers for one revalidateTag, in the same turn. + it('records a revalidateTag that reaches both handlers only once', async () => { + const invalidationTable = makeInvalidationTable(); + const deps = makeDeps({ invalidation: invalidationTable, now: 1000 }); + + await Promise.all([recordInvalidation(['products'], undefined, deps), recordInvalidation(['products'], undefined, deps)]); + + assert.equal(invalidationTable.puts.length, 1); + assert.equal(deps.sweepCount(), 1); + + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['products'], undefined, deps); + assert.equal(invalidationTable.puts.length, 2, 'a later invalidation of the same tag is recorded'); + }); + + it('sweeps a hard expiry, including implicit route tags', async () => { + const isr = makeCacheTable([{ id: '/a', tags: ['_N_T_/layout'], lastModified: 500 }]); + const deps = makeDeps({ isr, now: 1000 }); + + await recordInvalidation(['_N_T_/layout'], undefined, deps); + await deps.runSweeps(); + + assert.deepEqual(isr.deleted, ['/a']); + }); + + it('does not sweep a stale-then-expire invalidation, which the tombstone alone must carry', async () => { + const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); + const deps = makeDeps({ isr, now: 1000 }); + + await recordInvalidation(['products'], { expire: 60 }, deps); + + assert.equal(deps.sweepCount(), 0); + assert.deepEqual(isr.deleted, [], 'a stale entry must stay servable while it regenerates'); }); }); describe('sweepTag', () => { - beforeEach(() => cacheInvalidations.clear()); + beforeEach(() => resetInvalidationStateForTesting()); - it('invalidates matching records rather than deleting them, preserving stale-while-revalidate', async () => { + // Harper 5.2.0 refuses an indexed lookup while it reports the index as rebuilding — and can keep + // reporting that on every worker but one until they restart. + it('falls back to an exact-match scan while Harper reports the tags index as rebuilding', async () => { const isr = makeCacheTable([ - { id: '/a', tags: ['products'], lastModified: 500 }, - { id: '/b', tags: ['products'], lastModified: 500 }, + { id: '/posts', tags: ['posts'], lastModified: 500 }, + { id: '/archive', tags: ['posts-archive'], lastModified: 500 }, ]); + const search = isr.search.bind(isr); + isr.search = (request) => { + if (request.conditions.length > 0) { + const error = new Error('"tags" is not indexed yet, can not search for this attribute'); + error.name = 'IndexRebuildingError'; + return (async function* () { + throw error; + })(); + } + return (async function* () { + for (const row of isr.rows.values()) yield { ...row }; + })(); + }; + void search; const deps = makeDeps({ isr }); - await sweepTag('products', 1000, deps); + await sweepTag('posts', 1000, deps); - assert.deepEqual(isr.patched.sort(), ['/a', '/b']); - assert.deepEqual(isr.deleted, [], 'must mark, not destroy — a miss costs a full render'); + assert.deepEqual(isr.deleted, ['/posts']); }); - it('skips records written after the invalidation timestamp', async () => { + it('looks entries up by tag with an index-backed equals, not a substring scan', async () => { + const isr = makeCacheTable([ + { id: '/posts', tags: ['posts'], lastModified: 500 }, + { id: '/archive', tags: ['posts-archive'], lastModified: 500 }, + ]); + const deps = makeDeps({ isr }); + + await sweepTag('posts', 1000, deps); + + assert.deepEqual(isr.deleted, ['/posts'], '`contains` would also have matched posts-archive'); + assert.deepEqual( + isr.conditions.map((condition) => condition.comparator), + ['equals'] + ); + }); + + it('leaves entries written after the invalidation', async () => { const isr = makeCacheTable([ { id: '/stale', tags: ['products'], lastModified: 500 }, { id: '/fresh', tags: ['products'], lastModified: 1500 }, @@ -172,27 +453,33 @@ describe('sweepTag', () => { await sweepTag('products', 1000, deps); - assert.deepEqual(isr.patched, ['/stale']); + assert.deepEqual(isr.deleted, ['/stale']); }); - it('sweeps both cache tables, each on its own time column', async () => { - const isr = makeCacheTable([{ id: '/page', tags: ['products'], lastModified: 500 }]); - const useCache = makeCacheTable([{ id: 'uc-1', tags: ['products'], timestamp: 500 }]); - const deps = makeDeps({ isr, useCache }); + it('spares an entry regenerated between the search and the delete', async () => { + const isr = makeCacheTable([{ id: '/raced', tags: ['products'], lastModified: 500 }]); + const search = isr.search.bind(isr); + isr.search = (request) => { + const results = search(request); + isr.rows.set('/raced', { id: '/raced', tags: ['products'], lastModified: 1500 }); + return results; + }; + const deps = makeDeps({ isr }); await sweepTag('products', 1000, deps); - assert.deepEqual(isr.patched, ['/page']); - assert.deepEqual(useCache.patched, ['uc-1']); + assert.deepEqual(isr.deleted, []); }); - it('clears the invalidation row once the sweep completes', async () => { - const invalidation = makeInvalidationTable(); - const deps = makeDeps({ invalidation }); + it('sweeps both cache tables, each on its own write-time column', async () => { + const isr = makeCacheTable([{ id: '/page', tags: ['products'], lastModified: 500 }]); + const useCache = makeCacheTable([{ id: 'uc-1', tags: ['products'], timestamp: 500 }]); + const deps = makeDeps({ isr, useCache }); await sweepTag('products', 1000, deps); - assert.deepEqual(invalidation.deleted, ['products']); + assert.deepEqual(isr.deleted, ['/page']); + assert.deepEqual(useCache.deleted, ['uc-1']); }); it('pauses between chunks but not after the final one', async () => { @@ -205,112 +492,88 @@ describe('sweepTag', () => { await sweepTag('products', 1000, deps); - // 250 records over a 100-record chunk size = 3 chunks = 2 inter-chunk pauses, per table. + // 250 records over a 100-record chunk size = 3 chunks = 2 inter-chunk pauses. assert.equal(deps.sleeps.length, 2); }); +}); - it('leaves the invalidation row in place when the sweep fails, so soft invalidation still covers reads', async () => { - const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); - isr.patch = async () => { - throw new Error('boom'); - }; - const invalidation = makeInvalidationTable(); - const deps = makeDeps({ isr, invalidation }); +describe('surviving a worker restart', () => { + beforeEach(() => resetInvalidationStateForTesting()); - await sweepTag('products', 1000, deps); + it('restores an invalidation the dead worker had only in memory', async () => { + await hydrateInvalidations(makeInvalidationTable([{ id: 'products', timestamp: 2000 }]), { + tagsManifestModule: null, + now: () => 3000, + }); - assert.deepEqual(invalidation.deleted, [], 'row must survive a failed sweep'); - assert.equal(deps.errors.length, 1, 'failure is logged, not thrown'); + assert.equal(tagState(['products'], 1000, 3000), 'expired', 'the invalidation did not survive the restart'); }); -}); - -describe('recordInvalidation', () => { - beforeEach(() => cacheInvalidations.clear()); - it('writes a row per tag and updates the in-memory map synchronously', async () => { - const invalidation = makeInvalidationTable(); - const deps = makeDeps({ invalidation }); - - await recordInvalidation(['products', 'deals'], undefined, deps); + it('does not resurrect an entry written after the invalidation', async () => { + await hydrateInvalidations(makeInvalidationTable([{ id: 'products', timestamp: 2000 }]), { + tagsManifestModule: null, + now: () => 3000, + }); - assert.deepEqual( - invalidation.puts.map((put) => put.key).sort(), - ['deals', 'products'] - ); - assert.ok(cacheInvalidations.has('products')); - assert.ok(cacheInvalidations.has('deals')); + assert.equal(tagState(['products'], 5000, 6000), undefined); }); +}); - it('does not sweep Next implicit tags', async () => { - const isr = makeCacheTable([{ id: '/a', tags: [`${NEXT_IMPLICIT_TAG_PREFIX}/layout`], lastModified: 500 }]); - const deps = makeDeps({ isr }); +describe('initializeInvalidationSubscription', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('subscribes before hydrating, so an invalidation written during the scan is not lost', async () => { + const invalidationTable = makeInvalidationTable([{ id: 'old', timestamp: 1000 }]); + const search = invalidationTable.search.bind(invalidationTable); + invalidationTable.search = () => { + // Another worker's invalidation lands while this one is still reading the table back. + invalidationTable.listener?.({ type: 'put', id: 'during', value: { timestamp: 2000 } }); + return search(); + }; + const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); - await recordInvalidation([`${NEXT_IMPLICIT_TAG_PREFIX}/layout`], undefined, deps); - await deps.pendingSweeps?.(); + await initializeInvalidationSubscription(deps); - assert.deepEqual(isr.patched, [], 'implicit tags are left to TTL'); + assert.ok(cacheInvalidations.has('old')); + assert.ok(cacheInvalidations.has('during')); }); - it('skips sweeping when the invalidation table is over the admission threshold', async () => { - const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); - const deps = makeDeps({ isr, invalidation: makeInvalidationTable(100_000) }); + it('shares one attempt between concurrent callers, and reads are only served after it completes', async () => { + const invalidationTable = makeInvalidationTable([{ id: 'products', timestamp: 2000 }]); + const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); - await recordInvalidation(['products'], undefined, deps); - await deps.pendingSweeps?.(); + const first = initializeInvalidationSubscription(deps); + const second = initializeInvalidationSubscription(deps); + await Promise.all([first, second]); - assert.deepEqual(isr.patched, [], 'sweep is shed under backpressure; soft invalidation still applies'); + assert.equal(invalidationTable.subscribeCalls, 1); + assert.ok(cacheInvalidations.has('products'), 'awaiting the shared attempt yields a hydrated map'); }); -}); - -describe('surviving a worker restart', () => { - beforeEach(() => cacheInvalidations.clear()); - /** A tombstone table as a restarted worker would find it. */ - function tombstoneTable(rows: Array<{ id: string; timestamp: number }>) { - return { - search() { - return (async function* () { - for (const row of rows) yield row; - })(); - }, + it('backs off after a failure rather than retrying on every request', async () => { + const invalidationTable = makeInvalidationTable(); + invalidationTable.subscribe = async () => { + invalidationTable.subscribeCalls++; + throw new Error('subscribe failed'); }; - } - - it('restores an invalidation the dead worker had only in memory', async () => { - // A worker invalidated a tag, then died before the entry was regenerated. Coming back with an - // empty map, it would serve the stale entry as fresh if the tombstone were not read back. - cacheInvalidations.set('products', 2000); - cacheInvalidations.clear(); + const deps = makeDeps({ invalidation: invalidationTable, now: 1000 }); - await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 2000 }])); + await initializeInvalidationSubscription(deps); + await initializeInvalidationSubscription(deps); + await initializeInvalidationSubscription(deps); - assert.equal( - isInvalidated(['products'], 1000, [], []), - true, - 'the invalidation did not survive the restart' - ); + assert.equal(invalidationTable.subscribeCalls, 1); + assert.equal(deps.errors.length, 1); }); - it('does not resurrect an entry written after the invalidation', async () => { - await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 2000 }])); - - assert.equal(isInvalidated(['products'], 5000, [], []), false); - }); + it('forgets an invalidation when its tombstone is deleted', async () => { + const invalidationTable = makeInvalidationTable([{ id: 'products', timestamp: 2000 }]); + const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); + await initializeInvalidationSubscription(deps); - it('leaves the tombstone in place when the sweep fails, so the next restart still sees it', async () => { - const isr = makeCacheTable([{ id: '/a', tags: ['products'], lastModified: 500 }]); - isr.patch = async () => { - throw new Error('sweep died mid-run'); - }; - const invalidation = makeInvalidationTable(); - const deps = makeDeps({ isr, invalidation }); - - await sweepTag('products', 1000, deps); - cacheInvalidations.clear(); - await hydrateInvalidations(tombstoneTable([{ id: 'products', timestamp: 1000 }])); + invalidationTable.listener?.({ type: 'delete', id: 'products' }); - assert.deepEqual(invalidation.deleted, []); - assert.equal(isInvalidated(['products'], 500, [], []), true); + assert.equal(cacheInvalidations.has('products'), false); }); }); From 826ef5fe7d9012bf9f345ace8a23aba2d794bc04 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 28 Sep 2026 13:12:32 -0500 Subject: [PATCH 10/29] docs: record the Harper and Next.js behaviour the cache handlers rely on Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f36ccda..5d9999d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,4 +29,7 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - `add_node` needs an explicit `authorization: "Basic "`. Passing `username`/`password` alone fails the cert-signing handshake with "No authorization provided". - Each node needs a distinct `node.hostname` (`NODE_HOSTNAME`); it defaults to `localhost`, so two nodes otherwise collide. - Do not `cpSync` a staged rig per node: npm links a `file:` dependency as a relative symlink and Node rewrites it to an absolute host path, which does not exist in the container. Mount one staged directory into every node instead. +- Playwright's Chromium cannot launch inside the Claude Code sandbox (`bootstrap_check_in … Permission denied`): every integration test then fails at browser launch before any assertion. Run integration tests outside the sandbox. +- Harper facts the cache handlers depend on (verified in `node_modules/harper` 5.1.23): each element of an `@indexed` array attribute is indexed, so `{attribute: 'tags', comparator: 'equals', value}` is an index lookup, while `contains` is a full scan doing a *substring* match on `String(array)`. Every write — `patch` included — resets a record's expiry to now + the table's `expiration` unless `put(id, value, { expiresAt })` overrides it, and a sourceless table returns `null` for an expired record. TTL eviction emits no subscription `delete` event. `patch` on a missing record creates a partial one. Adding `@indexed` to an existing attribute backfills in the background, and on 5.2.0 the worker threads that did not run the backfill were observed to keep throwing `IndexRebuildingError` for that attribute after "Finished indexing" was logged, until a restart — which is why the sweep falls back to an exact-match scan. +- Next.js `IncrementalCache` treats a handler-returned `lastModified: -1` as an on-demand (blocking) revalidation, not stale-while-revalidate. Next 16 serves tag-stale FETCH/APP_PAGE/APP_ROUTE entries stale from its `tagsManifest` (`{stale, expired}` per tag); Next 15's manifest holds a number per tag; Next 14 has none. - CI is currently disabled (`if: false` in `.github/workflows/integration-tests.yml`). Run tests locally. From 919d31c4b899145dc09199cad8a264995cf9e5dd Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 28 Sep 2026 15:59:20 -0500 Subject: [PATCH 11/29] fix: set each cache entry's Harper expiry from Next's expire, capped at the table TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both handlers stored Next's `expire` only as a column and left the record's Harper lifetime to the table default, so an entry Next considered dead (e.g. a cacheLife('minutes') entry after an hour) stayed stored for the full 7 days. The tombstone guarantee — no invalidated entry outlives its tombstone — also only held because nothing happened to set a per-record expiry; it was assumed, not enforced. Each write now passes `expiresAt` = write time + Next's `expire`, capped at the table's configured `expiration`, which is the same bound tombstone lifetimes are sized from. With no usable `expire` the table default still applies. Longer entry lifetimes come from raising the table's expiration; tombstones follow it because their lifetime is read from the live table. Verified: 99 unit; 42 integration, including a new check that a cacheLife('hours') row's Harper `$expiresAt` equals its timestamp plus Next's expire. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- integrationTests/next-16-use-cache.pw.ts | 5 +++- src/CacheHandler.cts | 5 +++- src/CacheHandler.test.ts | 21 +++++++++++++--- src/UseCacheHandler.cts | 31 +++++++++++++++--------- src/UseCacheHandler.test.ts | 15 +++++++++++- src/cacheInvalidation.cts | 23 ++++++++++++++++-- src/cacheInvalidation.test.ts | 17 +++++++++++++ 8 files changed, 99 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a1f4635..d45bb13 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ Invalidation follows Next.js's own semantics. `revalidateTag(tag)` and `updateTa 3. On the next `cache.get()`, an entry written before the invalidation is withheld if the tag has expired. If the tag is only stale, it is handed to Next.js to serve stale while regenerating wherever Next.js supports that (Next.js 16 for pages, route handlers and `fetch`; the `'use cache'` handler always). Otherwise it is withheld. 4. A worker that restarts rebuilds its map from the tombstones, so an invalidation survives the process that issued it. -**A tombstone outlives every entry it can invalidate.** Harper caps each cache entry at its table's `expiration`, counted from its last write, so each tombstone is written with its own expiry: the longer of the two cache tables' expirations, plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses. A cache table configured with no expiration breaks that bound and is reported in the log. +**A tombstone outlives every entry it can invalidate.** Each cache entry is written with its own Harper expiry: when Next.js says it stops being usable (its `expire`), capped at its table's `expiration`. An entry Next.js considers dead is evicted then rather than kept for the full table TTL. Each tombstone is written with an expiry of the longer of the two cache tables' expirations, plus an hour for renders that finish after the invalidation and for replication lag. To keep entries longer than the default 7 days, raise the cache tables' `expiration`; tombstone lifetimes follow it automatically. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses. A cache table configured with no expiration breaks that bound and is reported in the log. An immediate expiry is also **swept**: entries carrying the tag are looked up through the indexed `tags` column (an exact-match index lookup, not a scan) and deleted in throttled chunks, so they stop taking up space before their own expiry. This includes Next.js's implicit route tags, so `revalidatePath` is swept too. While Harper reports the index as still being built, the sweep scans with the same exact match instead. The sweep only reclaims space, and reads never depend on it: a sweep that fails or is interrupted leaves the entries to expire on their own. A stale-then-expire invalidation is not swept, because those entries must stay servable while they regenerate. diff --git a/integrationTests/next-16-use-cache.pw.ts b/integrationTests/next-16-use-cache.pw.ts index 5a957c6..259c62c 100644 --- a/integrationTests/next-16-use-cache.pw.ts +++ b/integrationTests/next-16-use-cache.pw.ts @@ -161,10 +161,13 @@ test('cache lives from cacheLife round-trip into the stored entry', async ({ pag await page.goto(`${harper.httpURL}/cached`); await expect(page.getByTestId('nonce')).not.toHaveText('loading'); - const rows = await searchUseCache(request, harper, ['id', 'revalidate', 'expire', 'stale']); + const rows = await searchUseCache(request, harper, ['id', 'timestamp', 'revalidate', 'expire', 'stale', '$expiresAt']); const withLives = rows.find((row: { revalidate?: number }) => typeof row.revalidate === 'number'); // Without these persisted, the entry reads back as revalidate: 0 and is regenerated on every read. expect(withLives, 'no entry carried its cache lives').toBeTruthy(); expect(withLives.expire).toBeGreaterThan(withLives.revalidate); + // Harper evicts the row when Next says it expires (cacheLife('hours') is 1 day), not after the + // table's 7-day default. + expect(withLives.$expiresAt).toBe(withLives.timestamp + withLives.expire * 1000); }); diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index d4977de..d0b3147 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -15,6 +15,7 @@ import type { import type { databases as DatabasesType } from 'harper'; import { + entryExpiresAt, initializeInvalidationSubscription, nextServesStaleTags, recordInvalidation, @@ -143,7 +144,9 @@ export default class HarperCacheHandler implements CacheHandler { const revalidate = typeof cacheControl?.revalidate === 'number' ? Math.floor(cacheControl.revalidate) : undefined; const expire = typeof cacheControl?.expire === 'number' ? Math.floor(cacheControl.expire) : undefined; - await table.put(key, { data, tags, revalidate, expire }); + const now = Date.now(); + const expiresAt = entryExpiresAt(table as { expirationMS?: number }, now, expire, now); + await table.put(key, { data, tags, revalidate, expire }, expiresAt === undefined ? undefined : ({ expiresAt } as never)); } async revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise { diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 3d10c96..cde0f1b 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -32,7 +32,7 @@ interface StoredRecord { } function installDatabases(records: Record = {}) { - const puts: Array<{ key: string; value: Record }> = []; + const puts: Array<{ key: string; value: Record; context?: { expiresAt?: number } }> = []; const invalidationPuts: Array<{ key: string; value: Record }> = []; (globalThis as Record).databases = { @@ -42,8 +42,8 @@ function installDatabases(records: Record = {}) { async get(key: string) { return records[key]; }, - async put(key: string, value: Record) { - puts.push({ key, value }); + async put(key: string, value: Record, context?: { expiresAt?: number }) { + puts.push({ key, value, context }); records[key] = { ...(value as unknown as StoredRecord), lastModified: Date.now() }; }, search() { @@ -112,6 +112,21 @@ describe('HarperCacheHandler per-entry cache lives', () => { assert.equal(puts[0].value.expire, 3600); }); + it("sets the record's Harper expiry from Next's expire, capped at the table's", async () => { + const { puts } = installDatabases(); + const handler = new HarperCacheHandler(); + const before = Date.now(); + + await handler.set('/short', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 3600 } } as never); + await handler.set('/long', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 31_536_000 } } as never); + await handler.set('/none', appPage([]) as never, { cacheControl: { revalidate: 60 } } as never); + + const [short, long, none] = puts.map((put) => put.context?.expiresAt); + assert.ok(short! >= before + 3_600_000 && short! <= Date.now() + 3_600_000); + assert.ok(long! <= Date.now() + 604_800_000, 'no entry may outlive the table TTL its tombstones are sized for'); + assert.equal(none, undefined); + }); + it('stores no revalidate when Next supplies false', async () => { const { puts } = installDatabases(); const handler = new HarperCacheHandler(); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index 9a73e36..b4b1048 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -4,6 +4,7 @@ import type { CacheEntry, CacheHandler, Timestamp } from 'next/dist/server/lib/c import type { databases as DatabasesType } from 'harper'; import { + entryExpiresAt, initializeInvalidationSubscription, passedExpiration, recordInvalidation, @@ -24,7 +25,8 @@ interface StoredEntry { interface UseCacheTable { get(key: string): Promise; - put(key: string, value: Record): Promise | unknown; + put(key: string, value: Record, context?: { expiresAt: number }): Promise | unknown; + expirationMS?: number; } /** @@ -242,16 +244,23 @@ class HarperUseCacheHandler implements CacheHandler { if (!bytes) return undefined; const storageKey = toStorageKey(cacheKey); - await table.put(storageKey, { - // The untruncated key, only for a row whose id had to be shortened. Not indexed. - ...(storageKey === cacheKey ? {} : { cacheKey }), - value: toStoredValue(bytes), - tags: entry.tags ?? [], - timestamp: toInteger(entry.timestamp) ?? Date.now(), - stale: toInteger(entry.stale), - revalidate: toInteger(entry.revalidate), - expire: toInteger(entry.expire), - }); + const timestamp = toInteger(entry.timestamp) ?? Date.now(); + const expire = toInteger(entry.expire); + const expiresAt = entryExpiresAt(table, timestamp, expire); + await table.put( + storageKey, + { + // The untruncated key, only for a row whose id had to be shortened. Not indexed. + ...(storageKey === cacheKey ? {} : { cacheKey }), + value: toStoredValue(bytes), + tags: entry.tags ?? [], + timestamp, + stale: toInteger(entry.stale), + revalidate: toInteger(entry.revalidate), + expire, + }, + expiresAt === undefined ? undefined : { expiresAt } + ); return bytes; })(); diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 0a74065..e16dcff 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -102,8 +102,11 @@ function enforceSchema(value: Record) { } } +const putContexts = new Map(); + function installDatabases() { const rows = new Map>(); + putContexts.clear(); (globalThis as Record).databases = { harperfast_nextjs: { nextjs_use_cache: { @@ -111,10 +114,11 @@ function installDatabases() { async get(key: string) { return rows.get(key); }, - async put(key: string, value: Record) { + async put(key: string, value: Record, context?: { expiresAt?: number }) { enforcePrimaryKey(key); enforceSchema(value); rows.set(key, { id: key, ...value }); + putContexts.set(key, context); }, search() { return (async function* () {})(); @@ -242,6 +246,15 @@ describe('UseCacheHandler cache lives', () => { assert.ok(Number.isInteger(result.expire)); }); + it("sets the record's Harper expiry from the entry's own expire, capped at the table's", async () => { + const now = Date.now(); + await handler.set('hours', Promise.resolve(entry({ timestamp: now, expire: 86_400 }))); + await handler.set('max', Promise.resolve(entry({ timestamp: now, expire: 31_536_000 }))); + + assert.equal(putContexts.get('hours')?.expiresAt, now + 86_400_000); + assert.ok(putContexts.get('max')!.expiresAt! <= Date.now() + 604_800_000); + }); + it('preserves the entry cache lives across the round trip', async () => { await handler.set('lives', Promise.resolve(entry({ stale: 30, revalidate: 120, expire: 900 }))); diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 63d51a0..c62ea62 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -140,10 +140,29 @@ export async function runWithConcurrency( let unboundedCacheReported = false; +/** + * Harper's per-record expiry for a cache entry: when Next says it stops being usable (`expire` seconds + * after `writtenAt`), capped at the table's `expiration`. The cap is what `tombstoneLifetimeMs` relies + * on — no entry may outlive the tombstones that can invalidate it — so the handlers set it explicitly + * rather than leave the record's lifetime to whatever a write's context happens to carry. Undefined + * leaves the table default in place. + */ +export function entryExpiresAt( + table: { expirationMS?: number } | undefined, + writtenAt: number, + expireSeconds: number | undefined, + now: number = Date.now() +): number | undefined { + if (typeof expireSeconds !== 'number' || !Number.isFinite(expireSeconds) || expireSeconds <= 0) return undefined; + const ttl = table?.expirationMS; + const expiresAt = writtenAt + expireSeconds * 1000; + return typeof ttl === 'number' && ttl > 0 ? Math.min(expiresAt, now + ttl) : expiresAt; +} + /** * How long a tombstone must live: past the last moment any entry written before the invalidation can - * still be read. Harper caps every record at its table's `expiration`, counted from its last write, so - * that bound is the longer of the two cache tables' TTLs plus the margin. + * still be read. Entries expire at most their table's `expiration` after their last write (see + * `entryExpiresAt`), so that bound is the longer of the two cache tables' TTLs plus the margin. */ export function tombstoneLifetimeMs(deps: InvalidationDeps = {}): number { const scope = getScope(deps); diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index 0f730ea..d4933cd 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert'; import { cacheInvalidations, chunk, + entryExpiresAt, hydrateInvalidations, initializeInvalidationSubscription, invalidationFor, @@ -302,6 +303,22 @@ describe('pruneInvalidations', () => { }); }); +describe('entryExpiresAt', () => { + it("expires an entry when Next says it stops being usable", () => { + assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 3600, 1000), 1000 + 3_600_000); + }); + + // The cap is what keeps every entry inside the lifetime its tombstones are sized for. + it("caps an entry at the table's expiration", () => { + assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 365 * 86_400, 1000), 1000 + WEEK_MS); + }); + + it('leaves the table default when Next gives no usable expire', () => { + assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, undefined), undefined); + assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 0), undefined); + }); +}); + describe('tombstoneLifetimeMs', () => { beforeEach(() => resetInvalidationStateForTesting()); From c1d82509dc46bc446edd7f39564378bb21d6e2a4 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 29 Sep 2026 12:36:56 -0500 Subject: [PATCH 12/29] feat: keep cache entries up to a year, and optionally clear earlier builds' "use cache" entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry lifetimes were capped at the cache tables' 7-day `expiration`, so the `weeks` and `max` cacheLife profiles were evicted before (or just as) they turned stale and never got their stale-while-revalidate window. The cap is now its own constant, one year (Next's `max` expire), independent of the tables' `expiration`: Harper honours a record's own `expiresAt` past the table default, so no table migration is needed. Tombstones are sized from the cap and now live a year plus the margin, or longer if a cache table's expiration is. Cache lives are stored bounded to 0..1 year. Next's `default` profile sets `expire` to INFINITE_CACHE (0xfffffffe), outside Harper's 32-bit Int range, so every such "use cache" write was refused — caught and logged, so the entry silently never landed and was re-rendered on every request. Next puts the build ID in every "use cache" key, so each deploy leaves the previous build's entries unreadable, and with a one-year cap they would stay a year. Setting HARPER_NEXTJS_SWEEP_OLD_BUILDS=true enables a sweep: five minutes after start (so a rolling restart reaches the other nodes first), worker 0 on each node deletes entries whose key names a build that is neither its own nor the latest successful build of any app in nextjs_build_info. Keys that cannot be attributed to a build, and `development`, are kept. Off by default. Verified: 117 unit; 11 integration (next-16-use-cache, next-16-caching), run before the opt-in flag and the lower bound on stored lives were added; and the TSC homepage on a local Harper 5.2.13 amd64 container with 13 threads. With the flag unset, seeded old-build rows survived past the delay. With it set, one worker (http/1) deleted exactly the three rows from old or failed builds and kept the current build's 13, another app's current build, `development`, and a FormData-shaped key. Real rows' $expiresAt equals timestamp + expire. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 +- src/CacheHandler.cts | 11 +-- src/CacheHandler.test.ts | 8 +- src/UseCacheHandler.cts | 16 ++-- src/UseCacheHandler.test.ts | 38 ++++++--- src/buildSweep.test.ts | 142 ++++++++++++++++++++++++++++++++++ src/buildSweep.ts | 118 ++++++++++++++++++++++++++++ src/cacheInvalidation.cts | 40 +++++++--- src/cacheInvalidation.test.ts | 54 ++++++++++--- src/plugin.ts | 25 ++++++ 10 files changed, 412 insertions(+), 48 deletions(-) create mode 100644 src/buildSweep.test.ts create mode 100644 src/buildSweep.ts diff --git a/README.md b/README.md index d45bb13..8c402de 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ export default withHarper( Requires Next.js 16, which is where the interface exists. Opt-in for now — it becomes the default in the next major, because turning it on changes where existing apps' `'use cache'` entries are stored. - **Values are streamed.** Each entry's rendered bytes are stored as a Harper `Blob` and streamed back from storage on every read, rather than read into memory whole. A write is buffered until the render completes, so a render that fails partway is never stored. +- **Entries from earlier builds can be deleted (opt-in).** Next.js puts the build ID in every `'use cache'` key, so a deploy leaves the previous build's entries unreadable — and with a `max` profile each would otherwise stay a year. Set `HARPER_NEXTJS_SWEEP_OLD_BUILDS=true` in the Harper process's environment to clear them: five minutes after starting a build, one worker per node deletes every entry whose key names a build that is neither its own nor the latest successful build of another app in `nextjs_build_info`. The delay lets a rolling restart reach the other nodes first; deleting an entry a node on the old build is still serving would only cost it a render. Keys that cannot be attributed to a build (Next.js encodes non-JSON arguments differently) are left to expire on their own. Off by default, the entries simply expire. - **Keys of any size work.** Next.js builds a `'use cache'` key from the component's props and puts no limit on its size, while Harper caps a primary key at 1978 bytes. A key over 1500 bytes is stored under a truncated prefix plus a hash of the full key, and the full key is kept in the row's `cacheKey` column. Shorter keys are stored as they are. Large props still make large keys, so keep a cached component's props small. ### Per-entry cache lives @@ -326,7 +327,9 @@ Invalidation follows Next.js's own semantics. `revalidateTag(tag)` and `updateTa 3. On the next `cache.get()`, an entry written before the invalidation is withheld if the tag has expired. If the tag is only stale, it is handed to Next.js to serve stale while regenerating wherever Next.js supports that (Next.js 16 for pages, route handlers and `fetch`; the `'use cache'` handler always). Otherwise it is withheld. 4. A worker that restarts rebuilds its map from the tombstones, so an invalidation survives the process that issued it. -**A tombstone outlives every entry it can invalidate.** Each cache entry is written with its own Harper expiry: when Next.js says it stops being usable (its `expire`), capped at its table's `expiration`. An entry Next.js considers dead is evicted then rather than kept for the full table TTL. Each tombstone is written with an expiry of the longer of the two cache tables' expirations, plus an hour for renders that finish after the invalidation and for replication lag. To keep entries longer than the default 7 days, raise the cache tables' `expiration`; tombstone lifetimes follow it automatically. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses. A cache table configured with no expiration breaks that bound and is reported in the log. +**A tombstone outlives every entry it can invalidate.** Each cache entry is written with its own Harper expiry: when Next.js says it stops being usable (its `expire`), capped at one year — the `expire` of Next.js's `max` profile. That cap is independent of the tables' 7-day `expiration`, which applies only to an entry written without an `expire`, so the `weeks` and `max` profiles keep their full stale-while-revalidate window. An entry Next.js considers dead is evicted then rather than kept longer. Each tombstone is written with an expiry of the longest an entry can be kept (a year, or a cache table's `expiration` if that is longer), plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses, so it holds one small record per tag invalidated in the past year: fine for a few thousand distinct tags, worth sizing before invalidating a tag per product. A cache table configured with no expiration breaks that bound and is reported in the log. + +Cache lives are stored bounded to that year. Next.js's `default` profile sets `expire` to `INFINITE_CACHE` (0xfffffffe), which does not fit the 32-bit `Int` column; stored unbounded, Harper would refuse the write and the entry would never be cached. An immediate expiry is also **swept**: entries carrying the tag are looked up through the indexed `tags` column (an exact-match index lookup, not a scan) and deleted in throttled chunks, so they stop taking up space before their own expiry. This includes Next.js's implicit route tags, so `revalidatePath` is swept too. While Harper reports the index as still being built, the sweep scans with the same exact match instead. The sweep only reclaims space, and reads never depend on it: a sweep that fails or is interrupted leaves the entries to expire on their own. A stale-then-expire invalidation is not swept, because those entries must stay servable while they regenerate. @@ -345,7 +348,7 @@ Enabling the cache handler adds these tables to the `harperfast_nextjs` database | `nextjs_cache_invalidation` | One row per invalidated tag. `id` is the tag itself; `timestamp` is when `revalidateTag` was called; `stale`/`expired` are the tag's invalidation in Next.js's terms; `lapsesAt` is when the row expires, which is later than any entry it invalidates. | > [!NOTE] -> A table's `expiration` is fixed when the table is created — changing it in `schema.graphql` does not migrate an existing table. Verify with `describe_table` after upgrading; an instance created before a change will still report the old value. Tombstone lifetimes are computed from the expiration each cache table actually has, so they stay correct either way. +> A table's `expiration` is fixed when the table is created — changing it in `schema.graphql` does not migrate an existing table. Verify with `describe_table` after upgrading; an instance created before a change will still report the old value. Tombstone lifetimes take a cache table's actual expiration into account when it is longer than a year, so they stay correct either way. ### Upgrading @@ -353,6 +356,7 @@ No manual migration is needed; existing tables, entries and tombstones keep work - **Existing entries are kept** and served without regenerating. - **`tags` becomes indexed on both cache tables.** Harper builds the index for existing rows in the background on first start; nothing is unavailable meanwhile. On Harper 5.2.0 some worker threads were seen to keep reporting that index as unfinished until the next restart; the sweep falls back to a scan in that case, and reads are unaffected. +- **Entries written by earlier versions** keep the Harper expiry they were written with: at most the table's 7 days. - **Tombstones written by earlier versions** (a bare `timestamp`) are read as an immediate expiry at that time. Pre-release builds of `'use cache'` support stored `revalidateTag(tag, profile)` as a timestamp up to a year ahead; those rows no longer block later invalidations of the same tag, and they expire on the table's own 7-day schedule. ## Contributing diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index d0b3147..3458b99 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -20,6 +20,7 @@ import { nextServesStaleTags, recordInvalidation, tagState, + toStoredLife, } from './cacheInvalidation.cjs'; const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; @@ -138,14 +139,14 @@ export default class HarperCacheHandler implements CacheHandler { const table = databases.harperfast_nextjs.nextjs_isr_cache; const tags = extractTags(data, ctx); - // Persisted so the entry's own `expire` is enforced on every node. Floored because Harper rejects a - // non-integer for an Int column, and a rejected write here is silent — the entry simply never lands. + // Persisted so the entry's own `expire` is enforced on every node. See `toStoredLife` for why the + // values are bounded: a rejected write here is silent — the entry simply never lands. const cacheControl = 'cacheControl' in ctx ? ctx.cacheControl : undefined; - const revalidate = typeof cacheControl?.revalidate === 'number' ? Math.floor(cacheControl.revalidate) : undefined; - const expire = typeof cacheControl?.expire === 'number' ? Math.floor(cacheControl.expire) : undefined; + const revalidate = toStoredLife(typeof cacheControl?.revalidate === 'number' ? cacheControl.revalidate : undefined); + const expire = toStoredLife(cacheControl?.expire); const now = Date.now(); - const expiresAt = entryExpiresAt(table as { expirationMS?: number }, now, expire, now); + const expiresAt = entryExpiresAt(now, expire, now); await table.put(key, { data, tags, revalidate, expire }, expiresAt === undefined ? undefined : ({ expiresAt } as never)); } diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index cde0f1b..2f8622c 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -112,18 +112,20 @@ describe('HarperCacheHandler per-entry cache lives', () => { assert.equal(puts[0].value.expire, 3600); }); - it("sets the record's Harper expiry from Next's expire, capped at the table's", async () => { + it("sets the record's Harper expiry from Next's expire, capped at a year", async () => { const { puts } = installDatabases(); const handler = new HarperCacheHandler(); const before = Date.now(); await handler.set('/short', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 3600 } } as never); - await handler.set('/long', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 31_536_000 } } as never); + await handler.set('/long', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 0xfffffffe } } as never); await handler.set('/none', appPage([]) as never, { cacheControl: { revalidate: 60 } } as never); const [short, long, none] = puts.map((put) => put.context?.expiresAt); assert.ok(short! >= before + 3_600_000 && short! <= Date.now() + 3_600_000); - assert.ok(long! <= Date.now() + 604_800_000, 'no entry may outlive the table TTL its tombstones are sized for'); + assert.ok(long! > Date.now() + 604_800_000, 'not cut to the 7-day table TTL'); + assert.ok(long! <= Date.now() + 31_536_000_000, 'no entry may outlive the year its tombstones are sized for'); + assert.equal(puts[1].value.expire, 31_536_000, 'bounded to fit the Int column'); assert.equal(none, undefined); }); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index b4b1048..af18058 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -9,6 +9,7 @@ import { passedExpiration, recordInvalidation, tagState, + toStoredLife, } from './cacheInvalidation.cjs'; const DATABASE = 'harperfast_nextjs'; @@ -26,7 +27,6 @@ interface StoredEntry { interface UseCacheTable { get(key: string): Promise; put(key: string, value: Record, context?: { expiresAt: number }): Promise | unknown; - expirationMS?: number; } /** @@ -165,9 +165,9 @@ async function openValue(value: unknown): Promise | u /** * Next derives entry timestamps from `performance.timeOrigin + performance.now()`, which is - * fractional, and cache lives can arrive fractional too. Harper rejects a non-integer for a Long/Int - * column, so an uncoerced write is refused outright — and, being caught, looks like a working cache - * that simply never stored anything. + * fractional. Harper rejects a non-integer for a Long column, so an uncoerced write is refused + * outright — and, being caught, looks like a working cache that simply never stored anything. Cache + * lives go through `toStoredLife`, which also bounds them. */ function toInteger(value: number | undefined): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : undefined; @@ -245,8 +245,8 @@ class HarperUseCacheHandler implements CacheHandler { const storageKey = toStorageKey(cacheKey); const timestamp = toInteger(entry.timestamp) ?? Date.now(); - const expire = toInteger(entry.expire); - const expiresAt = entryExpiresAt(table, timestamp, expire); + const expire = toStoredLife(entry.expire); + const expiresAt = entryExpiresAt(timestamp, expire); await table.put( storageKey, { @@ -255,8 +255,8 @@ class HarperUseCacheHandler implements CacheHandler { value: toStoredValue(bytes), tags: entry.tags ?? [], timestamp, - stale: toInteger(entry.stale), - revalidate: toInteger(entry.revalidate), + stale: toStoredLife(entry.stale), + revalidate: toStoredLife(entry.revalidate), expire, }, expiresAt === undefined ? undefined : { expiresAt } diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index e16dcff..e9a9aab 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -72,11 +72,14 @@ function entry(overrides: Partial = {}): UseCacheEntry { } /** - * Harper rejects a non-integer for a Long/Int column. Next derives entry timestamps from - * `performance.timeOrigin + performance.now()`, which is fractional, so a mock that accepts anything - * hides a write that real Harper refuses outright. + * Harper rejects a non-integer for a Long/Int column, and an Int outside the 32-bit range + * (harper/resources/Table.ts, "must be an integer (from -2147483648 to 2147483647)"). Next derives + * entry timestamps from `performance.timeOrigin + performance.now()`, which is fractional, and its + * `default` profile's expire is 0xfffffffe, so a mock that accepts anything hides writes that real + * Harper refuses outright. */ -const INTEGER_COLUMNS = ['timestamp', 'stale', 'revalidate', 'expire']; +const LONG_COLUMNS = ['timestamp']; +const INT_COLUMNS = ['stale', 'revalidate', 'expire']; /** * Harper caps a primary key at MAX_KEY_BYTES = 1978 (harper/resources/Table.ts:146) and @@ -94,9 +97,11 @@ function enforcePrimaryKey(key: string) { } function enforceSchema(value: Record) { - for (const column of INTEGER_COLUMNS) { + for (const column of [...LONG_COLUMNS, ...INT_COLUMNS]) { const columnValue = value[column]; - if (columnValue !== undefined && columnValue !== null && !Number.isInteger(columnValue)) { + if (columnValue === undefined || columnValue === null) continue; + const isInt = INT_COLUMNS.includes(column); + if (isInt ? (columnValue as number) >> 0 !== columnValue : !Number.isInteger(columnValue)) { throw new Error(`Value ${String(columnValue)} in property ${column} must be an integer`); } } @@ -246,13 +251,28 @@ describe('UseCacheHandler cache lives', () => { assert.ok(Number.isInteger(result.expire)); }); - it("sets the record's Harper expiry from the entry's own expire, capped at the table's", async () => { + it("sets the record's Harper expiry from the entry's own expire, past the table's, capped at a year", async () => { const now = Date.now(); await handler.set('hours', Promise.resolve(entry({ timestamp: now, expire: 86_400 }))); - await handler.set('max', Promise.resolve(entry({ timestamp: now, expire: 31_536_000 }))); + await handler.set('weeks', Promise.resolve(entry({ timestamp: now, revalidate: 604_800, expire: 2_592_000 }))); + await handler.set('max', Promise.resolve(entry({ timestamp: now, revalidate: 2_592_000, expire: 31_536_000 }))); assert.equal(putContexts.get('hours')?.expiresAt, now + 86_400_000); - assert.ok(putContexts.get('max')!.expiresAt! <= Date.now() + 604_800_000); + assert.equal(putContexts.get('weeks')?.expiresAt, now + 2_592_000_000, 'not cut to the 7-day table TTL'); + assert.ok(putContexts.get('max')!.expiresAt! <= Date.now() + 31_536_000_000); + }); + + // Next's `default` profile. Stored as-is, Harper refuses the write and the entry never lands. + it('stores an entry whose expire is INFINITE_CACHE, bounded to a year', async () => { + const now = Date.now(); + await handler.set('default', Promise.resolve(entry({ timestamp: now, revalidate: 900, expire: 0xfffffffe }))); + + const result = await handler.get('default', []); + + assert.ok(result, 'the write was not refused'); + assert.equal(result.expire, 31_536_000); + assert.equal(result.revalidate, 900); + assert.ok(putContexts.get('default')!.expiresAt! <= Date.now() + 31_536_000_000); }); it('preserves the entry cache lives across the round trip', async () => { diff --git a/src/buildSweep.test.ts b/src/buildSweep.test.ts new file mode 100644 index 0000000..9aba9f5 --- /dev/null +++ b/src/buildSweep.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import { buildIdOfKey, oldBuildSweepEnabled, sweepOtherBuilds, type BuildSweepDeps } from './buildSweep.js'; + +/** A "use cache" key the way Next serializes it: `encodeReply([buildId, functionId, args])`. */ +function keyFor(buildId: string, props: unknown = { itemName: 'herozone' }) { + return JSON.stringify([buildId, 'c0fcbc549a9c66d11cf876f55f0f42138f07999a44', [props]]); +} + +function makeTable(rows: Array) { + const byId = new Map(rows.map((row) => [row.id, row])); + return { + byId, + deleted: [] as string[], + search() { + return (async function* () { + for (const row of byId.values()) yield { ...row }; + })(); + }, + async delete(id: string) { + this.deleted.push(id); + byId.delete(id); + }, + }; +} + +function makeDeps( + useCacheKeys: string[], + builds: Array<{ appName: string; buildId: string | null; status: string }> +): BuildSweepDeps & { useCache: ReturnType; logs: unknown[][] } { + const useCache = makeTable(useCacheKeys.map((id) => ({ id }))); + const buildInfo = makeTable(builds.map((build) => ({ id: build.appName, ...build }))); + const logs: unknown[][] = []; + return { + useCache, + logs, + databases: { harperfast_nextjs: { nextjs_use_cache: useCache, nextjs_build_info: buildInfo } } as never, + logger: { info: (...args: unknown[]) => logs.push(args), error: (...args: unknown[]) => logs.push(args) }, + sleep: async () => {}, + }; +} + +describe('oldBuildSweepEnabled', () => { + it('is off unless the environment opts in', () => { + assert.equal(oldBuildSweepEnabled({}), false); + assert.equal(oldBuildSweepEnabled({ HARPER_NEXTJS_SWEEP_OLD_BUILDS: 'false' }), false); + assert.equal(oldBuildSweepEnabled({ HARPER_NEXTJS_SWEEP_OLD_BUILDS: '' }), false); + }); + + it('is on for true or 1', () => { + assert.equal(oldBuildSweepEnabled({ HARPER_NEXTJS_SWEEP_OLD_BUILDS: 'true' }), true); + assert.equal(oldBuildSweepEnabled({ HARPER_NEXTJS_SWEEP_OLD_BUILDS: ' TRUE ' }), true); + assert.equal(oldBuildSweepEnabled({ HARPER_NEXTJS_SWEEP_OLD_BUILDS: '1' }), true); + }); +}); + +describe('buildIdOfKey', () => { + it('reads the build ID Next puts first in every key', () => { + assert.equal(buildIdOfKey(keyFor('B4tcOHFZN-dXMjHgx26oG')), 'B4tcOHFZN-dXMjHgx26oG'); + }); + + // Truncation keeps the prefix, and Next appends a root-params suffix after the array. + it('reads it from a truncated key and from a key with a root-params suffix', () => { + const truncated = `${keyFor('abc', { big: 'x'.repeat(2000) }).slice(0, 1480)}\u0000#0123456789abcdef`; + assert.equal(buildIdOfKey(truncated), 'abc'); + assert.equal(buildIdOfKey(`${keyFor('abc')}[["lang","en"]]`), 'abc'); + }); + + // Next falls back to a FormData encoding for arguments that are not plain JSON. + it('attributes no build to a key of any other shape', () => { + assert.equal(buildIdOfKey('1:0["abc","fn",[]]'), undefined); + assert.equal(buildIdOfKey('["abc"]'), undefined); + assert.equal(buildIdOfKey(JSON.stringify(['has"quote', 'fn', []])), undefined); + }); +}); + +describe('sweepOtherBuilds', () => { + it("deletes entries from earlier builds and keeps the current build's", async () => { + const deps = makeDeps( + [keyFor('old-1'), keyFor('old-2', { itemName: 'popularbrands' }), keyFor('current')], + [{ appName: 'home', buildId: 'current', status: 'success' }] + ); + + const deleted = await sweepOtherBuilds('current', deps); + + assert.equal(deleted, 2); + assert.deepEqual([...deps.useCache.byId.keys()], [keyFor('current')]); + }); + + // The database is shared by every Next app on the instance. + it("keeps entries of another app's current build", async () => { + const deps = makeDeps( + [keyFor('home-build'), keyFor('plp-build'), keyFor('plp-previous')], + [ + { appName: 'home', buildId: 'home-build', status: 'success' }, + { appName: 'plp', buildId: 'plp-build', status: 'success' }, + ] + ); + + await sweepOtherBuilds('home-build', deps); + + assert.deepEqual(deps.useCache.deleted, [keyFor('plp-previous')]); + }); + + // The current build is kept even when the record has since been overwritten, e.g. by another node. + it('keeps the build it was given whatever the build record says', async () => { + const deps = makeDeps([keyFor('mine')], [{ appName: 'home', buildId: 'theirs', status: 'success' }]); + + await sweepOtherBuilds('mine', deps); + + assert.deepEqual(deps.useCache.deleted, []); + }); + + it('does not keep a build that failed, and keeps entries it cannot attribute and a dev server’s', async () => { + const deps = makeDeps( + [keyFor('failed'), '1:0["abc","fn",[]]', keyFor('development')], + [{ appName: 'home', buildId: 'failed', status: 'failure' }] + ); + + await sweepOtherBuilds('current', deps); + + assert.deepEqual(deps.useCache.deleted, [keyFor('failed')]); + }); + + it('deletes in chunks across more entries than one chunk holds', async () => { + const keys = Array.from({ length: 250 }, (_, index) => keyFor('old', { index })); + const deps = makeDeps([...keys, keyFor('current')], []); + let pauses = 0; + deps.sleep = async () => { + pauses++; + }; + + assert.equal(await sweepOtherBuilds('current', deps), 250); + assert.equal(pauses, 2, 'a pause between each of the three chunks'); + assert.deepEqual([...deps.useCache.byId.keys()], [keyFor('current')]); + }); + + it('does nothing when the tables are not reachable', async () => { + assert.equal(await sweepOtherBuilds('current', { databases: {} as never }), 0); + }); +}); diff --git a/src/buildSweep.ts b/src/buildSweep.ts new file mode 100644 index 0000000..0db41c2 --- /dev/null +++ b/src/buildSweep.ts @@ -0,0 +1,118 @@ +import type { databases as DatabasesType } from 'harper'; + +const DATABASE = 'harperfast_nextjs'; +const USE_CACHE_TABLE = 'nextjs_use_cache'; +const BUILD_INFO_TABLE = 'nextjs_build_info'; + +/** Set to `true` (or `1`) to enable the sweep; it is off by default. */ +export const OLD_BUILD_SWEEP_ENV = 'HARPER_NEXTJS_SWEEP_OLD_BUILDS'; + +// Long enough for a rolling restart to reach the other nodes, so a node still on the previous build is not +// stripped of the entries it is serving while it waits its turn. Deleting them early would only cost it +// renders, never correctness. +export const OLD_BUILD_SWEEP_DELAY_MS = 300_000; + +// Same bounds as the tag sweep in cacheInvalidation.cts: short transactions, and a bounded share of a worker. +const SWEEP_RECORD_CONCURRENCY = 10; +const SWEEP_CHUNK_SIZE = 100; +const SWEEP_CHUNK_PAUSE_MAX_MS = 100; + +// `next dev` keys every entry with this build ID. A dev server sharing the instance writes no build record, +// so it is kept explicitly. +const DEV_BUILD_ID = 'development'; + +interface KeyRow { + id: string; +} + +interface BuildRow { + buildId?: string | null; + status?: string; +} + +interface SweepTable { + search(request: { conditions: never[]; select: string[] }): AsyncIterable; + delete(id: string): Promise | unknown; +} + +export interface BuildSweepDeps { + databases?: typeof DatabasesType; + logger?: { info?(...args: unknown[]): void; error(...args: unknown[]): void }; + sleep?: (ms: number) => Promise; +} + +/** + * The build ID a "use cache" entry was written under. Next keys each entry `[buildId, functionId, args]`, + * serialized with React's `encodeReply`, so a key starts `["",` — truncated storage keys keep + * that prefix. Undefined when the key has any other shape (Next falls back to a FormData encoding for + * arguments that are not plain JSON) or the build ID needed escaping: an entry that cannot be attributed + * to a build is left to its own expiry rather than guessed at. + */ +export function buildIdOfKey(id: string): string | undefined { + if (!id.startsWith('["')) return undefined; + const end = id.indexOf('"', 2); + if (end < 0 || id[end + 1] !== ',') return undefined; + const buildId = id.slice(2, end); + return buildId.includes('\\') ? undefined : buildId; +} + +/** + * Whether deleting earlier builds' entries is enabled. Opt-in, because it deletes rows on its own + * schedule: entries from other builds are otherwise left to expire, up to a year. + */ +export function oldBuildSweepEnabled(env: Record = process.env): boolean { + const value = env[OLD_BUILD_SWEEP_ENV]?.trim().toLowerCase(); + return value === 'true' || value === '1'; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Delete the "use cache" entries written by a build no app on this instance runs any more. Next puts the + * build ID in every key, so those entries can never be read again; left alone, each one stays until its + * own `expire`, up to a year. The build IDs kept are `currentBuildId` and the latest successful build of + * every app in `nextjs_build_info`, so apps sharing the database keep each other's entries. Returns the + * number of entries deleted. + */ +export async function sweepOtherBuilds(currentBuildId: string, deps: BuildSweepDeps = {}): Promise { + const databases = deps.databases ?? (globalThis as { databases?: typeof DatabasesType }).databases; + const scope = databases + ? (databases as unknown as Record>)[DATABASE] + : undefined; + const table = scope?.[USE_CACHE_TABLE] as SweepTable | undefined; + const buildInfo = scope?.[BUILD_INFO_TABLE] as SweepTable | undefined; + if (!table || !buildInfo) return 0; + const sleep = deps.sleep ?? defaultSleep; + + const keep = new Set([currentBuildId, DEV_BUILD_ID]); + for await (const row of buildInfo.search({ conditions: [], select: ['buildId', 'status'] })) { + if (row.status === 'success' && row.buildId) keep.add(row.buildId); + } + + const ids: string[] = []; + for await (const row of table.search({ conditions: [], select: ['id'] })) { + const buildId = buildIdOfKey(row.id); + if (buildId !== undefined && !keep.has(buildId)) ids.push(row.id); + } + + let deleted = 0; + for (let start = 0; start < ids.length; start += SWEEP_CHUNK_SIZE) { + const batch = ids.slice(start, start + SWEEP_CHUNK_SIZE); + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(SWEEP_RECORD_CONCURRENCY, batch.length) }, async () => { + while (cursor < batch.length) { + await table.delete(batch[cursor++]); + deleted++; + } + }) + ); + if (start + SWEEP_CHUNK_SIZE < ids.length) await sleep(Math.floor(Math.random() * SWEEP_CHUNK_PAUSE_MAX_MS)); + } + + const logger = deps.logger ?? (globalThis as { logger?: BuildSweepDeps['logger'] }).logger ?? console; + logger.info?.(`[UseCacheHandler] deleted ${deleted} "use cache" entries from builds other than ${[...keep].join(', ')}`); + return deleted; +} diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index c62ea62..cb98a41 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -8,6 +8,13 @@ const INVALIDATION_TABLE = 'nextjs_cache_invalidation'; // Matches the `expiration` of the cache tables in schema.graphql; used when a table does not report its own. const DEFAULT_CACHE_TTL_MS = 604_800_000; +// The longest an entry is kept, whatever Next asks for: one year, which is Next's own `max` profile. It is +// independent of the tables' `expiration`, which only applies to an entry written without an `expire` — +// Harper honours a record's own `expiresAt` beyond it. Tombstones are sized from it, so raising it makes +// every tombstone, and the in-memory map of them, live that much longer. +export const MAX_ENTRY_LIFETIME_MS = 31_536_000_000; +const MAX_ENTRY_LIFETIME_SECONDS = MAX_ENTRY_LIFETIME_MS / 1000; + // Covers an entry that was rendered before an invalidation but stored after it, and replication lag: both // let an entry's own TTL start after the invalidation was issued. const TOMBSTONE_MARGIN_MS = 3_600_000; @@ -142,31 +149,40 @@ let unboundedCacheReported = false; /** * Harper's per-record expiry for a cache entry: when Next says it stops being usable (`expire` seconds - * after `writtenAt`), capped at the table's `expiration`. The cap is what `tombstoneLifetimeMs` relies - * on — no entry may outlive the tombstones that can invalidate it — so the handlers set it explicitly - * rather than leave the record's lifetime to whatever a write's context happens to carry. Undefined - * leaves the table default in place. + * after `writtenAt`), capped at `MAX_ENTRY_LIFETIME_MS` from now. The cap is what `tombstoneLifetimeMs` + * relies on — no entry may outlive the tombstones that can invalidate it — so the handlers set it + * explicitly rather than leave the record's lifetime to whatever a write's context happens to carry. + * Undefined leaves the table default in place. */ export function entryExpiresAt( - table: { expirationMS?: number } | undefined, writtenAt: number, expireSeconds: number | undefined, now: number = Date.now() ): number | undefined { if (typeof expireSeconds !== 'number' || !Number.isFinite(expireSeconds) || expireSeconds <= 0) return undefined; - const ttl = table?.expirationMS; - const expiresAt = writtenAt + expireSeconds * 1000; - return typeof ttl === 'number' && ttl > 0 ? Math.min(expiresAt, now + ttl) : expiresAt; + return Math.min(writtenAt + expireSeconds * 1000, now + MAX_ENTRY_LIFETIME_MS); +} + +/** + * A cache life (`stale`, `revalidate`, `expire`, in seconds) as it can be stored. Harper rejects a + * non-integer, or one outside the 32-bit range, for an Int column, and the rejection is caught — so the + * entry silently never lands. Next's `default` profile sets `expire` to `INFINITE_CACHE` (0xfffffffe), + * which is out of range; no entry is kept past `MAX_ENTRY_LIFETIME_MS` anyway, so longer lives are stored + * as that. + */ +export function toStoredLife(seconds: number | undefined): number | undefined { + if (typeof seconds !== 'number' || Number.isNaN(seconds)) return undefined; + return Math.max(0, Math.min(Math.floor(seconds), MAX_ENTRY_LIFETIME_SECONDS)); } /** * How long a tombstone must live: past the last moment any entry written before the invalidation can - * still be read. Entries expire at most their table's `expiration` after their last write (see - * `entryExpiresAt`), so that bound is the longer of the two cache tables' TTLs plus the margin. + * still be read. An entry with an `expire` lives at most `MAX_ENTRY_LIFETIME_MS` (see `entryExpiresAt`); + * one without lives its table's `expiration`. The bound is the longest of those, plus the margin. */ export function tombstoneLifetimeMs(deps: InvalidationDeps = {}): number { const scope = getScope(deps); - let longest = 0; + let longest = MAX_ENTRY_LIFETIME_MS; for (const name of [ISR_TABLE, USE_CACHE_TABLE]) { const ttl = (scope?.[name] as { expirationMS?: number } | undefined)?.expirationMS; if (ttl === undefined) { @@ -180,7 +196,7 @@ export function tombstoneLifetimeMs(deps: InvalidationDeps = {}): number { ); } } - return (longest || DEFAULT_CACHE_TTL_MS) + TOMBSTONE_MARGIN_MS; + return longest + TOMBSTONE_MARGIN_MS; } /** diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index d4933cd..0c98bc7 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -18,12 +18,14 @@ import { tagState, tombstoneLifetimeMs, tombstoneToInvalidation, + toStoredLife, type InvalidationDeps, type TagInvalidation, } from './cacheInvalidation.cjs'; const DAY_MS = 86_400_000; const WEEK_MS = 7 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; const MARGIN_MS = 3_600_000; function invalidation(fields: Partial & { at: number }): TagInvalidation { @@ -305,26 +307,60 @@ describe('pruneInvalidations', () => { describe('entryExpiresAt', () => { it("expires an entry when Next says it stops being usable", () => { - assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 3600, 1000), 1000 + 3_600_000); + assert.equal(entryExpiresAt(1000, 3600, 1000), 1000 + 3_600_000); + }); + + // Past the 7-day table TTL, so the `weeks` and `max` profiles keep their stale-while-revalidate window. + it("keeps an entry past the table's expiration", () => { + assert.equal(entryExpiresAt(1000, 30 * 86_400, 1000), 1000 + 30 * DAY_MS); }); // The cap is what keeps every entry inside the lifetime its tombstones are sized for. - it("caps an entry at the table's expiration", () => { - assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 365 * 86_400, 1000), 1000 + WEEK_MS); + it('caps an entry at a year', () => { + assert.equal(entryExpiresAt(1000, 0xfffffffe, 1000), 1000 + YEAR_MS); }); it('leaves the table default when Next gives no usable expire', () => { - assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, undefined), undefined); - assert.equal(entryExpiresAt({ expirationMS: WEEK_MS }, 1000, 0), undefined); + assert.equal(entryExpiresAt(1000, undefined), undefined); + assert.equal(entryExpiresAt(1000, 0), undefined); + }); +}); + +describe('toStoredLife', () => { + it('floors a fractional life', () => { + assert.equal(toStoredLife(900.7), 900); + }); + + // Next's INFINITE_CACHE (0xfffffffe) does not fit Harper's 32-bit Int column. + it('bounds a life to a year, which fits an Int column', () => { + assert.equal(toStoredLife(0xfffffffe), 31_536_000); + assert.equal(toStoredLife(Infinity), 31_536_000); + assert.ok(toStoredLife(0xfffffffe)! <= 2_147_483_647); + }); + + it('never stores a negative life', () => { + assert.equal(toStoredLife(-Infinity), 0); + assert.equal(toStoredLife(-5), 0); + }); + + it('stores nothing for a missing life', () => { + assert.equal(toStoredLife(undefined), undefined); + assert.equal(toStoredLife(NaN), undefined); }); }); describe('tombstoneLifetimeMs', () => { beforeEach(() => resetInvalidationStateForTesting()); - it('outlives the longer of the two cache tables by the margin', () => { + it('outlives the longest an entry is kept, by the margin', () => { const deps = makeDeps({ isr: makeCacheTable([], 2 * DAY_MS), useCache: makeCacheTable([], 9 * DAY_MS) }); - assert.equal(tombstoneLifetimeMs(deps), 9 * DAY_MS + MARGIN_MS); + assert.equal(tombstoneLifetimeMs(deps), YEAR_MS + MARGIN_MS); + }); + + // A row written without an expire lives the table's expiration, which may be set longer than the cap. + it('outlives a cache table whose expiration is longer than a year', () => { + const deps = makeDeps({ useCache: makeCacheTable([], 2 * YEAR_MS) }); + assert.equal(tombstoneLifetimeMs(deps), 2 * YEAR_MS + MARGIN_MS); }); it('reports once when a cache table has no expiration, since nothing can then bound a tombstone', () => { @@ -348,7 +384,7 @@ describe('recordInvalidation', () => { const [put] = invalidationTable.puts; assert.equal(put.value.timestamp, 1_000_000); assert.equal(put.value.expired, 1_000_000); - assert.equal(put.value.lapsesAt, 1_000_000 + WEEK_MS + MARGIN_MS); + assert.equal(put.value.lapsesAt, 1_000_000 + YEAR_MS + MARGIN_MS); assert.equal(put.context?.expiresAt, put.value.lapsesAt, 'the per-record expiry is what outlasts the table TTL'); assert.ok(cacheInvalidations.has('products'), 'this worker sees it before the write returns'); }); @@ -374,7 +410,7 @@ describe('recordInvalidation', () => { timestamp: 2000, stale: 1000, expired: 2000, - lapsesAt: 2000 + WEEK_MS + MARGIN_MS, + lapsesAt: 2000 + YEAR_MS + MARGIN_MS, }); }); diff --git a/src/plugin.ts b/src/plugin.ts index 9868b45..d4667e0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { withBuildLock } from './buildLock.js'; +import { OLD_BUILD_SWEEP_DELAY_MS, oldBuildSweepEnabled, sweepOtherBuilds } from './buildSweep.js'; import type NextModule14 from 'next-14'; import type NextBuildModule14 from 'next-14/dist/cli/next-build.d.ts'; @@ -272,10 +273,34 @@ export async function handleApplication(scope: Scope) { } } + // Opt-in (see oldBuildSweepEnabled). `next dev` has no BUILD_ID, and its entries are not a build's to clear. + if (!config.dev && oldBuildSweepEnabled()) scheduleOldBuildSweep(scope); + // Finally, serve the application await serve(scope, config, next); } +type WorkerServer = { workerIndex?: number } | undefined; + +/** + * Clear out "use cache" entries left by earlier builds (see sweepOtherBuilds), once per node — on worker + * 0 — and only after a rolling restart has had time to reach the other nodes. Where the worker index is + * not exposed every worker sweeps: repeated work, but deleting an already-deleted entry is harmless. + */ +function scheduleOldBuildSweep(scope: Scope) { + const buildId = getBuildId(scope); + if (buildId === null) return; + const workerIndex = + (harper as unknown as { server?: WorkerServer }).server?.workerIndex ?? + (globalThis as { server?: WorkerServer }).server?.workerIndex; + if (workerIndex !== undefined && workerIndex !== 0) return; + setTimeout(() => { + sweepOtherBuilds(buildId).catch((error) => { + scope.logger.error?.('Failed to delete "use cache" entries from earlier builds; they are left to expire: ', error); + }); + }, OLD_BUILD_SWEEP_DELAY_MS).unref(); +} + async function build(scope: Scope, config: NextPluginConfig, next: NextPackage) { // Serialize builds across Harper worker threads (see buildLock.ts). `withBuildLock` decides whether // this worker builds, reuses a fresh build, or waits for a sibling; it only invokes `runBuild` (and From cd4e2d85fe22c971d42e3237ef7382fd6978b06b Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 5 Oct 2026 12:43:59 -0500 Subject: [PATCH 13/29] fix: fail closed until tombstones are hydrated, re-subscribe when the subscription ends - Reads in both cache handlers treat an entry as a miss until this worker's invalidation view is complete, instead of serving it unchecked after a failed or backing-off hydration. - A subscription that ends ('close', or 'error') is dropped, so the next read subscribes again and re-hydrates what it missed. - Tombstone writes carry their issue time as the Harper write version, so an older invalidation that commits late is dropped rather than replacing a newer tombstone. Versions are strictly increasing per worker so a same-ms pair does not tie. - A use-cache read waiting on an in-flight set no longer rejects when that set fails; it falls through to storage. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/CacheHandler.cts | 4 +- src/CacheHandler.test.ts | 18 ++++++++ src/UseCacheHandler.cts | 9 ++-- src/UseCacheHandler.test.ts | 33 ++++++++++++++ src/cacheInvalidation.cts | 75 ++++++++++++++++++++++++------- src/cacheInvalidation.test.ts | 84 ++++++++++++++++++++++++++++++++++- 6 files changed, 199 insertions(+), 24 deletions(-) diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index 3458b99..59134d6 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -96,8 +96,8 @@ export default class HarperCacheHandler implements CacheHandler { ): Promise { const databases = getDatabases(); if (!databases) return null; - // A worker that has not yet read the tombstones back would serve invalidated entries as fresh. - await initializeInvalidationSubscription(); + // A worker that has not read the tombstones back cannot tell an invalidated entry from a fresh one. + if (!(await initializeInvalidationSubscription())) return null; const table = databases.harperfast_nextjs.nextjs_isr_cache; const record = await table.get(key); diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 2f8622c..382970c 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -73,6 +73,14 @@ function installDatabases(records: Record = {}) { return { puts, invalidationPuts }; } +/** Make reading the tombstones back fail, so this worker's view of them never becomes complete. */ +function failTombstoneScan() { + const { databases } = globalThis as unknown as { databases: Record> }; + databases.harperfast_nextjs.nextjs_cache_invalidation.search = () => { + throw new Error('scan failed'); + }; +} + function invalidate(tag: string, fields: Omit) { cacheInvalidations.set(tag, { lapsesAt: Number.MAX_SAFE_INTEGER, ...fields }); } @@ -207,6 +215,16 @@ describe('HarperCacheHandler on tag invalidation', () => { assert.notEqual(await handler.get('/data', fetchCtx(['products'])), null); }); + it('treats every read as a miss until the tombstones have been read back', async () => { + installDatabases({ '/page': { data: appPage(['products']), tags: ['products'], lastModified: 1000 } }); + failTombstoneScan(); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/page', pageCtx); + + assert.equal(result, null, 'an invalidated entry would otherwise be served as fresh'); + }); + it('withholds a tag-stale entry of a kind Next never tag-checks', async () => { installDatabases({ '/legacy': { data: pagesValue(), tags: ['products'], lastModified: 1000 } }); invalidate('products', { stale: 2000, at: 2000 }); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index af18058..b728a6c 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -199,12 +199,13 @@ class HarperUseCacheHandler implements CacheHandler { async get(cacheKey: string, _softTags: string[]): Promise { const table = getTable(); if (!table) return undefined; - // A worker that has not yet read the tombstones back would serve invalidated entries as fresh. - await initializeInvalidationSubscription(); + // A worker that has not read the tombstones back cannot tell an invalidated entry from a fresh one. + if (!(await initializeInvalidationSubscription())) return undefined; - // An in-flight set for this key must be awaited rather than reported as a miss. + // An in-flight set for this key must be awaited rather than reported as a miss. Its failure is the + // set's to report; the read falls through to whatever is stored. const pending = pendingEntries.get(cacheKey); - if (pending) await pending; + if (pending) await pending.catch(() => undefined); const record = await table.get(toStorageKey(cacheKey)); if (!record) return undefined; diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index e9a9aab..207b2a2 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -149,6 +149,14 @@ function installDatabases() { return rows; } +/** Make reading the tombstones back fail, so this worker's view of them never becomes complete. */ +function failTombstoneScan() { + const { databases } = globalThis as unknown as { databases: Record> }; + databases.harperfast_nextjs.nextjs_cache_invalidation.search = () => { + throw new Error('scan failed'); + }; +} + describe('UseCacheHandler streaming', () => { beforeEach(() => { resetInvalidationStateForTesting(); @@ -205,6 +213,31 @@ describe('UseCacheHandler streaming', () => { assert.ok(result, 'a concurrent get must wait, not return undefined'); assert.equal(await readAll(result.value), 'deferred'); }); + + it('lets a concurrent get fall through to storage when the in-flight set fails', async () => { + let fail: (error: Error) => void = () => {}; + const pending = new Promise((_resolve, reject) => { + fail = reject; + }); + + const setPromise = handler.set('k6', pending); + const getPromise = handler.get('k6', []); + // Fail only once the get is waiting on the set; failing sooner retires the set before the get sees it. + await new Promise((resolve) => setImmediate(resolve)); + + fail(new Error('render failed')); + await setPromise; + + assert.equal(await getPromise, undefined, 'the read is a miss, not a rejection'); + }); + + it('treats every read as a miss until the tombstones have been read back', async () => { + const rows = installDatabases(); + rows.set(toStorageKey('k7'), { id: 'k7', value: Buffer.from('payload'), tags: [], timestamp: Date.now() }); + failTombstoneScan(); + + assert.equal(await handler.get('k7', []), undefined, 'an invalidated entry would otherwise be served as fresh'); + }); }); describe('UseCacheHandler cache lives', () => { diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index cb98a41..374f1e6 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -69,12 +69,19 @@ interface SweepableTable { expirationMS?: number; } +interface TombstoneSubscription { + on(event: string, listener: (event: { type: string; id: string; value?: Omit }) => void): void; + end?(): void; +} + interface InvalidationTable { - put(id: string, value: Omit, context?: { expiresAt?: number }): Promise | unknown; + put( + id: string, + value: Omit, + context?: { expiresAt?: number; timestamp?: number } + ): Promise | unknown; search(): AsyncIterable; - subscribe(request: { omitCurrent?: boolean }): Promise<{ - on(event: string, listener: (event: { type: string; id: string; value?: Omit }) => void): void; - }>; + subscribe(request: { omitCurrent?: boolean }): Promise; } export interface InvalidationDeps { @@ -394,12 +401,23 @@ export async function hydrateInvalidations( } } -let subscription: Awaited> | undefined; -let initialization: Promise | undefined; +let subscription: TombstoneSubscription | undefined; +let initialization: Promise | undefined; let initialized = false; let subscriptionRetryAt = 0; let subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; let pruneTimerStarted = false; +let lastTombstoneVersion = 0; + +/** + * The Harper version a tombstone write carries: its issue time, nudged past this worker's previous one. + * Harper keeps the existing record on a tie, which would drop a second invalidation issued in the same + * millisecond — and that one carries the merged, newer view of the tag. + */ +function nextTombstoneVersion(now: number): number { + lastTombstoneVersion = Math.max(now, lastTombstoneVersion + 0.001); + return lastTombstoneVersion; +} function onTombstoneEvent( event: { type: string; id: string; value?: Omit }, @@ -420,31 +438,51 @@ function startPruneTimer(deps: InvalidationDeps): void { setInterval(() => pruneInvalidations(deps), PRUNE_INTERVAL_MS).unref(); } +/** + * A subscription that has ended delivers nothing more, so this worker's view would silently fall behind. + * Dropping it makes the next read subscribe again and re-hydrate, which recovers anything missed meanwhile. + */ +function onSubscriptionLost(lost: TombstoneSubscription, deps: InvalidationDeps): void { + if (subscription !== lost) return; + subscription = undefined; + initialized = false; + getLogger(deps).error('[CacheHandler] invalidation subscription ended; it will be re-established'); +} + /** * Subscribe to tombstones, then hydrate from storage. Subscribing first means an invalidation written * while the hydration scan runs is still observed; `noteInvalidation` makes the overlap harmless. * Concurrent callers share one attempt, and a failure backs off rather than retrying on every request. + * + * Resolves true once this worker's view of the tombstones is complete. Until then a read cannot tell an + * invalidated entry from a fresh one, so callers must treat it as a miss. */ -export function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { - if (initialized) return Promise.resolve(); +export function initializeInvalidationSubscription(deps: InvalidationDeps = {}): Promise { + if (initialized) return Promise.resolve(true); if (initialization) return initialization; - if (nowFor(deps) < subscriptionRetryAt) return Promise.resolve(); + if (nowFor(deps) < subscriptionRetryAt) return Promise.resolve(false); const table = getScope(deps)?.[INVALIDATION_TABLE] as InvalidationTable | undefined; - if (!table) return Promise.resolve(); + if (!table) return Promise.resolve(false); initialization = (async () => { try { if (!subscription) { // Harper's TypeScript types require a SubscriptionRequest, but the runtime accepts a plain literal. - subscription = await table.subscribe({ omitCurrent: true }); - subscription.on('data', (event) => onTombstoneEvent(event, deps)); - subscription.on('error', (error) => { + const current = await table.subscribe({ omitCurrent: true }); + subscription = current; + current.on('data', (event) => onTombstoneEvent(event, deps)); + // Harper ends a subscription with 'close'; 'error' is kept for any source that reports one. + current.on('close', () => onSubscriptionLost(current, deps)); + current.on('error', (error) => { getLogger(deps).error('[CacheHandler] invalidation subscription error', error); + onSubscriptionLost(current, deps); + current.end?.(); }); } await hydrateInvalidations(table, deps); startPruneTimer(deps); - initialized = true; + // The subscription can end while the hydration scan runs; it is not live, so neither is the view. + initialized = subscription !== undefined; subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; } catch (error) { subscriptionRetryAt = nowFor(deps) + subscriptionRetryMs; @@ -453,6 +491,7 @@ export function initializeInvalidationSubscription(deps: InvalidationDeps = {}): } finally { initialization = undefined; } + return initialized; })(); return initialization; } @@ -470,6 +509,7 @@ export function resetInvalidationStateForTesting(): void { initialized = false; subscriptionRetryAt = 0; subscriptionRetryMs = SUBSCRIPTION_RETRY_MIN_MS; + lastTombstoneVersion = 0; loadedTagsManifest = undefined; unboundedCacheReported = false; queuedSweeps.clear(); @@ -605,6 +645,7 @@ export async function recordInvalidation( const tombstones = scope[INVALIDATION_TABLE] as InvalidationTable; const now = nowFor(deps); + const version = nextTombstoneVersion(now); const issued = invalidationFor(now, durations, tombstoneLifetimeMs(deps)); const merged = uniqueTags.map((tag) => { @@ -625,8 +666,10 @@ export async function recordInvalidation( tombstones.put( tag, { timestamp: now, stale: invalidation.stale, expired: invalidation.expired, lapsesAt: invalidation.lapsesAt }, - // Outlives the table's own expiration, which only bounds the entries, not their tombstones. - { expiresAt: invalidation.lapsesAt } + // `expiresAt` outlives the table's own expiration, which only bounds the entries, not their + // tombstones. `timestamp` makes the issue time the write's version: Harper drops a write older + // than the stored version, so an older invalidation landing late cannot replace a newer one. + { expiresAt: invalidation.lapsesAt, timestamp: version } ) ) ); diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index 0c98bc7..2d8677e 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -72,8 +72,12 @@ function makeInvalidationTable(rows: Array<{ id: string } & Record; context?: { expiresAt?: number } }>, - async put(key: string, value: Record, context?: { expiresAt?: number }) { + puts: [] as Array<{ + key: string; + value: Record; + context?: { expiresAt?: number; timestamp?: number }; + }>, + async put(key: string, value: Record, context?: { expiresAt?: number; timestamp?: number }) { this.puts.push({ key, value, context }); }, search() { @@ -82,12 +86,15 @@ function makeInvalidationTable(rows: Array<{ id: string } & Record }) => void), + /** Ends the latest subscription the way Harper does, with 'close'. */ + close: undefined as undefined | (() => void), subscribeCalls: 0, async subscribe() { this.subscribeCalls++; return { on: (event: string, listener: never) => { if (event === 'data') this.listener = listener; + if (event === 'close') this.close = listener; }, }; }, @@ -401,6 +408,29 @@ describe('recordInvalidation', () => { assert.equal(put.value.expired, 1_060_000); }); + // Harper drops a write whose version precedes the stored one, so an invalidation that is issued first + // but lands last cannot replace a newer tombstone. + it('makes each tombstone write versioned by its issue time', async () => { + const invalidationTable = makeInvalidationTable(); + + await recordInvalidation(['products'], undefined, makeDeps({ invalidation: invalidationTable, now: 1000 })); + + assert.equal(invalidationTable.puts[0].context?.timestamp, 1000); + }); + + it('versions a second invalidation issued in the same millisecond after the first', async () => { + const invalidationTable = makeInvalidationTable(); + const deps = makeDeps({ invalidation: invalidationTable, now: 1000 }); + + await recordInvalidation(['products'], { expire: 60 }, deps); + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['products'], undefined, deps); + + const [first, second] = invalidationTable.puts.map((put) => put.context?.timestamp as number); + assert.ok(second > first, 'a tie keeps the stored record, which would drop the newer invalidation'); + assert.ok(second - first < 1, 'the version stays within the millisecond it was issued in'); + }); + it('keeps the stale time of an earlier invalidation a hard expiry does not replace', async () => { const invalidationTable = makeInvalidationTable(); await recordInvalidation(['products'], { expire: 60 }, makeDeps({ invalidation: invalidationTable, now: 1000 })); @@ -619,6 +649,56 @@ describe('initializeInvalidationSubscription', () => { assert.equal(deps.errors.length, 1); }); + it('reports the view as not ready until hydration succeeds', async () => { + let now = 1000; + const invalidationTable = makeInvalidationTable([{ id: 'products', timestamp: 500 }]); + const search = invalidationTable.search.bind(invalidationTable); + let failing = true; + invalidationTable.search = () => { + if (failing) throw new Error('scan failed'); + return search(); + }; + const deps = { ...makeDeps({ invalidation: invalidationTable }), now: () => now }; + + assert.equal(await initializeInvalidationSubscription(deps), false); + assert.equal(await initializeInvalidationSubscription(deps), false, 'still not ready while backing off'); + + failing = false; + now += 60_000; + assert.equal(await initializeInvalidationSubscription(deps), true); + assert.equal(invalidationTable.subscribeCalls, 1, 'the retry reuses the live subscription'); + assert.ok(cacheInvalidations.has('products')); + }); + + it('re-subscribes and re-hydrates after the subscription ends', async () => { + const invalidationTable = makeInvalidationTable([{ id: 'old', timestamp: 1000 }]); + const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); + assert.equal(await initializeInvalidationSubscription(deps), true); + + invalidationTable.close?.(); + // Written while nothing was listening. + invalidationTable.rows.push({ id: 'missed', timestamp: 2000 }); + + assert.equal(await initializeInvalidationSubscription(deps), true); + assert.equal(invalidationTable.subscribeCalls, 2); + assert.ok(cacheInvalidations.has('missed'), 'the re-hydration recovers what the dead subscription missed'); + assert.equal(deps.errors.length, 1, 'the lost subscription is logged'); + }); + + it('ignores the end of a subscription it has already replaced', async () => { + const invalidationTable = makeInvalidationTable(); + const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); + await initializeInvalidationSubscription(deps); + const staleClose = invalidationTable.close; + staleClose?.(); + await initializeInvalidationSubscription(deps); + + staleClose?.(); + + assert.equal(await initializeInvalidationSubscription(deps), true); + assert.equal(invalidationTable.subscribeCalls, 2); + }); + it('forgets an invalidation when its tombstone is deleted', async () => { const invalidationTable = makeInvalidationTable([{ id: 'products', timestamp: 2000 }]); const deps = makeDeps({ invalidation: invalidationTable, now: 3000 }); From e03b4b0d15dfe4a1db7fa57787164e1635e17ee0 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 12:16:51 -0600 Subject: [PATCH 14/29] test: pin the four invalidation defects with independent regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived from the review findings rather than from the fix, and verified to discriminate: with the four behavioural fixes in cd4e2d8 reverted one line at a time, each of these five fails for its own reason; with them in place all pass. - both handlers report a miss while the tombstone scan has failed, instead of serving an entry another node invalidated (CacheHandler, UseCacheHandler) - a subscription reported as errored — not only Harper's own 'close' — drops the view, so the next read re-subscribes and reloads what it missed - an older concurrent invalidation whose put commits last does not replace the newer tombstone, asserted on the row a restarting worker hydrates rather than on the arguments the put was called with - a get awaiting an in-flight set falls through to storage when that set fails Each is paired with a guard that a fix cannot satisfy by withholding everything or by never recovering: an uninvalidated entry still serves, and a read that failed closed recovers once the scan succeeds. The invalidation-table fake now keeps the row a write would leave in storage and applies Harper's write-version rule — a write whose context.timestamp precedes the stored version loses to it (harper/resources/Table.ts:4770). That is what makes the concurrent-write outcome observable at all; asserting only that the put carries a version would not have caught an older write winning. Co-Authored-By: Claude Opus 5 --- src/CacheHandler.test.ts | 65 ++++++++++++++++++- src/UseCacheHandler.test.ts | 109 ++++++++++++++++++++++++++++++- src/cacheInvalidation.test.ts | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 4 deletions(-) diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 382970c..99d8b50 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -31,7 +31,13 @@ interface StoredRecord { lastModified?: number; } -function installDatabases(records: Record = {}) { +/** Tombstone rows the worker would hydrate, and whether that scan is allowed to succeed. */ +interface InvalidationStorage { + tombstones?: Array<{ id: string } & Record>; + failSearch?: boolean; +} + +function installDatabases(records: Record = {}, invalidation: InvalidationStorage = {}) { const puts: Array<{ key: string; value: Record; context?: { expiresAt?: number } }> = []; const invalidationPuts: Array<{ key: string; value: Record }> = []; @@ -61,7 +67,15 @@ function installDatabases(records: Record = {}) { invalidationPuts.push({ key, value }); }, search() { - return (async function* () {})(); + if (invalidation.failSearch) { + return (async function* () { + throw new Error('storage unavailable'); + })(); + } + const tombstones = invalidation.tombstones ?? []; + return (async function* () { + for (const row of tombstones) yield row; + })(); }, async subscribe() { return { on() {} }; @@ -297,3 +311,50 @@ describe('HarperCacheHandler on tag invalidation', () => { assert.equal(result?.lastModified, 5000); }); }); + +// What a read must do while the invalidation map is incomplete, and what failing closed must not cost. +describe('HarperCacheHandler reads before the tombstones are loaded', () => { + beforeEach(() => { + resetInvalidationStateForTesting(); + useTagsManifestModuleForTesting(NEXT_16_MANIFEST); + }); + + // Before the fix, a swallowed hydration failure left `get` consulting an empty invalidation map and + // handing Next an entry another node had already invalidated. + it('does not serve an entry a stored tombstone invalidated', async () => { + const now = Date.now(); + installDatabases( + { '/page': { data: appPage(['products']), tags: ['products'], lastModified: now - 10_000 } }, + { tombstones: [{ id: 'products', timestamp: now - 5000 }], failSearch: true } + ); + const handler = new HarperCacheHandler(); + + assert.equal( + await handler.get('/page', pageCtx), + null, + 'the tombstone could not be read, so a miss is the only safe answer' + ); + }); + + // The counterpart: once the scan succeeds the entry is withheld for the usual reason, not because + // reads stayed closed. This one passes today. + it('withholds the same entry once the tombstones load', async () => { + const now = Date.now(); + installDatabases( + { '/page': { data: appPage(['products']), tags: ['products'], lastModified: now - 10_000 } }, + { tombstones: [{ id: 'products', timestamp: now - 5000 }] } + ); + const handler = new HarperCacheHandler(); + + assert.equal(await handler.get('/page', pageCtx), null); + }); + + // Failing closed must not become "miss everything": an entry nothing invalidated still serves once + // the scan succeeds. This one passes today. + it('serves an uninvalidated entry when the scan succeeds', async () => { + installDatabases({ '/page': { data: appPage([]), tags: [], lastModified: Date.now() } }); + const handler = new HarperCacheHandler(); + + assert.notEqual(await handler.get('/page', pageCtx), null); + }); +}); diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 207b2a2..3efd6c5 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -109,7 +109,13 @@ function enforceSchema(value: Record) { const putContexts = new Map(); -function installDatabases() { +/** Tombstone rows the worker would hydrate, and whether that scan is allowed to succeed. */ +interface InvalidationStorage { + tombstones?: Array<{ id: string } & Record>; + failSearch?: boolean; +} + +function installDatabases(invalidation: InvalidationStorage = {}) { const rows = new Map>(); putContexts.clear(); (globalThis as Record).databases = { @@ -138,7 +144,15 @@ function installDatabases() { nextjs_cache_invalidation: { async put() {}, search() { - return (async function* () {})(); + if (invalidation.failSearch) { + return (async function* () { + throw new Error('storage unavailable'); + })(); + } + const tombstones = invalidation.tombstones ?? []; + return (async function* () { + for (const row of tombstones) yield row; + })(); }, async subscribe() { return { on() {} }; @@ -240,6 +254,97 @@ describe('UseCacheHandler streaming', () => { }); }); +// What a read must do while the invalidation map is incomplete, and what failing closed must not cost. +describe('UseCacheHandler reads before the tombstones are loaded', () => { + // Before the fix, a swallowed hydration failure left `get` consulting an empty invalidation map and + // handing back an entry another node had already invalidated. + it('does not serve an entry a stored tombstone invalidated', async () => { + resetInvalidationStateForTesting(); + const now = Date.now(); + const rows = installDatabases({ tombstones: [{ id: 'products', timestamp: now - 5000 }], failSearch: true }); + rows.set('tagged', { + id: 'tagged', + value: Buffer.from('stale'), + tags: ['products'], + timestamp: now - 10_000, + stale: 60, + revalidate: 300, + expire: 3600, + }); + + assert.equal( + await handler.get('tagged', []), + undefined, + 'the tombstone could not be read, so a miss is the only safe answer' + ); + }); + + // The counterpart: once the scan succeeds the entry is withheld for the usual reason, not because + // reads stayed closed. This one passes today. + it('withholds the same entry once the tombstones load', async () => { + resetInvalidationStateForTesting(); + const now = Date.now(); + const rows = installDatabases({ tombstones: [{ id: 'products', timestamp: now - 5000 }] }); + rows.set('tagged', { + id: 'tagged', + value: Buffer.from('stale'), + tags: ['products'], + timestamp: now - 10_000, + stale: 60, + revalidate: 300, + expire: 3600, + }); + + assert.equal(await handler.get('tagged', []), undefined); + }); + + // A failed scan must not turn every read into a miss for an entry nothing invalidated — that would + // satisfy the first test by emptying the cache. This one passes today. + it('still serves an untagged entry when the scan succeeds', async () => { + resetInvalidationStateForTesting(); + const rows = installDatabases(); + rows.set('plain', { + id: 'plain', + value: Buffer.from('fresh'), + tags: [], + timestamp: Date.now(), + stale: 60, + revalidate: 300, + expire: 3600, + }); + + assert.ok(await handler.get('plain', [])); + }); +}); + +describe('UseCacheHandler pending writes', () => { + beforeEach(() => { + resetInvalidationStateForTesting(); + installDatabases(); + }); + + // `set` catches its own failure; before the fix a `get` awaiting the same pending work rejected with + // it, failing a separate request that was free to render its own value. + it('reports a miss, rather than rejecting, when the set it waited on fails', async () => { + let fail: (error: Error) => void = () => {}; + const pending = new Promise((_resolve, reject) => { + fail = reject; + }); + + // Not awaited: `get` has to observe the in-flight set synchronously. + const setPromise = handler.set('failing', pending); + const getPromise = handler.get('failing', []); + + // Let `get` reach the pending-entry wait before the write fails; rejecting in this tick would + // have `set` clear the entry first, and `get` would never wait on it at all. + await new Promise((resolve) => setTimeout(resolve, 10)); + fail(new Error('render failed')); + await setPromise; + + assert.equal(await getPromise, undefined); + }); +}); + describe('UseCacheHandler cache lives', () => { beforeEach(() => { resetInvalidationStateForTesting(); diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index 2d8677e..4faa73e 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -68,10 +68,18 @@ function makeCacheTable(records: CacheRecord[], expirationMS = WEEK_MS) { }; } +/** + * A stand-in for the tombstone table. `put` keeps the row it would leave in storage, under Harper's + * write-version rule: a write whose `context.timestamp` precedes the stored record's version "loses to + * the existing record version" (`harper/resources/Table.ts:4770`) rather than replacing it. A write + * carrying no version always lands, the way an unversioned `put` does. Modelling this is what lets a + * test read a row back the way a worker that restarts would. + */ function makeInvalidationTable(rows: Array<{ id: string } & Record> = []) { return { rows, expirationMS: WEEK_MS, + versions: new Map(), puts: [] as Array<{ key: string; value: Record; @@ -79,8 +87,18 @@ function makeInvalidationTable(rows: Array<{ id: string } & Record, async put(key: string, value: Record, context?: { expiresAt?: number; timestamp?: number }) { this.puts.push({ key, value, context }); + const version = context?.timestamp; + const stored = this.versions.get(key); + if (version !== undefined && stored !== undefined && version < stored) return; + if (version !== undefined) this.versions.set(key, version); + const row = { id: key, ...value }; + const index = rows.findIndex((existing) => existing.id === key); + if (index === -1) rows.push(row); + else rows[index] = row; }, + searchCalls: 0, search() { + this.searchCalls++; return (async function* () { for (const row of rows) yield row; })(); @@ -88,6 +106,7 @@ function makeInvalidationTable(rows: Array<{ id: string } & Record }) => void), /** Ends the latest subscription the way Harper does, with 'close'. */ close: undefined as undefined | (() => void), + errorListener: undefined as undefined | ((error: unknown) => void), subscribeCalls: 0, async subscribe() { this.subscribeCalls++; @@ -95,6 +114,7 @@ function makeInvalidationTable(rows: Array<{ id: string } & Record { if (event === 'data') this.listener = listener; if (event === 'close') this.close = listener; + if (event === 'error') this.errorListener = listener; }, }; }, @@ -710,6 +730,103 @@ describe('initializeInvalidationSubscription', () => { }); }); +// The read-path half of this defect is asserted through the handlers themselves, in +// CacheHandler.test.ts and UseCacheHandler.test.ts: what has to hold is that a read misses, not where +// in the call chain that is decided. +describe('recovering from a failed start-up', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + // Failing a read closed while the tombstones are unreadable must not become permanent. Passes today; + // it pins that whatever makes the handler tests pass still recovers once storage answers again. + it('knows the invalidation once the retry succeeds', async () => { + const table = makeInvalidationTable([{ id: 'products', timestamp: 2000 }]); + const search = table.search.bind(table); + let failing = true; + table.search = () => + failing + ? (async function* () { + throw new Error('storage unavailable'); + })() + : search(); + const deps = makeDeps({ invalidation: table }); + let clock = 3000; + deps.now = () => clock; + + await initializeInvalidationSubscription(deps); + failing = false; + // Past the 1s minimum backoff, so the next read is allowed to retry. + clock += 2000; + await initializeInvalidationSubscription(deps); + + assert.equal(tagState(['products'], 1000, clock), 'expired'); + }); +}); + +describe('recovering from a lost subscription', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + // The 'error' half of the fix: 'close' is Harper's own end event, but a subscription reported as + // errored has equally stopped delivering, and a worker that kept `initialized` set would serve + // entries other nodes invalidated until it restarted. + it('reloads the tombstones after the subscription errors', async () => { + const table = makeInvalidationTable(); + const deps = makeDeps({ invalidation: table }); + let clock = 3000; + deps.now = () => clock; + + await initializeInvalidationSubscription(deps); + assert.equal(table.searchCalls, 1); + + // The subscription drops, then another node invalidates the tag. No event is delivered. + table.errorListener?.(new Error('subscription closed')); + table.rows.push({ id: 'products', timestamp: 4000 }); + clock = 5000; + + // The next read, which every `get` makes. + await initializeInvalidationSubscription(deps); + + assert.equal(table.searchCalls, 2, 'the worker kept trusting a map that had stopped updating'); + assert.equal(tagState(['products'], 1000, clock), 'expired'); + }); +}); + +describe('concurrent invalidations of one tag', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + // Two nodes invalidate the same tag and the older `put` commits last. What has to hold is the row + // left in storage, so this reads it back the way a worker that restarts would: the in-memory map + // orders the two correctly on a running worker and would hide a regression there. + it('does not let an older write replace a newer tombstone', async () => { + const table = makeInvalidationTable(); + let releaseOlder = () => {}; + const held = new Promise((resolve) => { + releaseOlder = resolve; + }); + const put = table.put.bind(table); + table.put = async (key, value, context) => { + if (value.timestamp === 1000) await held; + return put(key, value, context); + }; + + const older = recordInvalidation(['products'], undefined, makeDeps({ invalidation: table, now: 1000 })); + // A later turn, so the per-turn deduplication does not swallow the second node's invalidation. + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['products'], undefined, makeDeps({ invalidation: table, now: 2000 })); + releaseOlder(); + await older; + + // A worker that starts now sees only what storage holds. + resetInvalidationStateForTesting(); + await hydrateInvalidations(table, { tagsManifestModule: null, now: () => 3000 }); + + assert.equal( + tagState(['products'], 1500, 3000), + 'expired', + 'an entry written between the two invalidations survived the newer one' + ); + }); +}); + describe('chunk', () => { it('splits into fixed-size batches with a short tail', () => { assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); From 1fba8ff6dd00d267d719c081826cfca1ffd7239a Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 12:34:41 -0600 Subject: [PATCH 15/29] test: pin the same-millisecond ordering gap the write-version fix leaves open `recordInvalidation` nudges each write's Harper version past the last so two invalidations issued in one millisecond cannot tie in storage, but that version never reaches the row: the `timestamp` column stays the whole millisecond and subscription events carry no version. `noteInvalidation` orders by that integer and lets an equal `at` overwrite, so where the two orderings disagree the older view wins. The reachable path is start-up: the subscription is live before the hydration scan, so a newer hard expiry delivered during the scan is overwritten by the older profiled row the scan returns. An entry the hard expiry had to withhold goes back to being served stale, and initialization reports ready. This test fails. Carrying a comparable version through the row, the hydration and the events would fix it, as would refusing to weaken a hard expiry on an equal-time merge. Co-Authored-By: Claude Opus 5 --- src/cacheInvalidation.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index 4faa73e..4225de7 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -827,6 +827,37 @@ describe('concurrent invalidations of one tag', () => { }); }); +// FAILS: still open. `recordInvalidation` nudges each write's Harper version past the last so a +// same-millisecond pair cannot tie, but that version lives only in the write context — the row's own +// `timestamp` stays the whole millisecond, and events carry no version at all. `noteInvalidation` +// orders by that integer and lets an equal `at` overwrite, so on the one path where the two orderings +// disagree the older view can win. +describe('two invalidations issued in the same millisecond', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('does not let an older hydration row downgrade a newer hard expiry', async () => { + const lapsesAt = 1000 + YEAR_MS; + // What the start-up scan finds: the earlier, profiled invalidation. + const table = makeInvalidationTable([{ id: 'products', timestamp: 1000, stale: 1000, expired: 61_000, lapsesAt }]); + const search = table.search.bind(table); + table.search = () => { + // The later hard expiry arrives on the subscription while that scan is still running, so the + // scan's older row is applied second. + table.listener?.({ type: 'put', id: 'products', value: { timestamp: 1000, expired: 1000, lapsesAt } }); + return search(); + }; + const deps = makeDeps({ invalidation: table, now: 2000 }); + + await initializeInvalidationSubscription(deps); + + assert.equal( + tagState(['products'], 500, 2000), + 'expired', + 'the hard expiry was downgraded to stale, so the worker serves what it had to withhold' + ); + }); +}); + describe('chunk', () => { it('splits into fixed-size batches with a short tail', () => { assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); From f9526769bb227e625765f19bd509f70ad81cc7b2 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 13:21:21 -0600 Subject: [PATCH 16/29] test: pin the lone-surrogate cache-key collision, and rule out the cheap fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toStorageKey` derives both its truncated prefix and its digest through UTF-8, which collapses every lone surrogate to U+FFFD. Two oversized keys differing only there produce an identical storage key, and `get` never checks which key the row it found belongs to, so one cache boundary is served another's bytes. Verified directly: `toStorageKey('x'.repeat(2000) + '\uD800')` equals `toStorageKey('x'.repeat(2000) + '\uD801')`. The second test is the one that matters for choosing the fix. Comparing a stored `cacheKey` column against the incoming key — the obvious one-liner — cannot work here: Harper encodes records with msgpackr, which does not preserve a lone surrogate, so the stored key never equals the key that wrote it and the comparison turns every such read into a permanent miss. The digest has to distinguish the keys instead, by hashing UTF-16 code units rather than their UTF-8 encoding. So the use-cache table fake now stores strings the way Harper does. Checked against `harper`'s own msgpackr: for a string long enough to take its Buffer.write path — every key this module truncates — the round trip is exactly a UTF-8 round trip. Storing JS strings verbatim would have let the cheap fix look correct. The collision test fails; the round-trip guard passes and must keep passing. Co-Authored-By: Claude Opus 5 --- src/UseCacheHandler.test.ts | 53 ++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 3efd6c5..2941609 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -107,6 +107,17 @@ function enforceSchema(value: Record) { } } +/** + * Harper encodes records with msgpackr, which does not preserve a lone surrogate: it survives the round + * trip as U+FFFD. Verified against `harper`'s own msgpackr — for a string long enough to take its + * Buffer.write path, which is every key this module truncates, the result is exactly a UTF-8 round + * trip. A mock that stores JS strings verbatim would hide that a stored key cannot be compared against + * the key it came from. + */ +function asStored(value: unknown): unknown { + return typeof value === 'string' ? Buffer.from(value, 'utf8').toString('utf8') : value; +} + const putContexts = new Map(); /** Tombstone rows the worker would hydrate, and whether that scan is allowed to succeed. */ @@ -128,7 +139,8 @@ function installDatabases(invalidation: InvalidationStorage = {}) { async put(key: string, value: Record, context?: { expiresAt?: number }) { enforcePrimaryKey(key); enforceSchema(value); - rows.set(key, { id: key, ...value }); + const stored = Object.fromEntries(Object.entries(value).map(([name, held]) => [name, asStored(held)])); + rows.set(key, { id: key, ...stored }); putContexts.set(key, context); }, search() { @@ -629,3 +641,42 @@ describe('UseCacheHandler oversized keys', () => { assert.equal(rows.get('short')?.cacheKey, undefined); }); }); + +/** + * Next composes a "use cache" key with `encodeReply`, whose binary/FormData encoding can leave lone + * surrogates in the string. `toStorageKey` derives both its truncated prefix and its digest through + * UTF-8, which collapses every lone surrogate to U+FFFD, so two keys differing only there land on one + * row — and `get` never checks which key that row belongs to. + */ +describe('UseCacheHandler oversized keys carrying a lone surrogate', () => { + // Oversized, so they are truncated and hashed, and identical apart from one unpaired high surrogate. + const keyA = 'x'.repeat(2000) + '\uD800'; + const keyB = 'x'.repeat(2000) + '\uD801'; + + beforeEach(() => { + resetInvalidationStateForTesting(); + installDatabases(); + }); + + // FAILS: still open. Serving one cache boundary's bytes for another's key. + it('does not serve one key\'s entry for a different key', async () => { + await handler.set(keyA, Promise.resolve(entry({ value: streamOf('A payload') }))); + + const result = await handler.get(keyB, []); + + assert.equal(result, undefined, 'keyB got keyA\'s entry: the two keys collapsed onto one row'); + }); + + // The guard that rules out the cheap fix. Comparing a stored `cacheKey` column against the incoming + // key cannot work here: Harper cannot store the key faithfully, so that comparison fails for the + // rightful owner too and turns every such read into a permanent miss. The digest has to distinguish + // the keys instead — hash the UTF-16 code units rather than their UTF-8 encoding. + it('still serves an entry to the key that wrote it', async () => { + await handler.set(keyA, Promise.resolve(entry({ value: streamOf('A payload') }))); + + const result = await handler.get(keyA, []); + + assert.ok(result, 'the rightful key must still hit'); + assert.equal(await readAll(result.value), 'A payload'); + }); +}); From ea563820094554851c97dfa8ed677d7f4c834c71 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 13:39:12 -0600 Subject: [PATCH 17/29] test: write the regression-test comments for the next reader, not the reviewer Co-Authored-By: Claude Opus 5 --- src/CacheHandler.test.ts | 10 ++++------ src/UseCacheHandler.test.ts | 20 +++++++++----------- src/cacheInvalidation.test.ts | 2 +- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 99d8b50..25f2a5d 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -319,8 +319,8 @@ describe('HarperCacheHandler reads before the tombstones are loaded', () => { useTagsManifestModuleForTesting(NEXT_16_MANIFEST); }); - // Before the fix, a swallowed hydration failure left `get` consulting an empty invalidation map and - // handing Next an entry another node had already invalidated. + // A swallowed hydration failure leaves the invalidation map empty, which is indistinguishable from + // "nothing is invalidated" — so the read has to withhold rather than hand Next a stale entry. it('does not serve an entry a stored tombstone invalidated', async () => { const now = Date.now(); installDatabases( @@ -336,8 +336,7 @@ describe('HarperCacheHandler reads before the tombstones are loaded', () => { ); }); - // The counterpart: once the scan succeeds the entry is withheld for the usual reason, not because - // reads stayed closed. This one passes today. + // The same entry, withheld for the ordinary reason rather than because reads stayed closed. it('withholds the same entry once the tombstones load', async () => { const now = Date.now(); installDatabases( @@ -349,8 +348,7 @@ describe('HarperCacheHandler reads before the tombstones are loaded', () => { assert.equal(await handler.get('/page', pageCtx), null); }); - // Failing closed must not become "miss everything": an entry nothing invalidated still serves once - // the scan succeeds. This one passes today. + // Withholding cannot generalise into "miss everything": an entry nothing invalidated still serves. it('serves an uninvalidated entry when the scan succeeds', async () => { installDatabases({ '/page': { data: appPage([]), tags: [], lastModified: Date.now() } }); const handler = new HarperCacheHandler(); diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index 2941609..d5be16e 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -268,8 +268,8 @@ describe('UseCacheHandler streaming', () => { // What a read must do while the invalidation map is incomplete, and what failing closed must not cost. describe('UseCacheHandler reads before the tombstones are loaded', () => { - // Before the fix, a swallowed hydration failure left `get` consulting an empty invalidation map and - // handing back an entry another node had already invalidated. + // A swallowed hydration failure leaves the invalidation map empty, which is indistinguishable from + // "nothing is invalidated" — so the read has to withhold rather than guess. it('does not serve an entry a stored tombstone invalidated', async () => { resetInvalidationStateForTesting(); const now = Date.now(); @@ -291,8 +291,7 @@ describe('UseCacheHandler reads before the tombstones are loaded', () => { ); }); - // The counterpart: once the scan succeeds the entry is withheld for the usual reason, not because - // reads stayed closed. This one passes today. + // The same entry, withheld for the ordinary reason rather than because reads stayed closed. it('withholds the same entry once the tombstones load', async () => { resetInvalidationStateForTesting(); const now = Date.now(); @@ -310,8 +309,7 @@ describe('UseCacheHandler reads before the tombstones are loaded', () => { assert.equal(await handler.get('tagged', []), undefined); }); - // A failed scan must not turn every read into a miss for an entry nothing invalidated — that would - // satisfy the first test by emptying the cache. This one passes today. + // Withholding cannot generalise into "miss everything": an entry nothing invalidated still serves. it('still serves an untagged entry when the scan succeeds', async () => { resetInvalidationStateForTesting(); const rows = installDatabases(); @@ -658,7 +656,7 @@ describe('UseCacheHandler oversized keys carrying a lone surrogate', () => { installDatabases(); }); - // FAILS: still open. Serving one cache boundary's bytes for another's key. + // This test fails: the collision is unfixed, and it serves one cache boundary's bytes for another's key. it('does not serve one key\'s entry for a different key', async () => { await handler.set(keyA, Promise.resolve(entry({ value: streamOf('A payload') }))); @@ -667,10 +665,10 @@ describe('UseCacheHandler oversized keys carrying a lone surrogate', () => { assert.equal(result, undefined, 'keyB got keyA\'s entry: the two keys collapsed onto one row'); }); - // The guard that rules out the cheap fix. Comparing a stored `cacheKey` column against the incoming - // key cannot work here: Harper cannot store the key faithfully, so that comparison fails for the - // rightful owner too and turns every such read into a permanent miss. The digest has to distinguish - // the keys instead — hash the UTF-16 code units rather than their UTF-8 encoding. + // Comparing a stored `cacheKey` column against the incoming key cannot resolve the collision above: + // Harper cannot store the key faithfully, so the comparison fails for the rightful owner too and + // turns every such read into a permanent miss. The digest has to separate the keys instead, by + // hashing UTF-16 code units rather than their UTF-8 encoding. it('still serves an entry to the key that wrote it', async () => { await handler.set(keyA, Promise.resolve(entry({ value: streamOf('A payload') }))); diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index 4225de7..fdcc57a 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -827,7 +827,7 @@ describe('concurrent invalidations of one tag', () => { }); }); -// FAILS: still open. `recordInvalidation` nudges each write's Harper version past the last so a +// This test fails: the gap is unfixed. `recordInvalidation` nudges each write's Harper version so a // same-millisecond pair cannot tie, but that version lives only in the write context — the row's own // `timestamp` stays the whole millisecond, and events carry no version at all. `noteInvalidation` // orders by that integer and lets an equal `at` overwrite, so on the one path where the two orderings From c5c33650414dad98806cc4d59e0874b87cff336b Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 14:42:11 -0600 Subject: [PATCH 18/29] ci: run the unit suite on every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No workflow ran `npm test`. The only test invocation in CI was `npm run test:integration`, so the 138 unit tests — including every regression test on this branch — were never executed by CI, and a red unit suite showed up as a green PR. A separate workflow rather than a job in integration-tests.yml: that file is also a reusable `workflow_call` consumed by HarperFast/harper, so a job added there would run for the caller too. The unit suite needs no Harper instance, fixtures or browsers, so it is a seconds-long check on the same Node matrix. This job is RED on this branch by design. Two committed tests pin defects that are still open — the same-millisecond invalidation ordering gap and the lone-surrogate cache-key collision. Both have a fix described in their test comments. Also corrects AGENTS.md, which claimed CI was disabled via `if: false`. That flag was removed in a52c3c7; the integration workflow has been running on every PR since. The unit suite was undocumented there too. Co-Authored-By: Claude Opus 5 --- .github/workflows/unit-tests.yml | 38 ++++++++++++++++++++++++++++++++ AGENTS.md | 3 ++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..83eaee8 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,38 @@ +name: Unit Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [22, 24, 26] + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + # No separate build step: `npm test` compiles first, because the suite runs the emitted + # `dist/**/*.test.js` rather than the TypeScript sources. Unlike the integration suite this + # needs no Harper instance, fixtures or browsers, so it stays a seconds-long check. + - name: Run unit tests + run: npm test diff --git a/AGENTS.md b/AGENTS.md index 5d9999d..b5e0094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Use `npm link` in this directory and `npm link @harperfast/nextjs` in other project directories to test out changes locally - Run `npm run install:fixtures` before running tests for the first time, and again after changing any fixture's `package.json` **or any plugin source under `src/`**. Fixtures install the plugin with `--install-links`, so each one holds a *copy* of `dist/`, not a symlink — `npm run build` alone does not reach them. +- Run `npm test` for the unit suite. It is `npm run build` plus `node --test` over the emitted `dist/**/*.test.js`, so it tests compiled output, not `src/` — a stale `dist/` tests stale code. - Run `npm run test:integration` to run all tests, or `npm run test:integration -- integrationTests/next-15.pw.ts` for a single file. - Test startup is slow by design — each test file starts a real Harper instance and waits for Next.js to build (up to 2 minutes). A slow start is not a failure. - The ISR cache tests in `integrationTests/next-16.pw.ts` are intentionally skipped; `CacheHandler.cts` is a work in progress. @@ -32,4 +33,4 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Playwright's Chromium cannot launch inside the Claude Code sandbox (`bootstrap_check_in … Permission denied`): every integration test then fails at browser launch before any assertion. Run integration tests outside the sandbox. - Harper facts the cache handlers depend on (verified in `node_modules/harper` 5.1.23): each element of an `@indexed` array attribute is indexed, so `{attribute: 'tags', comparator: 'equals', value}` is an index lookup, while `contains` is a full scan doing a *substring* match on `String(array)`. Every write — `patch` included — resets a record's expiry to now + the table's `expiration` unless `put(id, value, { expiresAt })` overrides it, and a sourceless table returns `null` for an expired record. TTL eviction emits no subscription `delete` event. `patch` on a missing record creates a partial one. Adding `@indexed` to an existing attribute backfills in the background, and on 5.2.0 the worker threads that did not run the backfill were observed to keep throwing `IndexRebuildingError` for that attribute after "Finished indexing" was logged, until a restart — which is why the sweep falls back to an exact-match scan. - Next.js `IncrementalCache` treats a handler-returned `lastModified: -1` as an on-demand (blocking) revalidation, not stale-while-revalidate. Next 16 serves tag-stale FETCH/APP_PAGE/APP_ROUTE entries stale from its `tagsManifest` (`{stale, expired}` per tag); Next 15's manifest holds a number per tag; Next 14 has none. -- CI is currently disabled (`if: false` in `.github/workflows/integration-tests.yml`). Run tests locally. +- CI runs on every pull request: `unit-tests.yml` (`npm test`, Node 22/24/26) and `integration-tests.yml` (`npm run test:integration`, Node 22/24/26), plus commitlint. The integration suite needs the machine setup described above, so it is the one to re-run locally; the unit suite is self-contained. From fd6f5a91d31106aeea7ceaee096a45b204d50b29 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:15:55 -0600 Subject: [PATCH 19/29] fix: separate oversized "use cache" keys that differ only by a lone surrogate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next composes a "use cache" key with `encodeReply`, whose FormData/binary encoding can leave lone surrogates in the string. `toStorageKey` derived both the truncated prefix and the digest through UTF-8, which collapses every lone surrogate to U+FFFD, so two keys differing only there produced the same storage key — and `get` never checks which key a row belongs to, so one cache boundary's bytes were served for another's key. The prefix has to go through UTF-8 (two nodes must derive the same key, and Harper stores the id as UTF-8 either way), so the digest is what has to tell them apart: it is now taken over UTF-16 code units, which keeps every code unit verbatim. Co-Authored-By: Claude Opus 5 --- src/UseCacheHandler.cts | 7 ++++++- src/UseCacheHandler.test.ts | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index b728a6c..09b722d 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -108,7 +108,12 @@ const KEY_HASH_SEPARATOR = '\u0000#'; export function toStorageKey(cacheKey: string): string { if (Buffer.byteLength(cacheKey, 'utf8') <= MAX_KEY_BYTES) return cacheKey; - const digest = createHash('sha256').update(cacheKey, 'utf8').digest('hex').slice(0, KEY_HASH_BYTES); + // Hashed over UTF-16 code units, not their UTF-8 encoding. Next composes a key with `encodeReply`, + // whose FormData/binary encoding can leave lone surrogates in the string; UTF-8 collapses every one + // of them to U+FFFD, so two keys differing only there would hash alike — and, since the truncated + // prefix collapses the same way, land on one row and serve one cache boundary's bytes for another's + // key. `utf16le` writes each code unit verbatim, so the digest keeps them apart. + const digest = createHash('sha256').update(cacheKey, 'utf16le').digest('hex').slice(0, KEY_HASH_BYTES); const budget = MAX_KEY_BYTES - Buffer.byteLength(KEY_HASH_SEPARATOR, 'utf8') - digest.length; // Truncate by BYTES on a character boundary. Slicing by .length would overshoot on diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index d5be16e..b09ef8b 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -642,9 +642,10 @@ describe('UseCacheHandler oversized keys', () => { /** * Next composes a "use cache" key with `encodeReply`, whose binary/FormData encoding can leave lone - * surrogates in the string. `toStorageKey` derives both its truncated prefix and its digest through - * UTF-8, which collapses every lone surrogate to U+FFFD, so two keys differing only there land on one - * row — and `get` never checks which key that row belongs to. + * surrogates in the string. The truncated prefix is necessarily derived through UTF-8, which collapses + * every lone surrogate to U+FFFD, so keys differing only there share a prefix; the digest has to be the + * part that separates them, and it is taken over UTF-16 code units for exactly that reason. Deriving it + * through UTF-8 too put both keys on one row, and `get` never checks which key a row belongs to. */ describe('UseCacheHandler oversized keys carrying a lone surrogate', () => { // Oversized, so they are truncated and hashed, and identical apart from one unpaired high surrogate. @@ -656,7 +657,7 @@ describe('UseCacheHandler oversized keys carrying a lone surrogate', () => { installDatabases(); }); - // This test fails: the collision is unfixed, and it serves one cache boundary's bytes for another's key. + // Before the digest moved to UTF-16 code units this served one cache boundary's bytes for another's key. it('does not serve one key\'s entry for a different key', async () => { await handler.set(keyA, Promise.resolve(entry({ value: streamOf('A payload') }))); From 201b33cb80ab40068845ec4bd1b21dd7bbf36fa6 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:16:02 -0600 Subject: [PATCH 20/29] fix: resolve a same-millisecond invalidation tie by strictness, not arrival order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordInvalidation` nudges each tombstone write's Harper version so two invalidations of one tag issued in the same millisecond cannot tie on storage, but that version lives only in the write context: the row's own `timestamp` stays the whole millisecond and subscription events carry no version at all. So `noteInvalidation` cannot order such a pair by `at`, and letting an equal `at` overwrite made arrival order decide — which differs per worker. A hard expiry delivered on the subscription was downgraded to stale by the profiled row the start-up scan applied second, and the worker served what it had to withhold. A tie is now resolved by taking the stricter of the two: the earliest `stale` and the earliest `expired`. That withholds the most, and it is the same answer whichever arrived first. Co-Authored-By: Claude Opus 5 --- src/cacheInvalidation.cts | 32 +++++++++++++++++++++++++++++--- src/cacheInvalidation.test.ts | 10 +++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 374f1e6..7009f24 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -360,16 +360,42 @@ function revalidatedAtOf(invalidation: TagInvalidation): number { return invalidation.expired ?? invalidation.stale ?? invalidation.at; } +function earliestDefined(left: number | undefined, right: number | undefined): number | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.min(left, right); +} + +/** + * Two invalidations of one tag issued in the same millisecond. `at` cannot order them — the write + * version that separates them on storage is not carried by the row or by a subscription event — so + * arrival order would otherwise decide, and the same pair can arrive in either order on different + * workers. Take the stricter of the two instead: the earliest `stale` and the earliest `expired` + * withhold the most, which is the safe direction, and it is the same answer whichever arrived first. + * Without it a hard expiry delivered first is downgraded by a deferred one applied second, and the + * worker serves what it was told to withhold. + */ +function stricterOf(existing: TagInvalidation, incoming: TagInvalidation): TagInvalidation { + return { + stale: earliestDefined(existing.stale, incoming.stale), + expired: earliestDefined(existing.expired, incoming.expired), + at: incoming.at, + lapsesAt: Math.max(existing.lapsesAt, incoming.lapsesAt), + }; +} + /** * Adopt an invalidation into this worker's view. The newest (by `at`) wins, so the subscription, the - * start-up hydration and this worker's own writes can arrive in any order. + * start-up hydration and this worker's own writes can arrive in any order; a tie is resolved by + * `stricterOf` rather than by which arrived last. */ export function noteInvalidation(tag: string, invalidation: TagInvalidation, deps: InvalidationDeps = {}): void { const existing = cacheInvalidations.get(tag); if (existing && existing.at > invalidation.at) return; if (invalidation.lapsesAt <= nowFor(deps)) return; - cacheInvalidations.set(tag, invalidation); - mirrorToTagsManifest(tag, invalidation, existing, deps); + const adopted = existing && existing.at === invalidation.at ? stricterOf(existing, invalidation) : invalidation; + cacheInvalidations.set(tag, adopted); + mirrorToTagsManifest(tag, adopted, existing, deps); } function forgetInvalidation(tag: string, deps: InvalidationDeps): void { diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index fdcc57a..c48ae0a 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -827,11 +827,11 @@ describe('concurrent invalidations of one tag', () => { }); }); -// This test fails: the gap is unfixed. `recordInvalidation` nudges each write's Harper version so a -// same-millisecond pair cannot tie, but that version lives only in the write context — the row's own -// `timestamp` stays the whole millisecond, and events carry no version at all. `noteInvalidation` -// orders by that integer and lets an equal `at` overwrite, so on the one path where the two orderings -// disagree the older view can win. +// `recordInvalidation` nudges each write's Harper version so a same-millisecond pair cannot tie on +// storage, but that version lives only in the write context — the row's own `timestamp` stays the whole +// millisecond, and events carry no version at all. So `noteInvalidation` cannot order a tie by `at`; it +// takes the stricter of the two instead, which is the same answer whichever arrived first. Letting an +// equal `at` simply overwrite let the older view win on the one path where the two orderings disagree. describe('two invalidations issued in the same millisecond', () => { beforeEach(() => resetInvalidationStateForTesting()); From 04105433a23ed2617499e45c4be60dd0b656d16e Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:20:16 -0600 Subject: [PATCH 21/29] fix: leave an unbounded cache life to the table's expiration instead of pinning it a year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next's `default` cacheLife profile sets `expire` to `INFINITE_CACHE` (0xfffffffe), and that is what every `'use cache'` boundary with no `cacheLife()` gets — the common case, not an edge one. `toStoredLife` bounds it to a year so it fits the Int column, and `entryExpiresAt` was then writing that bounded year as the record's Harper expiry, overriding the cache tables' 7-day `expiration` for the default case. Next puts the build ID in every `'use cache'` key, so entries from an earlier build can never be read again. Pinning the default case for a year meant every deploy left a full generation of unreadable blobs behind for a year, with the sweep that reclaims them opt-in and off by default. `entryExpiresAt` now returns undefined for a life longer than the year it caps at, which is only reachable from an unbounded `expire`, so those rows follow the table's expiration. A definite life is still honoured exactly: `cacheLife('max')` asks for 31_536_000 and gets its year, and `weeks` still outlives the table TTL. The handlers pass the raw `expire` rather than the stored one, since `toStoredLife` has already erased the difference. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++--- src/CacheHandler.cts | 4 +++- src/CacheHandler.test.ts | 23 +++++++++++++++++------ src/UseCacheHandler.cts | 4 +++- src/UseCacheHandler.test.ts | 11 +++++++---- src/cacheInvalidation.cts | 20 ++++++++++++++++---- src/cacheInvalidation.test.ts | 18 +++++++++++++++--- 7 files changed, 64 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8c402de..24c43fc 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ export default withHarper( Requires Next.js 16, which is where the interface exists. Opt-in for now — it becomes the default in the next major, because turning it on changes where existing apps' `'use cache'` entries are stored. - **Values are streamed.** Each entry's rendered bytes are stored as a Harper `Blob` and streamed back from storage on every read, rather than read into memory whole. A write is buffered until the render completes, so a render that fails partway is never stored. -- **Entries from earlier builds can be deleted (opt-in).** Next.js puts the build ID in every `'use cache'` key, so a deploy leaves the previous build's entries unreadable — and with a `max` profile each would otherwise stay a year. Set `HARPER_NEXTJS_SWEEP_OLD_BUILDS=true` in the Harper process's environment to clear them: five minutes after starting a build, one worker per node deletes every entry whose key names a build that is neither its own nor the latest successful build of another app in `nextjs_build_info`. The delay lets a rolling restart reach the other nodes first; deleting an entry a node on the old build is still serving would only cost it a render. Keys that cannot be attributed to a build (Next.js encodes non-JSON arguments differently) are left to expire on their own. Off by default, the entries simply expire. +- **Entries from earlier builds can be deleted (opt-in).** Next.js puts the build ID in every `'use cache'` key, so a deploy leaves the previous build's entries unreadable. Most of them expire with the table, 7 days later; one written under an explicit long profile such as `cacheLife('max')` keeps its own expiry and can sit there for a year. Set `HARPER_NEXTJS_SWEEP_OLD_BUILDS=true` in the Harper process's environment to clear them: five minutes after starting a build, one worker per node deletes every entry whose key names a build that is neither its own nor the latest successful build of another app in `nextjs_build_info`. The delay lets a rolling restart reach the other nodes first; deleting an entry a node on the old build is still serving would only cost it a render. Keys that cannot be attributed to a build (Next.js encodes non-JSON arguments differently) are left to expire on their own. Off by default, the entries simply expire. - **Keys of any size work.** Next.js builds a `'use cache'` key from the component's props and puts no limit on its size, while Harper caps a primary key at 1978 bytes. A key over 1500 bytes is stored under a truncated prefix plus a hash of the full key, and the full key is kept in the row's `cacheKey` column. Shorter keys are stored as they are. Large props still make large keys, so keep a cached component's props small. ### Per-entry cache lives @@ -327,9 +327,9 @@ Invalidation follows Next.js's own semantics. `revalidateTag(tag)` and `updateTa 3. On the next `cache.get()`, an entry written before the invalidation is withheld if the tag has expired. If the tag is only stale, it is handed to Next.js to serve stale while regenerating wherever Next.js supports that (Next.js 16 for pages, route handlers and `fetch`; the `'use cache'` handler always). Otherwise it is withheld. 4. A worker that restarts rebuilds its map from the tombstones, so an invalidation survives the process that issued it. -**A tombstone outlives every entry it can invalidate.** Each cache entry is written with its own Harper expiry: when Next.js says it stops being usable (its `expire`), capped at one year — the `expire` of Next.js's `max` profile. That cap is independent of the tables' 7-day `expiration`, which applies only to an entry written without an `expire`, so the `weeks` and `max` profiles keep their full stale-while-revalidate window. An entry Next.js considers dead is evicted then rather than kept longer. Each tombstone is written with an expiry of the longest an entry can be kept (a year, or a cache table's `expiration` if that is longer), plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses, so it holds one small record per tag invalidated in the past year: fine for a few thousand distinct tags, worth sizing before invalidating a tag per product. A cache table configured with no expiration breaks that bound and is reported in the log. +**A tombstone outlives every entry it can invalidate.** An entry whose `expire` is a definite length is written with its own Harper expiry, so it is evicted when Next.js says it stops being usable. That is independent of the tables' 7-day `expiration`, so the `weeks` and `max` profiles keep their full stale-while-revalidate window — up to one year, the `expire` of `max` and the longest any entry is kept. Each tombstone is written with an expiry of that longest life (a year, or a cache table's `expiration` if that is longer), plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses, so it holds one small record per tag invalidated in the past year: fine for a few thousand distinct tags, worth sizing before invalidating a tag per product. A cache table configured with no expiration breaks that bound and is reported in the log. -Cache lives are stored bounded to that year. Next.js's `default` profile sets `expire` to `INFINITE_CACHE` (0xfffffffe), which does not fit the 32-bit `Int` column; stored unbounded, Harper would refuse the write and the entry would never be cached. +**An entry with no definite `expire` keeps the table's 7-day `expiration`.** Next.js's `default` profile — every `'use cache'` boundary with no `cacheLife()` — sets `expire` to `INFINITE_CACHE` (0xfffffffe). That is stored as one year, because it does not fit the 32-bit `Int` column and Harper would otherwise refuse the write and never cache the entry at all, but the row itself is left to the table's expiration rather than pinned for the year. Pinning it would override the table TTL for the common case, and because Next.js puts the build ID in every `'use cache'` key, every deploy would leave a full generation of unreadable entries behind for a year. Ask for a long life explicitly — `cacheLife('max')` — and it is honoured. An immediate expiry is also **swept**: entries carrying the tag are looked up through the indexed `tags` column (an exact-match index lookup, not a scan) and deleted in throttled chunks, so they stop taking up space before their own expiry. This includes Next.js's implicit route tags, so `revalidatePath` is swept too. While Harper reports the index as still being built, the sweep scans with the same exact match instead. The sweep only reclaims space, and reads never depend on it: a sweep that fails or is interrupted leaves the entries to expire on their own. A stale-then-expire invalidation is not swept, because those entries must stay servable while they regenerate. diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index 59134d6..febfc18 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -146,7 +146,9 @@ export default class HarperCacheHandler implements CacheHandler { const expire = toStoredLife(cacheControl?.expire); const now = Date.now(); - const expiresAt = entryExpiresAt(now, expire, now); + // The raw `expire`, not the stored one: see `entryExpiresAt` on why an unbounded life is left to + // the table's own expiration rather than pinned for the year `toStoredLife` bounds it to. + const expiresAt = entryExpiresAt(now, cacheControl?.expire, now); await table.put(key, { data, tags, revalidate, expire }, expiresAt === undefined ? undefined : ({ expiresAt } as never)); } diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts index 25f2a5d..7beeb95 100644 --- a/src/CacheHandler.test.ts +++ b/src/CacheHandler.test.ts @@ -134,23 +134,34 @@ describe('HarperCacheHandler per-entry cache lives', () => { assert.equal(puts[0].value.expire, 3600); }); - it("sets the record's Harper expiry from Next's expire, capped at a year", async () => { + it("sets the record's Harper expiry from Next's expire", async () => { const { puts } = installDatabases(); const handler = new HarperCacheHandler(); const before = Date.now(); await handler.set('/short', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 3600 } } as never); - await handler.set('/long', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 0xfffffffe } } as never); + await handler.set('/weeks', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 2_592_000 } } as never); await handler.set('/none', appPage([]) as never, { cacheControl: { revalidate: 60 } } as never); - const [short, long, none] = puts.map((put) => put.context?.expiresAt); + const [short, weeks, none] = puts.map((put) => put.context?.expiresAt); assert.ok(short! >= before + 3_600_000 && short! <= Date.now() + 3_600_000); - assert.ok(long! > Date.now() + 604_800_000, 'not cut to the 7-day table TTL'); - assert.ok(long! <= Date.now() + 31_536_000_000, 'no entry may outlive the year its tombstones are sized for'); - assert.equal(puts[1].value.expire, 31_536_000, 'bounded to fit the Int column'); + assert.ok(weeks! > Date.now() + 604_800_000, 'not cut to the 7-day table TTL'); + assert.ok(weeks! <= Date.now() + 31_536_000_000, 'no entry may outlive the year its tombstones are sized for'); assert.equal(none, undefined); }); + // An unbounded `expire` is left to the table's own expiration rather than pinned for the year the + // Int column bounds it to. See entryExpiresAt. + it("leaves an unbounded expire to the table's expiration, but still stores a usable life", async () => { + const { puts } = installDatabases(); + const handler = new HarperCacheHandler(); + + await handler.set('/long', appPage([]) as never, { cacheControl: { revalidate: 60, expire: 0xfffffffe } } as never); + + assert.equal(puts[0].context?.expiresAt, undefined); + assert.equal(puts[0].value.expire, 31_536_000, 'bounded to fit the Int column'); + }); + it('stores no revalidate when Next supplies false', async () => { const { puts } = installDatabases(); const handler = new HarperCacheHandler(); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts index 09b722d..619fbd0 100644 --- a/src/UseCacheHandler.cts +++ b/src/UseCacheHandler.cts @@ -252,7 +252,9 @@ class HarperUseCacheHandler implements CacheHandler { const storageKey = toStorageKey(cacheKey); const timestamp = toInteger(entry.timestamp) ?? Date.now(); const expire = toStoredLife(entry.expire); - const expiresAt = entryExpiresAt(timestamp, expire); + // The raw `expire`, not the stored one: `toStoredLife` bounds an unbounded life to a year to fit + // the Int column, and `entryExpiresAt` has to be able to tell that apart from a deliberate year. + const expiresAt = entryExpiresAt(timestamp, entry.expire); await table.put( storageKey, { diff --git a/src/UseCacheHandler.test.ts b/src/UseCacheHandler.test.ts index b09ef8b..30c75cd 100644 --- a/src/UseCacheHandler.test.ts +++ b/src/UseCacheHandler.test.ts @@ -410,17 +410,20 @@ describe('UseCacheHandler cache lives', () => { assert.ok(putContexts.get('max')!.expiresAt! <= Date.now() + 31_536_000_000); }); - // Next's `default` profile. Stored as-is, Harper refuses the write and the entry never lands. - it('stores an entry whose expire is INFINITE_CACHE, bounded to a year', async () => { + // Next's `default` profile — every boundary with no cacheLife(). Stored as-is, Harper refuses the + // write (INFINITE_CACHE does not fit the Int column) and the entry never lands. The row is left to + // the table's own expiration rather than pinned for the year the column is bounded to: the build ID + // is part of every "use cache" key, so pinning the default case orphans a generation per deploy. + it("stores an entry whose expire is INFINITE_CACHE, and leaves it to the table's expiration", async () => { const now = Date.now(); await handler.set('default', Promise.resolve(entry({ timestamp: now, revalidate: 900, expire: 0xfffffffe }))); const result = await handler.get('default', []); assert.ok(result, 'the write was not refused'); - assert.equal(result.expire, 31_536_000); + assert.equal(result.expire, 31_536_000, 'bounded to fit the Int column'); assert.equal(result.revalidate, 900); - assert.ok(putContexts.get('default')!.expiresAt! <= Date.now() + 31_536_000_000); + assert.equal(putContexts.get('default')?.expiresAt, undefined); }); it('preserves the entry cache lives across the round trip', async () => { diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 7009f24..3977517 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -156,10 +156,21 @@ let unboundedCacheReported = false; /** * Harper's per-record expiry for a cache entry: when Next says it stops being usable (`expire` seconds - * after `writtenAt`), capped at `MAX_ENTRY_LIFETIME_MS` from now. The cap is what `tombstoneLifetimeMs` - * relies on — no entry may outlive the tombstones that can invalidate it — so the handlers set it - * explicitly rather than leave the record's lifetime to whatever a write's context happens to carry. - * Undefined leaves the table default in place. + * after `writtenAt`). Setting it explicitly is what lets the `weeks` and `max` profiles outlive the + * tables' own `expiration`, rather than leaving the record's lifetime to whatever a write's context + * happens to carry. + * + * Undefined leaves the table's `expiration` in place, and that is deliberately what an unbounded + * `expire` gets. Next's `default` profile — every `'use cache'` boundary with no `cacheLife()`, which + * is the common case — sets `expire` to `INFINITE_CACHE` (0xfffffffe). Pinning those for the full year + * `toStoredLife` bounds them to would override the table TTL for the default case and keep entries no + * one can read: Next puts the build ID in every `'use cache'` key, so each deploy orphans a whole + * generation of them, and the build sweep that reclaims them is opt-in. An app that wants a definite + * long life asks for one — `cacheLife('max')` is 31_536_000 exactly, and is honoured. + * + * The result is still capped at `MAX_ENTRY_LIFETIME_MS` from now, which is what `tombstoneLifetimeMs` + * relies on: no entry may outlive the tombstones that can invalidate it. An unbounded entry is covered + * by the same bound, since `tombstoneLifetimeMs` also takes the tables' `expiration` into account. */ export function entryExpiresAt( writtenAt: number, @@ -167,6 +178,7 @@ export function entryExpiresAt( now: number = Date.now() ): number | undefined { if (typeof expireSeconds !== 'number' || !Number.isFinite(expireSeconds) || expireSeconds <= 0) return undefined; + if (expireSeconds > MAX_ENTRY_LIFETIME_SECONDS) return undefined; return Math.min(writtenAt + expireSeconds * 1000, now + MAX_ENTRY_LIFETIME_MS); } diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index c48ae0a..d8e1ed0 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -342,15 +342,27 @@ describe('entryExpiresAt', () => { assert.equal(entryExpiresAt(1000, 30 * 86_400, 1000), 1000 + 30 * DAY_MS); }); - // The cap is what keeps every entry inside the lifetime its tombstones are sized for. - it('caps an entry at a year', () => { - assert.equal(entryExpiresAt(1000, 0xfffffffe, 1000), 1000 + YEAR_MS); + // `cacheLife('max')` asks for exactly a year, and gets it: it is a definite life, not an unbounded one. + it('honours a life of exactly a year', () => { + assert.equal(entryExpiresAt(1000, YEAR_MS / 1000, 1000), 1000 + YEAR_MS); + }); + + // Next's `default` profile — every "use cache" boundary with no cacheLife() — asks for INFINITE_CACHE. + // Pinning those would override the table TTL for the common case and keep a generation of entries per + // deploy, since the build ID is part of every "use cache" key. + it("leaves an unbounded life to the table's expiration", () => { + assert.equal(entryExpiresAt(1000, 0xfffffffe, 1000), undefined); }); it('leaves the table default when Next gives no usable expire', () => { assert.equal(entryExpiresAt(1000, undefined), undefined); assert.equal(entryExpiresAt(1000, 0), undefined); }); + + // Clock skew must not put an entry past the lifetime its tombstones are sized for. + it('caps an entry written in the future at a year from now', () => { + assert.equal(entryExpiresAt(1000 + YEAR_MS, YEAR_MS / 1000, 1000), 1000 + YEAR_MS); + }); }); describe('toStoredLife', () => { From e8070996fe24bdd088f2476c642ef9b6dbb508c7 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:48:26 -0600 Subject: [PATCH 22/29] test: run the cluster suite on any container runtime, and make it loud in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker was a hard dependency: the rig spawned `docker` directly. On macOS `docker` is usually a *shell alias* for podman, which a direct spawn never sees, so the suite reported "Docker is not available" and skipped itself on machines that could have run it perfectly well. The runtime is now probed — `docker`, then `podman`, with `HARPER_CONTAINER_CLI` to force one. Default image moves from `harper-pro-openshift:5.0.26` to `harper-pro:5.3.1`, and a missing image is pulled rather than being a precondition that silently skips the suite. A skip is how this suite came to be green everywhere and exercised nowhere, so `HARPER_CLUSTER_REQUIRED=1` turns "cannot run here" into a failure. CI sets it, and gets 30 minutes instead of 15 — the rig stages by running `npm install` and `next build` inside the image before any test runs. Two startup races surfaced once the suite actually ran: - `add_node` fired as soon as the operations API answered, but the replication listener on 9933 binds later, failing with "connect ECONNREFUSED :9933 and connection was required to sign certificate". It is now retried, and only for that failure — bad credentials or an OSS image still surface at once. - `restartNode` waited on the operations API plus a fixed 5s sleep, then the test fetched a page. Harper binds 9925 well before Next.js is up on 9926, so under load the request was reset mid-flight. Both restart and start-up now wait for the app itself to answer. `operation()` also reports the response body; a failed handshake was a bare 500. Co-Authored-By: Claude Opus 5 --- .github/workflows/integration-tests.yml | 8 +- AGENTS.md | 4 +- integrationTests/cluster.pw.ts | 6 + integrationTests/cluster.ts | 146 +++++++++++++++++++++--- 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 592dcab..e895e20 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -121,10 +121,16 @@ jobs: - name: Run integration tests run: npm run test:integration - timeout-minutes: 15 + # The cluster suite stages its rig by running `npm install` and `next build` inside the Pro + # image before any test runs, which dominates a cold run. + timeout-minutes: 30 env: HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs FORCE_COLOR: '1' + # The runner has Docker and the Pro image is public, so the cluster suite is expected to run + # here. Without this it would skip itself on any setup problem and the job would still be + # green — which is exactly how it went unexercised before. + HARPER_CLUSTER_REQUIRED: '1' - name: Upload Harper logs on failure if: failure() diff --git a/AGENTS.md b/AGENTS.md index b5e0094..208342b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,9 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Integration tests need the macOS loopback alias pool (127.0.0.2+). Without it every fixture fails at startup with `LoopbackAddressValidationError` / `EADDRNOTAVAIL` before any assertion runs — that is a machine-setup gap, not a test failure. `ifconfig lo0 | grep 'inet '` shows what is configured. - There is no `format:fix` script in `package.json` despite the Code Style note below; match the surrounding file style (tabs, semicolons). - `next-16-static-data` is run by two test files: `next-16-static-data.pw.ts` on the default VM module loader (where Harper's component `harper` allowlist omits `flushDatabases`, so the plugin's pre-build flush is a no-op and a read-only build child can't see unflushed writes) and `next-16-static-data-native.pw.ts` under `applications.moduleLoader: native`, where the flush does run. The pair is what pins that behavior down — keep both. -- The cluster suite (`integrationTests/cluster.pw.ts`) needs Docker and a **harper-pro** image. OSS `harper` has no replication module and rejects `add_node` as an unknown operation, so it cannot form a cluster. Override the image with `HARPER_PRO_IMAGE`; the suite skips itself when Docker or the image is missing. `HARPER_CLUSTER_KEEP=1` leaves the containers up for inspection. +- The cluster suite (`integrationTests/cluster.pw.ts`) needs a container runtime and a **harper-pro** image (default `harperfast/harper-pro:5.3.1`, public). OSS `harper` has no replication module and rejects `add_node` as an unknown operation, so it cannot form a cluster. Docker is **not** required: the rig probes for a `docker` binary, then `podman`, and `HARPER_CONTAINER_CLI` forces one. On macOS `docker` is usually a *shell alias* for podman, which a direct `execFileSync` never sees — hence the probe. The image is pulled when missing rather than being a precondition. +- Cluster-suite environment: `HARPER_PRO_IMAGE` overrides the image, `HARPER_CONTAINER_CLI` the runtime, `HARPER_CLUSTER_KEEP=1` leaves the containers up for inspection, and `HARPER_CLUSTER_REQUIRED=1` turns "cannot run here" from a skip into a failure. CI sets the last one — without it the suite skips itself on any setup problem and the job stays green, which is how it went unexercised. +- `add_node` is retried while the replication listener comes up. `waitForNode` only proves the operations API answers; port 9933 binds later, and a too-early `add_node` fails with `connect ECONNREFUSED :9933 and connection was required to sign certificate`. Only that failure is retried — bad credentials or an OSS image surface immediately. - `add_node` needs an explicit `authorization: "Basic "`. Passing `username`/`password` alone fails the cert-signing handshake with "No authorization provided". - Each node needs a distinct `node.hostname` (`NODE_HOSTNAME`); it defaults to `localhost`, so two nodes otherwise collide. - Do not `cpSync` a staged rig per node: npm links a `file:` dependency as a relative symlink and Node rewrites it to an absolute host path, which does not exist in the container. Mount one staged directory into every node instead. diff --git a/integrationTests/cluster.pw.ts b/integrationTests/cluster.pw.ts index 5354544..c0bbffa 100644 --- a/integrationTests/cluster.pw.ts +++ b/integrationTests/cluster.pw.ts @@ -2,6 +2,7 @@ import { test as base, expect } from '@playwright/test'; import { join } from 'node:path'; import { + CLUSTER_REQUIRED, NODES, authHeader, clusterUnavailable, @@ -26,6 +27,11 @@ test.setTimeout(900_000); test.beforeAll(async () => { // Hooks carry their own timeout, which the describe-level one does not extend. test.setTimeout(900_000); + // Where the suite is meant to run, not running it is a failure. A silent skip is how this suite + // came to be green on machines that never started a single node. + if (unavailable && CLUSTER_REQUIRED) { + throw new Error(`HARPER_CLUSTER_REQUIRED is set but the cluster suite cannot run: ${unavailable}`); + } test.skip(unavailable !== null, unavailable ?? ''); await startCluster(PLUGIN_ROOT); }); diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index 5cc0bcd..5d20828 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -10,10 +10,38 @@ const execFileAsync = promisify(execFile); * Harper replication lives in harper-pro; the OSS `harper` distribution has no replication module and * rejects `add_node` as an unknown operation. These tests therefore need a Pro image. */ -export const PRO_IMAGE = process.env.HARPER_PRO_IMAGE ?? 'harperfast/harper-pro-openshift:5.0.26'; +export const PRO_IMAGE = process.env.HARPER_PRO_IMAGE ?? 'harperfast/harper-pro:5.3.1'; export const NETWORK = 'harper-nextjs-clusternet'; export const CREDENTIALS = { username: 'admin', password: 'pilotpass' }; +/** + * Docker or Podman, whichever this machine has. Podman is a drop-in for every subcommand the rig uses, + * and it is what the macOS workstations run — but `docker` there is usually a *shell alias*, which a + * direct spawn never sees, so the binary has to be probed rather than assumed. `HARPER_CONTAINER_CLI` + * forces one. + */ +function detectContainerCli(): string | null { + const forced = process.env.HARPER_CONTAINER_CLI; + for (const candidate of forced ? [forced] : ['docker', 'podman']) { + try { + execFileSync(candidate, ['version'], { stdio: 'ignore' }); + return candidate; + } catch { + // Not installed, or not on PATH as a real executable. + } + } + return null; +} + +export const CONTAINER_CLI = detectContainerCli(); + +/** + * Set this where the cluster suite is expected to run — CI above all. Without it a suite that cannot + * start silently skips, which is how it came to be green everywhere and exercised nowhere. With it, a + * missing runtime or an unpullable image fails the run instead. + */ +export const CLUSTER_REQUIRED = process.env.HARPER_CLUSTER_REQUIRED === '1'; + export interface ClusterNode { name: string; rigNode: string; @@ -27,28 +55,56 @@ export const NODES: ClusterNode[] = [ ]; function docker(args: string[], options: { allowFailure?: boolean } = {}): string { + if (!CONTAINER_CLI) throw new Error('no container CLI: install Docker or Podman, or set HARPER_CONTAINER_CLI'); try { - return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return execFileSync(CONTAINER_CLI, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (error) { if (options.allowFailure) return ''; throw error; } } -/** Why the cluster suite cannot run here, or null when it can. */ +/** + * Why the cluster suite cannot run here, or null when it can. Only the container runtime is checked: + * a missing image is not a blocker, because `ensureImage` pulls it. + */ export function clusterUnavailable(): string | null { + if (!CONTAINER_CLI) return 'neither docker nor podman is available (set HARPER_CONTAINER_CLI to force one)'; try { - execFileSync('docker', ['info'], { stdio: 'ignore' }); + execFileSync(CONTAINER_CLI, ['info'], { stdio: 'ignore' }); } catch { - return 'Docker is not available'; - } - const images = docker(['images', '--format', '{{.Repository}}:{{.Tag}}'], { allowFailure: true }); - if (!images.split('\n').includes(PRO_IMAGE)) { - return `${PRO_IMAGE} is not present locally (replication requires harper-pro; OSS harper has no add_node)`; + return `${CONTAINER_CLI} is installed but its daemon/machine is not running`; } return null; } +function imagePresent(): boolean { + // Podman prints repositories fully qualified (docker.io/harperfast/...), so match on the suffix too. + return docker(['images', '--format', '{{.Repository}}:{{.Tag}}'], { allowFailure: true }) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .some((image) => image === PRO_IMAGE || image.endsWith(`/${PRO_IMAGE}`)); +} + +/** + * Pull the Pro image if this machine does not already have it. Replication lives in harper-pro, so + * there is no OSS fallback — `add_node` is simply not an operation there. + */ +export function ensureImage(): void { + if (imagePresent()) return; + try { + docker(['pull', PRO_IMAGE]); + } catch (error) { + throw new Error( + `could not pull ${PRO_IMAGE} (replication requires harper-pro; OSS harper has no add_node). ` + + `If it is private here, log in to the registry first or set HARPER_PRO_IMAGE to one you can pull. ` + + `Cause: ${(error as Error).message}` + ); + } + if (!imagePresent()) throw new Error(`${PRO_IMAGE} still not present after a pull that reported success`); +} + export function authHeader(): string { return `Basic ${Buffer.from(`${CREDENTIALS.username}:${CREDENTIALS.password}`).toString('base64')}`; } @@ -100,6 +156,7 @@ export function stageRig(pluginRoot: string): string { } export async function startCluster(pluginRoot: string): Promise { + ensureImage(); const staging = stageRig(pluginRoot); docker(['network', 'create', NETWORK], { allowFailure: true }); @@ -130,16 +187,43 @@ export async function startCluster(pluginRoot: string): Promise { // `username`/`password` alone are rejected by the replication socket with "No authorization // provided"; the cert-signing handshake needs an explicit Authorization header. - await operation(NODES[0], { - operation: 'add_node', - hostname: NODES[1].name, - authorization: authHeader(), - verify_tls: false, - }); + // + // Retried because `waitForNode` only proves the operations API is up. The replication listener on + // 9933 binds later, and `add_node` against a node that has not got there yet fails with + // "connect ECONNREFUSED :9933 and connection was required to sign certificate". + await addNodeWhenReplicationIsUp(); + // Both nodes must be serving Next.js, not just answering the operations API, before the first test + // fetches a page. The settle afterwards is for replication itself, which has no readiness signal. + for (const node of NODES) await waitForApp(node); await new Promise((resolve) => setTimeout(resolve, 5000)); } +/** + * Join B to A, retrying while the replication socket is still coming up. Only the connect-refused + * handshake failure is retried; anything else (bad credentials, an OSS image with no `add_node`) is + * raised at once rather than hidden behind a timeout. + */ +async function addNodeWhenReplicationIsUp(timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + await operation(NODES[0], { + operation: 'add_node', + hostname: NODES[1].name, + authorization: authHeader(), + verify_tls: false, + }); + return; + } catch (error) { + const message = (error as Error).message; + const starting = message.includes('ECONNREFUSED') || message.includes('required to sign certificate'); + if (!starting || Date.now() >= deadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } +} + export async function waitForNode(node: ClusterNode, timeoutMs = 180_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -159,14 +243,42 @@ export async function operation(node: ClusterNode, body: Record headers: { 'Content-Type': 'application/json', Authorization: authHeader() }, body: JSON.stringify(body), }); - if (!response.ok) throw new Error(`${body.operation} failed: ${response.status}`); + if (!response.ok) { + // Harper puts the reason in the body; without it a failed handshake is just a bare 500. + const detail = await response.text().catch(() => ''); + throw new Error(`${body.operation} failed: ${response.status} ${detail.slice(0, 500)}`); + } return response.json(); } +/** + * Wait until the node is serving the Next.js app, not merely answering the operations API. Harper + * binds 9925 well before the plugin has Next.js up on 9926, so a request sent in between is reset + * mid-flight (`fetch failed` / `ECONNRESET`) rather than refused outright. A fixed sleep covered this + * only while the machine was idle; it is the restart path's counterpart to the `add_node` retry. + */ +export async function waitForApp(node: ClusterNode, timeoutMs = 180_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + const response = await fetch(`${node.httpURL}/rig`, { headers: { Cookie: 'bucket=readiness' } }); + // Any complete HTTP response means the app is listening; the status itself is the tests' business. + if (response.ok) { + await response.arrayBuffer(); + return; + } + } catch { + // Not up yet: connection refused, or reset part-way through the response. + } + if (Date.now() >= deadline) throw new Error(`${node.name} did not serve the app within ${timeoutMs}ms`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + export async function restartNode(node: ClusterNode): Promise { docker(['restart', node.name]); await waitForNode(node); - await new Promise((resolve) => setTimeout(resolve, 5000)); + await waitForApp(node); } export async function stopCluster(): Promise { From f09a64fcc628f2c09250ca5e13302f9268cc3174 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:49:33 -0600 Subject: [PATCH 23/29] docs: point at the packaged loopback setup script instead of hand-rolled ifconfig `@harperfast/integration-testing` ships `harper-integration-test-setup-loopback` and a launchd plist that re-adds the aliases at boot, which the loopback note did not mention. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 208342b..c17c492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Review the `README.md` and `CONTRIBUTING.md` for all relevant repository informa - Test startup is slow by design — each test file starts a real Harper instance and waits for Next.js to build (up to 2 minutes). A slow start is not a failure. - The ISR cache tests in `integrationTests/next-16.pw.ts` are intentionally skipped; `CacheHandler.cts` is a work in progress. - Integration tests need the macOS loopback alias pool (127.0.0.2+). Without it every fixture fails at startup with `LoopbackAddressValidationError` / `EADDRNOTAVAIL` before any assertion runs — that is a machine-setup gap, not a test failure. `ifconfig lo0 | grep 'inet '` shows what is configured. +- `@harperfast/integration-testing` ships the setup script; do not hand-roll `ifconfig` loops. Run `npx harper-integration-test-setup-loopback` (it prompts for sudo). `HARPER_INTEGRATION_TEST_LOOPBACK_POOL_START` (default 2) and `HARPER_INTEGRATION_TEST_LOOPBACK_POOL_COUNT` (default 32) size the pool. Aliases do not survive a reboot; the package also ships `scripts/io.harperdb.loopback-setup.plist`, a launchd job that re-adds them at boot. - There is no `format:fix` script in `package.json` despite the Code Style note below; match the surrounding file style (tabs, semicolons). - `next-16-static-data` is run by two test files: `next-16-static-data.pw.ts` on the default VM module loader (where Harper's component `harper` allowlist omits `flushDatabases`, so the plugin's pre-build flush is a no-op and a read-only build child can't see unflushed writes) and `next-16-static-data-native.pw.ts` under `applications.moduleLoader: native`, where the flush does run. The pair is what pins that behavior down — keep both. - The cluster suite (`integrationTests/cluster.pw.ts`) needs a container runtime and a **harper-pro** image (default `harperfast/harper-pro:5.3.1`, public). OSS `harper` has no replication module and rejects `add_node` as an unknown operation, so it cannot form a cluster. Docker is **not** required: the rig probes for a `docker` binary, then `podman`, and `HARPER_CONTAINER_CLI` forces one. On macOS `docker` is usually a *shell alias* for podman, which a direct `execFileSync` never sees — hence the probe. The image is pulled when missing rather than being a precondition. From db1cc89508ddb3a240d2893f77894746deac222b Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 15:58:06 -0600 Subject: [PATCH 24/29] fix(test): make the cluster rig's staged tree writable by the image's user The Pro image runs as `harperdb` (uid 1000). A Linux bind mount preserves the host's ownership, so on a CI runner (uid 1001) the staged rig arrives owned by someone else and the in-container `npm install` dies part-way through reify with "The operation was rejected by your operating system". It failed identically on Node 22, 24 and 26. macOS hid this completely: virtiofs presents the mount as the container's own user, so the suite passed locally while being unrunnable in CI. The staged tree is now opened up (`chmod -R a+rwX`) before the build container runs and again afterwards, so the host can clean it up and the node containers can write into `.next`. npm's cache and logs are pointed at /tmp rather than a home directory the container user may not own on that mount. Co-Authored-By: Claude Opus 5 --- integrationTests/cluster.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index 5d20828..2d8249c 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -109,6 +109,14 @@ export function authHeader(): string { return `Basic ${Buffer.from(`${CREDENTIALS.username}:${CREDENTIALS.password}`).toString('base64')}`; } +/** + * Make the staged tree writable whatever uid the image runs as. `a+rwX` sets the execute bit on + * directories only, so files do not come back executable. + */ +function openUpForContainerUser(dir: string): void { + execFileSync('chmod', ['-R', 'a+rwX', dir], { stdio: 'ignore' }); +} + /** * Stage the rig once: install dependencies and run `next build` inside the Pro image, so the native * binaries match the container rather than the host. Cached between runs — the build is slow and the @@ -142,15 +150,28 @@ export function stageRig(pluginRoot: string): string { pkg.dependencies['@harperfast/nextjs'] = 'file:./plugin'; writeFileSync(join(staging, 'package.json'), JSON.stringify(pkg, null, '\t')); + // The image runs as `harperdb` (uid 1000). A Linux bind mount keeps the host's ownership, so on a + // CI runner (uid 1001) the staged tree arrives owned by someone else and `npm install` fails with + // "The operation was rejected by your operating system" part-way through reify. macOS hides this: + // virtiofs presents the mount as the container's own user. Opening the tree up is what makes the + // rig portable — it is a scratch directory under the system temp dir, rebuilt from scratch above. + openUpForContainerUser(staging); + const run = (entrypoint: string, args: string[]) => docker([ 'run', '--rm', '-v', `${staging}:/app`, '-w', '/app', '-e', 'RIG_NODE=build', + // The container user may not own a home on this mount; keep npm's cache and logs off it. + '-e', 'HOME=/tmp', '-e', 'npm_config_cache=/tmp/.npm', '--entrypoint', entrypoint, PRO_IMAGE, ...args, ]); run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); run('npx', ['next', 'build']); + // `next build` and `npm install` wrote as the container user; reopen so the host can clean up and + // the node containers can write into `.next` at runtime. + openUpForContainerUser(staging); + writeFileSync(stamp, want); return staging; } From 1391c8fd4756d30f6bd79a7d3e2b6615d6419ab1 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 16:07:52 -0600 Subject: [PATCH 25/29] fix(test): reopen the staged rig from inside the image, which owns it by then MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-build `chmod` ran on the host, but by that point `npm install` and `next build` had rewritten the tree as the container's uid — and only an owner may chmod. CI got past the original permission error and then failed on `Command failed: chmod -R a+rwX /tmp/harper-nextjs-cluster-rig`. Ownership changes hands halfway through staging, so each side now opens up what it owns: the host before the build, the image after it. The second pass is what lets the host delete the tree on a re-stage. Co-Authored-By: Claude Opus 5 --- integrationTests/cluster.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index 2d8249c..405eda0 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -112,11 +112,19 @@ export function authHeader(): string { /** * Make the staged tree writable whatever uid the image runs as. `a+rwX` sets the execute bit on * directories only, so files do not come back executable. + * + * Only the owner can chmod, and ownership changes hands halfway through staging: the host writes the + * tree, then the container writes into it as its own uid. So each side opens up what it owns — the + * host before the build, the image after it. */ -function openUpForContainerUser(dir: string): void { +function openUpOnHost(dir: string): void { execFileSync('chmod', ['-R', 'a+rwX', dir], { stdio: 'ignore' }); } +function openUpInContainer(dir: string): void { + docker(['run', '--rm', '-v', `${dir}:/app`, '--entrypoint', 'chmod', PRO_IMAGE, '-R', 'a+rwX', '/app']); +} + /** * Stage the rig once: install dependencies and run `next build` inside the Pro image, so the native * binaries match the container rather than the host. Cached between runs — the build is slow and the @@ -155,7 +163,7 @@ export function stageRig(pluginRoot: string): string { // "The operation was rejected by your operating system" part-way through reify. macOS hides this: // virtiofs presents the mount as the container's own user. Opening the tree up is what makes the // rig portable — it is a scratch directory under the system temp dir, rebuilt from scratch above. - openUpForContainerUser(staging); + openUpOnHost(staging); const run = (entrypoint: string, args: string[]) => docker([ @@ -168,9 +176,9 @@ export function stageRig(pluginRoot: string): string { run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); run('npx', ['next', 'build']); - // `next build` and `npm install` wrote as the container user; reopen so the host can clean up and - // the node containers can write into `.next` at runtime. - openUpForContainerUser(staging); + // `next build` and `npm install` wrote as the container user, which now owns those files — so the + // reopen has to happen as that user too. Without it the host cannot delete the tree on a re-stage. + openUpInContainer(staging); writeFileSync(stamp, want); return staging; From 64afdb13deb5a766c77058a6286c6a6bf3d4af17 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 5 Oct 2026 16:17:08 -0600 Subject: [PATCH 26/29] fix(test): pin the cluster rig's build containers to the host uid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping ownership uniform is what makes the staged tree manageable. Left to itself the image runs as `harperdb` (uid 1000) while a Linux bind mount keeps the host's ownership, so on a CI runner (uid 1001) `npm install` could not write. Opening the tree up first was not enough: the container's own writes then landed as uid 1000, leaving a tree neither side owned outright — and since only an owner may chmod, neither the host pass nor an in-container pass could cover all of it. CI failed on each in turn. With `--user` pinned to the host's uid everything in the tree belongs to one user, so a single host-side chmod afterwards opens it to the node containers, and the host can still delete it on a re-stage. Verified under podman on macOS, where the host sees its own uid on the container's writes. Co-Authored-By: Claude Opus 5 --- integrationTests/cluster.ts | 39 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index 405eda0..d48e8b2 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -110,21 +110,17 @@ export function authHeader(): string { } /** - * Make the staged tree writable whatever uid the image runs as. `a+rwX` sets the execute bit on - * directories only, so files do not come back executable. + * Make the staged tree writable by whatever uid the node containers run as. `a+rwX` sets the execute + * bit on directories only, so files do not come back executable. Safe to do broadly: this is a + * scratch directory under the system temp dir, rebuilt from scratch whenever its inputs change. * - * Only the owner can chmod, and ownership changes hands halfway through staging: the host writes the - * tree, then the container writes into it as its own uid. So each side opens up what it owns — the - * host before the build, the image after it. + * Only an owner may chmod, so this is only ever called while the host owns the whole tree — see + * `stageRig` on why the build containers are pinned to the host's uid. */ function openUpOnHost(dir: string): void { execFileSync('chmod', ['-R', 'a+rwX', dir], { stdio: 'ignore' }); } -function openUpInContainer(dir: string): void { - docker(['run', '--rm', '-v', `${dir}:/app`, '--entrypoint', 'chmod', PRO_IMAGE, '-R', 'a+rwX', '/app']); -} - /** * Stage the rig once: install dependencies and run `next build` inside the Pro image, so the native * binaries match the container rather than the host. Cached between runs — the build is slow and the @@ -158,17 +154,20 @@ export function stageRig(pluginRoot: string): string { pkg.dependencies['@harperfast/nextjs'] = 'file:./plugin'; writeFileSync(join(staging, 'package.json'), JSON.stringify(pkg, null, '\t')); - // The image runs as `harperdb` (uid 1000). A Linux bind mount keeps the host's ownership, so on a - // CI runner (uid 1001) the staged tree arrives owned by someone else and `npm install` fails with - // "The operation was rejected by your operating system" part-way through reify. macOS hides this: - // virtiofs presents the mount as the container's own user. Opening the tree up is what makes the - // rig portable — it is a scratch directory under the system temp dir, rebuilt from scratch above. - openUpOnHost(staging); - + // The build containers are pinned to the host's uid. Left to itself the image runs as `harperdb` + // (uid 1000), and a Linux bind mount keeps the host's ownership — so on a CI runner (uid 1001) the + // staged tree arrives owned by someone else and `npm install` dies part-way through reify with + // "The operation was rejected by your operating system". Opening the tree up beforehand is not + // enough on its own either: the container's own writes then land as uid 1000, leaving a tree + // neither side owns outright and so neither side can chmod. Pinning the uid keeps ownership + // uniform, which is what lets the host chmod and later delete the tree. macOS hides the whole + // problem — virtiofs presents the mount as whichever user asks — so this only ever broke in CI. + const asHost = typeof process.getuid === 'function' ? [`${process.getuid()}:${process.getgid?.() ?? 0}`] : []; const run = (entrypoint: string, args: string[]) => docker([ 'run', '--rm', '-v', `${staging}:/app`, '-w', '/app', '-e', 'RIG_NODE=build', - // The container user may not own a home on this mount; keep npm's cache and logs off it. + ...(asHost.length ? ['--user', asHost[0]] : []), + // That uid has no home in the image, so keep npm's cache and logs off one. '-e', 'HOME=/tmp', '-e', 'npm_config_cache=/tmp/.npm', '--entrypoint', entrypoint, PRO_IMAGE, ...args, ]); @@ -176,9 +175,9 @@ export function stageRig(pluginRoot: string): string { run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); run('npx', ['next', 'build']); - // `next build` and `npm install` wrote as the container user, which now owns those files — so the - // reopen has to happen as that user too. Without it the host cannot delete the tree on a re-stage. - openUpInContainer(staging); + // Now that the whole tree is the host's, open it to the node containers, which run as the image's + // own user and need to write into `.next` at runtime. + openUpOnHost(staging); writeFileSync(stamp, want); return staging; From 31782caa76ca06b4ec467661f6bf53c9bce967ad Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Tue, 6 Oct 2026 16:16:28 -0600 Subject: [PATCH 27/29] fix(test): fingerprint every staged input, so the cluster rig cannot reuse a stale build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reuse stamp read five fixture files by name and recorded the plugin by nothing but the byte length of `dist/UseCacheHandler.cjs`. So a changed rig page under `app/**` — the pages the assertions actually read — left the stamp equal, as did any edit to `dist/cacheInvalidation.cjs` or `dist/CacheHandler.cjs`, which it never looked at at all. A rerun could reuse the previous `.next` and the previous vendored plugin and report a pass against code it never built. The stamp is now a sha256 over the full contents of every staged input: the fixture tree (minus generated directories) and the built plugin. Verified that a changed rig page, a same-length handler edit, and an edit to either of the other two emitted handlers each force a restage, while an unchanged tree still reuses — cold 1.4m, warm 23s. Reported-by: kriszyp Co-Authored-By: Claude Opus 5 --- integrationTests/cluster.ts | 54 ++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index d48e8b2..7966728 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -1,6 +1,7 @@ import { execFileSync, execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -121,6 +122,51 @@ function openUpOnHost(dir: string): void { execFileSync('chmod', ['-R', 'a+rwX', dir], { stdio: 'ignore' }); } +/** Every file under `dir`, relative and sorted, skipping what is generated rather than authored. */ +function sourceFiles(dir: string, prefix = ''): string[] { + const skip = new Set(['node_modules', '.next', '.git', '.stamp']); + const found: string[] = []; + for (const entry of readdirSync(dir).sort()) { + if (skip.has(entry)) continue; + const absolute = join(dir, entry); + const relative = prefix ? `${prefix}/${entry}` : entry; + if (statSync(absolute).isDirectory()) found.push(...sourceFiles(absolute, relative)); + else found.push(relative); + } + return found; +} + +/** + * What the staged rig was built from, as a digest over the full contents of every input: the fixture + * (its `app/**` above all — the rig pages the assertions read) and the built plugin that gets vendored + * into it. + * + * Fingerprinting a hand-picked handful of files by name, and the plugin by nothing but its byte + * length, let a changed rig page or an edited handler reuse the previous `.next` and the previous + * vendored plugin — so a rerun could report a pass against code it never built. Only the plugin's + * emitted `dist/` is hashed, not `src/`: `dist/` is what the container installs, and it is rebuilt by + * `npm test` / `npm run build` before any of this runs. + */ +export function fingerprint(fixture: string, pluginRoot: string): string { + const digest = createHash('sha256'); + for (const [label, root] of [ + ['fixture', fixture], + ['plugin', join(pluginRoot, 'dist')], + ] as const) { + for (const file of sourceFiles(root)) { + digest.update(`${label}/${file}\0`); + digest.update(readFileSync(join(root, file))); + digest.update('\0'); + } + } + for (const file of ['config.yaml', 'schema.graphql', 'package.json']) { + digest.update(`plugin-root/${file}\0`); + digest.update(readFileSync(join(pluginRoot, file))); + digest.update('\0'); + } + return digest.digest('hex'); +} + /** * Stage the rig once: install dependencies and run `next build` inside the Pro image, so the native * binaries match the container rather than the host. Cached between runs — the build is slow and the @@ -130,11 +176,7 @@ export function stageRig(pluginRoot: string): string { const staging = join(tmpdir(), 'harper-nextjs-cluster-rig'); const fixture = join(pluginRoot, 'fixtures', 'next-16-cluster'); const stamp = join(staging, '.stamp'); - const inputs = ['config.yaml', 'next.config.mjs', 'package.json', 'probe.js', 'schema.graphql'] - .map((file) => readFileSync(join(fixture, file), 'utf8')) - .join('\n'); - const pluginStamp = readFileSync(join(pluginRoot, 'dist', 'UseCacheHandler.cjs'), 'utf8'); - const want = `${inputs}\n${pluginStamp.length}`; + const want = fingerprint(fixture, pluginRoot); if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want && existsSync(join(staging, '.next'))) { return staging; From c364be265dc5513da79f83045ad2a134f01d177e Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Tue, 6 Oct 2026 16:37:24 -0600 Subject: [PATCH 28/29] fix: keep a passed expiry irrevocable, so a later profiled invalidation cannot revive entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expired` is one scalar carrying two different claims — "everything before T is dead now" and "everything before T will be dead at T" — and a later invalidation replaces it wholesale. So `revalidateTag(tag)` followed by `revalidateTag(tag, 'max')` moved `expired` into the future, and entries the first call had withheld came back as merely stale for the profile's whole window. Reproduced on a single worker: no race needed, contrary to how this was first reported. Invalidations now carry `hardExpiredAt`, the newest expiry of the tag that has already passed. It only moves forward, `tagState` withholds anything written before it, `passedExpiration` reports it, and it is persisted on the tombstone so a restarted worker keeps it. It is also absorbed from an invalidation that arrives *older* than the view already held, which is how a worker learns of another node's immediate expiry after adopting a newer profiled one. Three of the new tests fail without the fix; two guard the other direction — an entry written after the hard expiry is still servable, and a newer hard expiry still moves the mark forward. This does not close the cross-worker write race: each tag still has one tombstone row written from one worker's view, so two workers invalidating the same tag within the replication window can still lose the immediate expiry from storage. That needs an atomic merge or an append-only log; it is written up in the README and tracked separately. Reported-by: kriszyp Co-Authored-By: Claude Opus 5 --- README.md | 5 +++ schema.graphql | 5 +++ src/cacheInvalidation.cts | 71 +++++++++++++++++++++++++++++----- src/cacheInvalidation.test.ts | 73 ++++++++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 24c43fc..07d2c01 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,11 @@ Invalidation follows Next.js's own semantics. `revalidateTag(tag)` and `updateTa **A tombstone outlives every entry it can invalidate.** An entry whose `expire` is a definite length is written with its own Harper expiry, so it is evicted when Next.js says it stops being usable. That is independent of the tables' 7-day `expiration`, so the `weeks` and `max` profiles keep their full stale-while-revalidate window — up to one year, the `expire` of `max` and the longest any entry is kept. Each tombstone is written with an expiry of that longest life (a year, or a cache table's `expiration` if that is longer), plus an hour for renders that finish after the invalidation and for replication lag. An invalidated entry therefore expires before its tombstone does and cannot come back. The in-memory map drops a tag once its tombstone lapses, so it holds one small record per tag invalidated in the past year: fine for a few thousand distinct tags, worth sizing before invalidating a tag per product. A cache table configured with no expiration breaks that bound and is reported in the log. +**An expiry that has passed is never walked back.** A tag's invalidation carries a high-water mark (`hardExpiredAt`) alongside `expired`. Once an expiry has passed, every entry older than it is withheld for as long as the tombstone lives, even if a later invalidation of the same tag moves `expired` into the future. Without it, calling `revalidateTag(tag)` and then `revalidateTag(tag, 'max')` would bring back entries the first call had already killed, serving them stale for the profile's whole window. + +> [!NOTE] +> One gap remains, and it needs both forms on one tag to reach. Each tag has a single tombstone row, and a worker writes it from its own view. If two workers invalidate the same tag within the replication window — one immediately, one with a profile — the second can write a row that does not yet know about the first, and the immediate expiry is lost from storage; a worker that restarts afterwards reads only the surviving row. Live workers that saw both events merge them correctly. Using one form consistently per tag avoids it entirely: all-immediate and all-profile invalidations both order correctly on their own. Closing it properly needs an atomic merge or an append-only invalidation log, tracked separately. + **An entry with no definite `expire` keeps the table's 7-day `expiration`.** Next.js's `default` profile — every `'use cache'` boundary with no `cacheLife()` — sets `expire` to `INFINITE_CACHE` (0xfffffffe). That is stored as one year, because it does not fit the 32-bit `Int` column and Harper would otherwise refuse the write and never cache the entry at all, but the row itself is left to the table's expiration rather than pinned for the year. Pinning it would override the table TTL for the common case, and because Next.js puts the build ID in every `'use cache'` key, every deploy would leave a full generation of unreadable entries behind for a year. Ask for a long life explicitly — `cacheLife('max')` — and it is honoured. An immediate expiry is also **swept**: entries carrying the tag are looked up through the indexed `tags` column (an exact-match index lookup, not a scan) and deleted in throttled chunks, so they stop taking up space before their own expiry. This includes Next.js's implicit route tags, so `revalidatePath` is swept too. While Harper reports the index as still being built, the sweep scans with the same exact match instead. The sweep only reclaims space, and reads never depend on it: a sweep that fails or is interrupted leaves the entries to expire on their own. A stale-then-expire invalidation is not swept, because those entries must stay servable while they regenerate. diff --git a/schema.graphql b/schema.graphql index 919ebbe..8883d7b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -54,4 +54,9 @@ type NextCacheInvalidation @table(database: "harperfast_nextjs", table: "nextjs_ stale: Long expired: Long lapsesAt: Long + # High-water mark: the newest expiry of this tag that had already passed when the row was written. + # `expired` is replaced wholesale by a later invalidation, which can move it into the future; this + # only moves forward, so entries an earlier hard expiry withheld can never come back as merely + # stale. See TagInvalidation in src/cacheInvalidation.cts. + hardExpiredAt: Long } diff --git a/src/cacheInvalidation.cts b/src/cacheInvalidation.cts index 3977517..66ea34b 100644 --- a/src/cacheInvalidation.cts +++ b/src/cacheInvalidation.cts @@ -40,6 +40,17 @@ export interface TagInvalidation { expired?: number; at: number; lapsesAt: number; + /** + * The newest expiry of this tag that has already passed — a high-water mark. Once an expiry passes + * it is irrevocable: every entry older than it was declared dead and must never be served again. + * + * `expired` alone cannot carry that. It is a single scalar holding two different things ("everything + * before T is dead now" and "everything before T will be dead at T"), and a later invalidation + * replaces it wholesale. A hard `revalidateTag(tag)` followed by `revalidateTag(tag, 'max')` would + * otherwise move `expired` into the future, and entries the first call withheld would come back as + * merely stale. This field only ever moves forward, so no merge can weaken it. + */ + hardExpiredAt?: number; } export type TagState = 'expired' | 'stale' | undefined; @@ -50,6 +61,7 @@ interface TombstoneRow { stale?: number; expired?: number; lapsesAt?: number; + hardExpiredAt?: number; } interface CacheRow { @@ -254,6 +266,9 @@ export function tombstoneToInvalidation( expired: legacy ? row.timestamp : (row.expired ?? undefined), at: legacy ? Math.min(row.timestamp, now) : row.timestamp, lapsesAt: row.lapsesAt ?? row.timestamp + DEFAULT_CACHE_TTL_MS, + // A row written before this field existed carries none; `noteInvalidation` still derives one + // from `expired` once that has passed, so an upgraded instance loses nothing. + hardExpiredAt: row.hardExpiredAt ?? undefined, }; } @@ -268,7 +283,9 @@ export function tagState(tags: Iterable, writtenAt: number, now: number for (const tag of tags) { const invalidation = cacheInvalidations.get(tag); if (!invalidation) continue; - const { stale, expired } = invalidation; + const { stale, expired, hardExpiredAt } = invalidation; + // An expiry that has already passed withholds every older entry, whatever `expired` says now. + if (hardExpiredAt !== undefined && hardExpiredAt > writtenAt) return 'expired'; if (expired !== undefined && expired <= now && expired > writtenAt) return 'expired'; if (stale !== undefined && stale > writtenAt) state = 'stale'; } @@ -283,8 +300,8 @@ export function tagState(tags: Iterable, writtenAt: number, now: number export function passedExpiration(tags: Iterable, now: number = Date.now()): number { let newest = 0; for (const tag of tags) { - const expired = cacheInvalidations.get(tag)?.expired; - if (expired !== undefined && expired <= now && expired > newest) newest = expired; + // Includes the high-water mark: a passed expiry stays reportable after `expired` has moved on. + newest = Math.max(newest, passedExpiryOf(cacheInvalidations.get(tag), now)); } return newest; } @@ -372,6 +389,17 @@ function revalidatedAtOf(invalidation: TagInvalidation): number { return invalidation.expired ?? invalidation.stale ?? invalidation.at; } +/** + * The part of an invalidation that can no longer be taken back: its expiry, once that has passed, and + * any high-water mark it already carries. + */ +function passedExpiryOf(invalidation: TagInvalidation | undefined, now: number): number { + if (!invalidation) return 0; + const { expired, hardExpiredAt } = invalidation; + const justPassed = expired !== undefined && expired <= now ? expired : 0; + return Math.max(justPassed, hardExpiredAt ?? 0); +} + function earliestDefined(left: number | undefined, right: number | undefined): number | undefined { if (left === undefined) return right; if (right === undefined) return left; @@ -393,6 +421,7 @@ function stricterOf(existing: TagInvalidation, incoming: TagInvalidation): TagIn expired: earliestDefined(existing.expired, incoming.expired), at: incoming.at, lapsesAt: Math.max(existing.lapsesAt, incoming.lapsesAt), + hardExpiredAt: Math.max(existing.hardExpiredAt ?? 0, incoming.hardExpiredAt ?? 0) || undefined, }; } @@ -400,14 +429,28 @@ function stricterOf(existing: TagInvalidation, incoming: TagInvalidation): TagIn * Adopt an invalidation into this worker's view. The newest (by `at`) wins, so the subscription, the * start-up hydration and this worker's own writes can arrive in any order; a tie is resolved by * `stricterOf` rather than by which arrived last. + * + * An expiry that has already passed survives all of that. It is carried forward even when the + * incoming view is older and does not replace what is held, and even when a newer view moves + * `expired` into the future — a worker that learns of a hard expiry late must not un-withhold the + * entries it covers. */ export function noteInvalidation(tag: string, invalidation: TagInvalidation, deps: InvalidationDeps = {}): void { + const now = nowFor(deps); + if (invalidation.lapsesAt <= now) return; const existing = cacheInvalidations.get(tag); - if (existing && existing.at > invalidation.at) return; - if (invalidation.lapsesAt <= nowFor(deps)) return; - const adopted = existing && existing.at === invalidation.at ? stricterOf(existing, invalidation) : invalidation; - cacheInvalidations.set(tag, adopted); - mirrorToTagsManifest(tag, adopted, existing, deps); + const hardExpiredAt = Math.max(passedExpiryOf(existing, now), passedExpiryOf(invalidation, now)); + + let adopted: TagInvalidation; + if (!existing) adopted = invalidation; + else if (existing.at > invalidation.at) adopted = existing; + else if (existing.at === invalidation.at) adopted = stricterOf(existing, invalidation); + else adopted = invalidation; + + if (adopted === existing && hardExpiredAt === (existing.hardExpiredAt ?? 0)) return; + const next = hardExpiredAt > 0 ? { ...adopted, hardExpiredAt } : adopted; + cacheInvalidations.set(tag, next); + mirrorToTagsManifest(tag, next, existing, deps); } function forgetInvalidation(tag: string, deps: InvalidationDeps): void { @@ -696,14 +739,22 @@ export async function recordInvalidation( expired: issued.expired ?? existing?.expired, }; noteInvalidation(tag, invalidation, deps); - return { tag, invalidation }; + // The adopted view, not the issued one: it carries any already-passed expiry merged in above, + // so the row persists the high-water mark rather than dropping it on the next write. + return { tag, invalidation: cacheInvalidations.get(tag) ?? invalidation }; }); await Promise.all( merged.map(({ tag, invalidation }) => tombstones.put( tag, - { timestamp: now, stale: invalidation.stale, expired: invalidation.expired, lapsesAt: invalidation.lapsesAt }, + { + timestamp: now, + stale: invalidation.stale, + expired: invalidation.expired, + lapsesAt: invalidation.lapsesAt, + hardExpiredAt: invalidation.hardExpiredAt, + }, // `expiresAt` outlives the table's own expiration, which only bounds the entries, not their // tombstones. `timestamp` makes the issue time the write's version: Harper drops a write older // than the stored version, so an older invalidation landing late cannot replace a newer one. diff --git a/src/cacheInvalidation.test.ts b/src/cacheInvalidation.test.ts index d8e1ed0..90fc29f 100644 --- a/src/cacheInvalidation.test.ts +++ b/src/cacheInvalidation.test.ts @@ -199,7 +199,8 @@ describe('tombstoneToInvalidation', () => { it('keeps the stale and expired a current row carries', () => { const result = tombstoneToInvalidation({ timestamp: 5000, stale: 5000, expired: 65_000, lapsesAt: 99_000 }); - assert.deepEqual(result, { stale: 5000, expired: 65_000, at: 5000, lapsesAt: 99_000 }); + // No high-water mark: this row's expiry is still in the future, so nothing is irrevocable yet. + assert.deepEqual(result, { stale: 5000, expired: 65_000, at: 5000, lapsesAt: 99_000, hardExpiredAt: undefined }); }); it('ignores an event that carries no timestamp', () => { @@ -473,6 +474,8 @@ describe('recordInvalidation', () => { stale: 1000, expired: 2000, lapsesAt: 2000 + YEAR_MS + MARGIN_MS, + // The hard expiry has passed by definition, so the row carries it as the high-water mark. + hardExpiredAt: 2000, }); }); @@ -844,6 +847,74 @@ describe('concurrent invalidations of one tag', () => { // millisecond, and events carry no version at all. So `noteInvalidation` cannot order a tie by `at`; it // takes the stricter of the two instead, which is the same answer whichever arrived first. Letting an // equal `at` simply overwrite let the older view win on the one path where the two orderings disagree. +/** + * `expired` is one scalar holding two different claims: "everything before T is dead now" and + * "everything before T will be dead at T". A later invalidation replaces it wholesale, so a profiled + * call could move it into the future and un-withhold entries an earlier hard expiry had killed. + * `hardExpiredAt` is the high-water mark that cannot be walked back. + */ +describe('a hard expiry that has already passed', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + const YEAR = YEAR_MS; + + it('is not weakened by a later profiled invalidation on the same worker', async () => { + const table = makeInvalidationTable(); + await recordInvalidation(['posts'], undefined, makeDeps({ invalidation: table, now: 1000 })); + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['posts'], { expire: 60 }, makeDeps({ invalidation: table, now: 2000 })); + + assert.equal( + tagState(['posts'], 500, 3000), + 'expired', + 'an entry the hard expiry killed came back as merely stale' + ); + }); + + // The same thing, but the hard expiry is the one that arrives late — another node issued it and + // this worker only hears about it after adopting the newer profiled view. + it('is adopted even when it arrives older than the view already held', () => { + const deps = { tagsManifestModule: null, now: () => 3000 }; + noteInvalidation('posts', { stale: 2000, expired: 62_000, at: 2000, lapsesAt: 2000 + YEAR }, deps); + noteInvalidation('posts', { expired: 1000, at: 1000, lapsesAt: 1000 + YEAR }, deps); + + assert.equal(tagState(['posts'], 500, 3000), 'expired'); + }); + + it('survives a restart through the tombstone', async () => { + const table = makeInvalidationTable(); + await recordInvalidation(['posts'], undefined, makeDeps({ invalidation: table, now: 1000 })); + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['posts'], { expire: 60 }, makeDeps({ invalidation: table, now: 2000 })); + + // A worker that starts now sees only what storage holds. + resetInvalidationStateForTesting(); + await hydrateInvalidations(table, { tagsManifestModule: null, now: () => 3000 }); + + assert.equal(tagState(['posts'], 500, 3000), 'expired'); + }); + + it('still lets an entry written after it through', async () => { + const table = makeInvalidationTable(); + await recordInvalidation(['posts'], undefined, makeDeps({ invalidation: table, now: 1000 })); + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['posts'], { expire: 60 }, makeDeps({ invalidation: table, now: 2000 })); + + // Regenerated after the hard expiry, so only the profiled invalidation applies: stale, not dead. + assert.equal(tagState(['posts'], 1500, 3000), 'stale'); + }); + + // The high-water mark must not freeze at the first hard expiry: a later one withholds more. + it('moves forward when a newer hard expiry lands', async () => { + const table = makeInvalidationTable(); + await recordInvalidation(['posts'], undefined, makeDeps({ invalidation: table, now: 1000 })); + await new Promise((resolve) => setImmediate(resolve)); + await recordInvalidation(['posts'], undefined, makeDeps({ invalidation: table, now: 5000 })); + + assert.equal(tagState(['posts'], 3000, 6000), 'expired', 'the newer hard expiry did not apply'); + }); +}); + describe('two invalidations issued in the same millisecond', () => { beforeEach(() => resetInvalidationStateForTesting()); From 239684b34e12dd757f5e9be55ea2ac4916e9af84 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 7 Oct 2026 09:46:25 -0600 Subject: [PATCH 29/29] docs: state the constraint in the cluster rig's comments, not how it was found Per the Harper comment convention: a minimal why, constraint or invariant that the code cannot express, never a narration of history. Several comments added with the podman and CI fixes recounted what used to break and how; they now state the rule the code depends on. Co-Authored-By: Claude Opus 5 --- integrationTests/cluster.ts | 61 ++++++++++++------------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/integrationTests/cluster.ts b/integrationTests/cluster.ts index 7966728..1822051 100644 --- a/integrationTests/cluster.ts +++ b/integrationTests/cluster.ts @@ -16,10 +16,9 @@ export const NETWORK = 'harper-nextjs-clusternet'; export const CREDENTIALS = { username: 'admin', password: 'pilotpass' }; /** - * Docker or Podman, whichever this machine has. Podman is a drop-in for every subcommand the rig uses, - * and it is what the macOS workstations run — but `docker` there is usually a *shell alias*, which a - * direct spawn never sees, so the binary has to be probed rather than assumed. `HARPER_CONTAINER_CLI` - * forces one. + * Podman is a drop-in for every subcommand the rig uses. `docker` on macOS is commonly a shell alias + * for it, which a direct spawn cannot see, so the binary is probed rather than assumed. + * `HARPER_CONTAINER_CLI` forces one. */ function detectContainerCli(): string | null { const forced = process.env.HARPER_CONTAINER_CLI; @@ -36,11 +35,7 @@ function detectContainerCli(): string | null { export const CONTAINER_CLI = detectContainerCli(); -/** - * Set this where the cluster suite is expected to run — CI above all. Without it a suite that cannot - * start silently skips, which is how it came to be green everywhere and exercised nowhere. With it, a - * missing runtime or an unpullable image fails the run instead. - */ +/** Set where the suite must run, CI above all: a setup problem then fails the run instead of skipping it. */ export const CLUSTER_REQUIRED = process.env.HARPER_CLUSTER_REQUIRED === '1'; export interface ClusterNode { @@ -111,12 +106,8 @@ export function authHeader(): string { } /** - * Make the staged tree writable by whatever uid the node containers run as. `a+rwX` sets the execute - * bit on directories only, so files do not come back executable. Safe to do broadly: this is a - * scratch directory under the system temp dir, rebuilt from scratch whenever its inputs change. - * - * Only an owner may chmod, so this is only ever called while the host owns the whole tree — see - * `stageRig` on why the build containers are pinned to the host's uid. + * Make the staged tree writable by whatever uid the node containers run as. `a+rwX` spares files the + * execute bit. Only an owner may chmod, so the host must own the whole tree at every call site. */ function openUpOnHost(dir: string): void { execFileSync('chmod', ['-R', 'a+rwX', dir], { stdio: 'ignore' }); @@ -137,15 +128,9 @@ function sourceFiles(dir: string, prefix = ''): string[] { } /** - * What the staged rig was built from, as a digest over the full contents of every input: the fixture - * (its `app/**` above all — the rig pages the assertions read) and the built plugin that gets vendored - * into it. - * - * Fingerprinting a hand-picked handful of files by name, and the plugin by nothing but its byte - * length, let a changed rig page or an edited handler reuse the previous `.next` and the previous - * vendored plugin — so a rerun could report a pass against code it never built. Only the plugin's - * emitted `dist/` is hashed, not `src/`: `dist/` is what the container installs, and it is rebuilt by - * `npm test` / `npm run build` before any of this runs. + * Digest of every staged input, so a reused `.next` can never have been built from different code: + * the whole fixture tree, including the `app/**` pages the assertions read, and the plugin vendored + * into it. Hashing the plugin's emitted `dist/` rather than `src/` is what the container installs. */ export function fingerprint(fixture: string, pluginRoot: string): string { const digest = createHash('sha256'); @@ -196,14 +181,11 @@ export function stageRig(pluginRoot: string): string { pkg.dependencies['@harperfast/nextjs'] = 'file:./plugin'; writeFileSync(join(staging, 'package.json'), JSON.stringify(pkg, null, '\t')); - // The build containers are pinned to the host's uid. Left to itself the image runs as `harperdb` - // (uid 1000), and a Linux bind mount keeps the host's ownership — so on a CI runner (uid 1001) the - // staged tree arrives owned by someone else and `npm install` dies part-way through reify with - // "The operation was rejected by your operating system". Opening the tree up beforehand is not - // enough on its own either: the container's own writes then land as uid 1000, leaving a tree - // neither side owns outright and so neither side can chmod. Pinning the uid keeps ownership - // uniform, which is what lets the host chmod and later delete the tree. macOS hides the whole - // problem — virtiofs presents the mount as whichever user asks — so this only ever broke in CI. + // One uid must own the whole staged tree: only an owner may chmod, and the host has to chmod it + // below and delete it on a re-stage. The image runs as `harperdb` (uid 1000) while a Linux bind + // mount keeps the host's ownership, so without pinning, the host's files and the container's + // writes land under different uids and neither can chmod all of it. macOS remaps the mount to + // whichever user asks, so only Linux hosts and CI depend on this. const asHost = typeof process.getuid === 'function' ? [`${process.getuid()}:${process.getgid?.() ?? 0}`] : []; const run = (entrypoint: string, args: string[]) => docker([ @@ -217,8 +199,7 @@ export function stageRig(pluginRoot: string): string { run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); run('npx', ['next', 'build']); - // Now that the whole tree is the host's, open it to the node containers, which run as the image's - // own user and need to write into `.next` at runtime. + // The node containers run as the image's own user and write into `.next` at runtime. openUpOnHost(staging); writeFileSync(stamp, want); @@ -258,9 +239,8 @@ export async function startCluster(pluginRoot: string): Promise { // `username`/`password` alone are rejected by the replication socket with "No authorization // provided"; the cert-signing handshake needs an explicit Authorization header. // - // Retried because `waitForNode` only proves the operations API is up. The replication listener on - // 9933 binds later, and `add_node` against a node that has not got there yet fails with - // "connect ECONNREFUSED :9933 and connection was required to sign certificate". + // Retried: `waitForNode` only proves the operations API is up, and the replication listener on + // 9933 binds later. await addNodeWhenReplicationIsUp(); // Both nodes must be serving Next.js, not just answering the operations API, before the first test @@ -322,10 +302,9 @@ export async function operation(node: ClusterNode, body: Record } /** - * Wait until the node is serving the Next.js app, not merely answering the operations API. Harper - * binds 9925 well before the plugin has Next.js up on 9926, so a request sent in between is reset - * mid-flight (`fetch failed` / `ECONNRESET`) rather than refused outright. A fixed sleep covered this - * only while the machine was idle; it is the restart path's counterpart to the `add_node` retry. + * Wait until the node serves the Next.js app, not merely the operations API. Harper binds 9925 well + * before the plugin has Next.js up on 9926, and a request in between is reset mid-flight rather than + * refused, so connection failures cannot be distinguished from a node that is simply still starting. */ export async function waitForApp(node: ClusterNode, timeoutMs = 180_000): Promise { const deadline = Date.now() + timeoutMs;