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/.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/.gitignore b/.gitignore index cb5ff26..2954bb6 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/ diff --git a/AGENTS.md b/AGENTS.md index 60821b2..c17c492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,21 @@ 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. +- 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. -- CI is currently disabled (`if: false` in `.github/workflows/integration-tests.yml`). Run tests locally. +- 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. +- 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 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. diff --git a/README.md b/README.md index 8349232..07d2c01 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 @@ -177,6 +194,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,24 +272,97 @@ 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`: + +```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. + +- **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. 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 + +Both handlers persist the `revalidate` and `expire` Next.js supplies for each entry. + +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 -The cache handler uses a **soft-invalidation** model: +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. + +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. + +**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. -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. +> [!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. -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. +`'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. ### 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_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. 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 + +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. +- **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/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-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/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..4d2e44e --- /dev/null +++ b/fixtures/next-16-use-cache/app/api/revalidate/route.js @@ -0,0 +1,14 @@ +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 }); + } + // `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/cached/page.js b/fixtures/next-16-use-cache/app/cached/page.js new file mode 100644 index 0000000..6f94974 --- /dev/null +++ b/fixtures/next-16-use-cache/app/cached/page.js @@ -0,0 +1,36 @@ +import { Suspense } from 'react'; +import { cacheLife, cacheTag } from 'next/cache'; +import { cookies } from 'next/headers'; + +// 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'); + + 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

}> + +
+
+ ); +} 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/cluster.pw.ts b/integrationTests/cluster.pw.ts new file mode 100644 index 0000000..c0bbffa --- /dev/null +++ b/integrationTests/cluster.pw.ts @@ -0,0 +1,181 @@ +import { test as base, expect } from '@playwright/test'; +import { join } from 'node:path'; + +import { + CLUSTER_REQUIRED, + 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); + // 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); +}); + +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..1822051 --- /dev/null +++ b/integrationTests/cluster.ts @@ -0,0 +1,364 @@ +import { execFileSync, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +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'; + +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:5.3.1'; +export const NETWORK = 'harper-nextjs-clusternet'; +export const CREDENTIALS = { username: 'admin', password: 'pilotpass' }; + +/** + * 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; + 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 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 { + 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 { + if (!CONTAINER_CLI) throw new Error('no container CLI: install Docker or Podman, or set HARPER_CONTAINER_CLI'); + try { + 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. 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(CONTAINER_CLI, ['info'], { stdio: 'ignore' }); + } catch { + 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')}`; +} + +/** + * 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' }); +} + +/** 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; +} + +/** + * 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'); + 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 + * 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 want = fingerprint(fixture, pluginRoot); + + 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')); + + // 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([ + 'run', '--rm', '-v', `${staging}:/app`, '-w', '/app', '-e', 'RIG_NODE=build', + ...(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, + ]); + + run('npm', ['install', '--install-links', '--no-audit', '--no-fund']); + run('npx', ['next', 'build']); + + // The node containers run as the image's own user and write into `.next` at runtime. + openUpOnHost(staging); + + writeFileSync(stamp, want); + return staging; +} + +export async function startCluster(pluginRoot: string): Promise { + ensureImage(); + 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. + // + // 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 + // 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) { + 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) { + // 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 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; + 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 waitForApp(node); +} + +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/integrationTests/next-16-caching.pw.ts b/integrationTests/next-16-caching.pw.ts index 709400d..a3157bb 100644 --- a/integrationTests/next-16-caching.pw.ts +++ b/integrationTests/next-16-caching.pw.ts @@ -133,7 +133,7 @@ test('ISR cache record is updated after revalidation', async ({ request, harper expect(after[0].lastModified).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`; @@ -150,8 +150,10 @@ test('revalidateTag writes invalidation row and forces regeneration', async ({ r const revalidateResponse = await request.post(revalidateURL); expect(revalidateResponse.status()).toBe(200); + // 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. // nextjs_cache_invalidation is keyed by the tag itself, so an exact match is correct here. - const invalidationRow = await request.post(harper.operationsAPIURL, { + const tombstones = await request.post(harper.operationsAPIURL, { headers: { 'Content-Type': 'application/json', 'Authorization': authHeader(harper) }, data: { operation: 'search_by_value', @@ -159,16 +161,51 @@ test('revalidateTag writes invalidation row and forces regeneration', async ({ r table: 'nextjs_cache_invalidation', search_attribute: 'id', search_value: 'test-tag', - get_attributes: ['id', 'timestamp'], + get_attributes: ['id', 'timestamp', 'expired', 'lapsesAt'], }, }); - const rows = await invalidationRow.json(); - expect(rows).toHaveLength(1); - expect(rows[0].id).toBe('test-tag'); - expect(typeof rows[0].timestamp).toBe('number'); + 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); + + // 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`; - // Next page request must regenerate (new nonce). await page.goto(taggedURL); - const nonceAfter = await page.getByTestId('nonce').innerText(); - expect(nonceAfter).not.toBe(nonceBefore); + 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 new file mode 100644 index 0000000..259c62c --- /dev/null +++ b/integrationTests/next-16-use-cache.pw.ts @@ -0,0 +1,173 @@ +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); +}); + +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: tag, + get_attributes: ['id', 'timestamp', 'stale', 'expired', 'lapsesAt', '$expiresAt'], + }, + }); + 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); + + 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 }) => { + await page.goto(`${harper.httpURL}/cached`); + await expect(page.getByTestId('nonce')).not.toHaveText('loading'); + + 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/schema.graphql b/schema.graphql index 647bea2..8883d7b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -9,14 +9,54 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in status: String } +# `revalidate`/`expire` mirror the `cacheControl` Next.js supplies on set, so an entry's own `expire` is +# enforced wherever it is read. +# +# `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 lastModified: Long @updatedTime } +# 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` 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, only when `id` had to be shortened. Not indexed. + cacheKey: String + value: Blob + tags: [String] @indexed + timestamp: Long + stale: Int + revalidate: Int + expire: Int +} + +# 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 + # 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/CacheHandler.cts b/src/CacheHandler.cts index e010f92..febfc18 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -14,68 +14,34 @@ import type { import type { databases as DatabasesType } from 'harper'; -const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; +import { + entryExpiresAt, + initializeInvalidationSubscription, + nextServesStaleTags, + recordInvalidation, + tagState, + toStoredLife, +} 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 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 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 +54,32 @@ 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; +function canServeStale(data: unknown): boolean { + const value = data as { kind?: string; headers?: Record } | null; + 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 { + 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 +87,7 @@ export default class HarperCacheHandler implements CacheHandler { constructor(ctx?: CacheHandlerContext) { this.revalidatedTags = ctx?.revalidatedTags ?? []; - void initializeSubscription(); + void initializeInvalidationSubscription(); } async get( @@ -128,21 +96,31 @@ export default class HarperCacheHandler implements CacheHandler { ): Promise { const databases = getDatabases(); if (!databases) return null; + // 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); - 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; - if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { - 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, @@ -160,26 +138,23 @@ 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 }); + + // 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 = toStoredLife(typeof cacheControl?.revalidate === 'number' ? cacheControl.revalidate : undefined); + const expire = toStoredLife(cacheControl?.expire); + + const now = Date.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)); } - async revalidateTag(tags: string | string[]): Promise { + async revalidateTag(tags: string | string[], durations?: { expire?: number }): 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(); - - // 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 Promise.all(tagList.map((tag) => table.put(tag, { timestamp }))); + await recordInvalidation(tagList, durations); } resetRequestCache(): void {} diff --git a/src/CacheHandler.test.ts b/src/CacheHandler.test.ts new file mode 100644 index 0000000..7beeb95 --- /dev/null +++ b/src/CacheHandler.test.ts @@ -0,0 +1,369 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; + +import { createRequire } from 'node:module'; + +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. +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'; +const NEXT_16_MANIFEST = { tagsManifest: new Map(), areTagsStale: () => false }; +const NEXT_15_MANIFEST = { tagsManifest: new Map(), isStale: () => false }; + +interface StoredRecord { + data: unknown; + tags?: string[]; + revalidate?: number; + expire?: number; + lastModified?: number; +} + +/** 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 }> = []; + + (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, context?: { expiresAt?: number }) { + puts.push({ key, value, context }); + records[key] = { ...(value as unknown as StoredRecord), lastModified: Date.now() }; + }, + search() { + return (async function* () {})(); + }, + }, + nextjs_use_cache: { + expirationMS: 604_800_000, + search() { + return (async function* () {})(); + }, + }, + nextjs_cache_invalidation: { + async put(key: string, value: Record) { + invalidationPuts.push({ key, value }); + }, + search() { + 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() {} }; + }, + }, + }, + }; + + 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 }); +} + +/** 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(',') } }; +} + +function fetchValue() { + return { kind: 'FETCH', data: { body: 'x' }, revalidate: 60 }; +} + +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(() => { + resetInvalidationStateForTesting(); + useTagsManifestModuleForTesting(NEXT_16_MANIFEST); + }); + + 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("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('/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, weeks, none] = puts.map((put) => put.context?.expiresAt); + assert.ok(short! >= before + 3_600_000 && short! <= Date.now() + 3_600_000); + 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(); + + 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', pageCtx), 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', pageCtx), null); + }); + + 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 }); + + 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 on tag invalidation', () => { + beforeEach(() => { + resetInvalidationStateForTesting(); + useTagsManifestModuleForTesting(NEXT_16_MANIFEST); + }); + + 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 }, + }); + invalidate('products', { stale: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/page', pageCtx); + + assert.equal(result?.lastModified, 1000, 'a blocking miss here is what an invalidation must not cause'); + }); + + 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 }, + }); + invalidate('products', { stale: 2000, at: 2000 }); + const handler = new HarperCacheHandler(); + + 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 }); + 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.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'] }); + + 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 }, + }); + invalidate('products', { expired: 1000, at: 1000 }); + const handler = new HarperCacheHandler(); + + const result = await handler.get('/page', pageCtx); + + 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); + }); + + // 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( + { '/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 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( + { '/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); + }); + + // 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(); + + assert.notEqual(await handler.get('/page', pageCtx), null); + }); +}); diff --git a/src/UseCacheHandler.cts b/src/UseCacheHandler.cts new file mode 100644 index 0000000..619fbd0 --- /dev/null +++ b/src/UseCacheHandler.cts @@ -0,0 +1,307 @@ +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'; + +import { + entryExpiresAt, + initializeInvalidationSubscription, + passedExpiration, + recordInvalidation, + tagState, + toStoredLife, +} 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, context?: { expiresAt: number }): 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; +} + +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(); + 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(): { 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. */ +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's primary key limit is MAX_KEY_BYTES = 1978 (harper/resources/Table.ts:146). + * 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. Observed in the wild at 87,465 bytes, which Harper + * rejects with `Primary key size is too large`. The rejection surfaces as a rejected + * cache boundary, which aborts the streamed subtree: a dead page with nothing in the + * server log, because the error is a Next streaming rejection rather than a Harper one. + * + * Oversized keys are truncated and suffixed with a hash of the FULL key. Short keys are + * passed through untouched, deliberately: hashing unconditionally would change every + * key and silently invalidate the whole cache on upgrade, and it would throw away a + * readable id for no benefit. + * + * Budgeted well under 1978 rather than up to it — Harper's check serializes the key, and + * the encoded form can exceed the raw byte count. + */ +const MAX_KEY_BYTES = 1500; +const KEY_HASH_BYTES = 16; +/** Cannot occur in a Next cache key, so a short key can never collide with a truncated one. */ +const KEY_HASH_SEPARATOR = '\u0000#'; + +export function toStorageKey(cacheKey: string): string { + if (Buffer.byteLength(cacheKey, 'utf8') <= MAX_KEY_BYTES) return cacheKey; + + // 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 + // multi-byte input, and slicing mid-codepoint could re-encode differently on another + // node — two nodes must derive the same key for the same entry. + const truncated = Buffer.from(cacheKey, 'utf8').subarray(0, budget).toString('utf8').replace(/\uFFFD+$/, ''); + + return `${truncated}${KEY_HASH_SEPARATOR}${digest}`; +} + +/** + * 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 (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') { + try { + return streamOf(new Uint8Array(await blob.arrayBuffer())); + } catch { + return undefined; + } + } + return undefined; +} + +/** + * Next derives entry timestamps from `performance.timeOrigin + performance.now()`, which is + * 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; +} + +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: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +class HarperUseCacheHandler implements CacheHandler { + /** + * 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 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. 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.catch(() => undefined); + + const record = await table.get(toStorageKey(cacheKey)); + if (!record) return undefined; + + const now = Date.now(); + const timestamp = record.timestamp ?? 0; + const expire = record.expire ?? 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; + + const value = await openValue(record.value); + if (!value) return undefined; + + return { + value, + tags, + stale: record.stale ?? 0, + timestamp, + expire, + // 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), + }; + } + + 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; + + const storageKey = toStorageKey(cacheKey); + const timestamp = toInteger(entry.timestamp) ?? Date.now(); + const expire = toStoredLife(entry.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, + { + // 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: toStoredLife(entry.stale), + revalidate: toStoredLife(entry.revalidate), + expire, + }, + expiresAt === undefined ? undefined : { expiresAt } + ); + + return bytes; + })(); + + pendingEntries.set(cacheKey, work); + + try { + await work; + } catch (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); + } + } + + /** + * 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 expiration across the tags that has already passed, or 0 if there is none. */ + async getExpiration(tags: string[]): Promise { + return passedExpiration(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..30c75cd --- /dev/null +++ b/src/UseCacheHandler.test.ts @@ -0,0 +1,684 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +import { + cacheInvalidations, + resetInvalidationStateForTesting, + type TagInvalidation, +} from './cacheInvalidation.cjs'; +import { toStorageKey } from './UseCacheHandler.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, + }; +} + +/** + * 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 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 + * 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 [...LONG_COLUMNS, ...INT_COLUMNS]) { + const columnValue = value[column]; + 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`); + } + } +} + +/** + * 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. */ +interface InvalidationStorage { + tombstones?: Array<{ id: string } & Record>; + failSearch?: boolean; +} + +function installDatabases(invalidation: InvalidationStorage = {}) { + const rows = new Map>(); + putContexts.clear(); + (globalThis as Record).databases = { + harperfast_nextjs: { + nextjs_use_cache: { + expirationMS: 604_800_000, + async get(key: string) { + return rows.get(key); + }, + async put(key: string, value: Record, context?: { expiresAt?: number }) { + enforcePrimaryKey(key); + enforceSchema(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() { + return (async function* () {})(); + }, + }, + nextjs_isr_cache: { + expirationMS: 604_800_000, + search() { + return (async function* () {})(); + }, + }, + nextjs_cache_invalidation: { + async put() {}, + search() { + 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() {} }; + }, + }, + }, + }; + 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(); + 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'); + }); + + 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'); + }); +}); + +// 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', () => { + // 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(); + 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 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(); + 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); + }); + + // 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(); + 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(); + 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('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. + // 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', []); + + 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("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('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.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 — 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, 'bounded to fit the Int column'); + assert.equal(result.revalidate, 900); + assert.equal(putContexts.get('default')?.expiresAt, 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); + }); +}); + +function invalidate(tag: string, fields: Omit) { + cacheInvalidations.set(tag, { lapsesAt: Number.MAX_SAFE_INTEGER, ...fields }); +} + +describe('UseCacheHandler tags', () => { + beforeEach(() => { + resetInvalidationStateForTesting(); + installDatabases(); + }); + + 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); + }); + + it('records an invalidation for every tag', async () => { + await handler.updateTags(['products'], undefined); + + assert.ok(cacheInvalidations.has('products')); + }); + + 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 }))); + 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'); + assert.equal(result.revalidate, -1); + assert.equal(result.timestamp, now, 'the entry keeps its real timestamp'); + }); + + it('withholds a tag-expired entry', async () => { + const now = Date.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(() => { + resetInvalidationStateForTesting(); + 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); + }); + + 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); + }); +}); + +/** + * Next composes a "use cache" key with `encodeReply`, whose binary/FormData encoding can leave lone + * 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. + const keyA = 'x'.repeat(2000) + '\uD800'; + const keyB = 'x'.repeat(2000) + '\uD801'; + + beforeEach(() => { + resetInvalidationStateForTesting(); + installDatabases(); + }); + + // 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') }))); + + const result = await handler.get(keyB, []); + + assert.equal(result, undefined, 'keyB got keyA\'s entry: the two keys collapsed onto one row'); + }); + + // 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') }))); + + const result = await handler.get(keyA, []); + + assert.ok(result, 'the rightful key must still hit'); + assert.equal(await readAll(result.value), 'A payload'); + }); +}); 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 new file mode 100644 index 0000000..66ea34b --- /dev/null +++ b/src/cacheInvalidation.cts @@ -0,0 +1,769 @@ +import type { databases as DatabasesType } from 'harper'; + +const DATABASE = 'harperfast_nextjs'; +const ISR_TABLE = 'nextjs_isr_cache'; +const USE_CACHE_TABLE = 'nextjs_use_cache'; +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; + +// 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; + +/** + * 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. + */ +export interface TagInvalidation { + stale?: number; + 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; + +interface TombstoneRow { + id: string; + timestamp?: number; + stale?: number; + expired?: number; + lapsesAt?: number; + hardExpiredAt?: number; +} + +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; + get(id: string): Promise | CacheRow | undefined; + delete(id: string): Promise | unknown; + 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; timestamp?: number } + ): Promise | unknown; + search(): AsyncIterable; + subscribe(request: { omitCurrent?: boolean }): Promise; +} + +export interface InvalidationDeps { + databases?: typeof DatabasesType; + sleep?: (ms: number) => Promise; + 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. + */ +function getDatabases(deps: InvalidationDeps): typeof DatabasesType | undefined { + return deps.databases ?? (globalThis as { databases?: typeof DatabasesType }).databases; +} + +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)); +} + +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); +} + +let unboundedCacheReported = false; + +/** + * Harper's per-record expiry for a cache entry: when Next says it stops being usable (`expire` seconds + * 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, + expireSeconds: number | undefined, + 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); +} + +/** + * 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. 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 = 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) { + 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 + 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, + // 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, + }; +} + +/** 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; +} + +/** 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, 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'; + } + return state; +} + +/** + * 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 passedExpiration(tags: Iterable, now: number = Date.now()): number { + let newest = 0; + for (const tag of tags) { + // 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; +} + +type TagsManifest = + | { kind: 'none' } + | { kind: 'entries'; manifest: Map } + | { kind: 'timestamps'; manifest: Map }; + +let loadedTagsManifest: TagsManifest | undefined; + +/** + * 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 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 + loadedTagsManifest = classifyTagsManifest(require('next/dist/server/lib/incremental-cache/tags-manifest.external.js')); + } catch (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; +} + +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 }; +} + +/** 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; +} + +/** + * 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; + 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), + hardExpiredAt: Math.max(existing.hardExpiredAt ?? 0, incoming.hardExpiredAt ?? 0) || undefined, + }; +} + +/** + * 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); + 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 { + 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, so a worker that restarts between + * `revalidateTag` and the entry's regeneration still knows the entry is invalid. + */ +export async function hydrateInvalidations( + table: Pick, + deps: InvalidationDeps = {} +): Promise { + for await (const row of table.search()) { + const invalidation = tombstoneToInvalidation(row, nowFor(deps)); + if (invalidation) noteInvalidation(row.id, invalidation, deps); + } +} + +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 }, + 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); +} + +function startPruneTimer(deps: InvalidationDeps): void { + if (pruneTimerStarted || deps.maintenance === false) return; + pruneTimerStarted = true; + 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(true); + if (initialization) return initialization; + if (nowFor(deps) < subscriptionRetryAt) return Promise.resolve(false); + const table = getScope(deps)?.[INVALIDATION_TABLE] as InvalidationTable | undefined; + 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. + 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); + // 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; + subscriptionRetryMs = Math.min(subscriptionRetryMs * 2, SUBSCRIPTION_RETRY_MAX_MS); + getLogger(deps).error('[CacheHandler] failed to initialize invalidation subscription', error); + } finally { + initialization = undefined; + } + return initialized; + })(); + return initialization; +} + +/** 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); +} + +/** 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; + lastTombstoneVersion = 0; + loadedTagsManifest = undefined; + unboundedCacheReported = false; + queuedSweeps.clear(); + activeSweeps = 0; + recordedThisTurn.clear(); +} + +function writtenAtOf(row: CacheRow): number { + return row.timestamp ?? row.lastModified ?? 0; +} + +/** + * 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 row of rowsTagged(table, tag)) { + if (writtenAtOf(row) < expiredAt) ids.push(row.id); + } + + let deleted = 0; + const batches = chunk(ids, SWEEP_CHUNK_SIZE); + for (let index = 0; index < batches.length; index++) { + 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(SWEEP_CHUNK_PAUSE_MAX_MS)); + } + return deleted; +} + +/** + * 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, 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 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); + }); + } +} + +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: 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 { + // 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 scope = getScope(deps); + if (!scope) return; + 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) => { + // 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); + // 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, + 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. + { expiresAt: invalidation.lapsesAt, timestamp: version } + ) + ) + ); + + 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 new file mode 100644 index 0000000..90fc29f --- /dev/null +++ b/src/cacheInvalidation.test.ts @@ -0,0 +1,974 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; + +import { + cacheInvalidations, + chunk, + entryExpiresAt, + hydrateInvalidations, + initializeInvalidationSubscription, + invalidationFor, + noteInvalidation, + passedExpiration, + pruneInvalidations, + recordInvalidation, + resetInvalidationStateForTesting, + runWithConcurrency, + sweepTag, + 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 { + 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 { + rows, + expirationMS, + deleted: [] as string[], + 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 !== '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 }; + })(); + }, + async get(id: string) { + return rows.get(id); + }, + async delete(id: string) { + this.deleted.push(id); + rows.delete(id); + }, + }; +} + +/** + * 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; + context?: { expiresAt?: number; timestamp?: number }; + }>, + 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; + })(); + }, + listener: undefined as undefined | ((event: { type: string; id: string; value?: 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++; + return { + on: (event: string, listener: never) => { + if (event === 'data') this.listener = listener; + if (event === 'close') this.close = listener; + if (event === 'error') this.errorListener = listener; + }, + }; + }, + }; +} + +function makeDeps( + overrides: { + isr?: ReturnType; + useCache?: ReturnType; + invalidation?: ReturnType; + now?: number; + } = {} +): InvalidationDeps & { sleeps: number[]; errors: unknown[]; runSweeps: () => Promise; sweepCount: () => number } { + const isr = overrides.isr ?? makeCacheTable([]); + const useCache = overrides.useCache ?? makeCacheTable([]); + 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); + }, + 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: invalidationTable, + }, + } as never, + sleep: async (ms: number) => { + sleeps.push(ms); + }, + logger: { + info: () => {}, + error: (...args: unknown[]) => { + errors.push(args); + }, + }, + }; +} + +describe('invalidationFor', () => { + it('expires immediately when Next passes no durations', () => { + assert.deepEqual(invalidationFor(1000, undefined, 10), { expired: 1000, at: 1000, lapsesAt: 1010 }); + }); + + 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('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 }); + // 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', () => { + assert.equal(tombstoneToInvalidation({}), undefined); + }); + + // 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('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); + }); + + // 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(() => 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('entryExpiresAt', () => { + it("expires an entry when Next says it stops being usable", () => { + 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); + }); + + // `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', () => { + 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 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), 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', () => { + 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 + 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'); + }); + + 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); + }); + + // 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 })); + await recordInvalidation(['products'], undefined, makeDeps({ invalidation: invalidationTable, now: 2000 })); + + assert.deepEqual(invalidationTable.puts[1].value, { + timestamp: 2000, + 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, + }); + }); + + // 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(() => resetInvalidationStateForTesting()); + + // 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: '/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('posts', 1000, deps); + + assert.deepEqual(isr.deleted, ['/posts']); + }); + + 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 }, + ]); + const deps = makeDeps({ isr }); + + await sweepTag('products', 1000, deps); + + assert.deepEqual(isr.deleted, ['/stale']); + }); + + 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.deleted, []); + }); + + 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(isr.deleted, ['/page']); + assert.deepEqual(useCache.deleted, ['uc-1']); + }); + + 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. + assert.equal(deps.sleeps.length, 2); + }); +}); + +describe('surviving a worker restart', () => { + beforeEach(() => resetInvalidationStateForTesting()); + + it('restores an invalidation the dead worker had only in memory', async () => { + await hydrateInvalidations(makeInvalidationTable([{ id: 'products', timestamp: 2000 }]), { + tagsManifestModule: null, + now: () => 3000, + }); + + assert.equal(tagState(['products'], 1000, 3000), 'expired', 'the invalidation did not survive the restart'); + }); + + it('does not resurrect an entry written after the invalidation', async () => { + await hydrateInvalidations(makeInvalidationTable([{ id: 'products', timestamp: 2000 }]), { + tagsManifestModule: null, + now: () => 3000, + }); + + assert.equal(tagState(['products'], 5000, 6000), undefined); + }); +}); + +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 initializeInvalidationSubscription(deps); + + assert.ok(cacheInvalidations.has('old')); + assert.ok(cacheInvalidations.has('during')); + }); + + 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 }); + + const first = initializeInvalidationSubscription(deps); + const second = initializeInvalidationSubscription(deps); + await Promise.all([first, second]); + + assert.equal(invalidationTable.subscribeCalls, 1); + assert.ok(cacheInvalidations.has('products'), 'awaiting the shared attempt yields a hydrated map'); + }); + + 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'); + }; + const deps = makeDeps({ invalidation: invalidationTable, now: 1000 }); + + await initializeInvalidationSubscription(deps); + await initializeInvalidationSubscription(deps); + await initializeInvalidationSubscription(deps); + + assert.equal(invalidationTable.subscribeCalls, 1); + 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 }); + await initializeInvalidationSubscription(deps); + + invalidationTable.listener?.({ type: 'delete', id: 'products' }); + + assert.equal(cacheInvalidations.has('products'), false); + }); +}); + +// 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' + ); + }); +}); + +// `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. +/** + * `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()); + + 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]]); + }); + + 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]); + }); +}); 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 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')); + }); +});