diff --git a/dev-docs/specs/2026-05-05-geotiff-readahead-cache-design.md b/dev-docs/specs/2026-05-05-geotiff-readahead-cache-design.md new file mode 100644 index 00000000..e41783f7 --- /dev/null +++ b/dev-docs/specs/2026-05-05-geotiff-readahead-cache-design.md @@ -0,0 +1,226 @@ +# GeoTIFF exponential read-ahead cache + +**Date:** 2026-05-05 (revised 2026-05-06) +**Issue:** [#500](https://github.com/developmentseed/deck.gl-raster/issues/500) +**Status:** Initial implementation in PR [#509](https://github.com/developmentseed/deck.gl-raster/pull/509); follow-up refinements specified below. + +## Problem + +When opening a GeoTIFF over HTTP, [`GeoTIFF.fromUrl`](../../packages/geotiff/src/geotiff.ts) wraps the source with chunkd's `[SourceChunk, SourceCache]` middleware pair. `SourceChunk` aligns each request to a fixed `chunkSize` (default 32 KiB) and is stateless: every fetch uses the same chunk size regardless of how many fetches preceded it. + +For TIFF metadata reads — IFD chains, tag values, GeoKeys, GDAL metadata — access is sequential from the start of the file, but the size of metadata varies widely between files (small COGs may fit in 16 KiB; files with many IFDs or large tag arrays may need 1+ MiB). A fixed chunk size is the wrong shape: too small means many round trips, too large means wasted bytes for small files. + +## Solution + +Replace the `[SourceChunk, SourceCache]` pair on the header source with a single new middleware that maintains a **sequential read-ahead cache** rooted at offset 0. Each underlying fetch grows by a configurable multiplier, so successive metadata reads use exponentially larger chunks. + +This is a direct port of [async-tiff's `ReadaheadMetadataCache`](https://github.com/developmentseed/async-tiff/blob/3dd77e3/src/metadata/cache.rs) ([PR #140](https://github.com/developmentseed/async-tiff/pull/140)) to TypeScript and to the chunkd `SourceMiddleware` interface. + +### Why sequential-from-zero? + +TIFF metadata is laid out near the start of the file: header → IFD → tag values → next IFD → … . Cogeotiff's reads land in this region. A cache that grows contiguously from offset 0 captures every metadata read with at most one underlying fetch beyond what the previous read already pulled in. Tile data reads, by contrast, are at arbitrary large offsets — those continue to use the raw `dataSource` with no caching, exactly as they do today. + +## Components + +All new code lives under `packages/geotiff/src/`. + +### `concurrency.ts` — `mutex()` + +A standalone helper that returns a function for running async tasks one at a time. Used by the read-ahead cache to serialize cache extension across concurrent fetches. + +```ts +/** + * Create a mutex: a function that runs async tasks one at a time. + * + * Tasks submitted while another is running are queued and executed in + * submission order — never concurrently with each other. + * + * Useful when an async operation must observe and mutate shared state + * across awaits without races. The TypeScript analogue of holding a + * `tokio::sync::Mutex` across an `await`. + * + * @example + * const lock = mutex(); + * const a = lock(async () => { ... }); // executes immediately + * const b = lock(async () => { ... }); // waits for `a` to settle, then runs + * + * @returns A function that schedules tasks on the queue. + */ +export function mutex(): (task: () => Promise) => Promise { + let tail: Promise = Promise.resolve(); + return (task: () => Promise): Promise => { + const result = tail.then(task, task); + tail = result.catch(() => {}); + return result; + }; +} +``` + +Notes: +- `tail.then(task, task)` ensures the next task runs whether the previous task resolved or rejected. +- `tail = result.catch(() => {})` swallows errors only on the queue chain, not on the returned promise — the caller still observes the original rejection. +- No timeouts, no cancellation. Keep it minimal. + +### `readahead-cache.ts` + +Two pieces in one file. Internal — not exported from `index.ts`. + +#### `SequentialBlockCache` (internal helper class) + +Stores contiguous buffers from offset 0. + +- Fields: `buffers: Uint8Array[]`, `len: number` (sum of buffer lengths). +- `contains(start, end)` → `boolean`. True iff `end <= len`. +- `slice(start, end)` → `ArrayBuffer`. Crosses block boundaries when needed; returns a zero-copy slice when the range fits in one block. +- `appendBuffer(buf: ArrayBuffer)` → mutates. + +#### `SourceReadaheadCache` (the middleware) + +Implements chunkd's [`SourceMiddleware`](../../packages/geotiff/node_modules/@chunkd/source/build/src/middleware.d.ts) interface (`{ name, fetch(req, next) }`). + +- Constructor options: `{ initial: number; multiplier: number }`. +- Fields: `cache: SequentialBlockCache`, `initial`, `multiplier`, `lock: ReturnType`. +- `fetch(req, next)`: + 1. If `req.offset < 0` or `req.length == null`, bypass: `return next(req)`. + 2. Inside `this.lock(...)`: + - While `!cache.contains(req.offset, req.offset + req.length)`: + - `needed = req.offset + req.length - cache.len`. + - `fetchSize = max(nextFetchSize(cache.len), needed)`, clamped against `req.source.metadata?.size - cache.len` if known. + - `buf = await next({ ...req, offset: cache.len, length: fetchSize })`. + - If `buf.byteLength === 0`, break (EOF). + - `cache.appendBuffer(buf)`. + - Return `cache.slice(req.offset, req.offset + req.length)`. +- `nextFetchSize(existingLen)`: `existingLen === 0 ? initial : round(existingLen * multiplier)`. + +### Wiring in `geotiff.ts` + +Update [`GeoTIFF.fromUrl`](../../packages/geotiff/src/geotiff.ts): + +- New options shape (breaking): + ```ts + { + prefetch?: number; // default 32 * 1024 + multiplier?: number; // default 2.0 + } + ``` +- Drop `chunkSize` and `cacheSize`. +- Replace `[new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize })]` with `[new SourceReadaheadCache({ initial: prefetch, multiplier })]`. +- Continue passing `prefetch` to `Tiff.create({ defaultReadSize: prefetch })` via `GeoTIFF.open`, so the very first read is correctly sized. +- Update JSDoc on `fromUrl` to describe the new behavior. + +`GeoTIFF.open` and `GeoTIFF.fromArrayBuffer` are unchanged. Memory sources don't need read-ahead, and `open` callers compose their own middleware. + +## Tests + +### `concurrency.test.ts` + +- Tasks run one at a time (use a "concurrent counter" to detect overlap). +- Submission order is preserved. +- A rejecting task does not block subsequent tasks. +- Each call's result/error is delivered to the right caller. + +### `readahead-cache.test.ts` + +Port the async-tiff unit tests: + +- Initial fetch returns the requested range; underlying fetch count = 1. +- Subsequent fetch within the cached range: count unchanged. +- Fetch exceeding cached range: count + 1; growth size matches `initial * multiplier^n` (use `initial=2, multiplier=3` like the upstream test). +- Fetch larger than `initial * multiplier^n` triggers a single fetch sized to `needed`. +- `SequentialBlockCache.contains`/`slice` works across multiple blocks, including empty buffers and EOF (port `test_sequential_block_cache_empty_buffers`). +- Concurrent test: fire N parallel `fetch` calls and assert the cache only grows by the expected number of underlying fetches (i.e. requests overlap correctly via the mutex). + +### Smoke test against `GeoTIFF.fromUrl` + +Mock a `Source.fetch` with a counter and assert that opening a real fixture takes fewer underlying calls than the previous `[SourceChunk, SourceCache]` pipeline. + +## Out of scope + +- No upstream PR to `@chunkd/middleware`. +- No public export of `SourceReadaheadCache`, `SequentialBlockCache`, or `mutex` (per "minimal public APIs" preference). Easy to expose later if a concrete external use case appears. +- No backwards-compatibility shim for `chunkSize` / `cacheSize` — this is a 0.x package and release-please will surface the breaking change. +- No timeouts or cancellation in `mutex()`. +- No per-source registry — one middleware instance is created per `fromUrl` call and tied to that source's lifetime, same as the existing `SourceChunk`/`SourceCache` lifecycle. + +## Revision (2026-05-06): scope cache to the open phase + +After live-testing PR [#509](https://github.com/developmentseed/deck.gl-raster/pull/509) against the land-cover example, the readahead cache caused catastrophic over-fetching when zooming. The middleware was being consulted *throughout the lifetime* of the `Tiff` instance — not just during initial `Tiff.create()` — because cogeotiff/core lazily reads tile metadata from the `headerSource` whenever a tile from a previously-untouched IFD is requested. + +Concretely, with default settings, panning the land-cover fixture issued underlying fetches of 14 MiB, 42 MiB, 127 MiB, 382 MiB. Each new far-offset request landed past `cache.len`, and the exponential growth (`fetchSize = round(cache.len * multiplier)`) blew up. + +### Root causes + +1. **cogeotiff lazy IFD reads.** When `Overview.fetchTile()` runs against an IFD whose tag values weren't bulk-read at open time, cogeotiff issues per-entry 4–8 byte reads against the `headerSource` for `TileOffsets`/`TileByteCounts`. Today's [`prefetchTags`](../../packages/geotiff/src/ifd.ts) only runs on the *primary* image — overviews and masks are not prefetched. +2. **Cache strategy mismatch.** `SourceReadaheadCache` is a sequential-from-zero cache. It's right for the open phase (IFDs and tag values cluster near the start of the file), but wrong for arbitrary-offset reads after open. Each far-offset request pulls the cache forward exponentially. +3. **Initial size too small for some files.** Default `prefetch = 32 KiB` requires 3 underlying fetches even for moderate metadata. geotiff.js uses 65536 (64 KiB) blocks; async-tiff uses 32 KiB. + +### Decisions + +1. **The readahead cache runs *only during the open phase*.** After `Tiff.create()` and the initial `prefetchTags(primaryImage)` complete, `SourceReadaheadCache.disable()` is called. From that point on, every call to `SourceReadaheadCache.fetch(req, next)` short-circuits to `next(req)` — the cache is neither consulted nor extended. Cogeotiff still holds a reference to the wrapped `SourceView`, but the middleware becomes a no-op pass-through. We do not mutate the `Tiff` instance; we don't need to. +2. **Lazy per-IFD bulk prefetch on first tile request.** Each `Overview` lazily triggers a one-shot bulk read of `TileOffsets` + `TileByteCounts` (via cogeotiff's `image.fetch(TiffTag.…)` API) on its first `fetchTile`. The overview caches the resulting promise so concurrent first-tile requests share a single underlying fetch. Since these calls happen post-`disable()`, they bypass the readahead cache and go straight to raw HTTP — exactly one bulk request per array, per overview. +3. **Default `prefetch` bumped from 32 KiB to 64 KiB.** Aligns with geotiff.js's default block size; cuts one round trip on moderately-sized COGs without measurably penalizing tiny ones. The multiplier dominates open-time round-trip count, but a slightly larger initial moves us closer to one-shot opens for typical files. +4. **Background pre-warming is *not* in scope.** Hooking into `onTilesLoaded` to prefetch unvisited overviews is appealing but adds scheduling complexity. Track as a follow-up issue once the lazy mechanism above is in place — it can be layered on by calling the same per-IFD prefetch path opportunistically. + +### `SourceReadaheadCache.disable()` contract + +```ts +class SourceReadaheadCache implements SourceMiddleware { + // ... + /** + * Permanently bypass the cache. + * + * After this is called, every {@link fetch} returns `next(req)` immediately + * — no cache consultation, no cache extension. Existing in-flight requests + * complete normally (the mutex preserves serialization). + * + * Intended to be called once `GeoTIFF.fromUrl` has finished its open-phase + * reads (`Tiff.create` + `prefetchTags(primaryImage)`). At that point the + * readahead cache has done its job; subsequent reads from cogeotiff are at + * arbitrary offsets (lazy IFD lookups, GDAL ghost-header probes) and do + * not benefit from sequential-from-zero growth. + * + * Idempotent. One-way: there is no `enable()`. + */ + disable(): void; +} +``` + +### Per-overview lazy prefetch contract + +`Overview.fetchTile` becomes: + +```ts +async fetchTile(x, y, options): Promise { + await this.ensureTagsLoaded(); // memoized; resolves once + return /* existing fetch path */; +} + +private ensureTagsLoaded(): Promise { + if (!this._tagsPromise) { + this._tagsPromise = Promise.all([ + this.dataImage.fetch(TiffTag.TileOffsets), + this.dataImage.fetch(TiffTag.TileByteCounts), + this.maskImage?.fetch(TiffTag.TileOffsets) ?? Promise.resolve(null), + this.maskImage?.fetch(TiffTag.TileByteCounts) ?? Promise.resolve(null), + ]).then(() => undefined); + } + return this._tagsPromise; +} +``` + +`Overview.fetchTiles` calls `ensureTagsLoaded` once before launching parallel tile fetches. The primary image's `GeoTIFF.fetchTile` doesn't need this — `prefetchTags` has already loaded those tags during open. + +### Tests + +Update existing tests and add new ones: +- `readahead-cache.test.ts`: add a test for `disable()` — fetches before disable hit the cache normally; fetches after disable always pass through, never grow `cache.len`. +- `integration-readahead.test.ts`: add an assertion that after open, simulated tile-offset reads at far offsets do *not* trigger cache growth (count underlying fetches; there should be one fetch per requested tag, not exponential growth). +- New: `overview.test.ts` (or extend an existing test): assert that the second tile request from an overview does not trigger any new underlying tag fetches (i.e. `ensureTagsLoaded` is memoized). + +## References + +- Issue: [developmentseed/deck.gl-raster#500](https://github.com/developmentseed/deck.gl-raster/issues/500) +- Initial PR: [developmentseed/deck.gl-raster#509](https://github.com/developmentseed/deck.gl-raster/pull/509) +- Reference implementation: [developmentseed/async-tiff PR #140](https://github.com/developmentseed/async-tiff/pull/140), file [`src/metadata/cache.rs`](https://github.com/developmentseed/async-tiff/blob/3dd77e3/src/metadata/cache.rs) +- Existing source pipeline: [`packages/geotiff/src/geotiff.ts:233-262`](../../packages/geotiff/src/geotiff.ts#L233-L262) +- chunkd `SourceMiddleware` interface: `@chunkd/source/build/src/middleware.d.ts` diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 513681bc..2152c3b8 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -1,4 +1,3 @@ -import { SourceCache, SourceChunk } from "@chunkd/middleware"; import { SourceView } from "@chunkd/source"; import { SourceHttp } from "@chunkd/source-http"; import { SourceMemory } from "@chunkd/source-memory"; @@ -14,6 +13,7 @@ import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; import { Overview } from "./overview.js"; import type { DecoderPool } from "./pool/pool.js"; +import { SourceReadaheadCache } from "./source/readahead-cache.js"; import type { Tile } from "./tile.js"; import { createTransform, index, xy } from "./transform.js"; @@ -229,25 +229,43 @@ export class GeoTIFF { /** * Create a new GeoTIFF from a URL. * + * Wraps the HTTP source with a sequential exponential read-ahead cache + * tuned for TIFF metadata: the first underlying fetch is `prefetch` bytes, + * and each subsequent fetch grows by `multiplier`. Tile data reads bypass + * the cache and use the raw HTTP source directly. + * + * The cache is **frozen at the end of the open phase**. Once `Tiff.create` + * and `prefetchTags(primaryImage)` finish, {@link SourceReadaheadCache.freeze} + * is called. After that, cache hits are still served from memory, but + * misses bypass to raw HTTP — the cache never extends again. This is + * intentional: cogeotiff/core lazily reads tile-offset/bytecount entries + * from the header source whenever a tile from a previously-untouched IFD + * is requested, and those reads are at arbitrary far offsets. With the + * cache able to extend, each one would pull the cache forward + * exponentially (e.g. a tile lookup at offset 8 MB with cache.len = 2 MB + * triggers a 4 MB underlying fetch). With it frozen, those reads go + * straight to raw HTTP and the cache stops growing. + * + * Per-IFD bulk loading of `TileOffsets`/`TileByteCounts` happens lazily + * in {@link Overview.fetchTile} on first use — see {@link Overview} for + * details. + * * @param url The URL of the GeoTIFF to open. - * @param options Optional parameters for chunk size and cache size. - * @param options.chunkSize The minimum size for each request made to the source while reading header metadata. Defaults to 32KB. - * @param options.cacheSize The size of the cache for recently accessed header chunks. Currently no caching is applied to data fetches. Defaults to 1MB. - * @param options.prefetch Number of bytes to prefetch when reading TIFF tags and IFDs. Defaults to 32KB, which is enough for most tags and small IFDs. Increase if you have many tags or large IFDs. + * @param options Optional parameters for the read-ahead cache. + * @param options.prefetch Initial fetch size in bytes for header/metadata reads. Defaults to 64KB, which covers most COGs in a single round trip. + * @param options.multiplier Growth factor applied to the previous fetch size on each subsequent header read. Defaults to 4.0. * @param options.signal An optional {@link AbortSignal} to cancel the header reads. * @returns A Promise that resolves to a GeoTIFF instance. */ static async fromUrl( url: string | URL, { - chunkSize = 1024 * 1024, - cacheSize = 10 * 1024 * 1024, - prefetch = 32 * 1024, + prefetch = 64 * 1024, + multiplier = 4, signal, }: { - chunkSize?: number; - cacheSize?: number; prefetch?: number; + multiplier?: number; signal?: AbortSignal; } = {}, ): Promise { @@ -261,35 +279,37 @@ export class GeoTIFF { // In a browser, `Content-Range` is only readable when the server lists it in // `Access-Control-Expose-Headers` (S3 does not by default), so the // `Content-Length` fallback — the length of a single *chunk*, not the file — - // gets recorded as the file size. `@chunkd/middleware`'s chunk layer then - // rejects any later read past that bogus size with - // "SourceError: Request outside of bounds". + // gets recorded as the file size. Reads past that bogus size would then be + // rejected as out-of-bounds. // // Seed `metadata` ourselves so `SourceHttp` never records a size (it only // fills in `metadata` while it is still null), treating the source as having // unbounded length. Remove once the upstream fix lands. source.metadata = { size: Number.POSITIVE_INFINITY }; - // Figure out optimal defaults in light of - // https://github.com/blacha/cogeotiff/issues/1431 - // Defaulting to 32KB chunks is too small for tile data. - // https://github.com/developmentseed/deck.gl-raster/issues/294 - - // read files in chunks - const chunk = new SourceChunk({ size: chunkSize }); - // 10MB cache for recently accessed chunks - const cache = new SourceCache({ size: cacheSize }); + const readahead = new SourceReadaheadCache({ + initial: prefetch, + multiplier, + }); - const view = new SourceView(source, [chunk, cache]); + const view = new SourceView(source, [readahead]); - return await GeoTIFF.open({ + const geotiff = await GeoTIFF.open({ // Use raw source for tile data to avoid unnecessary copying through the - // cache and chunk layers. + // read-ahead cache layer. dataSource: source, headerSource: view, prefetch, signal, }); + + // Open phase complete: freeze the cache so it stops extending. Subsequent + // reads (lazy overview tag lookups, GDAL ghost-header probes, etc.) are + // still served from the cache if covered, but misses go straight to raw + // HTTP instead of triggering exponential growth. + readahead.freeze(); + + return geotiff; } // ── Properties from the primary image ───────────────────────────────── diff --git a/packages/geotiff/src/overview.ts b/packages/geotiff/src/overview.ts index 195d9229..e03ba1c1 100644 --- a/packages/geotiff/src/overview.ts +++ b/packages/geotiff/src/overview.ts @@ -1,4 +1,5 @@ import type { Source, TiffImage, TiffImageTileCount } from "@cogeotiff/core"; +import { TiffTag } from "@cogeotiff/core"; import type { Affine } from "@developmentseed/affine"; import { compose, scale } from "@developmentseed/affine"; import type { ProjJson } from "@developmentseed/proj"; @@ -32,6 +33,9 @@ export class Overview { /** The IFD for the mask associated with this overview level, if any. */ readonly maskImage: TiffImage | null = null; + /** Memoized promise from {@link ensureTagsLoaded}. */ + private _tagsPromise: Promise | null = null; + constructor( geotiff: GeoTIFF, gkd: GeoKeyDirectory, @@ -118,9 +122,49 @@ export class Overview { signal?: AbortSignal; } = {}, ): Promise { + await this.ensureTagsLoaded(); return await fetchTile(this, x, y, options); } + /** + * Bulk-load `TileOffsets` and `TileByteCounts` for this overview's data IFD + * (and mask IFD, if present) on first call. Subsequent calls return the + * same memoized promise — no additional underlying fetches. + * + * Why this exists: cogeotiff/core lazily reads individual entries from the + * tile-offset/bytecount arrays via the header source, one 4–8 byte entry + * per tile request. For overviews not pre-loaded by `prefetchTags` (i.e. + * everything except the primary image), this means many tiny per-tile + * range requests on every tile fetch. Calling + * `image.fetch(TiffTag.TileOffsets)` once forces cogeotiff to bulk-load + * the full array; thereafter all per-tile lookups are served from memory. + * + * The bulk fetch goes through the source originally passed to + * `Tiff.create` — for {@link GeoTIFF.fromUrl}, that's the wrapped header + * source. After {@link GeoTIFF.fromUrl} disables its read-ahead cache, + * the wrapper is a pass-through, so this read hits raw HTTP directly. + */ + ensureTagsLoaded(): Promise { + if (this._tagsPromise === null) { + this._tagsPromise = this.loadTags(); + } + return this._tagsPromise; + } + + private async loadTags(): Promise { + const dataPromises = [ + this.image.fetch(TiffTag.TileOffsets), + this.image.fetch(TiffTag.TileByteCounts), + ]; + const maskPromises = this.maskImage + ? [ + this.maskImage.fetch(TiffTag.TileOffsets), + this.maskImage.fetch(TiffTag.TileByteCounts), + ] + : []; + await Promise.all([...dataPromises, ...maskPromises]); + } + /** * Fetch multiple tiles in parallel. * @@ -141,6 +185,7 @@ export class Overview { signal?: AbortSignal; } = {}, ): Promise { + await this.ensureTagsLoaded(); return await fetchTiles(this, xy, options); } diff --git a/packages/geotiff/src/source/concurrency.ts b/packages/geotiff/src/source/concurrency.ts new file mode 100644 index 00000000..f2704ee4 --- /dev/null +++ b/packages/geotiff/src/source/concurrency.ts @@ -0,0 +1,25 @@ +/** + * Create a mutex: a function that runs async tasks one at a time. + * + * Tasks submitted while another is running are queued and executed in + * submission order — never concurrently with each other. + * + * Useful when an async operation must observe and mutate shared state + * across awaits without races. The TypeScript analogue of holding a + * `tokio::sync::Mutex` across an `await`. + * + * @example + * const lock = mutex(); + * const a = lock(async () => { ... }); // executes immediately + * const b = lock(async () => { ... }); // waits for `a` to settle, then runs + * + * @returns A function that schedules tasks on the queue. + */ +export function mutex(): (task: () => Promise) => Promise { + let tail: Promise = Promise.resolve(); + return (task: () => Promise): Promise => { + const result = tail.then(task, task); + tail = result.catch(() => {}); + return result; + }; +} diff --git a/packages/geotiff/src/source/readahead-cache.ts b/packages/geotiff/src/source/readahead-cache.ts new file mode 100644 index 00000000..1395517d --- /dev/null +++ b/packages/geotiff/src/source/readahead-cache.ts @@ -0,0 +1,260 @@ +import type { + SourceCallback, + SourceMiddleware, + SourceRequest, +} from "@chunkd/source"; +import { mutex } from "./concurrency.js"; + +/** + * Contiguous-from-zero buffer cache. + * + * Stores a sequence of buffers logically concatenated from byte offset 0. + * Used by {@link SourceReadaheadCache} to retain previously fetched ranges. + * + * @internal + */ +export class SequentialBlockCache { + private readonly buffers: Uint8Array[] = []; + + /** Total cached length in bytes (sum of buffer lengths). */ + len = 0; + + /** Append a buffer to the end of the cache. */ + appendBuffer(buffer: ArrayBuffer): void { + const view = new Uint8Array(buffer); + this.len += view.byteLength; + this.buffers.push(view); + } + + /** True iff the byte range `[start, end)` is fully cached. */ + contains(_start: number, end: number): boolean { + return end <= this.len; + } + + /** + * Slice the byte range `[start, end)` out of the cached buffers. + * + * Returns a zero-copy slice when the range fits in one block; copies + * into a fresh buffer when it spans multiple blocks. Caller must ensure + * the range is fully cached (see {@link contains}). + */ + slice(start: number, end: number): ArrayBuffer { + const outLen = end - start; + if (outLen === 0) { + return new ArrayBuffer(0); + } + + let remainingStart = start; + let remainingEnd = end; + const parts: Uint8Array[] = []; + + for (const block of this.buffers) { + const blockLen = block.byteLength; + if (remainingStart >= blockLen) { + remainingStart -= blockLen; + remainingEnd -= blockLen; + continue; + } + const sliceStart = remainingStart; + const sliceEnd = Math.min(remainingEnd, blockLen); + if (sliceEnd > sliceStart) { + parts.push(block.subarray(sliceStart, sliceEnd)); + } + remainingStart = 0; + if (remainingEnd <= blockLen) { + break; + } + remainingEnd -= blockLen; + } + + if (parts.length === 1) { + const part = parts[0]!; + const out = new Uint8Array(part.byteLength); + out.set(part); + return out.buffer; + } + + const out = new Uint8Array(outLen); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out.buffer; + } +} + +/** + * Options for {@link SourceReadaheadCache}. + */ +export interface SourceReadaheadCacheOptions { + /** Bytes fetched on the first underlying read. */ + initial: number; + /** Multiplier applied to the previous fetch size on each subsequent read. */ + multiplier: number; + /** + * Maximum bytes from the end of the cache to the start of a request before + * the middleware bypasses to next(). If a request starts more than this far + * past `cache.len`, it is served by one direct fetch instead of triggering + * a cache extension that spans the gap. + * + * Sequential reads (gap ≈ 0) are unaffected — the cache extends naturally + * by `nextFetchSize(cache.len)` regardless of how big that is, so even + * files with hundreds of megabytes of metadata can be opened in a few + * exponentially-growing fetches. The cap only kicks in for far-offset + * probes (e.g. GDAL ghost-header reads near EOF on a large file). + * + * Defaults to {@link DEFAULT_MAX_GAP}. + */ + maxGap?: number; +} + +/** + * Default cap on the distance from `cache.len` to a request's start before + * the middleware bypasses: 128 MiB. + * + * Chosen to be larger than any realistic TIFF metadata region (so sequential + * extension of the cache is never artificially stopped) while still small + * compared to typical large-COG file sizes (so a far-offset probe near EOF + * does not pull hundreds of MB of unused data into the cache). + */ +export const DEFAULT_MAX_GAP = 128 * 1024 * 1024; + +/** + * A chunkd {@link SourceMiddleware} that caches sequential reads from offset 0 + * and grows underlying fetch sizes exponentially. + * + * Designed for TIFF metadata access, which is laid out near the start of the + * file: an initial small fetch covers most files, and subsequent fetches grow + * by `multiplier` to handle larger header structures with few round trips. + * + * # Lifecycle + * + * The cache has two states: + * + * - **Active** (the default): on a miss, the cache extends by exponentially + * growing reads. On a hit (range fully covered by `[0, cache.len)`), it + * serves directly from the in-memory buffer. + * - **Frozen** (after {@link freeze} is called): cache hits are still served, + * but misses bypass to `next()` directly — the cache never extends again. + * + * `GeoTIFF.fromUrl` calls {@link freeze} once the open phase (`Tiff.create` + + * `prefetchTags(primaryImage)`) finishes. From that point on, the cache acts + * as a read-only in-memory index of the bytes already pulled during open. + * + * # Bounded extension + * + * Sequential extension is unbounded — the cache grows as far as cogeotiff's + * sequential reads require, even for files with very large metadata regions + * (a 200 GB COG can easily have a 60+ MB header). The bound is on the + * *gap* between `cache.len` and the start of a request: if a request lands + * more than {@link SourceReadaheadCacheOptions.maxGap} bytes past the + * cache, the middleware bypasses for that one request instead of pulling + * the entire gap into the cache. This protects against pathological probes + * (e.g. GDAL ghost-header reads near the end of the file) without + * artificially capping the legitimate sequential growth path. + * + * # Bypass cases + * + * Requests with negative offsets, or with `length == null` (full-file + * reads), always bypass to the next layer regardless of state. + * + * Stateful per instance: pairs one-to-one with a single source's lifetime. + * + * @internal + */ +export class SourceReadaheadCache implements SourceMiddleware { + readonly name = "source:readahead-cache"; + + private readonly cache = new SequentialBlockCache(); + private readonly initial: number; + private readonly multiplier: number; + private readonly maxGap: number; + private readonly lock = mutex(); + private frozen = false; + + constructor(options: SourceReadaheadCacheOptions) { + this.initial = options.initial; + this.multiplier = options.multiplier; + this.maxGap = options.maxGap ?? DEFAULT_MAX_GAP; + } + + /** + * Stop extending the cache. Hits continue to be served from memory; misses + * bypass to the next layer. + * + * Intended to be called once `GeoTIFF.fromUrl` has finished its open-phase + * reads. At that point cogeotiff's subsequent reads are at arbitrary + * offsets (lazy IFD lookups, GDAL ghost-header probes, per-tile lookups) + * and do not benefit from sequential-from-zero growth — and in fact would + * cause catastrophic over-fetching as the cache grows exponentially to + * encompass each new far-offset request. + * + * Idempotent. One-way: there is no `unfreeze()`. + */ + freeze(): void { + this.frozen = true; + } + + async fetch(req: SourceRequest, next: SourceCallback): Promise { + if (req.offset < 0 || req.length == null) { + return next(req); + } + const start = req.offset; + const end = req.offset + req.length; + const sourceSize = req.source.metadata?.size; + + return this.lock(async () => { + // Cache hits are always served from memory, regardless of frozen state. + if (this.cache.contains(start, end)) { + return this.cache.slice(start, end); + } + + // On miss after freeze: never extend; serve with a direct fetch. + if (this.frozen) { + return next(req); + } + + // While active: if the request starts too far past the cache, bypass + // and serve it with one direct fetch. Sequential extension is fine — + // even very large metadata regions are reached by exponential growth + // — but a far-offset probe (e.g. GDAL ghost header near EOF on a + // large file) shouldn't drag the cache through the gap. + const gap = start - this.cache.len; + if (gap > this.maxGap) { + return next(req); + } + + while (!this.cache.contains(start, end)) { + const cacheLen = this.cache.len; + const stepNeeded = end - cacheLen; + let fetchSize = Math.max(this.nextFetchSize(cacheLen), stepNeeded); + if (sourceSize != null) { + const remaining = sourceSize - cacheLen; + if (remaining <= 0) { + break; + } + fetchSize = Math.min(fetchSize, remaining); + } + const buf = await next({ + ...req, + offset: cacheLen, + length: fetchSize, + }); + if (buf.byteLength === 0) { + break; + } + this.cache.appendBuffer(buf); + } + const sliceEnd = Math.min(end, this.cache.len); + return this.cache.slice(start, sliceEnd); + }); + } + + private nextFetchSize(existingLen: number): number { + if (existingLen === 0) { + return this.initial; + } + return Math.round(existingLen * this.multiplier); + } +} diff --git a/packages/geotiff/tests/fromurl.test.ts b/packages/geotiff/tests/fromurl.test.ts index f6e22faf..0e3e813e 100644 --- a/packages/geotiff/tests/fromurl.test.ts +++ b/packages/geotiff/tests/fromurl.test.ts @@ -92,15 +92,16 @@ describe("GeoTIFF.fromUrl", () => { it("reads ranges past the first chunk when the server hides Content-Range (issue #524)", async () => { SourceHttp.fetch = browserLikeS3Fetch(FIXTURE) as typeof SourceHttp.fetch; - // A small chunk size makes the file span many chunks; without the fix the - // first chunk's `Content-Length` gets mistaken for the file size. + // A small initial prefetch forces multiple underlying fetches; without the + // fix the first response's `Content-Length` gets mistaken for the file + // size, and subsequent reads past it would be rejected as out-of-bounds. const tiff = await GeoTIFF.fromUrl("https://example.test/cog.tif", { - chunkSize: 1024, + prefetch: 1024, }); - // A header-source read near the end of the file must not be rejected by - // the chunk middleware (this is the read that `TiffImage.getTileSize` - // performs for GDAL "tile leader" bytes). + // A header-source read near the end of the file must not be rejected + // (this is the kind of read that `TiffImage.getTileSize` performs for + // GDAL "tile leader" bytes). const tail = await tiff.tiff.source.fetch(FIXTURE.byteLength - 16, 16); expect(tail.byteLength).toBe(16); }); diff --git a/packages/geotiff/tests/integration-readahead.test.ts b/packages/geotiff/tests/integration-readahead.test.ts new file mode 100644 index 00000000..bae599ba --- /dev/null +++ b/packages/geotiff/tests/integration-readahead.test.ts @@ -0,0 +1,132 @@ +import type { Source } from "@chunkd/source"; +import { SourceView } from "@chunkd/source"; +import { SourceFile } from "@chunkd/source-file"; +import { describe, expect, it } from "vitest"; +import { GeoTIFF } from "../src/geotiff.js"; +import { SourceReadaheadCache } from "../src/source/readahead-cache.js"; +import { fixturePath } from "./helpers.js"; + +/** + * Wrap a Source so we can count fetches that hit the underlying file. + */ +function counting(source: Source): { source: Source; count: () => number } { + let count = 0; + const wrapped: Source = { + type: source.type, + url: source.url, + metadata: source.metadata, + head: source.head.bind(source), + fetch: async (offset, length, options) => { + count++; + return source.fetch(offset, length, options); + }, + }; + return { source: wrapped, count: () => count }; +} + +describe("SourceReadaheadCache integration", () => { + const path = fixturePath("uint8_rgb_deflate_block64_cog", "rasterio"); + + it("opens a fixture through the new middleware", async () => { + const file = new SourceFile(path); + const { source, count } = counting(file); + const view = new SourceView(source, [ + new SourceReadaheadCache({ initial: 32 * 1024, multiplier: 2 }), + ]); + + const tiff = await GeoTIFF.open({ + dataSource: file, + headerSource: view, + prefetch: 32 * 1024, + }); + + expect(tiff.width).toBeGreaterThan(0); + expect(tiff.height).toBeGreaterThan(0); + expect(count()).toBeGreaterThan(0); + }); + + it("opens with a tiny initial size and grows the cache as needed", async () => { + // Force multiple cache extensions by starting with a tiny initial size. + const file = new SourceFile(path); + const { source, count } = counting(file); + const view = new SourceView(source, [ + new SourceReadaheadCache({ initial: 256, multiplier: 2 }), + ]); + + const tiff = await GeoTIFF.open({ + dataSource: file, + headerSource: view, + prefetch: 256, + }); + + expect(tiff.width).toBeGreaterThan(0); + // With a 256-byte initial size, we expect more than one underlying fetch + // to read the IFD chain — proving the cache extends correctly. + expect(count()).toBeGreaterThan(1); + }); + + it("freezes the readahead cache after open completes (full fromUrl path)", async () => { + const file = new SourceFile(path); + const counter = counting(file); + + const readahead = new SourceReadaheadCache({ + initial: 32 * 1024, + multiplier: 2, + }); + const view = new SourceView(counter.source, [readahead]); + + // Mirror what GeoTIFF.fromUrl does internally: + await GeoTIFF.open({ + dataSource: file, + headerSource: view, + prefetch: 32 * 1024, + }); + readahead.freeze(); + + const fetchesBeforePostOpenReads = counter.count(); + + // Issue reads at the very end of the file — definitely past whatever the + // cache extended to during open. Each post-freeze miss must hit raw + // underlying source 1:1; the cache must not extend. + const head = await view.head(); + const fileSize = head.size!; + + await view.fetch(fileSize - 24, 8); + await view.fetch(fileSize - 16, 8); + await view.fetch(fileSize - 8, 8); + + const fetchesAfterPostOpenReads = counter.count(); + const postOpenFetchCount = + fetchesAfterPostOpenReads - fetchesBeforePostOpenReads; + + // Three end-of-file reads → exactly three underlying fetches. No growth. + expect(postOpenFetchCount).toBe(3); + }); + + it("after freeze, cached ranges are served from memory without an underlying fetch", async () => { + const file = new SourceFile(path); + const counter = counting(file); + + const readahead = new SourceReadaheadCache({ + initial: 32 * 1024, + multiplier: 2, + }); + const view = new SourceView(counter.source, [readahead]); + + await GeoTIFF.open({ + dataSource: file, + headerSource: view, + prefetch: 32 * 1024, + }); + readahead.freeze(); + + const fetchesBeforePostOpenReads = counter.count(); + + // Re-read a range near the start: definitely covered by the cache + // (open phase always pulled the first 32 KiB at least). + await view.fetch(0, 16); + await view.fetch(100, 16); + + expect(counter.count()).toBe(fetchesBeforePostOpenReads); + }); +}); diff --git a/packages/geotiff/tests/overview.test.ts b/packages/geotiff/tests/overview.test.ts new file mode 100644 index 00000000..791def2f --- /dev/null +++ b/packages/geotiff/tests/overview.test.ts @@ -0,0 +1,106 @@ +import type { TiffImage } from "@cogeotiff/core"; +import { TiffTag } from "@cogeotiff/core"; +import { describe, expect, it } from "vitest"; +import { Overview } from "../src/overview.js"; + +/** + * A minimal fake TiffImage that counts calls to `fetch` per tag. + * + * Only the methods/fields actually used by `ensureTagsLoaded` need to work; + * everything else is stubbed since these tests don't call `fetchTile`. + */ +function makeFakeImage(): { + image: TiffImage; + fetchCalls: () => Map; +} { + const calls = new Map(); + const image = { + fetch: async (tag: number) => { + calls.set(tag, (calls.get(tag) ?? 0) + 1); + return null; + }, + } as unknown as TiffImage; + return { image, fetchCalls: () => calls }; +} + +describe("Overview.ensureTagsLoaded", () => { + it("bulk-fetches TileOffsets and TileByteCounts on first call", async () => { + const data = makeFakeImage(); + const overview = new Overview( + {} as never, // geotiff + {} as never, // gkd + data.image, + null, + {} as never, // cachedTags + { fetch: async () => new ArrayBuffer(0) }, // dataSource + ); + + await overview.ensureTagsLoaded(); + + const calls = data.fetchCalls(); + expect(calls.get(TiffTag.TileOffsets)).toBe(1); + expect(calls.get(TiffTag.TileByteCounts)).toBe(1); + }); + + it("memoizes: a second call does not refetch", async () => { + const data = makeFakeImage(); + const overview = new Overview( + {} as never, + {} as never, + data.image, + null, + {} as never, + { fetch: async () => new ArrayBuffer(0) }, + ); + + await overview.ensureTagsLoaded(); + await overview.ensureTagsLoaded(); + await overview.ensureTagsLoaded(); + + const calls = data.fetchCalls(); + expect(calls.get(TiffTag.TileOffsets)).toBe(1); + expect(calls.get(TiffTag.TileByteCounts)).toBe(1); + }); + + it("dedupes concurrent first-call invocations into one underlying load", async () => { + const data = makeFakeImage(); + const overview = new Overview( + {} as never, + {} as never, + data.image, + null, + {} as never, + { fetch: async () => new ArrayBuffer(0) }, + ); + + await Promise.all([ + overview.ensureTagsLoaded(), + overview.ensureTagsLoaded(), + overview.ensureTagsLoaded(), + ]); + + const calls = data.fetchCalls(); + expect(calls.get(TiffTag.TileOffsets)).toBe(1); + expect(calls.get(TiffTag.TileByteCounts)).toBe(1); + }); + + it("also fetches mask tags when a mask IFD is present", async () => { + const data = makeFakeImage(); + const mask = makeFakeImage(); + const overview = new Overview( + {} as never, + {} as never, + data.image, + mask.image, + {} as never, + { fetch: async () => new ArrayBuffer(0) }, + ); + + await overview.ensureTagsLoaded(); + + expect(data.fetchCalls().get(TiffTag.TileOffsets)).toBe(1); + expect(data.fetchCalls().get(TiffTag.TileByteCounts)).toBe(1); + expect(mask.fetchCalls().get(TiffTag.TileOffsets)).toBe(1); + expect(mask.fetchCalls().get(TiffTag.TileByteCounts)).toBe(1); + }); +}); diff --git a/packages/geotiff/tests/source/concurrency.test.ts b/packages/geotiff/tests/source/concurrency.test.ts new file mode 100644 index 00000000..ed525b01 --- /dev/null +++ b/packages/geotiff/tests/source/concurrency.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { mutex } from "../../src/source/concurrency.js"; + +describe("mutex", () => { + it("runs tasks one at a time", async () => { + const lock = mutex(); + let active = 0; + let maxActive = 0; + + const task = async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 5)); + active--; + }; + + await Promise.all([lock(task), lock(task), lock(task), lock(task)]); + + expect(maxActive).toBe(1); + }); + + it("preserves submission order", async () => { + const lock = mutex(); + const order: number[] = []; + + const promises = [1, 2, 3, 4, 5].map((n) => + lock(async () => { + await new Promise((r) => setTimeout(r, Math.random() * 5)); + order.push(n); + }), + ); + + await Promise.all(promises); + expect(order).toEqual([1, 2, 3, 4, 5]); + }); + + it("delivers each task's result to its caller", async () => { + const lock = mutex(); + const a = lock(async () => 1); + const b = lock(async () => "two"); + const c = lock(async () => ({ three: 3 })); + + expect(await a).toBe(1); + expect(await b).toBe("two"); + expect(await c).toEqual({ three: 3 }); + }); + + it("does not block subsequent tasks when a task rejects", async () => { + const lock = mutex(); + const failure = lock(async () => { + throw new Error("boom"); + }); + const after = lock(async () => 42); + + await expect(failure).rejects.toThrow("boom"); + await expect(after).resolves.toBe(42); + }); + + it("propagates rejections only to the failing caller", async () => { + const lock = mutex(); + const ok1 = lock(async () => "a"); + const bad = lock(async () => { + throw new Error("nope"); + }); + const ok2 = lock(async () => "b"); + + expect(await ok1).toBe("a"); + await expect(bad).rejects.toThrow("nope"); + expect(await ok2).toBe("b"); + }); +}); diff --git a/packages/geotiff/tests/source/readahead-cache.test.ts b/packages/geotiff/tests/source/readahead-cache.test.ts new file mode 100644 index 00000000..376910de --- /dev/null +++ b/packages/geotiff/tests/source/readahead-cache.test.ts @@ -0,0 +1,384 @@ +import type { SourceCallback, SourceRequest } from "@chunkd/source"; +import { describe, expect, it } from "vitest"; +import { + SequentialBlockCache, + SourceReadaheadCache, +} from "../../src/source/readahead-cache.js"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function asString(buf: ArrayBuffer): string { + return dec.decode(new Uint8Array(buf)); +} + +function buf(s: string): ArrayBuffer { + return enc.encode(s).buffer as ArrayBuffer; +} + +describe("SequentialBlockCache", () => { + it("starts empty", () => { + const cache = new SequentialBlockCache(); + expect(cache.len).toBe(0); + expect(cache.contains(0, 0)).toBe(true); + expect(cache.contains(0, 1)).toBe(false); + }); + + it("appendBuffer grows len", () => { + const cache = new SequentialBlockCache(); + cache.appendBuffer(buf("abc")); + expect(cache.len).toBe(3); + cache.appendBuffer(buf("def")); + expect(cache.len).toBe(6); + }); + + it("contains is true iff end <= len", () => { + const cache = new SequentialBlockCache(); + cache.appendBuffer(buf("abcd")); + expect(cache.contains(0, 4)).toBe(true); + expect(cache.contains(2, 4)).toBe(true); + expect(cache.contains(4, 4)).toBe(true); + expect(cache.contains(0, 5)).toBe(false); + expect(cache.contains(4, 5)).toBe(false); + }); + + it("slices a range that fits in one block", () => { + const cache = new SequentialBlockCache(); + cache.appendBuffer(buf("abcdef")); + expect(asString(cache.slice(0, 3))).toBe("abc"); + expect(asString(cache.slice(2, 5))).toBe("cde"); + expect(asString(cache.slice(0, 6))).toBe("abcdef"); + }); + + it("slices across multiple blocks", () => { + const cache = new SequentialBlockCache(); + cache.appendBuffer(buf("012")); + cache.appendBuffer(buf("345")); + cache.appendBuffer(buf("67")); + + expect(asString(cache.slice(0, 8))).toBe("01234567"); + expect(asString(cache.slice(2, 6))).toBe("2345"); + expect(asString(cache.slice(3, 8))).toBe("34567"); + expect(asString(cache.slice(5, 7))).toBe("56"); + }); + + it("handles empty buffers (port of async-tiff test_sequential_block_cache_empty_buffers)", () => { + const cache = new SequentialBlockCache(); + cache.appendBuffer(buf("012")); + cache.appendBuffer(buf("")); + cache.appendBuffer(buf("34")); + cache.appendBuffer(buf("")); + cache.appendBuffer(buf("5")); + cache.appendBuffer(buf("")); + cache.appendBuffer(buf("67")); + + expect(cache.contains(0, 3)).toBe(true); + expect(asString(cache.slice(0, 3))).toBe("012"); + expect(cache.contains(4, 7)).toBe(true); + expect(asString(cache.slice(4, 7))).toBe("456"); + expect(cache.contains(0, 8)).toBe(true); + expect(asString(cache.slice(0, 8))).toBe("01234567"); + expect(cache.contains(6, 6)).toBe(true); + expect(asString(cache.slice(6, 6))).toBe(""); + expect(cache.contains(6, 9)).toBe(false); + expect(cache.contains(9, 9)).toBe(false); + expect(cache.contains(8, 10)).toBe(false); + }); +}); + +/** + * Build a fake `next` callback backed by a string. Counts the number of + * underlying fetches and serves bytes from the string. + */ +function makeNext(data: string): { + next: SourceCallback; + count: () => number; +} { + const bytes = enc.encode(data); + let count = 0; + const next: SourceCallback = async (req) => { + count++; + if (req.offset >= bytes.byteLength) { + return new ArrayBuffer(0); + } + const end = Math.min( + req.offset + (req.length ?? bytes.byteLength - req.offset), + bytes.byteLength, + ); + return bytes.buffer.slice(req.offset, end); + }; + return { next, count: () => count }; +} + +function makeReq(offset: number, length: number): SourceRequest { + // Source isn't inspected by SourceReadaheadCache except for `metadata?.size`, + // which we leave undefined here. + return { + source: { metadata: undefined } as never, + offset, + length, + }; +} + +describe("SourceReadaheadCache", () => { + it("name is set", () => { + const m = new SourceReadaheadCache({ initial: 32, multiplier: 2 }); + expect(m.name).toBe("source:readahead-cache"); + }); + + it("ports the async-tiff readahead test", async () => { + const { next, count } = makeNext("abcdefghijklmnopqrstuvwxyz"); + const m = new SourceReadaheadCache({ initial: 2, multiplier: 3 }); + + // Initial request — fetches 2 bytes. + let buf = await m.fetch(makeReq(0, 2), next); + expect(asString(buf)).toBe("ab"); + expect(count()).toBe(1); + + // Within cached range — no new fetch. + buf = await m.fetch(makeReq(1, 1), next); + expect(asString(buf)).toBe("b"); + expect(count()).toBe(1); + + // Exceeds cached range — second fetch of 6 bytes (2 * 3) added. + buf = await m.fetch(makeReq(2, 3), next); + expect(asString(buf)).toBe("cde"); + expect(count()).toBe(2); + + // Cache len now 8 (2 + 6); request fully inside — no new fetch. + buf = await m.fetch(makeReq(5, 3), next); + expect(asString(buf)).toBe("fgh"); + expect(count()).toBe(2); + + // Request exceeds the next growth size — single fetch sized to the need. + buf = await m.fetch(makeReq(8, 12), next); + expect(asString(buf)).toBe("ijklmnopqrst"); + expect(count()).toBe(3); + }); + + it("bypasses negative offset reads", async () => { + const { next, count } = makeNext("abcdefgh"); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + + const req: SourceRequest = { + source: { metadata: undefined } as never, + offset: -4, + length: 4, + }; + await m.fetch(req, next); + expect(count()).toBe(1); + }); + + it("bypasses reads with no length (full file)", async () => { + const { next, count } = makeNext("abcdefgh"); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + + const req: SourceRequest = { + source: { metadata: undefined } as never, + offset: 0, + length: undefined, + }; + await m.fetch(req, next); + expect(count()).toBe(1); + }); + + it("serializes concurrent fetches that grow the cache", async () => { + const { next, count } = makeNext("abcdefghijklmnop"); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + + const [a, b, c] = await Promise.all([ + m.fetch(makeReq(0, 4), next), + m.fetch(makeReq(4, 4), next), + m.fetch(makeReq(8, 4), next), + ]); + + expect(asString(a)).toBe("abcd"); + expect(asString(b)).toBe("efgh"); + expect(asString(c)).toBe("ijkl"); + + // First fetch: 4 bytes (initial). Second: 8 bytes (4*2). Cache then has + // 12 bytes, so the third concurrent request is satisfied without a third + // underlying fetch. + expect(count()).toBe(2); + }); + + it("clamps fetch size to file size when metadata.size is known", async () => { + const bytes = enc.encode("abcdef"); + let count = 0; + const next: SourceCallback = async (req) => { + count++; + // If our middleware sends an over-sized request, this would fail. + expect(req.offset + (req.length ?? 0)).toBeLessThanOrEqual( + bytes.byteLength, + ); + const end = Math.min(req.offset + (req.length ?? 0), bytes.byteLength); + return bytes.buffer.slice(req.offset, end); + }; + + const m = new SourceReadaheadCache({ initial: 100, multiplier: 2 }); + const req: SourceRequest = { + source: { metadata: { size: 6 } } as never, + offset: 0, + length: 6, + }; + + const buf = await m.fetch(req, next); + expect(asString(buf)).toBe("abcdef"); + expect(count).toBe(1); + }); + + it("breaks on EOF (zero-byte underlying fetch) instead of looping", async () => { + // Source claims size unset, returns empty buffer past offset 4. + const bytes = enc.encode("abcd"); + let count = 0; + const next: SourceCallback = async (req) => { + count++; + if (req.offset >= bytes.byteLength) { + return new ArrayBuffer(0); + } + const end = Math.min(req.offset + (req.length ?? 0), bytes.byteLength); + return bytes.buffer.slice(req.offset, end); + }; + + const m = new SourceReadaheadCache({ initial: 2, multiplier: 2 }); + const req: SourceRequest = { + source: { metadata: undefined } as never, + offset: 0, + length: 10, // more than the file has + }; + + // Should not hang; should return whatever's available without infinite loop. + const buf = await m.fetch(req, next); + // Cache stops growing at EOF; the slice copy is bounded by what's cached. + expect(count).toBeLessThan(20); + expect(buf.byteLength).toBeGreaterThanOrEqual(0); + }); +}); + +describe("SourceReadaheadCache.freeze()", () => { + it("after freeze, cache hits are still served from memory", async () => { + const { next, count } = makeNext("abcdefghijklmnop"); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + + // Pre-freeze fetch: cache extends to 4 bytes. + await m.fetch(makeReq(0, 4), next); + expect(count()).toBe(1); + + m.freeze(); + + // Post-freeze: range fully within cache → served from memory, no fetch. + const buf = await m.fetch(makeReq(0, 4), next); + expect(asString(buf)).toBe("abcd"); + expect(count()).toBe(1); + }); + + it("after freeze, cache misses bypass to next without extending", async () => { + const { next, count } = makeNext("a".repeat(1024)); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + + // Establish some cache. + await m.fetch(makeReq(0, 4), next); + expect(count()).toBe(1); + + m.freeze(); + + // Far-offset read post-freeze: one direct fetch, no exponential growth. + await m.fetch(makeReq(512, 4), next); + expect(count()).toBe(2); + + // Another far-offset read: another single direct fetch. + await m.fetch(makeReq(800, 4), next); + expect(count()).toBe(3); + }); + + it("freeze() is idempotent", () => { + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + m.freeze(); + m.freeze(); + m.freeze(); + // No throw; still works. + expect(m.name).toBe("source:readahead-cache"); + }); + + it("preserves bypass behavior for negative offsets and undefined length after freeze", async () => { + const { next, count } = makeNext("abcd"); + const m = new SourceReadaheadCache({ initial: 4, multiplier: 2 }); + m.freeze(); + + const negReq: SourceRequest = { + source: { metadata: undefined } as never, + offset: -2, + length: 2, + }; + const fullReq: SourceRequest = { + source: { metadata: undefined } as never, + offset: 0, + length: undefined, + }; + + await m.fetch(negReq, next); + await m.fetch(fullReq, next); + expect(count()).toBe(2); + }); +}); + +describe("SourceReadaheadCache maxGap cap", () => { + it("bypasses a request that starts more than maxGap past cache.len", async () => { + const { next, count } = makeNext("a".repeat(10000)); + const m = new SourceReadaheadCache({ + initial: 4, + multiplier: 2, + maxGap: 100, + }); + + // Establish a small cache: cache.len = 4 after this. + await m.fetch(makeReq(0, 4), next); + expect(count()).toBe(1); + + // Request starts at offset 9000 — gap = 9000 - 4 = 8996, way past the + // cap. Bypass: one direct fetch, cache stays at len 4. + await m.fetch(makeReq(9000, 4), next); + expect(count()).toBe(2); + + // Cache is still small: next near-offset request extends from cache.len, + // not from far offset. + await m.fetch(makeReq(4, 4), next); + expect(count()).toBe(3); + }); + + it("does not cap sequential extension by total fetch size", async () => { + // multiplier = 4: each fetch is 4× the previous cache.len. With initial + // of 4, the second extension would be 16 bytes; absolute cap of 8 wouldn't + // stop it — only the *gap* matters now. + const { next, count } = makeNext("a".repeat(1024)); + const m = new SourceReadaheadCache({ + initial: 4, + multiplier: 4, + maxGap: 8, + }); + + // 1st extension: cache.len 0 → 4. Gap 0. Extend. + await m.fetch(makeReq(0, 4), next); + // 2nd: sequential at offset 4. Gap = 0. Extends by nextFetchSize=16. + // cache.len → 20. + await m.fetch(makeReq(4, 4), next); + // 3rd: sequential at offset 20. Gap = 0. Extends by nextFetchSize=80. + // cache.len → 100. + await m.fetch(makeReq(20, 4), next); + expect(count()).toBe(3); + }); + + it("allows extending when the gap is exactly at the cap", async () => { + const { next, count } = makeNext("a".repeat(1024)); + const m = new SourceReadaheadCache({ + initial: 4, + multiplier: 2, + maxGap: 50, + }); + + await m.fetch(makeReq(0, 4), next); + // Gap = 50 - 4 = 46. At cap. Extend. + await m.fetch(makeReq(50, 4), next); + expect(count()).toBe(2); + }); +});