From 98437f1ae0beb5bd8a4d3488a21b8a90d00f7cdc Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 12 May 2026 18:14:41 -0400 Subject: [PATCH 01/46] docs: spec for coalescing tile fetches through deck.gl getTileData Design for #273: a TileBatcher in deck.gl-raster that coalesces deck.gl's per-tile getTileData calls into one getMultiTileData (-> geotiff.fetchTiles) call per zoom level, plus a chunkd request-scheduler middleware in geotiff so maxRequests bounds actual concurrent HTTP requests. No public fetchTiles API change; no loaders.gl dependency in geotiff. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-12-getTileData-coalescing-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 dev-docs/specs/2026-05-12-getTileData-coalescing-design.md diff --git a/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md b/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md new file mode 100644 index 00000000..f2c91a63 --- /dev/null +++ b/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md @@ -0,0 +1,220 @@ +# Coalescing tile fetches through deck.gl `getTileData` (`COGLayer`) + +**Date:** 2026-05-12 +**Issues:** [#273](https://github.com/developmentseed/deck.gl-raster/issues/273); related deck.gl [visgl/deck.gl#10098](https://github.com/visgl/deck.gl/issues/10098) +**Status:** Design — builds on [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) (`geotiff.fetchTiles`, range-coalescing batched reader). + +## Background + +deck.gl's `TileLayer` has exactly one fetch hook, `getTileData(tile, { signal, … })`, called **once per tile**. For tile sources where each tile is a separate URL (MVT), that's unavoidable. For a COG, every tile in a viewport is a byte range of the *same* file, and adjacent tiles' ranges are contiguous (or near-contiguous) on disk — so N independent range requests could be a handful of coalesced ones. [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) gave `@developmentseed/geotiff` a `fetchTiles(xy[])` that does exactly that coalescing for a *known* batch. What's missing is getting deck.gl's stream of per-tile `getTileData` calls *to* `fetchTiles`. + +The deck.gl-native fix — a `getTileDataBatched` prop wired into `Tileset2D` — is proposed upstream ([visgl/deck.gl#10098](https://github.com/visgl/deck.gl/issues/10098)) but has no PR and no maintainer commitment, so this lives in our packages. We follow the upstream-proposed shape closely so that, if/when it lands, our shim collapses to a thin adapter. + +### How deck.gl loads tiles today (relevant facts) + +- `Tileset2D` constructs a loaders.gl `RequestScheduler({ maxRequests: 6, debounceTime })` internally; `Tile2DHeader._loadData` does `await requestScheduler.scheduleRequest(this, …)` then `await getTileData(...)`. So `getTileData` is gated to `maxRequests` (default 6) concurrent calls, in a rolling window (a slot frees when `getTileData` resolves). +- When `maxRequests <= 0` (and `debounceTime <= 0`), the scheduler's `throttleRequests` is `false` and `scheduleRequest` returns an already-resolved token — so `getTileData` is called for every needed tile with no throttling. +- `Tileset2D._updateTileStates` loops over the needed tile indices **synchronously**, spawning one `loadData` (async) each. With throttling off, each `getTileData` call is one microtask later — so the whole burst of `getTileData` calls for a viewport update completes within the current macrotask (before its microtask queue drains). +- `Tileset2D._pruneRequests` aborts *unselected* in-flight tiles when more than `maxRequests` are ongoing — a no-op when `maxRequests <= 0`. +- The `maxRequests` cap counts **`getTileData` calls (≈ tiles)**, not HTTP requests. But a single COG tile fetch can issue several HTTP requests — an (uncached) tile-offset metadata read, the tile-data read, and a mask read — so "tiles in flight" is a poor proxy for "HTTP requests in flight", which is what the browser's ~6-per-origin HTTP/1.1 limit actually constrains. + +### Current code shape in our packages + +- `@developmentseed/deck.gl-raster` — `RasterTileLayer` (subclass of `CompositeLayer`) builds the inner `@deck.gl/geo-layers` `TileLayer` in `_renderTileLayer`, passing `getTileData: tile => this._wrapGetTileData(tile, getTileData)` and a `TilesetClass` subclass of `RasterTileset2D`. `_wrapGetTileData` adapts deck.gl's `(tile, { signal, device }) => …` into the layer's `getTileData` and (for `COGLayer`) resolves `tile.index.z` to an overview vs. the primary image. Subclasses override `_getTileDataCallback()` / `_renderTileCallback()` / `_tilesetDescriptor()` to supply defaults. +- `@developmentseed/deck.gl-geotiff` — `COGLayer` extends `RasterTileLayer`; in `updateState` it opens a `GeoTIFF` (`GeoTIFF.open` / `fromUrl`) and stores it in state; its default `getTileData` calls `geotiff.fetchTile(image, { x, y, … })`. +- `@developmentseed/geotiff` — `GeoTIFF.open({ dataSource, headerSource })`; the header source is a chunkd `SourceView(http, [SourceChunk(64 KiB), SourceCache(…)])` (block cache) and the data source is a raw `SourceHttp` (uncached). `fetchTile` / `fetchTiles` / `coalesceRanges` / `assembleTile` issue `source.fetch(offset, length, { signal })` calls; `coalesceRanges` merges nearby ranges and dispatches up to `COALESCE_PARALLEL = 6` in parallel. + +## Goals + +1. A `COGLayer` viewport of N tiles should produce **far fewer than N HTTP requests** — adjacent tiles' byte ranges coalesced, via `geotiff.fetchTiles`. +2. **`maxRequests` should bound the number of actual HTTP requests in flight** (coalesced tile-data reads *and* uncached metadata reads *and* mask reads), since that's the browser-imposed limit that matters. +3. **Zero behavior change when the feature isn't used**: a layer with no batched callback takes the exact code path it does today. +4. Generic mechanism at the `RasterTileLayer` level (subclasses opt in by defining a batched callback); only `COGLayer` opts in for now. +5. No new dependency added to `@developmentseed/geotiff` (in particular, no `loaders.gl`); `geotiff`'s public `fetchTile` / `fetchTiles` signatures unchanged. + +## Non-goals + +- The deck.gl-native `getTileDataBatched` prop (upstream; out of scope here). +- `MultiCOGLayer` / `MosaicLayer` batching (the batcher's grouping key is designed to allow it later, but it isn't wired up). +- A `maxTilesPerBatch` cap (bounding how many tiles ride one all-or-nothing coalesced fetch — relevant given the composite-signal semantics below; a likely future knob, not built now). +- Per-tile *streaming* (`Promise[]` so a fast tile resolves before a slow one) — `fetchTiles` does one coalesced fetch then near-synchronous per-tile decode, so there's nothing to interleave; a single `Promise>` suffices. +- Exporting the scheduler middleware publicly (internal for now). + +## Architecture + +Two independent pieces: + +1. **`TileBatcher`** (in `@developmentseed/deck.gl-raster`) — *coalesces* deck.gl's per-tile `getTileData` calls into one batched call per zoom level. This is what turns N requests into a handful. +2. **A concurrency-limiter chunkd middleware** (in `@developmentseed/geotiff`) — *gates* the number of concurrent HTTP `fetch`es. This is what makes `maxRequests` mean "HTTP requests in flight". Independent of batching — it sits on the byte source, so it sees every real request (coalesced data ranges, uncached metadata, masks) regardless of who issued it. + +They're combined only at the `COGLayer` level: when a batched callback is in play, `RasterTileLayer` creates one limiter from `props.maxRequests`, hands it to the `GeoTIFF` (so the middleware is installed on its sources), and runs the batcher; the inner `TileLayer` gets `maxRequests: 0` so deck.gl's per-tile throttle steps aside for our HTTP-level one. + +``` + RasterTileLayer (getMultiTileData defined) + │ creates RequestScheduler({maxRequests: props.maxRequests}) ← loaders.gl class + inner TileLayer │ wrapped in a ~2-line adapter to geotiff's + maxRequests: 0 ────────────►│ ConcurrencyLimiter ({ acquire(): Promise<()=>void> }) + getTileData: t => batcher.fetch(t) + │ + deck.gl ──N×getTileData──► TileBatcher ──buffer, setTimeout(0)──► flush: + │ group by (sourceId, z); z→image; composite signal/group + │ + └──1× getMultiTileData(image, tiles[], {signal, device, pool})──► + COGLayer default ──► geotiff.fetchTilesSettled(xy) + │ source.fetch(...) × few + ▼ + GeoTIFF sources, opened with { concurrencyLimiter }: + header: SourceView(http, [SourceChunk, SourceCache, limiterMW]) + data: SourceView(http, [limiterMW]) + limiterMW: const release = await limiter.acquire(); try { next() } finally { release() } +``` + +## `@developmentseed/geotiff` changes + +### 1. A minimal `ConcurrencyLimiter` interface + +```ts +/** + * Minimal contract for capping the number of concurrent {@link Source.fetch} + * calls, without coupling this package to any particular limiter / scheduler + * implementation (e.g. loaders.gl's `RequestScheduler`). + */ +export interface ConcurrencyLimiter { + /** Acquire a slot. Resolves once a slot is free; call the returned function + * exactly once when the request finishes (success or failure) to release it. */ + acquire(): Promise<() => void>; +} +``` + +No `unknown`, no token object, no `null` — geotiff has no notion of request identity, priority, or cancellation, so the contract is just "wait for a slot, then release it". loaders.gl's `RequestScheduler.scheduleRequest(handle, getPriority?)` isn't structurally assignable to this, so `@developmentseed/deck.gl-raster` wraps it in a ~2-line adapter (see below) — geotiff stays loaders.gl-free. + +### 2. An internal limiter middleware + +```ts +import type { SourceMiddleware } from "@chunkd/source"; + +/** chunkd middleware: hold a {@link ConcurrencyLimiter} slot for the duration + * of each underlying `fetch`. */ +function limiterMiddleware(limiter: ConcurrencyLimiter): SourceMiddleware { + return { + name: "concurrency-limiter", + async fetch(req, next) { + const release = await limiter.acquire(); + try { + return await next(req); + } finally { + release(); + } + }, + }; +} +``` + +Internal (not exported from `index.ts`) for now. + +### 3. `concurrencyLimiter` option on `GeoTIFF.open` / `fromUrl` + +A new optional field, `concurrencyLimiter?: ConcurrencyLimiter`. When present, `GeoTIFF.open` / `fromUrl` append `limiterMiddleware(concurrencyLimiter)` to each source's middleware stack — **innermost** (last), after chunking and caching: header source `[SourceChunk(64 KiB), SourceCache(…), limiterMW]`, data source `[limiterMW]`. Innermost so a cache hit short-circuits before reaching the limiter (a cache hit is not an HTTP request and must not burn a slot), and a chunk-expanded read takes one slot for the single (block-sized) request it actually becomes. `fetchTile` / `fetchTiles` / `coalesceRanges` / `assembleTile` are **unchanged** — they call `source.fetch(...)` exactly as before; the middleware does the gating transparently. + +(If the caller passes already-constructed sources to `GeoTIFF.open`, the same `concurrencyLimiter` option still applies — `open` wraps them. Exact wrapping point in `open` vs. `fromUrl` is an implementation detail.) + +### 4. A `Promise.allSettled`-style batch reader for per-tile errors + +`fetchTiles(xy)` today is all-or-nothing — it throws on the first sparse/missing tile. For the layer path we want a viewport to survive a bad tile: add a settled variant that returns one result *or error* per requested coordinate, in input order: + +```ts +fetchTilesSettled(self, xy[], options?) : Promise> +``` + +(Name/shape provisional — could equally be `fetchTiles(xy, { onError: "collect" })`. Decided in the plan.) Implementation composes the pieces [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) already separated: one coalesced byte fetch (`getTiles` — still all-or-nothing at the *network* level: a `fetch` failure inside a merged range dooms every tile whose bytes were in that range, unavoidable with coalescing → those tiles all get that error), then `assembleTile` per tile wrapped in `try/catch` so per-tile decode errors / sparse tiles land in only that tile's slot. `getTiles` / `assembleTile` may need to be exported package-internally (they currently are) or lifted slightly — implementation detail. + +## `@developmentseed/deck.gl-raster` changes + +### 1. New `getMultiTileData` prop + accessor + +```ts +// on RasterTileLayerProps: +getMultiTileData?: ( + image: ImageT, // overview or primary, resolved by z + tiles: Tile[], // all share z (same IFD); same source + opts: { signal: AbortSignal; device: Device; pool: DecoderPool }, +) => Promise>; // aligned with `tiles`, in order +``` + +Sourced via a new `protected _getMultiTileDataCallback()` accessor, mirroring `_getTileDataCallback()` / `_renderTileCallback()`. Returns `undefined` if neither the prop nor a subclass default is set. No limiter in `opts` — it's invisible to the callback, baked into the GeoTIFF's sources. (Forward-compat: if deck.gl upstreams `getTileDataBatched` and passes its `_requestScheduler` in opts, we can ignore it — our gating is at the source layer — or honor it; minor.) + +### 2. Branch in `_renderTileLayer` + +``` +const multi = this._getMultiTileDataCallback(); +if (!multi) { + // unchanged from today + innerTileLayer.getTileData = tile => this._wrapGetTileData(tile, getTileData); + innerTileLayer.maxRequests = this.props.maxRequests; // straight through +} else { + const limiter = this.state.concurrencyLimiter; // created in updateState; see §4 + const batcher = this.state.tileBatcher; // wraps `multi` + innerTileLayer.getTileData = tile => batcher.fetch(tile); + innerTileLayer.maxRequests = 0; // deck.gl's per-tile throttle off +} +``` + +The no-batched-callback path is byte-for-byte today's. `maxRequests: 0` also disables `_pruneRequests` — fine: coalesced requests don't hit the connection limit, and per-tile aborts are still honored by the batcher. + +### 3. `TileBatcher` + +A small class (not a layer), one instance per `RasterTileLayer` (lifecycle-tied to the inner `TileLayer` / created in `updateState` when `multi` first becomes available, finalized with the layer). + +- `fetch(tile, { signal }): Promise` — push `{ tile, signal, resolve, reject }` onto a buffer; if the buffer was empty, arm `setTimeout(flush, 0)`. Return the promise. (`setTimeout(0)` deterministically fires after deck.gl's synchronous burst of `getTileData` calls + their microtask tail — see "Timing" below.) The `0` is an internal constant, not a public prop — the timing analysis shows it's sufficient, so there's nothing to tune; if a future deck.gl change makes a small delay useful it can be promoted to a prop then. +- `flush()` — drain the buffer; drop any entry whose `signal` is already aborted (reject it with the abort reason); group the rest by `(sourceId, z)` — for `COGLayer`, `sourceId` is constant (one COG) and `z` selects overview vs. primary, resolved to `image` once per group using the same logic `_wrapGetTileData` uses; for each group: build a **composite `AbortSignal`** that aborts only when *every* member tile's signal has aborted, call `getMultiTileData(image, groupTiles, { signal: composite, device, pool })`; on resolve, for each `i`: if `results[i]` is an `Error` (or the tile's signal aborted post-dispatch) reject `groupTiles[i].reject(...)`, else `groupTiles[i].resolve(results[i])`; on reject, reject every tile in the group with the error. All groups dispatched concurrently — the source-level `ConcurrencyLimiter` does the limiting. +- On layer finalize — reject every still-buffered entry with an abort reason; arm no further timers. + +Composite-signal helper: track a remaining count = group size; on each member signal's `abort`, decrement; at zero, abort a fresh `AbortController` and pass *its* signal to `getMultiTileData`. (This is the main reason a future `maxTilesPerBatch` cap is worth having — a huge group means many tiles share one all-or-nothing fetch and one composite signal.) + +### 4. loaders.gl `RequestScheduler` → `ConcurrencyLimiter` adapter + +Promote `@loaders.gl/loader-utils` to an explicit dependency of `@developmentseed/deck.gl-raster` (currently transitive via deck.gl). In `updateState`, when `multi` becomes available, create a loaders.gl `RequestScheduler` and adapt it to geotiff's `ConcurrencyLimiter`: + +```ts +const ls = new RequestScheduler({ maxRequests: this.props.maxRequests }); +const concurrencyLimiter: ConcurrencyLimiter = { + acquire: () => + // fresh {} per call — loaders.gl dedupes by handle identity, so reusing one + // would collapse all requests into a single slot. + ls.scheduleRequest({}).then((tok) => () => tok?.done()), +}; +``` + +Store `ls` (so `setProps` can update `maxRequests`), `concurrencyLimiter` (the adapter), and the `tileBatcher` in layer state. The subclass that opens the GeoTIFF threads `concurrencyLimiter` into `GeoTIFF.open` (see deck.gl-geotiff changes). If `props.maxRequests` is `0`/falsy, `RequestScheduler` is un-throttled (no cap) — which is the right behavior (the user asked for unlimited). *(`scheduleRequest` can in principle resolve to `null` if a request is cancelled via a priority callback; we never pass one, so it never happens — the `tok?.done()` just makes the adapter total.)* + +## `@developmentseed/deck.gl-geotiff` changes + +- `COGLayer` provides a default `getMultiTileData` (via overriding `_getMultiTileDataCallback()` analogously to `_getTileDataCallback()`): resolve the batch's `xy` from `tiles`, call `geotiff.fetchTilesSettled(xy, { signal, pool })`, map each `Tile` → run the existing decode/render path → `DataT`, and each `{ error }` → that `Error`. Keeps its existing default `getTileData` → `geotiff.fetchTile` unchanged. +- In `updateState`, pass `concurrencyLimiter: this.state.concurrencyLimiter` (created by the `RasterTileLayer` base in its `updateState`) into `GeoTIFF.open(...)`. Ordering: `RasterTileLayer.updateState` must create the limiter before `COGLayer.updateState` opens the GeoTIFF — e.g. `COGLayer.updateState` calls `super.updateState()` first, or the limiter is created in a base helper invoked early. Implementation detail for the plan. + +## Timing — why `setTimeout(flush, 0)` + +The JS event loop runs one **macrotask** at a time (a `setTimeout` callback, an event handler, …); after *each* macrotask it fully drains the **microtask** queue (`Promise.then` / `await` continuations, `queueMicrotask`) before the next macrotask. deck.gl's `Tileset2D._updateTileStates` synchronously spawns one `Tile2DHeader.loadData` per needed tile; with the inner layer's `maxRequests: 0`, each `loadData`'s `await scheduleRequest(...)` resolves immediately, so the continuation calling our `getTileData` runs as a microtask — therefore **every `getTileData` call for one viewport update lands within the current macrotask** (before its microtask queue drains). A `setTimeout(flush, 0)` callback is the *next* macrotask, which runs only after the current one's microtasks are all done — so it deterministically observes the whole burst. (A `queueMicrotask`-based flush would be too eager — it could fire mid-burst.) `0` is hard-coded (browsers clamp `setTimeout(0)` to ~1 ms anyway — still low-latency, still after the burst); not exposed as a prop. If a future deck.gl spreads tile requests across animation frames or the main thread is starved, a small delay would merge across those chunks — correctness degrades gracefully (more, smaller batches), not breaks — and the constant could be promoted to a prop at that point. + +## Errors & edge cases + +- **Per-tile failure in a batch**: surfaced — `getMultiTileData` returns `Array`; the batcher rejects only the failing tile's `getTileData` promise (deck.gl marks just that tile errored/`null`). `COGLayer`'s implementation reports per-tile decode/sparse-tile errors individually; a network failure inside a coalesced merged range dooms every tile whose bytes were in it (those get the same error) — inherent to coalescing. +- **Whole-batch failure**: `getMultiTileData` rejects ⇒ every tile in that group rejects ⇒ each marked errored/`null`, same as a per-tile `getTileData` throw today. +- **Aborts**: a tile aborted *before* flush is dropped from the batch and rejected. A tile aborted *after* dispatch is rejected (its bytes were already fetched — wasted, acceptable). The underlying coalesced fetch is aborted only when *all* tiles in its group are aborted. +- **`maxRequests: 0` on the inner layer**: also disables `_pruneRequests` (deck.gl's "abort unselected in-flight tiles past the limit") — desirable here. + +## Testing + +- `TileBatcher` unit tests (mock tiles/signals/`getMultiTileData`): N `fetch()` calls ⇒ one `getMultiTileData` per `(source, z)` group with the right tiles; results distributed in order; an `Error` element rejects only that tile; whole-call rejection rejects the group; pre-flush abort drops & rejects; post-flush abort rejects but doesn't abort the group; the composite signal aborts the group only when all members abort; finalize rejects buffered. +- `limiterMiddleware` unit test (mock limiter + source): `fetch` acquires a slot, calls `next`, and `release()`s in `finally` (on success and on throw); cache-hit path (no `next` call) never touches the limiter — exercised via a `[SourceCache, limiterMW]` stack with a pre-populated cache. +- `GeoTIFF.open({ concurrencyLimiter })` integration test: open a fixture with a recording-and-counting limiter; `fetchTiles` over a grid ⇒ limiter saw exactly the number of (post-coalesce) `fetch` calls; with `maxRequests: 1` it serializes them. +- `geotiff.fetchTilesSettled` test: a grid with one sparse tile ⇒ that slot is an error, the rest are `Tile`s; a network failure (mock source that throws on a particular range) ⇒ every tile whose bytes were in that merged range is an error. +- `COGLayer._getMultiTileDataCallback` default: calls `geotiff.fetchTilesSettled` with the right `xy`/`image`; maps `Tile`→`DataT` and `{error}`→`Error`. +- loaders.gl→`ConcurrencyLimiter` adapter: fresh handle per `acquire()` (two calls aren't deduped into one slot); the returned release function `done()`s the token. +- (A full deck.gl-in-jsdom integration test — pan a `COGLayer`, count `dataSource.fetch` calls ≪ tiles — is heavy; the unit tests above cover the logic. Optional stretch if a harness exists.) + +## Open questions / deferred to the plan + +- Name for the settled batch reader (`fetchTilesSettled` vs. a `fetchTiles(xy, { onError: "collect" })` option). +- Exact lifecycle wiring of the limiter/batcher in `RasterTileLayer.updateState` vs. `COGLayer.updateState` (who creates, who installs, ordering). +- Whether `coalesceRanges`'s internal `COALESCE_PARALLEL` should become configurable now (the source-level limiter already caps things globally; a per-call ceiling is mostly redundant once the middleware is in place — likely leave as-is). From 163bdf4fb67c085e6b8b232f08dfdf5ebe631b9d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 12:58:41 -0400 Subject: [PATCH 02/46] docs(spec): per-origin concurrency limiter (gating only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prior 2026-05-12 design (which scoped both gating and multi-tile coalescing into one spec). This narrows to gating: a ConcurrencyLimiter interface, PerOriginSemaphore default impl, Semaphore primitive (internal), and integration into GeoTIFF.fromUrl plus a module-level default on COGLayer.defaultProps so two layers on the same origin share one HTTP/1.1 connection pool out of the box. Multi-tile request coalescing (TileBatcher, getMultiTileData, fetchTilesSettled used from a layer) is explicitly deferred — the maxRequests / _pruneRequests interaction makes it more involved than expected and it's worth shipping gating first. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-12-getTileData-coalescing-design.md | 220 ------------------ .../2026-05-19-concurrency-limiter-design.md | 146 ++++++++++++ 2 files changed, 146 insertions(+), 220 deletions(-) delete mode 100644 dev-docs/specs/2026-05-12-getTileData-coalescing-design.md create mode 100644 dev-docs/specs/2026-05-19-concurrency-limiter-design.md diff --git a/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md b/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md deleted file mode 100644 index f2c91a63..00000000 --- a/dev-docs/specs/2026-05-12-getTileData-coalescing-design.md +++ /dev/null @@ -1,220 +0,0 @@ -# Coalescing tile fetches through deck.gl `getTileData` (`COGLayer`) - -**Date:** 2026-05-12 -**Issues:** [#273](https://github.com/developmentseed/deck.gl-raster/issues/273); related deck.gl [visgl/deck.gl#10098](https://github.com/visgl/deck.gl/issues/10098) -**Status:** Design — builds on [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) (`geotiff.fetchTiles`, range-coalescing batched reader). - -## Background - -deck.gl's `TileLayer` has exactly one fetch hook, `getTileData(tile, { signal, … })`, called **once per tile**. For tile sources where each tile is a separate URL (MVT), that's unavoidable. For a COG, every tile in a viewport is a byte range of the *same* file, and adjacent tiles' ranges are contiguous (or near-contiguous) on disk — so N independent range requests could be a handful of coalesced ones. [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) gave `@developmentseed/geotiff` a `fetchTiles(xy[])` that does exactly that coalescing for a *known* batch. What's missing is getting deck.gl's stream of per-tile `getTileData` calls *to* `fetchTiles`. - -The deck.gl-native fix — a `getTileDataBatched` prop wired into `Tileset2D` — is proposed upstream ([visgl/deck.gl#10098](https://github.com/visgl/deck.gl/issues/10098)) but has no PR and no maintainer commitment, so this lives in our packages. We follow the upstream-proposed shape closely so that, if/when it lands, our shim collapses to a thin adapter. - -### How deck.gl loads tiles today (relevant facts) - -- `Tileset2D` constructs a loaders.gl `RequestScheduler({ maxRequests: 6, debounceTime })` internally; `Tile2DHeader._loadData` does `await requestScheduler.scheduleRequest(this, …)` then `await getTileData(...)`. So `getTileData` is gated to `maxRequests` (default 6) concurrent calls, in a rolling window (a slot frees when `getTileData` resolves). -- When `maxRequests <= 0` (and `debounceTime <= 0`), the scheduler's `throttleRequests` is `false` and `scheduleRequest` returns an already-resolved token — so `getTileData` is called for every needed tile with no throttling. -- `Tileset2D._updateTileStates` loops over the needed tile indices **synchronously**, spawning one `loadData` (async) each. With throttling off, each `getTileData` call is one microtask later — so the whole burst of `getTileData` calls for a viewport update completes within the current macrotask (before its microtask queue drains). -- `Tileset2D._pruneRequests` aborts *unselected* in-flight tiles when more than `maxRequests` are ongoing — a no-op when `maxRequests <= 0`. -- The `maxRequests` cap counts **`getTileData` calls (≈ tiles)**, not HTTP requests. But a single COG tile fetch can issue several HTTP requests — an (uncached) tile-offset metadata read, the tile-data read, and a mask read — so "tiles in flight" is a poor proxy for "HTTP requests in flight", which is what the browser's ~6-per-origin HTTP/1.1 limit actually constrains. - -### Current code shape in our packages - -- `@developmentseed/deck.gl-raster` — `RasterTileLayer` (subclass of `CompositeLayer`) builds the inner `@deck.gl/geo-layers` `TileLayer` in `_renderTileLayer`, passing `getTileData: tile => this._wrapGetTileData(tile, getTileData)` and a `TilesetClass` subclass of `RasterTileset2D`. `_wrapGetTileData` adapts deck.gl's `(tile, { signal, device }) => …` into the layer's `getTileData` and (for `COGLayer`) resolves `tile.index.z` to an overview vs. the primary image. Subclasses override `_getTileDataCallback()` / `_renderTileCallback()` / `_tilesetDescriptor()` to supply defaults. -- `@developmentseed/deck.gl-geotiff` — `COGLayer` extends `RasterTileLayer`; in `updateState` it opens a `GeoTIFF` (`GeoTIFF.open` / `fromUrl`) and stores it in state; its default `getTileData` calls `geotiff.fetchTile(image, { x, y, … })`. -- `@developmentseed/geotiff` — `GeoTIFF.open({ dataSource, headerSource })`; the header source is a chunkd `SourceView(http, [SourceChunk(64 KiB), SourceCache(…)])` (block cache) and the data source is a raw `SourceHttp` (uncached). `fetchTile` / `fetchTiles` / `coalesceRanges` / `assembleTile` issue `source.fetch(offset, length, { signal })` calls; `coalesceRanges` merges nearby ranges and dispatches up to `COALESCE_PARALLEL = 6` in parallel. - -## Goals - -1. A `COGLayer` viewport of N tiles should produce **far fewer than N HTTP requests** — adjacent tiles' byte ranges coalesced, via `geotiff.fetchTiles`. -2. **`maxRequests` should bound the number of actual HTTP requests in flight** (coalesced tile-data reads *and* uncached metadata reads *and* mask reads), since that's the browser-imposed limit that matters. -3. **Zero behavior change when the feature isn't used**: a layer with no batched callback takes the exact code path it does today. -4. Generic mechanism at the `RasterTileLayer` level (subclasses opt in by defining a batched callback); only `COGLayer` opts in for now. -5. No new dependency added to `@developmentseed/geotiff` (in particular, no `loaders.gl`); `geotiff`'s public `fetchTile` / `fetchTiles` signatures unchanged. - -## Non-goals - -- The deck.gl-native `getTileDataBatched` prop (upstream; out of scope here). -- `MultiCOGLayer` / `MosaicLayer` batching (the batcher's grouping key is designed to allow it later, but it isn't wired up). -- A `maxTilesPerBatch` cap (bounding how many tiles ride one all-or-nothing coalesced fetch — relevant given the composite-signal semantics below; a likely future knob, not built now). -- Per-tile *streaming* (`Promise[]` so a fast tile resolves before a slow one) — `fetchTiles` does one coalesced fetch then near-synchronous per-tile decode, so there's nothing to interleave; a single `Promise>` suffices. -- Exporting the scheduler middleware publicly (internal for now). - -## Architecture - -Two independent pieces: - -1. **`TileBatcher`** (in `@developmentseed/deck.gl-raster`) — *coalesces* deck.gl's per-tile `getTileData` calls into one batched call per zoom level. This is what turns N requests into a handful. -2. **A concurrency-limiter chunkd middleware** (in `@developmentseed/geotiff`) — *gates* the number of concurrent HTTP `fetch`es. This is what makes `maxRequests` mean "HTTP requests in flight". Independent of batching — it sits on the byte source, so it sees every real request (coalesced data ranges, uncached metadata, masks) regardless of who issued it. - -They're combined only at the `COGLayer` level: when a batched callback is in play, `RasterTileLayer` creates one limiter from `props.maxRequests`, hands it to the `GeoTIFF` (so the middleware is installed on its sources), and runs the batcher; the inner `TileLayer` gets `maxRequests: 0` so deck.gl's per-tile throttle steps aside for our HTTP-level one. - -``` - RasterTileLayer (getMultiTileData defined) - │ creates RequestScheduler({maxRequests: props.maxRequests}) ← loaders.gl class - inner TileLayer │ wrapped in a ~2-line adapter to geotiff's - maxRequests: 0 ────────────►│ ConcurrencyLimiter ({ acquire(): Promise<()=>void> }) - getTileData: t => batcher.fetch(t) - │ - deck.gl ──N×getTileData──► TileBatcher ──buffer, setTimeout(0)──► flush: - │ group by (sourceId, z); z→image; composite signal/group - │ - └──1× getMultiTileData(image, tiles[], {signal, device, pool})──► - COGLayer default ──► geotiff.fetchTilesSettled(xy) - │ source.fetch(...) × few - ▼ - GeoTIFF sources, opened with { concurrencyLimiter }: - header: SourceView(http, [SourceChunk, SourceCache, limiterMW]) - data: SourceView(http, [limiterMW]) - limiterMW: const release = await limiter.acquire(); try { next() } finally { release() } -``` - -## `@developmentseed/geotiff` changes - -### 1. A minimal `ConcurrencyLimiter` interface - -```ts -/** - * Minimal contract for capping the number of concurrent {@link Source.fetch} - * calls, without coupling this package to any particular limiter / scheduler - * implementation (e.g. loaders.gl's `RequestScheduler`). - */ -export interface ConcurrencyLimiter { - /** Acquire a slot. Resolves once a slot is free; call the returned function - * exactly once when the request finishes (success or failure) to release it. */ - acquire(): Promise<() => void>; -} -``` - -No `unknown`, no token object, no `null` — geotiff has no notion of request identity, priority, or cancellation, so the contract is just "wait for a slot, then release it". loaders.gl's `RequestScheduler.scheduleRequest(handle, getPriority?)` isn't structurally assignable to this, so `@developmentseed/deck.gl-raster` wraps it in a ~2-line adapter (see below) — geotiff stays loaders.gl-free. - -### 2. An internal limiter middleware - -```ts -import type { SourceMiddleware } from "@chunkd/source"; - -/** chunkd middleware: hold a {@link ConcurrencyLimiter} slot for the duration - * of each underlying `fetch`. */ -function limiterMiddleware(limiter: ConcurrencyLimiter): SourceMiddleware { - return { - name: "concurrency-limiter", - async fetch(req, next) { - const release = await limiter.acquire(); - try { - return await next(req); - } finally { - release(); - } - }, - }; -} -``` - -Internal (not exported from `index.ts`) for now. - -### 3. `concurrencyLimiter` option on `GeoTIFF.open` / `fromUrl` - -A new optional field, `concurrencyLimiter?: ConcurrencyLimiter`. When present, `GeoTIFF.open` / `fromUrl` append `limiterMiddleware(concurrencyLimiter)` to each source's middleware stack — **innermost** (last), after chunking and caching: header source `[SourceChunk(64 KiB), SourceCache(…), limiterMW]`, data source `[limiterMW]`. Innermost so a cache hit short-circuits before reaching the limiter (a cache hit is not an HTTP request and must not burn a slot), and a chunk-expanded read takes one slot for the single (block-sized) request it actually becomes. `fetchTile` / `fetchTiles` / `coalesceRanges` / `assembleTile` are **unchanged** — they call `source.fetch(...)` exactly as before; the middleware does the gating transparently. - -(If the caller passes already-constructed sources to `GeoTIFF.open`, the same `concurrencyLimiter` option still applies — `open` wraps them. Exact wrapping point in `open` vs. `fromUrl` is an implementation detail.) - -### 4. A `Promise.allSettled`-style batch reader for per-tile errors - -`fetchTiles(xy)` today is all-or-nothing — it throws on the first sparse/missing tile. For the layer path we want a viewport to survive a bad tile: add a settled variant that returns one result *or error* per requested coordinate, in input order: - -```ts -fetchTilesSettled(self, xy[], options?) : Promise> -``` - -(Name/shape provisional — could equally be `fetchTiles(xy, { onError: "collect" })`. Decided in the plan.) Implementation composes the pieces [#531](https://github.com/developmentseed/deck.gl-raster/pull/531) already separated: one coalesced byte fetch (`getTiles` — still all-or-nothing at the *network* level: a `fetch` failure inside a merged range dooms every tile whose bytes were in that range, unavoidable with coalescing → those tiles all get that error), then `assembleTile` per tile wrapped in `try/catch` so per-tile decode errors / sparse tiles land in only that tile's slot. `getTiles` / `assembleTile` may need to be exported package-internally (they currently are) or lifted slightly — implementation detail. - -## `@developmentseed/deck.gl-raster` changes - -### 1. New `getMultiTileData` prop + accessor - -```ts -// on RasterTileLayerProps: -getMultiTileData?: ( - image: ImageT, // overview or primary, resolved by z - tiles: Tile[], // all share z (same IFD); same source - opts: { signal: AbortSignal; device: Device; pool: DecoderPool }, -) => Promise>; // aligned with `tiles`, in order -``` - -Sourced via a new `protected _getMultiTileDataCallback()` accessor, mirroring `_getTileDataCallback()` / `_renderTileCallback()`. Returns `undefined` if neither the prop nor a subclass default is set. No limiter in `opts` — it's invisible to the callback, baked into the GeoTIFF's sources. (Forward-compat: if deck.gl upstreams `getTileDataBatched` and passes its `_requestScheduler` in opts, we can ignore it — our gating is at the source layer — or honor it; minor.) - -### 2. Branch in `_renderTileLayer` - -``` -const multi = this._getMultiTileDataCallback(); -if (!multi) { - // unchanged from today - innerTileLayer.getTileData = tile => this._wrapGetTileData(tile, getTileData); - innerTileLayer.maxRequests = this.props.maxRequests; // straight through -} else { - const limiter = this.state.concurrencyLimiter; // created in updateState; see §4 - const batcher = this.state.tileBatcher; // wraps `multi` - innerTileLayer.getTileData = tile => batcher.fetch(tile); - innerTileLayer.maxRequests = 0; // deck.gl's per-tile throttle off -} -``` - -The no-batched-callback path is byte-for-byte today's. `maxRequests: 0` also disables `_pruneRequests` — fine: coalesced requests don't hit the connection limit, and per-tile aborts are still honored by the batcher. - -### 3. `TileBatcher` - -A small class (not a layer), one instance per `RasterTileLayer` (lifecycle-tied to the inner `TileLayer` / created in `updateState` when `multi` first becomes available, finalized with the layer). - -- `fetch(tile, { signal }): Promise` — push `{ tile, signal, resolve, reject }` onto a buffer; if the buffer was empty, arm `setTimeout(flush, 0)`. Return the promise. (`setTimeout(0)` deterministically fires after deck.gl's synchronous burst of `getTileData` calls + their microtask tail — see "Timing" below.) The `0` is an internal constant, not a public prop — the timing analysis shows it's sufficient, so there's nothing to tune; if a future deck.gl change makes a small delay useful it can be promoted to a prop then. -- `flush()` — drain the buffer; drop any entry whose `signal` is already aborted (reject it with the abort reason); group the rest by `(sourceId, z)` — for `COGLayer`, `sourceId` is constant (one COG) and `z` selects overview vs. primary, resolved to `image` once per group using the same logic `_wrapGetTileData` uses; for each group: build a **composite `AbortSignal`** that aborts only when *every* member tile's signal has aborted, call `getMultiTileData(image, groupTiles, { signal: composite, device, pool })`; on resolve, for each `i`: if `results[i]` is an `Error` (or the tile's signal aborted post-dispatch) reject `groupTiles[i].reject(...)`, else `groupTiles[i].resolve(results[i])`; on reject, reject every tile in the group with the error. All groups dispatched concurrently — the source-level `ConcurrencyLimiter` does the limiting. -- On layer finalize — reject every still-buffered entry with an abort reason; arm no further timers. - -Composite-signal helper: track a remaining count = group size; on each member signal's `abort`, decrement; at zero, abort a fresh `AbortController` and pass *its* signal to `getMultiTileData`. (This is the main reason a future `maxTilesPerBatch` cap is worth having — a huge group means many tiles share one all-or-nothing fetch and one composite signal.) - -### 4. loaders.gl `RequestScheduler` → `ConcurrencyLimiter` adapter - -Promote `@loaders.gl/loader-utils` to an explicit dependency of `@developmentseed/deck.gl-raster` (currently transitive via deck.gl). In `updateState`, when `multi` becomes available, create a loaders.gl `RequestScheduler` and adapt it to geotiff's `ConcurrencyLimiter`: - -```ts -const ls = new RequestScheduler({ maxRequests: this.props.maxRequests }); -const concurrencyLimiter: ConcurrencyLimiter = { - acquire: () => - // fresh {} per call — loaders.gl dedupes by handle identity, so reusing one - // would collapse all requests into a single slot. - ls.scheduleRequest({}).then((tok) => () => tok?.done()), -}; -``` - -Store `ls` (so `setProps` can update `maxRequests`), `concurrencyLimiter` (the adapter), and the `tileBatcher` in layer state. The subclass that opens the GeoTIFF threads `concurrencyLimiter` into `GeoTIFF.open` (see deck.gl-geotiff changes). If `props.maxRequests` is `0`/falsy, `RequestScheduler` is un-throttled (no cap) — which is the right behavior (the user asked for unlimited). *(`scheduleRequest` can in principle resolve to `null` if a request is cancelled via a priority callback; we never pass one, so it never happens — the `tok?.done()` just makes the adapter total.)* - -## `@developmentseed/deck.gl-geotiff` changes - -- `COGLayer` provides a default `getMultiTileData` (via overriding `_getMultiTileDataCallback()` analogously to `_getTileDataCallback()`): resolve the batch's `xy` from `tiles`, call `geotiff.fetchTilesSettled(xy, { signal, pool })`, map each `Tile` → run the existing decode/render path → `DataT`, and each `{ error }` → that `Error`. Keeps its existing default `getTileData` → `geotiff.fetchTile` unchanged. -- In `updateState`, pass `concurrencyLimiter: this.state.concurrencyLimiter` (created by the `RasterTileLayer` base in its `updateState`) into `GeoTIFF.open(...)`. Ordering: `RasterTileLayer.updateState` must create the limiter before `COGLayer.updateState` opens the GeoTIFF — e.g. `COGLayer.updateState` calls `super.updateState()` first, or the limiter is created in a base helper invoked early. Implementation detail for the plan. - -## Timing — why `setTimeout(flush, 0)` - -The JS event loop runs one **macrotask** at a time (a `setTimeout` callback, an event handler, …); after *each* macrotask it fully drains the **microtask** queue (`Promise.then` / `await` continuations, `queueMicrotask`) before the next macrotask. deck.gl's `Tileset2D._updateTileStates` synchronously spawns one `Tile2DHeader.loadData` per needed tile; with the inner layer's `maxRequests: 0`, each `loadData`'s `await scheduleRequest(...)` resolves immediately, so the continuation calling our `getTileData` runs as a microtask — therefore **every `getTileData` call for one viewport update lands within the current macrotask** (before its microtask queue drains). A `setTimeout(flush, 0)` callback is the *next* macrotask, which runs only after the current one's microtasks are all done — so it deterministically observes the whole burst. (A `queueMicrotask`-based flush would be too eager — it could fire mid-burst.) `0` is hard-coded (browsers clamp `setTimeout(0)` to ~1 ms anyway — still low-latency, still after the burst); not exposed as a prop. If a future deck.gl spreads tile requests across animation frames or the main thread is starved, a small delay would merge across those chunks — correctness degrades gracefully (more, smaller batches), not breaks — and the constant could be promoted to a prop at that point. - -## Errors & edge cases - -- **Per-tile failure in a batch**: surfaced — `getMultiTileData` returns `Array`; the batcher rejects only the failing tile's `getTileData` promise (deck.gl marks just that tile errored/`null`). `COGLayer`'s implementation reports per-tile decode/sparse-tile errors individually; a network failure inside a coalesced merged range dooms every tile whose bytes were in it (those get the same error) — inherent to coalescing. -- **Whole-batch failure**: `getMultiTileData` rejects ⇒ every tile in that group rejects ⇒ each marked errored/`null`, same as a per-tile `getTileData` throw today. -- **Aborts**: a tile aborted *before* flush is dropped from the batch and rejected. A tile aborted *after* dispatch is rejected (its bytes were already fetched — wasted, acceptable). The underlying coalesced fetch is aborted only when *all* tiles in its group are aborted. -- **`maxRequests: 0` on the inner layer**: also disables `_pruneRequests` (deck.gl's "abort unselected in-flight tiles past the limit") — desirable here. - -## Testing - -- `TileBatcher` unit tests (mock tiles/signals/`getMultiTileData`): N `fetch()` calls ⇒ one `getMultiTileData` per `(source, z)` group with the right tiles; results distributed in order; an `Error` element rejects only that tile; whole-call rejection rejects the group; pre-flush abort drops & rejects; post-flush abort rejects but doesn't abort the group; the composite signal aborts the group only when all members abort; finalize rejects buffered. -- `limiterMiddleware` unit test (mock limiter + source): `fetch` acquires a slot, calls `next`, and `release()`s in `finally` (on success and on throw); cache-hit path (no `next` call) never touches the limiter — exercised via a `[SourceCache, limiterMW]` stack with a pre-populated cache. -- `GeoTIFF.open({ concurrencyLimiter })` integration test: open a fixture with a recording-and-counting limiter; `fetchTiles` over a grid ⇒ limiter saw exactly the number of (post-coalesce) `fetch` calls; with `maxRequests: 1` it serializes them. -- `geotiff.fetchTilesSettled` test: a grid with one sparse tile ⇒ that slot is an error, the rest are `Tile`s; a network failure (mock source that throws on a particular range) ⇒ every tile whose bytes were in that merged range is an error. -- `COGLayer._getMultiTileDataCallback` default: calls `geotiff.fetchTilesSettled` with the right `xy`/`image`; maps `Tile`→`DataT` and `{error}`→`Error`. -- loaders.gl→`ConcurrencyLimiter` adapter: fresh handle per `acquire()` (two calls aren't deduped into one slot); the returned release function `done()`s the token. -- (A full deck.gl-in-jsdom integration test — pan a `COGLayer`, count `dataSource.fetch` calls ≪ tiles — is heavy; the unit tests above cover the logic. Optional stretch if a harness exists.) - -## Open questions / deferred to the plan - -- Name for the settled batch reader (`fetchTilesSettled` vs. a `fetchTiles(xy, { onError: "collect" })` option). -- Exact lifecycle wiring of the limiter/batcher in `RasterTileLayer.updateState` vs. `COGLayer.updateState` (who creates, who installs, ordering). -- Whether `coalesceRanges`'s internal `COALESCE_PARALLEL` should become configurable now (the source-level limiter already caps things globally; a per-call ceiling is mostly redundant once the middleware is in place — likely leave as-is). diff --git a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md new file mode 100644 index 00000000..9fa8839c --- /dev/null +++ b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md @@ -0,0 +1,146 @@ +# Per-origin concurrency limiter for tile-source HTTP requests + +- **Date:** 2026-05-19 +- **Issues:** [#273](https://github.com/developmentseed/deck.gl-raster/issues/273) +- **Status:** Design — supersedes [`2026-05-12-getTileData-coalescing-design.md`](2026-05-12-getTileData-coalescing-design.md), which was scoped to *both* coalescing and gating; this spec narrows to gating only and explicitly defers coalescing. + +## Background + +Most COGs `deck.gl-raster` targets live on AWS S3 or similar object stores, which serve HTTP/1.1 only. Chrome (and other major browsers) cap concurrent HTTP/1.1 connections per origin at ~6. Over-scheduling above that point means the browser queues the excess, and queued requests stick around even when the viewport pans — so stale-after-pan requests block fresh ones for the new viewport. The browser cap is **global to the page**, not per-layer. + +deck.gl's `Tileset2D` ships an internal `loaders.gl/RequestScheduler({ maxRequests: 6 })` but it's *per-`TileLayer` instance*: two `COGLayer`s targeting the same S3 bucket each get 6 slots, so the browser sees 12+ requests and queues them. That scheduler also counts `getTileData` calls (≈ tiles), not HTTP requests, and one COG tile fetch issues several requests (metadata + data + mask) — so the per-tile cap is a poor proxy for the actual network-cap that matters. + +The fix is a concurrency limiter at the *source* layer (between `Source.fetch` and the network), per-origin, **shared across layers** targeting the same host. This spec specifies that limiter and its integration into `@developmentseed/geotiff` and `@developmentseed/deck.gl-geotiff`. + +### How `Tileset2D._pruneRequests` interacts with `maxRequests` + +deck.gl's `Tileset2D` fires a tile's abort signal in exactly one place: `_pruneRequests`, which only triggers when ongoing requests exceed `maxRequests`. So setting `maxRequests = 0` disables that pruning entirely — stale tiles' signals never fire, and the source-level limiter never sees a cancellation. We therefore keep `maxRequests` at its current pass-through behavior (deck.gl default 6) and accept that the per-layer cap and the source-level cap coexist as two independent (slightly redundant) gates. A future change that wants per-tile pruning *without* a per-layer cap will need to subclass `Tileset2D`; that's out of scope here. + +## Goals + +1. Cap concurrent HTTP requests **per origin**, **shared across all layers** (and source formats — COG today, Zarr or similar tomorrow) targeting that origin. +2. Signal-aware queueing: when a queued request's `signal` aborts (e.g. user panned away), the request is dropped without firing a network call. +3. Zero-config default that works out of the box (cross-layer per-origin gating on, `maxRequests = 6`), with explicit opt-out and explicit override per layer. +4. No new dependency added; no implicit module-level state hidden inside `@developmentseed/geotiff`. + +## Non-goals (deferred, not removed from consideration) + +- **Multi-tile request coalescing** (`TileBatcher`, `getMultiTileData`, `fetchTilesSettled` used from a layer): the user-facing trade-off between batching shape (row/box/single) and time-to-first-pixel is real, and integrating with deck.gl's pruning is nontrivial. Decided to ship gating first and revisit coalescing as a follow-up; the API here doesn't preclude it. +- Subclassing `Tileset2D` to fire abort signals from `onTileUnload` independent of `maxRequests` — needed eventually if a future batcher wants `maxRequests = 0` with working cancellation. +- Pluggable batching strategy (when a batcher is added). +- Upstream deck.gl proposals (`getTileDataBatched`, exposing `_requestScheduler`, signalling abort on unload). +- Extracting the limiter to a new shared package (e.g. `@developmentseed/concurrency`). Lives in `@developmentseed/geotiff` for now; revisit if a non-geotiff source-type ever wants to share an instance. + +## Architecture + +Three types, all in `@developmentseed/geotiff`: + +```ts +/** The public contract a layer / source can accept. */ +export interface ConcurrencyLimiter { + /** Acquire a slot to perform one fetch to `url`. Resolves to a release + * function (call it once when the fetch settles). If `signal` aborts while + * the call is queued, the promise rejects with the signal's reason and no + * slot is consumed. */ + acquire(url: URL, signal?: AbortSignal): Promise<() => void>; +} + +/** Default implementation. Maintains one Semaphore per URL origin; new origins + * mint a new Semaphore lazily with the same `maxRequests`. Two layers on the + * same origin share one cap; two layers on different origins don't compete. */ +export class PerOriginSemaphore implements ConcurrencyLimiter { + constructor(opts: { maxRequests: number }); + acquire(url: URL, signal?: AbortSignal): Promise<() => void>; +} + +// Internal (not exported from index.ts): + +/** The standard counting semaphore primitive — FIFO queue, signal-aware + * acquire. Used by `PerOriginSemaphore` and `limitFetch`. */ +class Semaphore { + constructor(opts: { maxRequests: number }); + acquire(signal?: AbortSignal): Promise<() => void>; +} + +/** Wrap a `Source.fetch` so each call goes through `limiter.acquire(url, signal)`, + * forwarding the call's signal so a queued abort drops the request. */ +function limitFetch(fetch: Fetch, url: URL, limiter: ConcurrencyLimiter): Fetch; +``` + +`Semaphore` is internal because users have no reason to construct one directly — `PerOriginSemaphore` is the public class. Keeping it internal also avoids the "which one do I use?" question. Promote later if someone wants a flat (single-pool) limiter. + +## Integration + +### `@developmentseed/geotiff` + +- `GeoTIFF.fromUrl(url, { …, concurrencyLimiter? })` — `concurrencyLimiter: ConcurrencyLimiter | null | undefined`. When non-null, wraps the data source's `.fetch` via `limitFetch(fetch, new URL(url), concurrencyLimiter)` before constructing the `GeoTIFF`. When `null` or `undefined`, no gating. (`fromUrl` does *not* default to a shared limiter — that's a layer-level concern; see below.) +- `GeoTIFF.open({ … })` — unchanged. Users wanting gating with `open` wrap their sources themselves before calling. +- `Pick` is the only shape the wrapper needs; no `@chunkd/*` middleware machinery, no `SourceView`. + +### `@developmentseed/deck.gl-geotiff` + +A module-level default instance lives here (not in `@developmentseed/geotiff`, so consumers of `geotiff` that don't use layers don't get a stray module-load semaphore): + +```ts +// packages/deck.gl-geotiff/src/default-concurrency-limiter.ts (or top of cog-layer.ts) +import { PerOriginSemaphore } from "@developmentseed/geotiff"; + +/** Shared by every COGLayer / MultiCOGLayer that doesn't override its + * concurrencyLimiter prop, so multiple layers on the same origin share one + * HTTP/1.1 connection pool. */ +export const defaultConcurrencyLimiter = new PerOriginSemaphore({ maxRequests: 6 }); +``` + +`COGLayer`: + +```ts +class COGLayer extends RasterTileLayer { + static override defaultProps = { + ...RasterTileLayer.defaultProps, + concurrencyLimiter: defaultConcurrencyLimiter, + }; +} + +// props type: +type COGLayerProps = … & { + /** Caps concurrent HTTP requests to each origin this layer fetches from. + * Defaults to a module-level shared `PerOriginSemaphore({ maxRequests: 6 })` + * so two layers on the same bucket share one cap. Pass your own to override; + * pass `null` to disable gating. */ + concurrencyLimiter?: ConcurrencyLimiter | null; +}; +``` + +The layer threads its prop into `fetchGeoTIFF(url, { concurrencyLimiter })` → `GeoTIFF.fromUrl(url, { concurrencyLimiter })`. When `props.geotiff` is a pre-opened `GeoTIFF` instance, the prop is ignored (doc note: "you already wired the limiter at `fromUrl`/`open` time"). + +Same module-level default is reused by `MultiCOGLayer` (and any other layer that opens a `GeoTIFF`) so cross-layer-type sharing works out of the box. + +`RasterTileLayer.props.maxRequests` is unchanged — still passed through to deck.gl's `Tileset2D`. Independent cap from the source-level one; users typically leave it at deck.gl's default 6 so `_pruneRequests` keeps firing. + +## Cancellation flow + +1. User pans. deck.gl's `Tileset2D._pruneRequests` fires `tile.abort()` for unselected in-flight tiles (because `ongoing > maxRequests`). +2. The tile's `AbortController.signal` aborts. `getTileData(tile, { signal })` (already awaiting our chain) sees it. +3. The signal threads through `fetchTile(image, { x, y, signal })` → `dataSource.fetch(offset, length, { signal })`. +4. Our `limitFetch` wrapper passes the signal to `limiter.acquire(url, signal)`: + - Already aborted on entry → reject immediately, no slot consumed. + - Aborted while queued in the inner `Semaphore` → splice from the queue, reject, no slot consumed. + - Aborted in-flight (after acquiring the slot) → the underlying `fetch` itself aborts via its own signal handling; the `finally` releases the slot. + +## Testing + +- `Semaphore` (unit): FIFO ordering; `maxRequests` honored; `acquire(signal)` rejects on already-aborted; aborts while queued splice cleanly without consuming a slot; release is idempotent. +- `PerOriginSemaphore` (unit): two different-origin `acquire`s don't compete; two same-origin acquires share one pool; per-origin Semaphores are minted lazily. +- `limitFetch` (unit): forwards `offset`/`length`/`options` unmodified; releases on resolve and on throw; forwards `options.signal` to `acquire`. +- `GeoTIFF.fromUrl({ concurrencyLimiter })` (integration, with a recording counting limiter wrapping a fixture file source): with `maxRequests: 1`, `peak in-flight` never exceeds 1; the data source's `.fetch` is gated, header reads are not. +- `COGLayer.defaultProps.concurrencyLimiter` (unit): two `COGLayer` instances without explicit prop end up with the same limiter instance. + +## Future work (for design context, not built here) + +- **Coalescing**: `TileBatcher` / `getMultiTileData` / `fetchTilesSettled` from a layer-side dispatcher. The tension with `_pruneRequests` (which only fires when `ongoing > maxRequests`) means the batcher either accepts small (per-wave) coalescing windows or requires a `Tileset2D` subclass that fires aborts on `onTileUnload`. Pluggable batching strategy — row vs box vs single — exposed via a structured `groupKey: (tile) => { z, y }` (or similar) on the batcher. +- **Upstream deck.gl proposals** (likely worth opening issues for): + - Make `Tileset2D.pruneRequests` a *public* method (currently `_pruneRequests`) so callers can trigger cancellation of unselected in-flight tiles imperatively — e.g. our source-level limiter could ask the tileset to drop stale tiles when its queue grows past a threshold, instead of relying on the implicit "ongoing > maxRequests" trigger. + - Fire tile abort signals on `onTileUnload` (cache eviction) independent of `maxRequests`, so cancellation works when `maxRequests = 0`. + - Expose `_requestScheduler` as `requestScheduler` (or an interface) so callers can inspect / replace it. + - Add a native `getTileDataBatched` prop (the original request behind this whole design). +- **Shared concurrency package**: if a non-geotiff source format (Zarr, etc.) ever wants the same `ConcurrencyLimiter` *instance* a `COGLayer` is using, the limiter primitives extract cleanly to a new package and both packages depend on it. From fb201345593d1d97092ed7ada49773e8a661a300 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 13:17:32 -0400 Subject: [PATCH 03/46] feat(geotiff): Semaphore primitive with signal-aware acquire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A FIFO counting semaphore that hands out up to `maxRequests` concurrent slots. acquire(signal?) supports cancellation: an already-aborted signal rejects immediately without consuming a slot; aborting a queued acquire splices it from the queue and rejects, leaving the queue's FIFO order intact for surviving waiters. Internal primitive — not exported from the package. Building block for PerOriginSemaphore and limitFetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/limiter.ts | 76 ++++++++++++++++++++++++++ packages/geotiff/tests/limiter.test.ts | 73 +++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 packages/geotiff/src/limiter.ts create mode 100644 packages/geotiff/tests/limiter.test.ts diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts new file mode 100644 index 00000000..f64f9504 --- /dev/null +++ b/packages/geotiff/src/limiter.ts @@ -0,0 +1,76 @@ +/** A pending acquire waiting for a slot. */ +interface Waiter { + resolve(release: () => void): void; + reject(reason: unknown): void; + signal?: AbortSignal; + onAbort?: () => void; +} + +/** + * Counting semaphore with FIFO queueing and abort-aware acquire. Internal + * primitive used by {@link PerOriginSemaphore} and {@link limitFetch}. + * + * Hands out up to `maxRequests` concurrent slots. Further `acquire()`s queue. + * Acquires with an `AbortSignal` reject (and never consume a slot) if the + * signal aborts before the slot is granted — either because it's already + * aborted at call time, or because it aborts while queued. + */ +export class Semaphore { + private active = 0; + private readonly maxRequests: number; + private readonly queue: Waiter[] = []; + + constructor(options: { maxRequests: number }) { + this.maxRequests = options.maxRequests; + } + + acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) { + return Promise.reject(signal.reason); + } + if (this.active < this.maxRequests) { + this.active += 1; + return Promise.resolve(this._makeRelease()); + } + return new Promise<() => void>((resolve, reject) => { + const waiter: Waiter = { resolve, reject, signal }; + if (signal) { + const onAbort = () => { + const idx = this.queue.indexOf(waiter); + if (idx >= 0) { + this.queue.splice(idx, 1); + reject(signal.reason); + } + }; + waiter.onAbort = onAbort; + signal.addEventListener("abort", onAbort, { once: true }); + } + this.queue.push(waiter); + }); + } + + private _makeRelease(): () => void { + let released = false; + return () => { + if (released) { + return; + } + released = true; + this._releaseOne(); + }; + } + + private _releaseOne(): void { + const next = this.queue.shift(); + if (!next) { + this.active -= 1; + return; + } + if (next.signal && next.onAbort) { + next.signal.removeEventListener("abort", next.onAbort); + } + // Hand the slot directly to the next waiter — `active` stays the same + // because we're transferring ownership, not freeing and re-taking. + next.resolve(this._makeRelease()); + } +} diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts new file mode 100644 index 00000000..240c6455 --- /dev/null +++ b/packages/geotiff/tests/limiter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { Semaphore } from "../src/limiter.js"; + +describe("Semaphore", () => { + it("allows up to maxRequests concurrent acquires; further acquires queue", async () => { + const sem = new Semaphore({ maxRequests: 2 }); + const a = await sem.acquire(); + const b = await sem.acquire(); + let cResolved = false; + const cPromise = sem.acquire().then((release) => { + cResolved = true; + return release; + }); + // give the microtask queue a chance — c must NOT resolve while a+b hold slots + await new Promise((r) => setTimeout(r, 0)); + expect(cResolved).toBe(false); + a(); + const c = await cPromise; + expect(cResolved).toBe(true); + b(); + c(); + }); + + it("waiters resolve in FIFO order", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: number[] = []; + const p1 = sem.acquire().then((r) => { + order.push(1); + r(); + }); + const p2 = sem.acquire().then((r) => { + order.push(2); + r(); + }); + const p3 = sem.acquire().then((r) => { + order.push(3); + r(); + }); + hold(); + await Promise.all([p1, p2, p3]); + expect(order).toEqual([1, 2, 3]); + }); + + it("acquire(signal) with already-aborted signal rejects and consumes no slot", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const ac = new AbortController(); + ac.abort(new Error("nope")); + await expect(sem.acquire(ac.signal)).rejects.toThrow("nope"); + // The slot was never consumed — a fresh acquire should resolve immediately. + const release = await sem.acquire(); + expect(typeof release).toBe("function"); + release(); + }); + + it("aborting a queued acquire rejects it and frees its queue slot", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const ac = new AbortController(); + const queued = sem.acquire(ac.signal); + ac.abort(new Error("pan-away")); + await expect(queued).rejects.toThrow("pan-away"); + // A fresh acquire (no signal) should be next-in-line, not blocked behind the aborted one. + let nextResolved = false; + const next = sem.acquire().then((r) => { + nextResolved = true; + return r; + }); + hold(); + await next; + expect(nextResolved).toBe(true); + }); +}); From 93a391b69406df0f6ccb27b16af544f91d06701a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:11:50 -0400 Subject: [PATCH 04/46] feat(geotiff): ConcurrencyLimiter interface + PerOriginSemaphore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small public contract — acquire(url, signal?) returning a release fn — plus the default implementation that scopes slots per url.origin via an internal Map. Multiple consumers (e.g. two COGLayers on the same S3 bucket) targeting one origin share that origin's pool; consumers on different origins don't compete. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/limiter.ts | 44 ++++++++++++++++ packages/geotiff/tests/limiter.test.ts | 71 +++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index f64f9504..77666d8b 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -74,3 +74,47 @@ export class Semaphore { next.resolve(this._makeRelease()); } } + +/** + * Minimal contract for capping concurrent {@link Source.fetch} calls. An + * implementation hands out slots scoped however it likes; the default + * {@link PerOriginSemaphore} scopes per `url.origin`. + */ +export interface ConcurrencyLimiter { + /** + * Acquire a slot to perform one fetch to `url`. Resolves to a release + * function — call it exactly once when the fetch settles. If `signal` + * aborts while waiting in the queue, the returned promise rejects with the + * signal's reason and no slot is consumed. + */ + acquire(url: URL, signal?: AbortSignal): Promise<() => void>; +} + +/** + * Default {@link ConcurrencyLimiter}. Maintains a separate {@link Semaphore} + * per `url.origin`, minted lazily on first encounter. Multiple consumers (e.g. + * two `COGLayer`s on the same S3 bucket) targeting one origin share that + * origin's slot pool; consumers targeting different origins don't compete. + * + * The browser's HTTP/1.1 per-origin connection cap (~6 on Chrome) is the + * reason the cap is *per origin*, shared across layers — exceeding it just + * makes the browser queue requests, blocking fresh ones behind stale ones. + */ +export class PerOriginSemaphore implements ConcurrencyLimiter { + private readonly maxRequests: number; + private readonly byOrigin = new Map(); + + constructor(options: { maxRequests: number }) { + this.maxRequests = options.maxRequests; + } + + acquire(url: URL, signal?: AbortSignal): Promise<() => void> { + const { origin } = url; + let sem = this.byOrigin.get(origin); + if (!sem) { + sem = new Semaphore({ maxRequests: this.maxRequests }); + this.byOrigin.set(origin, sem); + } + return sem.acquire(signal); + } +} diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts index 240c6455..602e3b0b 100644 --- a/packages/geotiff/tests/limiter.test.ts +++ b/packages/geotiff/tests/limiter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { Semaphore } from "../src/limiter.js"; +import type { ConcurrencyLimiter } from "../src/limiter.js"; +import { PerOriginSemaphore, Semaphore } from "../src/limiter.js"; describe("Semaphore", () => { it("allows up to maxRequests concurrent acquires; further acquires queue", async () => { @@ -71,3 +72,71 @@ describe("Semaphore", () => { expect(nextResolved).toBe(true); }); }); + +describe("PerOriginSemaphore", () => { + const A = new URL("https://a.example.com/file-1.tif"); + const A2 = new URL("https://a.example.com/file-2.tif"); + const B = new URL("https://b.example.com/file-1.tif"); + + it("implements ConcurrencyLimiter", () => { + const limiter: ConcurrencyLimiter = new PerOriginSemaphore({ + maxRequests: 2, + }); + expect(typeof limiter.acquire).toBe("function"); + }); + + it("acquire/release works for one origin", async () => { + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + const release = await limiter.acquire(A); + let secondResolved = false; + const second = limiter.acquire(A2).then((r) => { + secondResolved = true; + return r; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(secondResolved).toBe(false); // same origin, queued + release(); + (await second)(); + }); + + it("different origins don't compete: saturating origin A doesn't block origin B", async () => { + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + const holdA = await limiter.acquire(A); + // origin A is saturated. origin B should still grant immediately. + let bResolved = false; + const b = limiter.acquire(B).then((r) => { + bResolved = true; + return r; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(bResolved).toBe(true); + holdA(); + (await b)(); + }); + + it("same origin URLs with different paths share one pool", async () => { + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + const holdA1 = await limiter.acquire(A); + let a2Resolved = false; + const a2 = limiter.acquire(A2).then((r) => { + a2Resolved = true; + return r; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(a2Resolved).toBe(false); + holdA1(); + (await a2)(); + }); + + it("mints a new per-origin Semaphore lazily on first acquire", async () => { + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + // Saturate origin A. + const hold = await limiter.acquire(A); + // A brand-new origin C should resolve immediately even though A is full. + const C = new URL("https://c.example.com/file.tif"); + const release = await limiter.acquire(C); + expect(typeof release).toBe("function"); + release(); + hold(); + }); +}); From d9d8612993ff0dca368f095999e1e652cd3a2c1f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:13:04 -0400 Subject: [PATCH 05/46] feat(geotiff): limitFetch helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps a Source.fetch so each call goes through a ConcurrencyLimiter's acquire(url, signal?) — holding the slot for the underlying call's duration, releasing on resolve and on reject, and forwarding the caller's AbortSignal so a queued call dropped via abort never fires network I/O. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/limiter.ts | 27 ++++++ packages/geotiff/tests/limiter.test.ts | 109 ++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index 77666d8b..003dab96 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -1,3 +1,8 @@ +import type { Source } from "@cogeotiff/core"; + +/** The shape of a {@link Source.fetch} call. */ +type Fetch = Pick["fetch"]; + /** A pending acquire waiting for a slot. */ interface Waiter { resolve(release: () => void): void; @@ -118,3 +123,25 @@ export class PerOriginSemaphore implements ConcurrencyLimiter { return sem.acquire(signal); } } + +/** + * Wrap a `Source.fetch` so each call holds a {@link ConcurrencyLimiter} slot + * for its duration — releasing on resolve, on reject, and never otherwise + * interfering. Forwards `options.signal` to `limiter.acquire`, so if the + * caller aborts while the call is queued the request is dropped before any + * network I/O fires. + */ +export function limitFetch( + fetch: Fetch, + url: URL, + limiter: ConcurrencyLimiter, +): Fetch { + return async (offset, length, options) => { + const release = await limiter.acquire(url, options?.signal); + try { + return await fetch(offset, length, options); + } finally { + release(); + } + }; +} diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts index 602e3b0b..6702628d 100644 --- a/packages/geotiff/tests/limiter.test.ts +++ b/packages/geotiff/tests/limiter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ConcurrencyLimiter } from "../src/limiter.js"; -import { PerOriginSemaphore, Semaphore } from "../src/limiter.js"; +import { limitFetch, PerOriginSemaphore, Semaphore } from "../src/limiter.js"; describe("Semaphore", () => { it("allows up to maxRequests concurrent acquires; further acquires queue", async () => { @@ -140,3 +140,110 @@ describe("PerOriginSemaphore", () => { hold(); }); }); + +describe("limitFetch", () => { + const URL_A = new URL("https://a.example.com/cog.tif"); + + function makeRecorder() { + const calls: Array<{ offset: number; length: number | undefined }> = []; + const fetch = async ( + offset: number, + length?: number, + ): Promise => { + calls.push({ offset, length }); + return new ArrayBuffer(length ?? 0); + }; + return { fetch, calls }; + } + + it("only invokes the inner fetch after acquiring a slot", async () => { + const order: string[] = []; + const limiter: ConcurrencyLimiter = { + acquire: async () => { + order.push("acquire"); + return () => order.push("release"); + }, + }; + const inner = async () => { + order.push("fetch"); + return new ArrayBuffer(0); + }; + const wrapped = limitFetch(inner, URL_A, limiter); + await wrapped(0, 4); + expect(order).toEqual(["acquire", "fetch", "release"]); + }); + + it("forwards offset, length, and options to the inner fetch unchanged", async () => { + const calls: unknown[][] = []; + const limiter: ConcurrencyLimiter = { + acquire: async () => () => {}, + }; + const inner = async (...args: unknown[]) => { + calls.push(args); + return new ArrayBuffer(0); + }; + const wrapped = limitFetch( + inner as Parameters[0], + URL_A, + limiter, + ); + const signal = new AbortController().signal; + await wrapped(100, 200, { signal }); + expect(calls).toEqual([[100, 200, { signal }]]); + }); + + it("releases the slot when the inner fetch resolves", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const limiter: ConcurrencyLimiter = { + acquire: (_url, signal) => sem.acquire(signal), + }; + const { fetch } = makeRecorder(); + const wrapped = limitFetch(fetch, URL_A, limiter); + await wrapped(0, 8); + // If the slot wasn't released, a second call would hang. + await wrapped(0, 8); + }); + + it("releases the slot when the inner fetch rejects (and propagates the error)", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const limiter: ConcurrencyLimiter = { + acquire: (_url, signal) => sem.acquire(signal), + }; + const wrapped = limitFetch( + async () => { + throw new Error("network down"); + }, + URL_A, + limiter, + ); + await expect(wrapped(0, 8)).rejects.toThrow("network down"); + // Slot was released — a second call must not hang. + const { fetch } = makeRecorder(); + const ok = limitFetch(fetch, URL_A, limiter); + await ok(0, 8); + }); + + it("forwards options.signal to limiter.acquire so a queued abort drops the call", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const limiter: ConcurrencyLimiter = { + acquire: (_url, signal) => sem.acquire(signal), + }; + // Saturate the (per-test-shared) semaphore so the next acquire queues. + const hold = await sem.acquire(); + let innerCalled = false; + const wrapped = limitFetch( + async () => { + innerCalled = true; + return new ArrayBuffer(0); + }, + URL_A, + limiter, + ); + const ac = new AbortController(); + const pending = wrapped(0, 8, { signal: ac.signal }); + ac.abort(new Error("pan-away")); + await expect(pending).rejects.toThrow("pan-away"); + expect(innerCalled).toBe(false); + hold(); + }); +}); From 7282713aabe3b0313301e28acd6e0cb6adf0c2a6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:13:41 -0400 Subject: [PATCH 06/46] feat(geotiff): export ConcurrencyLimiter type and PerOriginSemaphore class Semaphore and limitFetch stay internal. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index 30fc2ab9..f12a49b5 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -18,6 +18,8 @@ export type { export { DECODER_REGISTRY } from "./decode.js"; export { GeoTIFF } from "./geotiff.js"; export type { CachedTags, GeoKeyDirectory } from "./ifd.js"; +export type { ConcurrencyLimiter } from "./limiter.js"; +export { PerOriginSemaphore } from "./limiter.js"; export { Overview } from "./overview.js"; export type { DecoderPoolOptions } from "./pool/pool.js"; export { DecoderPool, defaultDecoderPool } from "./pool/pool.js"; From c09b50839af4c1890b71f7eda71cc2b5b46f6ef5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:15:28 -0400 Subject: [PATCH 07/46] feat(geotiff): concurrencyLimiter option on GeoTIFF.fromUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When supplied, wraps the data source's .fetch via limitFetch so every tile-data HTTP fetch holds a slot in the supplied ConcurrencyLimiter for its duration, forwarding the caller's signal so pan-away aborts drop queued requests. Header/metadata reads (through the cached SourceView) are not gated. Default is undefined (no gating) — the deck.gl-geotiff layers wire a shared PerOriginSemaphore default at their defaultProps level. Pass `null` to explicitly disable. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/geotiff.ts | 21 ++- .../tests/geotiff-concurrency-limiter.test.ts | 127 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/geotiff/tests/geotiff-concurrency-limiter.test.ts diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 3c427c0f..558d5ec3 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -12,6 +12,8 @@ import type { BandStatistics, GDALMetadata } from "./gdal-metadata.js"; import { parseGDALMetadata } from "./gdal-metadata.js"; import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; +import type { ConcurrencyLimiter } from "./limiter.js"; +import { limitFetch } from "./limiter.js"; import { Overview } from "./overview.js"; import type { DecoderPool } from "./pool/pool.js"; import type { Tile } from "./tile.js"; @@ -269,6 +271,7 @@ export class GeoTIFF { * @param options.cacheSize Total cache size in bytes. Defaults to 8 MiB (~128 blocks at the default chunk size). * @param options.signal An optional {@link AbortSignal} to cancel the header reads. * @param options.debug When true, the returned GeoTIFF logs each tile/mask data fetch to the console with offset/length and a `data`/`mask` label. Off by default. + * @param options.concurrencyLimiter Caps concurrent HTTP requests for the *tile data* path. Header / metadata reads (through the cached SourceView) are not gated. Pass `null` to explicitly disable; omit (or pass `undefined`) for the same effect — `GeoTIFF.fromUrl` does *not* default to a shared limiter on its own. The deck.gl-geotiff layers default to a shared {@link PerOriginSemaphore} via their `defaultProps`. * @returns A Promise that resolves to a GeoTIFF instance. */ static async fromUrl( @@ -278,11 +281,13 @@ export class GeoTIFF { cacheSize = 8 * 1024 * 1024, signal, debug, + concurrencyLimiter, }: { chunkSize?: number; cacheSize?: number; signal?: AbortSignal; debug?: boolean; + concurrencyLimiter?: ConcurrencyLimiter | null; } = {}, ): Promise { const source = new SourceHttp(url, {}); @@ -308,9 +313,21 @@ export class GeoTIFF { new SourceCache({ size: cacheSize }), ]); + // Wrap the *raw* data source's `.fetch` when a limiter is supplied so + // every tile-data fetch holds a slot for its duration. Header reads go + // through the cached SourceView and are not gated. + const dataSource: Pick = concurrencyLimiter + ? { + fetch: limitFetch( + source.fetch.bind(source), + new URL(url), + concurrencyLimiter, + ), + } + : source; + return await GeoTIFF.open({ - // Tile data reads bypass the header cache (raw source). - dataSource: source, + dataSource, headerSource: view, signal, debug, diff --git a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts new file mode 100644 index 00000000..b21dbb76 --- /dev/null +++ b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts @@ -0,0 +1,127 @@ +/** + * Verifies that GeoTIFF.fromUrl wraps the data source's .fetch with a + * ConcurrencyLimiter when one is supplied. The header source (the cached + * SourceView) is not wrapped — the limiter caps tile/data reads only. + * + * The SourceHttp stubbing pattern mirrors fromurl.test.ts. + */ + +import { readFileSync } from "node:fs"; +import { SourceHttp } from "@chunkd/source-http"; +import { afterEach, describe, expect, it } from "vitest"; +import { GeoTIFF } from "../src/geotiff.js"; +import type { ConcurrencyLimiter } from "../src/limiter.js"; +import { fixturePath } from "./helpers.js"; + +const FIXTURE = readFileSync( + fixturePath("uint8_rgb_deflate_block64_cog", "rasterio"), +); + +function makeResponse(body: Uint8Array) { + return { + ok: true, + status: 206, + statusText: "", + headers: { + get: (key: string) => + key.toLowerCase() === "content-length" ? String(body.byteLength) : null, + }, + body: null, + arrayBuffer: async () => + body.buffer.slice( + body.byteOffset, + body.byteOffset + body.byteLength, + ) as ArrayBuffer, + }; +} + +function staticFetch(file: Uint8Array) { + return async ( + _url: string | URL, + init?: { method?: string; headers?: Record }, + ) => { + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "HEAD") { + return { + ok: true, + status: 200, + statusText: "", + headers: { + get: (key: string) => + key.toLowerCase() === "content-length" + ? String(file.byteLength) + : null, + }, + body: null, + arrayBuffer: async () => new ArrayBuffer(0), + }; + } + const range = init?.headers?.range ?? ""; + const match = /^bytes=(\d+)-(\d+)?$/.exec(range); + const start = match ? Number(match[1]) : 0; + const end = + match?.[2] != null + ? Math.min(Number(match[2]), file.byteLength - 1) + : file.byteLength - 1; + return makeResponse(file.subarray(start, end + 1)); + }; +} + +describe("GeoTIFF.fromUrl({ concurrencyLimiter })", () => { + const realFetch = SourceHttp.fetch; + afterEach(() => { + SourceHttp.fetch = realFetch; + }); + + it("routes tile-data fetches through the limiter (header reads are not gated)", async () => { + SourceHttp.fetch = staticFetch(FIXTURE) as typeof SourceHttp.fetch; + + const acquired: URL[] = []; + const limiter: ConcurrencyLimiter = { + acquire: async (url) => { + acquired.push(url); + return () => {}; + }, + }; + const url = "https://example.test/cog.tif"; + const tiff = await GeoTIFF.fromUrl(url, { concurrencyLimiter: limiter }); + + // Opening the TIFF reads headers — those go through the header source + // (cached SourceView), NOT through the limiter, so `acquired` should + // still be empty (or near-empty — it must contain zero tile-data calls). + const headerOnlyCount = acquired.length; + expect(headerOnlyCount).toBe(0); + + await tiff.fetchTile(0, 0); + + // After fetching a tile, the data-source path was exercised — the + // limiter must have seen at least one acquire, and every URL must be + // ours. + expect(acquired.length).toBeGreaterThan(headerOnlyCount); + for (const u of acquired) { + expect(u.href).toBe(url); + } + }); + + it("with concurrencyLimiter: null does not wrap (no acquires)", async () => { + SourceHttp.fetch = staticFetch(FIXTURE) as typeof SourceHttp.fetch; + const acquired: URL[] = []; + const limiter: ConcurrencyLimiter = { + acquire: async (url) => { + acquired.push(url); + return () => {}; + }, + }; + // null = explicitly off. + const tiff = await GeoTIFF.fromUrl("https://example.test/cog.tif", { + concurrencyLimiter: null, + }); + await tiff.fetchTile(0, 0); + // Limiter was passed `null`, so `acquired` only contains entries from + // explicit calls — but no one called this `limiter` from anywhere, so + // it must be exactly empty. + expect(acquired).toEqual([]); + // Reference `limiter` so it isn't flagged as unused. + expect(limiter.acquire).toBeDefined(); + }); +}); From 62a13f1cc02e012439f9d2d67456947760a53360 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:16:22 -0400 Subject: [PATCH 08/46] feat(deck.gl-geotiff): default PerOriginSemaphore + fetchGeoTIFF option A module-level defaultConcurrencyLimiter = new PerOriginSemaphore({ maxRequests: 6 }) will be wired into COGLayer.defaultProps and MultiCOGLayer.defaultProps in the following commits, so layers on the same origin share one HTTP/1.1 connection pool by default. fetchGeoTIFF gains an options arg with concurrencyLimiter; forwarded to GeoTIFF.fromUrl for URL/string inputs. ArrayBuffer and pre-opened GeoTIFF inputs ignore it (the user already wired their own at open time, or there's no network to gate). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/default-concurrency-limiter.ts | 12 ++++++++++++ packages/deck.gl-geotiff/src/geotiff/geotiff.ts | 13 +++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 packages/deck.gl-geotiff/src/default-concurrency-limiter.ts diff --git a/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts b/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts new file mode 100644 index 00000000..93e64533 --- /dev/null +++ b/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts @@ -0,0 +1,12 @@ +import { PerOriginSemaphore } from "@developmentseed/geotiff"; + +/** + * Shared default concurrency limiter for every COGLayer / MultiCOGLayer that + * doesn't override its `concurrencyLimiter` prop. A single module-level + * `PerOriginSemaphore({ maxRequests: 6 })` so two layers fetching from the + * same origin (e.g. the same S3 bucket) share *one* HTTP/1.1 connection + * pool. The cap matches Chrome's default per-origin HTTP/1.1 limit. + */ +export const defaultConcurrencyLimiter = new PerOriginSemaphore({ + maxRequests: 6, +}); diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index 6a3cbe15..f4ce51c2 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -1,6 +1,6 @@ // Utilities for interacting with a GeoTIFF -import type { RasterArray } from "@developmentseed/geotiff"; +import type { ConcurrencyLimiter, RasterArray } from "@developmentseed/geotiff"; import { GeoTIFF } from "@developmentseed/geotiff"; import type { Converter } from "proj4"; @@ -54,9 +54,18 @@ export function addAlphaChannel(rgbImage: RasterArray): RasterArray { export async function fetchGeoTIFF( input: GeoTIFF | string | URL | ArrayBuffer, + options: { + /** Forwarded to {@link GeoTIFF.fromUrl} when `input` is a URL or string. + * Ignored when `input` is already a `GeoTIFF` instance or an + * `ArrayBuffer` (there's no network to gate, and a pre-opened GeoTIFF + * has already had its limiter wired at construction time). */ + concurrencyLimiter?: ConcurrencyLimiter | null; + } = {}, ): Promise { if (typeof input === "string" || input instanceof URL) { - return await GeoTIFF.fromUrl(input); + return await GeoTIFF.fromUrl(input, { + concurrencyLimiter: options.concurrencyLimiter, + }); } if (input instanceof ArrayBuffer) { From 9fdc43f93defed8d9221352cf7dfc7b97ae0d2c0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:18:39 -0400 Subject: [PATCH 09/46] feat(deck.gl-geotiff): COGLayer.concurrencyLimiter prop + default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds concurrencyLimiter to COGLayerProps with a default of the shared module-level PerOriginSemaphore({ maxRequests: 6 }). Two COGLayers on the same origin share one HTTP/1.1 connection pool out of the box; pass a custom ConcurrencyLimiter to override, pass null to disable. The prop is threaded into _parseGeoTIFF → fetchGeoTIFF → GeoTIFF.fromUrl. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deck.gl-geotiff/src/cog-layer.ts | 25 +++++++++++++++++-- .../tests/concurrency-limiter.test.ts | 14 +++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index b84fc042..5704f025 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -7,7 +7,12 @@ import type { TilesetDescriptor, } from "@developmentseed/deck.gl-raster"; import { RasterTileLayer } from "@developmentseed/deck.gl-raster"; -import type { DecoderPool, GeoTIFF, Overview } from "@developmentseed/geotiff"; +import type { + ConcurrencyLimiter, + DecoderPool, + GeoTIFF, + Overview, +} from "@developmentseed/geotiff"; import { defaultDecoderPool } from "@developmentseed/geotiff"; import type { EpsgResolver, ProjectionDefinition } from "@developmentseed/proj"; import { @@ -18,6 +23,7 @@ import { } from "@developmentseed/proj"; import type { Texture } from "@luma.gl/core"; import proj4 from "proj4"; +import { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import type { TextureDataT } from "./geotiff/render-pipeline.js"; import { inferRenderPipeline } from "./geotiff/render-pipeline.js"; @@ -133,6 +139,18 @@ export type COGLayerProps = Omit< * automatically aborted. */ signal?: AbortSignal; + + /** + * Caps concurrent HTTP requests for this layer's tile-data fetches. + * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: + * 6 })` so multiple `COGLayer`s targeting the same origin (e.g. the + * same S3 bucket) share one HTTP/1.1 connection pool. Pass your own + * `ConcurrencyLimiter` to override; pass `null` to disable gating. + * + * Ignored when `geotiff` is a pre-opened `GeoTIFF` instance — wire the + * limiter via {@link GeoTIFF.fromUrl} at construction time instead. + */ + concurrencyLimiter?: ConcurrencyLimiter | null; }; /** @@ -150,6 +168,7 @@ export class COGLayer< static override defaultProps = { ...RasterTileLayer.defaultProps, epsgResolver, + concurrencyLimiter: defaultConcurrencyLimiter, } as typeof RasterTileLayer.defaultProps; declare state: { @@ -189,7 +208,9 @@ export class COGLayer< } async _parseGeoTIFF(): Promise { - const geotiff = await fetchGeoTIFF(this.props.geotiff); + const geotiff = await fetchGeoTIFF(this.props.geotiff, { + concurrencyLimiter: this.props.concurrencyLimiter, + }); const crs = geotiff.crs; const sourceProjection = typeof crs === "number" diff --git a/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts new file mode 100644 index 00000000..5e9dcaa2 --- /dev/null +++ b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { COGLayer } from "../src/cog-layer.js"; +import { defaultConcurrencyLimiter } from "../src/default-concurrency-limiter.js"; + +describe("COGLayer default concurrencyLimiter", () => { + it("defaultProps.concurrencyLimiter is the shared module-level instance", () => { + // @ts-expect-error — defaultProps is cast to the base type at the + // declaration site, so the field isn't visible on its static type. The + // *value* is still the one we want. + expect(COGLayer.defaultProps.concurrencyLimiter).toBe( + defaultConcurrencyLimiter, + ); + }); +}); From c71dcd8f96f575598ae27028d300a26c2cbff370 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:20:25 -0400 Subject: [PATCH 10/46] feat(deck.gl-geotiff): MultiCOGLayer.concurrencyLimiter prop + default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as COGLayer's prop: defaults to the shared module-level PerOriginSemaphore so a COGLayer and a MultiCOGLayer hitting the same origin share one HTTP/1.1 connection pool. Threaded through _parseAllSources → fetchGeoTIFF → GeoTIFF.fromUrl. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deck.gl-geotiff/src/multi-cog-layer.ts | 17 ++++++++++++++++- .../tests/concurrency-limiter.test.ts | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 09965cba..53684803 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -32,6 +32,7 @@ import { CompositeBands, } from "@developmentseed/deck.gl-raster/gpu-modules"; import type { + ConcurrencyLimiter, DecoderPool, GeoTIFF, Overview, @@ -47,6 +48,7 @@ import { } from "@developmentseed/proj"; import type { Device, Texture, TextureFormat } from "@luma.gl/core"; import proj4 from "proj4"; +import { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; @@ -257,12 +259,23 @@ export type MultiCOGLayerProps = CompositeLayerProps & * @default 1 */ debugLevel?: 1 | 2 | 3; + + /** + * Caps concurrent HTTP requests for this layer's tile-data fetches. + * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: + * 6 })` (the *same* instance COGLayer uses by default), so a + * COGLayer and a MultiCOGLayer hitting the same origin share one + * HTTP/1.1 connection pool. Pass your own `ConcurrencyLimiter` to + * override; pass `null` to disable gating. + */ + concurrencyLimiter?: ConcurrencyLimiter | null; }; const defaultProps = { ...RasterTileLayer.defaultProps, epsgResolver: { type: "accessor" as const, value: defaultEpsgResolver }, debugLevel: { type: "number" as const, value: 1 }, + concurrencyLimiter: defaultConcurrencyLimiter, }; /** @@ -334,7 +347,9 @@ export class MultiCOGLayer extends RasterTileLayer< // Open all COGs in parallel const cogSources = await Promise.all( entries.map(async ([name, config]) => { - const geotiff = await fetchGeoTIFF(config.url); + const geotiff = await fetchGeoTIFF(config.url, { + concurrencyLimiter: this.props.concurrencyLimiter, + }); const crs = geotiff.crs; const sourceProjection = typeof crs === "number" diff --git a/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts index 5e9dcaa2..67d67aba 100644 --- a/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts +++ b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { COGLayer } from "../src/cog-layer.js"; import { defaultConcurrencyLimiter } from "../src/default-concurrency-limiter.js"; +import { MultiCOGLayer } from "../src/multi-cog-layer.js"; describe("COGLayer default concurrencyLimiter", () => { it("defaultProps.concurrencyLimiter is the shared module-level instance", () => { @@ -12,3 +13,17 @@ describe("COGLayer default concurrencyLimiter", () => { ); }); }); + +describe("MultiCOGLayer default concurrencyLimiter", () => { + it("defaultProps.concurrencyLimiter is the same shared instance as COGLayer's", () => { + // @ts-expect-error — see COGLayer test above + expect(MultiCOGLayer.defaultProps.concurrencyLimiter).toBe( + defaultConcurrencyLimiter, + ); + // @ts-expect-error + expect(MultiCOGLayer.defaultProps.concurrencyLimiter).toBe( + // @ts-expect-error + COGLayer.defaultProps.concurrencyLimiter, + ); + }); +}); From 46a763e9f591dfa862393cf9488942e2c7443347 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:31:54 -0400 Subject: [PATCH 11/46] docs(geotiff): docstrings on Semaphore internals (PR #557 review) JSDoc on the Waiter fields and the _makeRelease / _releaseOne private helpers so each unit of the Semaphore primitive carries its own short explanation. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/limiter.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index 003dab96..915f478f 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -3,11 +3,17 @@ import type { Source } from "@cogeotiff/core"; /** The shape of a {@link Source.fetch} call. */ type Fetch = Pick["fetch"]; -/** A pending acquire waiting for a slot. */ +/** A pending acquire parked in {@link Semaphore.queue}, waiting for a slot. */ interface Waiter { + /** Settles the caller's `acquire(...)` promise with a release function. */ resolve(release: () => void): void; + /** Settles the caller's `acquire(...)` promise as rejected (e.g. on abort). */ reject(reason: unknown): void; + /** Optional caller-supplied signal. If it aborts while we're queued, the + * waiter is spliced out and {@link Waiter.reject reject}ed. */ signal?: AbortSignal; + /** The listener installed on `signal` so we can later + * `removeEventListener("abort", onAbort)` when the slot is granted. */ onAbort?: () => void; } @@ -54,6 +60,8 @@ export class Semaphore { }); } + /** Build a single-use release function for a freshly-granted slot. + * Calls beyond the first are no-ops, so double-releasing is safe. */ private _makeRelease(): () => void { let released = false; return () => { @@ -65,6 +73,9 @@ export class Semaphore { }; } + /** Hand off one slot: dequeue the next waiter and grant it the slot, or — + * if the queue is empty — decrement {@link Semaphore.active} so the next + * `acquire` can take it directly. */ private _releaseOne(): void { const next = this.queue.shift(); if (!next) { From ee60fef1f94395de550db81fa1fa15da98c1bd3f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:39:58 -0400 Subject: [PATCH 12/46] =?UTF-8?q?refactor(geotiff):=20limitFetch=20?= =?UTF-8?q?=E2=86=92=20LimiterMiddleware=20class=20(PR=20#557=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the limitFetch fetch-wrapper with a LimiterMiddleware class that implements chunkd's SourceMiddleware, matching the shape of SourceChunk and SourceCache. Slot it into the existing SourceView middleware list in GeoTIFF.fromUrl, after SourceChunk + SourceCache, so cache hits skip the limiter and only uncached network reads consume slots. Header reads (through the cached header view) are now also gated — they're still mostly served from cache, but the rare cache miss now counts toward the per-origin cap, which is what callers actually want. No type cast (the @chunkd vs @cogeotiff Source-type impedance is avoided because everything composing the middleware in fromUrl is chunkd-typed from the start). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/geotiff.ts | 28 ++-- packages/geotiff/src/limiter.ts | 68 ++++++--- .../tests/geotiff-concurrency-limiter.test.ts | 18 ++- packages/geotiff/tests/limiter.test.ts | 133 +++++++++--------- 4 files changed, 138 insertions(+), 109 deletions(-) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 558d5ec3..07904fcb 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -13,7 +13,7 @@ import { parseGDALMetadata } from "./gdal-metadata.js"; import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; import type { ConcurrencyLimiter } from "./limiter.js"; -import { limitFetch } from "./limiter.js"; +import { LimiterMiddleware } from "./limiter.js"; import { Overview } from "./overview.js"; import type { DecoderPool } from "./pool/pool.js"; import type { Tile } from "./tile.js"; @@ -308,22 +308,26 @@ export class GeoTIFF { // unbounded length. Remove once the upstream fix lands. source.metadata = { size: Number.POSITIVE_INFINITY }; + // When a limiter is supplied, slot a LimiterMiddleware into both + // sources' middleware stacks. On the header source, it sits *after* + // SourceChunk + SourceCache so a cache hit short-circuits and never + // consumes a slot — only network reads that escape the cache are gated. + // On the data source (no caching), every fetch is gated. + const limiterMW = concurrencyLimiter + ? new LimiterMiddleware({ + url: new URL(url), + limiter: concurrencyLimiter, + }) + : null; + const view = new SourceView(source, [ new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize }), + ...(limiterMW ? [limiterMW] : []), ]); - // Wrap the *raw* data source's `.fetch` when a limiter is supplied so - // every tile-data fetch holds a slot for its duration. Header reads go - // through the cached SourceView and are not gated. - const dataSource: Pick = concurrencyLimiter - ? { - fetch: limitFetch( - source.fetch.bind(source), - new URL(url), - concurrencyLimiter, - ), - } + const dataSource: Pick = limiterMW + ? new SourceView(source, [limiterMW]) : source; return await GeoTIFF.open({ diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index 915f478f..e47679fd 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -1,7 +1,8 @@ -import type { Source } from "@cogeotiff/core"; - -/** The shape of a {@link Source.fetch} call. */ -type Fetch = Pick["fetch"]; +import type { + SourceCallback, + SourceMiddleware, + SourceRequest, +} from "@chunkd/source"; /** A pending acquire parked in {@link Semaphore.queue}, waiting for a slot. */ interface Waiter { @@ -135,24 +136,55 @@ export class PerOriginSemaphore implements ConcurrencyLimiter { } } +/** Options for {@link LimiterMiddleware}. */ +interface LimiterMiddlewareOptions { + /** The URL the wrapped source is reading from. Passed to + * `limiter.acquire(url, signal?)` on every fetch — the limiter uses it for + * per-origin routing. */ + url: URL; + /** The {@link ConcurrencyLimiter} to gate through. */ + limiter: ConcurrencyLimiter; +} + /** - * Wrap a `Source.fetch` so each call holds a {@link ConcurrencyLimiter} slot - * for its duration — releasing on resolve, on reject, and never otherwise - * interfering. Forwards `options.signal` to `limiter.acquire`, so if the - * caller aborts while the call is queued the request is dropped before any - * network I/O fires. + * chunkd middleware that holds a {@link ConcurrencyLimiter} slot for the + * duration of each underlying `fetch` — releasing on resolve, on reject, and + * never otherwise interfering. Forwards the request's `signal` to + * `limiter.acquire`, so if the caller aborts while the call is queued the + * request is dropped before any network I/O fires. + * + * Composed into a {@link SourceView}'s middleware list alongside the chunkd + * middlewares (`SourceChunk`, `SourceCache`, …). Place it after caching so + * cache hits don't burn a slot. + * + * @example + * ```ts + * import { SourceView } from "@chunkd/source"; + * import { SourceCache, SourceChunk } from "@chunkd/middleware"; + * + * const view = new SourceView(source, [ + * new SourceChunk({ size: 64 * 1024 }), + * new SourceCache({ size: 8 * 1024 * 1024 }), + * new LimiterMiddleware({ url, limiter }), + * ]); + * ``` */ -export function limitFetch( - fetch: Fetch, - url: URL, - limiter: ConcurrencyLimiter, -): Fetch { - return async (offset, length, options) => { - const release = await limiter.acquire(url, options?.signal); +export class LimiterMiddleware implements SourceMiddleware { + readonly name = "limiter"; + private readonly url: URL; + private readonly limiter: ConcurrencyLimiter; + + constructor(opts: LimiterMiddlewareOptions) { + this.url = opts.url; + this.limiter = opts.limiter; + } + + async fetch(req: SourceRequest, next: SourceCallback): Promise { + const release = await this.limiter.acquire(this.url, req.signal); try { - return await fetch(offset, length, options); + return await next(req); } finally { release(); } - }; + } } diff --git a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts index b21dbb76..ea022662 100644 --- a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts +++ b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts @@ -73,7 +73,7 @@ describe("GeoTIFF.fromUrl({ concurrencyLimiter })", () => { SourceHttp.fetch = realFetch; }); - it("routes tile-data fetches through the limiter (header reads are not gated)", async () => { + it("routes both header and tile-data fetches through the limiter (cache hits skip it)", async () => { SourceHttp.fetch = staticFetch(FIXTURE) as typeof SourceHttp.fetch; const acquired: URL[] = []; @@ -86,18 +86,16 @@ describe("GeoTIFF.fromUrl({ concurrencyLimiter })", () => { const url = "https://example.test/cog.tif"; const tiff = await GeoTIFF.fromUrl(url, { concurrencyLimiter: limiter }); - // Opening the TIFF reads headers — those go through the header source - // (cached SourceView), NOT through the limiter, so `acquired` should - // still be empty (or near-empty — it must contain zero tile-data calls). - const headerOnlyCount = acquired.length; - expect(headerOnlyCount).toBe(0); + // Opening the TIFF reads headers — those network reads (cache misses + // through the SourceView) go through the limiter too. + expect(acquired.length).toBeGreaterThan(0); + const headerCount = acquired.length; await tiff.fetchTile(0, 0); - // After fetching a tile, the data-source path was exercised — the - // limiter must have seen at least one acquire, and every URL must be - // ours. - expect(acquired.length).toBeGreaterThan(headerOnlyCount); + // The tile fetch added at least one more acquire (the data-source + // path). Every URL must be ours. + expect(acquired.length).toBeGreaterThan(headerCount); for (const u of acquired) { expect(u.href).toBe(url); } diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts index 6702628d..b0ae6443 100644 --- a/packages/geotiff/tests/limiter.test.ts +++ b/packages/geotiff/tests/limiter.test.ts @@ -1,6 +1,11 @@ +import type { SourceCallback, SourceRequest } from "@chunkd/source"; import { describe, expect, it } from "vitest"; import type { ConcurrencyLimiter } from "../src/limiter.js"; -import { limitFetch, PerOriginSemaphore, Semaphore } from "../src/limiter.js"; +import { + LimiterMiddleware, + PerOriginSemaphore, + Semaphore, +} from "../src/limiter.js"; describe("Semaphore", () => { it("allows up to maxRequests concurrent acquires; further acquires queue", async () => { @@ -141,22 +146,15 @@ describe("PerOriginSemaphore", () => { }); }); -describe("limitFetch", () => { +describe("LimiterMiddleware", () => { const URL_A = new URL("https://a.example.com/cog.tif"); + const REQ: SourceRequest = { + source: {} as never, + offset: 0, + length: 4, + }; - function makeRecorder() { - const calls: Array<{ offset: number; length: number | undefined }> = []; - const fetch = async ( - offset: number, - length?: number, - ): Promise => { - calls.push({ offset, length }); - return new ArrayBuffer(length ?? 0); - }; - return { fetch, calls }; - } - - it("only invokes the inner fetch after acquiring a slot", async () => { + it("only invokes `next` after acquiring a slot, and releases after", async () => { const order: string[] = []; const limiter: ConcurrencyLimiter = { acquire: async () => { @@ -164,86 +162,83 @@ describe("limitFetch", () => { return () => order.push("release"); }, }; - const inner = async () => { - order.push("fetch"); + const mw = new LimiterMiddleware({ url: URL_A, limiter }); + const next: SourceCallback = async () => { + order.push("next"); return new ArrayBuffer(0); }; - const wrapped = limitFetch(inner, URL_A, limiter); - await wrapped(0, 4); - expect(order).toEqual(["acquire", "fetch", "release"]); + await mw.fetch(REQ, next); + expect(order).toEqual(["acquire", "next", "release"]); }); - it("forwards offset, length, and options to the inner fetch unchanged", async () => { - const calls: unknown[][] = []; + it("forwards req to `next` unchanged", async () => { + const calls: SourceRequest[] = []; const limiter: ConcurrencyLimiter = { acquire: async () => () => {}, }; - const inner = async (...args: unknown[]) => { - calls.push(args); - return new ArrayBuffer(0); - }; - const wrapped = limitFetch( - inner as Parameters[0], - URL_A, - limiter, - ); + const mw = new LimiterMiddleware({ url: URL_A, limiter }); const signal = new AbortController().signal; - await wrapped(100, 200, { signal }); - expect(calls).toEqual([[100, 200, { signal }]]); - }); - - it("releases the slot when the inner fetch resolves", async () => { - const sem = new Semaphore({ maxRequests: 1 }); - const limiter: ConcurrencyLimiter = { - acquire: (_url, signal) => sem.acquire(signal), + const req: SourceRequest = { + source: {} as never, + offset: 100, + length: 200, + signal, }; - const { fetch } = makeRecorder(); - const wrapped = limitFetch(fetch, URL_A, limiter); - await wrapped(0, 8); - // If the slot wasn't released, a second call would hang. - await wrapped(0, 8); + const next: SourceCallback = async (r) => { + calls.push(r); + return new ArrayBuffer(0); + }; + await mw.fetch(req, next); + expect(calls).toEqual([req]); }); - it("releases the slot when the inner fetch rejects (and propagates the error)", async () => { + it("releases the slot when `next` rejects (and propagates the error)", async () => { const sem = new Semaphore({ maxRequests: 1 }); const limiter: ConcurrencyLimiter = { acquire: (_url, signal) => sem.acquire(signal), }; - const wrapped = limitFetch( - async () => { + const mw = new LimiterMiddleware({ url: URL_A, limiter }); + await expect( + mw.fetch(REQ, async () => { throw new Error("network down"); - }, - URL_A, - limiter, - ); - await expect(wrapped(0, 8)).rejects.toThrow("network down"); - // Slot was released — a second call must not hang. - const { fetch } = makeRecorder(); - const ok = limitFetch(fetch, URL_A, limiter); - await ok(0, 8); + }), + ).rejects.toThrow("network down"); + // Slot was released — a second fetch must not hang. + await mw.fetch(REQ, async () => new ArrayBuffer(0)); }); - it("forwards options.signal to limiter.acquire so a queued abort drops the call", async () => { + it("forwards req.signal to limiter.acquire so a queued abort drops the call", async () => { const sem = new Semaphore({ maxRequests: 1 }); const limiter: ConcurrencyLimiter = { acquire: (_url, signal) => sem.acquire(signal), }; - // Saturate the (per-test-shared) semaphore so the next acquire queues. + // Saturate the semaphore so the next acquire queues. const hold = await sem.acquire(); - let innerCalled = false; - const wrapped = limitFetch( - async () => { - innerCalled = true; - return new ArrayBuffer(0); - }, - URL_A, - limiter, - ); + let nextCalled = false; + const mw = new LimiterMiddleware({ url: URL_A, limiter }); const ac = new AbortController(); - const pending = wrapped(0, 8, { signal: ac.signal }); + const req: SourceRequest = { + source: {} as never, + offset: 0, + length: 8, + signal: ac.signal, + }; + const pending = mw.fetch(req, async () => { + nextCalled = true; + return new ArrayBuffer(0); + }); ac.abort(new Error("pan-away")); await expect(pending).rejects.toThrow("pan-away"); - expect(innerCalled).toBe(false); + expect(nextCalled).toBe(false); hold(); }); + + it("has the expected SourceMiddleware shape (name + fetch)", () => { + const mw = new LimiterMiddleware({ + url: URL_A, + limiter: { acquire: async () => () => {} }, + }); + expect(mw.name).toBe("limiter"); + expect(typeof mw.fetch).toBe("function"); + }); }); From db6b69f97fb654766a0197c1fe7776b70884d62f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 14:42:48 -0400 Subject: [PATCH 13/46] cleaner --- packages/geotiff/src/geotiff.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 07904fcb..e059483d 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -313,7 +313,7 @@ export class GeoTIFF { // SourceChunk + SourceCache so a cache hit short-circuits and never // consumes a slot — only network reads that escape the cache are gated. // On the data source (no caching), every fetch is gated. - const limiterMW = concurrencyLimiter + const limiter = concurrencyLimiter ? new LimiterMiddleware({ url: new URL(url), limiter: concurrencyLimiter, @@ -323,11 +323,11 @@ export class GeoTIFF { const view = new SourceView(source, [ new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize }), - ...(limiterMW ? [limiterMW] : []), + ...(limiter ? [limiter] : []), ]); - const dataSource: Pick = limiterMW - ? new SourceView(source, [limiterMW]) + const dataSource: Pick = limiter + ? new SourceView(source, [limiter]) : source; return await GeoTIFF.open({ From e413fc917a46507a5d59c8bbdba1bb8f50deaac7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:11:32 -0400 Subject: [PATCH 14/46] feat(geotiff): dynamic priority on ConcurrencyLimiter / Semaphore Adds an optional `getPriority` callback to `Semaphore.acquire`, `ConcurrencyLimiter.acquire`, and `LimiterMiddleware`. The callback returns a `number | readonly number[]` (lex-compared, missing trailing elements treated as 0); it's re-invoked by the limiter on every slot-open so dynamic state (e.g. distance from viewport center) re-sorts the queue without callers having to manage queue position themselves. Implementation uses an unsorted queue + linear-scan find-min on each release. A heap doesn't help here because dynamic priorities force us to re-evaluate every waiter's getPriority on every release anyway (we extract one minimum per release, not several). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/index.ts | 2 +- packages/geotiff/src/limiter.ts | 115 ++++++++++++-- packages/geotiff/tests/limiter.test.ts | 200 ++++++++++++++++++++++++- 3 files changed, 302 insertions(+), 15 deletions(-) diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index f12a49b5..c87439f3 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -18,7 +18,7 @@ export type { export { DECODER_REGISTRY } from "./decode.js"; export { GeoTIFF } from "./geotiff.js"; export type { CachedTags, GeoKeyDirectory } from "./ifd.js"; -export type { ConcurrencyLimiter } from "./limiter.js"; +export type { ConcurrencyLimiter, Priority } from "./limiter.js"; export { PerOriginSemaphore } from "./limiter.js"; export { Overview } from "./overview.js"; export type { DecoderPoolOptions } from "./pool/pool.js"; diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index e47679fd..50911c95 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -4,6 +4,37 @@ import type { SourceRequest, } from "@chunkd/source"; +/** + * Numeric priority used to order waiters in a {@link Semaphore}'s queue. Lower + * = serviced sooner. A single `number` is equivalent to a 1-tuple; arrays are + * compared lexicographically (element-wise), with missing trailing elements + * treated as 0. Returning `0` (or omitting `getPriority` entirely) makes the + * waiter effectively un-prioritized — FIFO arrival order wins among ties. + */ +export type Priority = number | readonly number[]; + +/** + * Compare two priorities. Returns negative if `a` should be serviced before + * `b`, positive if `b` should go first, 0 on tie (queue then breaks the tie + * by FIFO arrival order). Both shapes are normalised to arrays for compare. + */ +function comparePriorities(a: Priority, b: Priority): number { + const arrA = typeof a === "number" ? [a] : a; + const arrB = typeof b === "number" ? [b] : b; + const len = Math.max(arrA.length, arrB.length); + for (let i = 0; i < len; i++) { + const ai = arrA[i] ?? 0; + const bi = arrB[i] ?? 0; + if (ai < bi) { + return -1; + } + if (ai > bi) { + return 1; + } + } + return 0; +} + /** A pending acquire parked in {@link Semaphore.queue}, waiting for a slot. */ interface Waiter { /** Settles the caller's `acquire(...)` promise with a release function. */ @@ -16,16 +47,33 @@ interface Waiter { /** The listener installed on `signal` so we can later * `removeEventListener("abort", onAbort)` when the slot is granted. */ onAbort?: () => void; + /** Dynamic priority callback. Re-invoked by `_releaseOne` on every slot- + * open so the queue can re-sort if priorities have changed (e.g. viewport + * panned, distance-from-center changed). Omitted = priority 0. */ + getPriority?: () => Priority; } /** - * Counting semaphore with FIFO queueing and abort-aware acquire. Internal - * primitive used by {@link PerOriginSemaphore} and {@link limitFetch}. + * Counting semaphore with abort-aware acquire and dynamic priority. Internal + * primitive used by {@link PerOriginSemaphore} and {@link LimiterMiddleware}. * * Hands out up to `maxRequests` concurrent slots. Further `acquire()`s queue. + * On every slot-open, the queue is searched for the lowest-priority waiter + * (re-evaluating `getPriority` on each — so panning the viewport re-sorts the + * queue if callers' priorities depend on viewport state). Ties break by FIFO + * arrival order. A `Semaphore` with no priorities is therefore equivalent to + * a plain FIFO queue. + * * Acquires with an `AbortSignal` reject (and never consume a slot) if the * signal aborts before the slot is granted — either because it's already * aborted at call time, or because it aborts while queued. + * + * We use a single linear-scan find-min instead of a priority queue (heap) + * because priorities are *dynamic* — we have to re-evaluate every waiter's + * `getPriority` on each release anyway, which costs O(N). Linear scan + find- + * min in the same pass also costs O(N), with a smaller constant and simpler + * code; a heap would only win if we extracted multiple minima per release, + * which we don't (one slot opens at a time). */ export class Semaphore { private active = 0; @@ -36,7 +84,10 @@ export class Semaphore { this.maxRequests = options.maxRequests; } - acquire(signal?: AbortSignal): Promise<() => void> { + acquire( + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void> { if (signal?.aborted) { return Promise.reject(signal.reason); } @@ -45,7 +96,7 @@ export class Semaphore { return Promise.resolve(this._makeRelease()); } return new Promise<() => void>((resolve, reject) => { - const waiter: Waiter = { resolve, reject, signal }; + const waiter: Waiter = { resolve, reject, signal, getPriority }; if (signal) { const onAbort = () => { const idx = this.queue.indexOf(waiter); @@ -74,15 +125,28 @@ export class Semaphore { }; } - /** Hand off one slot: dequeue the next waiter and grant it the slot, or — - * if the queue is empty — decrement {@link Semaphore.active} so the next - * `acquire` can take it directly. */ + /** Hand off one slot: pick the lowest-priority waiter (re-evaluating each + * waiter's `getPriority` for dynamic ordering), grant it the slot — or, if + * the queue is empty, decrement {@link Semaphore.active} so the next + * `acquire` can take the freed slot directly. FIFO break on ties. */ private _releaseOne(): void { - const next = this.queue.shift(); - if (!next) { + if (this.queue.length === 0) { this.active -= 1; return; } + // Linear scan find-min. `bestIdx === 0` initially gives the earliest + // arrival the implicit tiebreaker — only strictly-lower priorities can + // bump it. + let bestIdx = 0; + let bestPrio: Priority = this.queue[0]!.getPriority?.() ?? 0; + for (let i = 1; i < this.queue.length; i++) { + const p: Priority = this.queue[i]!.getPriority?.() ?? 0; + if (comparePriorities(p, bestPrio) < 0) { + bestIdx = i; + bestPrio = p; + } + } + const next = this.queue.splice(bestIdx, 1)[0]!; if (next.signal && next.onAbort) { next.signal.removeEventListener("abort", next.onAbort); } @@ -103,8 +167,18 @@ export interface ConcurrencyLimiter { * function — call it exactly once when the fetch settles. If `signal` * aborts while waiting in the queue, the returned promise rejects with the * signal's reason and no slot is consumed. + * + * `getPriority` is an optional callback re-evaluated by the limiter on + * every slot-open, so queued waiters can be re-ordered if their priority + * depends on dynamic state (e.g. distance from viewport center, which + * changes on pan). Lower-numeric = serviced sooner. A tuple sorts + * lexicographically. Omitted = priority 0, FIFO among ties. */ - acquire(url: URL, signal?: AbortSignal): Promise<() => void>; + acquire( + url: URL, + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void>; } /** @@ -125,14 +199,18 @@ export class PerOriginSemaphore implements ConcurrencyLimiter { this.maxRequests = options.maxRequests; } - acquire(url: URL, signal?: AbortSignal): Promise<() => void> { + acquire( + url: URL, + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void> { const { origin } = url; let sem = this.byOrigin.get(origin); if (!sem) { sem = new Semaphore({ maxRequests: this.maxRequests }); this.byOrigin.set(origin, sem); } - return sem.acquire(signal); + return sem.acquire(signal, getPriority); } } @@ -144,6 +222,11 @@ interface LimiterMiddlewareOptions { url: URL; /** The {@link ConcurrencyLimiter} to gate through. */ limiter: ConcurrencyLimiter; + /** Optional dynamic priority for every fetch through this middleware. The + * limiter re-invokes this callback on each slot-open, so closures over + * dynamic state (e.g. layer viewport center) re-sort the queue when that + * state changes. Lower = serviced sooner. */ + getPriority?: () => Priority; } /** @@ -173,14 +256,20 @@ export class LimiterMiddleware implements SourceMiddleware { readonly name = "limiter"; private readonly url: URL; private readonly limiter: ConcurrencyLimiter; + private readonly getPriority?: () => Priority; constructor(opts: LimiterMiddlewareOptions) { this.url = opts.url; this.limiter = opts.limiter; + this.getPriority = opts.getPriority; } async fetch(req: SourceRequest, next: SourceCallback): Promise { - const release = await this.limiter.acquire(this.url, req.signal); + const release = await this.limiter.acquire( + this.url, + req.signal, + this.getPriority, + ); try { return await next(req); } finally { diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts index b0ae6443..3e5e56da 100644 --- a/packages/geotiff/tests/limiter.test.ts +++ b/packages/geotiff/tests/limiter.test.ts @@ -1,6 +1,6 @@ import type { SourceCallback, SourceRequest } from "@chunkd/source"; import { describe, expect, it } from "vitest"; -import type { ConcurrencyLimiter } from "../src/limiter.js"; +import type { ConcurrencyLimiter, Priority } from "../src/limiter.js"; import { LimiterMiddleware, PerOriginSemaphore, @@ -76,6 +76,161 @@ describe("Semaphore", () => { await next; expect(nextResolved).toBe(true); }); + + it("orders queued waiters by priority (lower = sooner)", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: number[] = []; + // Queue in arrival order [c, a, b] but priorities say a < b < c. + const c = sem + .acquire(undefined, () => 3) + .then((r) => { + order.push(3); + r(); + }); + const a = sem + .acquire(undefined, () => 1) + .then((r) => { + order.push(1); + r(); + }); + const b = sem + .acquire(undefined, () => 2) + .then((r) => { + order.push(2); + r(); + }); + hold(); + await Promise.all([a, b, c]); + expect(order).toEqual([1, 2, 3]); + }); + + it("FIFO tiebreak among waiters with the same priority", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: number[] = []; + const p1 = sem + .acquire(undefined, () => 5) + .then((r) => { + order.push(1); + r(); + }); + const p2 = sem + .acquire(undefined, () => 5) + .then((r) => { + order.push(2); + r(); + }); + const p3 = sem + .acquire(undefined, () => 5) + .then((r) => { + order.push(3); + r(); + }); + hold(); + await Promise.all([p1, p2, p3]); + expect(order).toEqual([1, 2, 3]); + }); + + it("re-evaluates getPriority on every slot-open (dynamic priority)", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + // Two waiters; each reads from a shared mutable state. + const prio = { a: 10, b: 1 }; + const order: string[] = []; + const aPromise = sem + .acquire(undefined, () => prio.a) + .then((r) => { + order.push("a"); + r(); + }); + const bPromise = sem + .acquire(undefined, () => prio.b) + .then((r) => { + order.push("b"); + r(); + }); + // Right now b's priority (1) < a's (10), so on the first release b wins. + hold(); + // Wait for b to finish. + await bPromise; + // Now flip priorities BEFORE a gets serviced. Only a is in the queue, so + // there's no contender, but this exercises that getPriority is read fresh + // on each call rather than memoised at acquire time. + prio.a = 0; + await aPromise; + expect(order).toEqual(["b", "a"]); + }); + + it("sorts tuple priorities lexicographically with missing trailing elements as 0", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: string[] = []; + // [5, 3] vs [5, 1] vs [5] — second-element decides; [5] = [5, 0] (smallest). + const p53 = sem + .acquire(undefined, () => [5, 3] as const) + .then((r) => { + order.push("[5,3]"); + r(); + }); + const p51 = sem + .acquire(undefined, () => [5, 1] as const) + .then((r) => { + order.push("[5,1]"); + r(); + }); + const p5 = sem + .acquire(undefined, () => [5] as const) + .then((r) => { + order.push("[5]"); + r(); + }); + hold(); + await Promise.all([p5, p51, p53]); + expect(order).toEqual(["[5]", "[5,1]", "[5,3]"]); + }); + + it("mixes number and tuple priorities — number is treated as 1-tuple", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: string[] = []; + // priority 3 (= [3]) and [3, 5] tie on first element; second decides — [3] (= [3,0]) wins. + const p3tuple = sem + .acquire(undefined, () => [3, 5] as const) + .then((r) => { + order.push("[3,5]"); + r(); + }); + const p3num = sem + .acquire(undefined, () => 3) + .then((r) => { + order.push("3"); + r(); + }); + hold(); + await Promise.all([p3num, p3tuple]); + expect(order).toEqual(["3", "[3,5]"]); + }); + + it("treats omitted getPriority as priority 0 (so unprio'd waiters lead the queue)", async () => { + const sem = new Semaphore({ maxRequests: 1 }); + const hold = await sem.acquire(); + const order: string[] = []; + // priority 5 first arrival; no-priority second arrival. No-prio = 0 < 5 → wins. + const p5 = sem + .acquire(undefined, () => 5) + .then((r) => { + order.push("5"); + r(); + }); + const pNone = sem.acquire().then((r) => { + order.push("none"); + r(); + }); + hold(); + await Promise.all([p5, pNone]); + expect(order).toEqual(["none", "5"]); + }); }); describe("PerOriginSemaphore", () => { @@ -144,6 +299,27 @@ describe("PerOriginSemaphore", () => { release(); hold(); }); + + it("forwards getPriority to the per-origin Semaphore", async () => { + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + const hold = await limiter.acquire(A); + const order: string[] = []; + const low = limiter + .acquire(A, undefined, () => 99) + .then((r) => { + order.push("low"); + r(); + }); + const high = limiter + .acquire(A2, undefined, () => 1) + .then((r) => { + order.push("high"); + r(); + }); + hold(); + await Promise.all([low, high]); + expect(order).toEqual(["high", "low"]); + }); }); describe("LimiterMiddleware", () => { @@ -241,4 +417,26 @@ describe("LimiterMiddleware", () => { expect(mw.name).toBe("limiter"); expect(typeof mw.fetch).toBe("function"); }); + + it("threads getPriority from constructor through to limiter.acquire", async () => { + const calls: Array<{ + url: URL; + signal?: AbortSignal; + priority: Priority | undefined; + }> = []; + const limiter: ConcurrencyLimiter = { + acquire: async (url, signal, getPriority) => { + calls.push({ url, signal, priority: getPriority?.() }); + return () => {}; + }, + }; + const mw = new LimiterMiddleware({ + url: URL_A, + limiter, + getPriority: () => [2, 7], + }); + await mw.fetch(REQ, async () => new ArrayBuffer(0)); + expect(calls).toHaveLength(1); + expect(calls[0]!.priority).toEqual([2, 7]); + }); }); From 8d58e10032f4bff33923ec252fd1f0ad31261960 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:13:09 -0400 Subject: [PATCH 15/46] feat(geotiff): thread getPriority through GeoTIFF.fromUrl + fetchGeoTIFF Adds optional getPriority callback to GeoTIFF.fromUrl and to the deck.gl-geotiff fetchGeoTIFF wrapper. fromUrl bakes it into the LimiterMiddleware so every header + tile-data fetch through this GeoTIFF's sources is dynamically prioritised against other waiters in the shared per-origin queue. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deck.gl-geotiff/src/geotiff/geotiff.ts | 11 ++++++++++- packages/geotiff/src/geotiff.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index f4ce51c2..29c7ab21 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -1,6 +1,10 @@ // Utilities for interacting with a GeoTIFF -import type { ConcurrencyLimiter, RasterArray } from "@developmentseed/geotiff"; +import type { + ConcurrencyLimiter, + Priority, + RasterArray, +} from "@developmentseed/geotiff"; import { GeoTIFF } from "@developmentseed/geotiff"; import type { Converter } from "proj4"; @@ -60,11 +64,16 @@ export async function fetchGeoTIFF( * `ArrayBuffer` (there's no network to gate, and a pre-opened GeoTIFF * has already had its limiter wired at construction time). */ concurrencyLimiter?: ConcurrencyLimiter | null; + /** Forwarded to {@link GeoTIFF.fromUrl} as the dynamic priority for every + * fetch through this GeoTIFF's sources. Only meaningful when + * `concurrencyLimiter` is set. */ + getPriority?: () => Priority; } = {}, ): Promise { if (typeof input === "string" || input instanceof URL) { return await GeoTIFF.fromUrl(input, { concurrencyLimiter: options.concurrencyLimiter, + getPriority: options.getPriority, }); } diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index e059483d..da53f200 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -12,7 +12,7 @@ import type { BandStatistics, GDALMetadata } from "./gdal-metadata.js"; import { parseGDALMetadata } from "./gdal-metadata.js"; import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; -import type { ConcurrencyLimiter } from "./limiter.js"; +import type { ConcurrencyLimiter, Priority } from "./limiter.js"; import { LimiterMiddleware } from "./limiter.js"; import { Overview } from "./overview.js"; import type { DecoderPool } from "./pool/pool.js"; @@ -272,6 +272,7 @@ export class GeoTIFF { * @param options.signal An optional {@link AbortSignal} to cancel the header reads. * @param options.debug When true, the returned GeoTIFF logs each tile/mask data fetch to the console with offset/length and a `data`/`mask` label. Off by default. * @param options.concurrencyLimiter Caps concurrent HTTP requests for the *tile data* path. Header / metadata reads (through the cached SourceView) are not gated. Pass `null` to explicitly disable; omit (or pass `undefined`) for the same effect — `GeoTIFF.fromUrl` does *not* default to a shared limiter on its own. The deck.gl-geotiff layers default to a shared {@link PerOriginSemaphore} via their `defaultProps`. + * @param options.getPriority Optional dynamic priority for every fetch through this GeoTIFF's sources. Re-invoked by the limiter on each slot-open, so closures over dynamic state (e.g. layer viewport center, tile bbox) re-sort the queue when that state changes. Lower = serviced sooner. Only meaningful when `concurrencyLimiter` is set. * @returns A Promise that resolves to a GeoTIFF instance. */ static async fromUrl( @@ -282,12 +283,14 @@ export class GeoTIFF { signal, debug, concurrencyLimiter, + getPriority, }: { chunkSize?: number; cacheSize?: number; signal?: AbortSignal; debug?: boolean; concurrencyLimiter?: ConcurrencyLimiter | null; + getPriority?: () => Priority; } = {}, ): Promise { const source = new SourceHttp(url, {}); @@ -317,13 +320,14 @@ export class GeoTIFF { ? new LimiterMiddleware({ url: new URL(url), limiter: concurrencyLimiter, + getPriority, }) : null; const view = new SourceView(source, [ new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize }), - ...(limiter ? [limiter] : []), + // ...(limiter ? [limiter] : []), ]); const dataSource: Pick = limiter From 8afac6e757929f5704db4be425c543467674ee5c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:14:19 -0400 Subject: [PATCH 16/46] feat(deck.gl-geotiff): MosaicLayer passes distance-from-viewport-center as getPriority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MosaicLayer now computes a dynamic `getPriority` closure per source, based on euclidean distance from the source's bbox center to the layer's current viewport center (degree-space — just used as an ordering key, great-circle isn't needed for sort order). It's threaded into the user-provided `getSource` callback's options so the consumer can forward it into `fetchGeoTIFF` / `GeoTIFF.fromUrl`. Each closure reads `this.context.viewport` fresh at call time, so the limiter re-evaluates priority on every slot-open — panning the viewport pulls newly-central sources to the front of the queue ahead of older edge sources, without callers having to manage queue position. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mosaic-layer/mosaic-layer.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 83bb2191..a96a097e 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -2,6 +2,7 @@ import type { CompositeLayerProps, Layer, LayersList } from "@deck.gl/core"; import { CompositeLayer } from "@deck.gl/core"; import type { TileLayerProps } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; +import type { Priority } from "@developmentseed/geotiff"; import type { MosaicSource } from "./mosaic-tileset-2d.js"; import { MosaicTileset2D } from "./mosaic-tileset-2d.js"; @@ -42,7 +43,19 @@ export type MosaicLayerProps< /** Fetch data for this source. */ getSource?: ( source: MosaicT, - opts: { signal?: AbortSignal }, + opts: { + signal?: AbortSignal; + /** + * Dynamic priority for fetches related to this source. Re-invoked by + * the limiter on every slot-open, so the queue re-sorts on viewport + * pan. Computed from the source's `bbox` center and the layer's + * current viewport: closer to viewport center ⇒ lower number ⇒ + * serviced sooner. Threaded into {@link fetchGeoTIFF} / + * `GeoTIFF.fromUrl` to bias center-of-screen rendering ahead of + * edges; passing it on is otherwise optional. + */ + getPriority: () => Priority; + }, ) => Promise; /** Render a source */ @@ -112,9 +125,30 @@ export class MosaicLayer< // `TileIndex`, which only defines x,y,z const index = data.index as MosaicT; const { signal } = data; + // Dynamic priority for the limiter: euclidean distance from this + // source's bbox center to the layer's current viewport center, in + // degree-space (just used as an ordering key; great-circle isn't + // needed). Re-evaluated on every limiter slot-open, so panning the + // viewport re-sorts the queue and pulls newly-central sources to the + // front of the line ahead of older edge sources. + const [minX, minY, maxX, maxY] = index.bbox; + const sourceCx = (minX + maxX) / 2; + const sourceCy = (minY + maxY) / 2; + const getPriority = (): number => { + const { viewport } = this.context; + // Viewport exposes longitude/latitude for geographic viewports; + // fall back to (0, 0) defensively if not available. + const lon = + "longitude" in viewport ? (viewport.longitude as number) : 0; + const lat = + "latitude" in viewport ? (viewport.latitude as number) : 0; + const dx = sourceCx - lon; + const dy = sourceCy - lat; + return Math.hypot(dx, dy); + }; const userData = this.props.getSource && - (await this.props.getSource(index, { signal })); + (await this.props.getSource(index, { signal, getPriority })); return { source: index, From 85c5e4624170e1426f9a9425b1ce3c5e94657f36 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:15:24 -0400 Subject: [PATCH 17/46] feat(examples/naip-mosaic): thread getPriority through getCachedGeoTIFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MosaicLayer now provides a dynamic `getPriority` closure (distance from viewport center to source bbox) in `getSource`'s opts. The example forwards it into `getCachedGeoTIFF` → `GeoTIFF.fromUrl({ getPriority })`, which bakes it into the LimiterMiddleware so every fetch through that COG's sources is dynamically prioritised against other waiters in the shared per-origin queue. Result: center-of-screen COGs jump ahead of edges in the limiter queue, and re-sort on pan. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/naip-mosaic/src/App.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 5d4d8764..623ced11 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -11,7 +11,7 @@ import { LinearRescale, } from "@developmentseed/deck.gl-raster/gpu-modules"; import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png"; -import type { Overview } from "@developmentseed/geotiff"; +import type { Overview, Priority } from "@developmentseed/geotiff"; import { GeoTIFF } from "@developmentseed/geotiff"; import type { Device, Texture } from "@luma.gl/core"; import type { ShaderModule } from "@luma.gl/shadertools"; @@ -85,10 +85,14 @@ type TextureDataT = { */ const geotiffCache = new Map>(); -function getCachedGeoTIFF(url: string, signal?: AbortSignal): Promise { +function getCachedGeoTIFF( + url: string, + signal?: AbortSignal, + getPriority?: () => Priority, +): Promise { let promise = geotiffCache.get(url); if (!promise) { - promise = GeoTIFF.fromUrl(url, { signal }).catch((err) => { + promise = GeoTIFF.fromUrl(url, { signal, getPriority }).catch((err) => { geotiffCache.delete(url); throw err; }); @@ -411,8 +415,8 @@ export default function App() { // the MosaicLayer's TileLayer cache so we can keep cheap header metadata // around indefinitely without pinning every parent tile (and its inner // COGLayer's in-flight tile requests) in memory. - getSource: async (source, { signal }) => - getCachedGeoTIFF(source.assets.image.href, signal), + getSource: async (source, { signal, getPriority }) => + getCachedGeoTIFF(source.assets.image.href, signal, getPriority), renderSource: (source, { data, signal }) => { const url = source.assets.image.href; return new COGLayer({ From d1abbf0c368c870eaf8d4976d387928fd386555e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:26:08 -0400 Subject: [PATCH 18/46] fix(examples/naip-mosaic): actually install the limiter so priority can fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCachedGeoTIFF was calling GeoTIFF.fromUrl with `getPriority` but no `concurrencyLimiter` — so fromUrl saw the limiter as undefined, skipped installing LimiterMiddleware entirely, and the priority closure was never consulted. Pass the shared defaultConcurrencyLimiter (now exported from @developmentseed/deck.gl-geotiff) so opens and tile-data fetches both go through the per-origin queue and dynamically re-sort by viewport-center distance. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/naip-mosaic/src/App.tsx | 16 ++++++++++++++-- packages/deck.gl-geotiff/src/index.ts | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 623ced11..932c4de6 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -1,4 +1,8 @@ -import { COGLayer, MosaicLayer } from "@developmentseed/deck.gl-geotiff"; +import { + COGLayer, + defaultConcurrencyLimiter, + MosaicLayer, +} from "@developmentseed/deck.gl-geotiff"; import type { RasterModule, RenderTileResult, @@ -92,7 +96,15 @@ function getCachedGeoTIFF( ): Promise { let promise = geotiffCache.get(url); if (!promise) { - promise = GeoTIFF.fromUrl(url, { signal, getPriority }).catch((err) => { + // Pass the shared module-level limiter so every header + tile-data fetch + // through this GeoTIFF's sources is gated against the per-origin slot + // pool — and re-ordered dynamically by `getPriority` (distance from + // viewport center, computed by MosaicLayer). + promise = GeoTIFF.fromUrl(url, { + signal, + concurrencyLimiter: defaultConcurrencyLimiter, + getPriority, + }).catch((err) => { geotiffCache.delete(url); throw err; }); diff --git a/packages/deck.gl-geotiff/src/index.ts b/packages/deck.gl-geotiff/src/index.ts index aeadd412..5dd1d461 100644 --- a/packages/deck.gl-geotiff/src/index.ts +++ b/packages/deck.gl-geotiff/src/index.ts @@ -4,6 +4,7 @@ export type { MinimalTileData, } from "./cog-layer.js"; export { COGLayer } from "./cog-layer.js"; +export { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; export { addAlphaChannel } from "./geotiff/geotiff.js"; export * as texture from "./geotiff/texture.js"; export type { MosaicLayerProps } from "./mosaic-layer/mosaic-layer.js"; From b99a551567e6a0cbbece7c4d62ce55076fc63f86 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:59:15 -0400 Subject: [PATCH 19/46] feat(deck.gl-geotiff): MosaicLayer concurrencyLimiter prop Match COGLayer / MultiCOGLayer by accepting a `concurrencyLimiter` prop (default: shared `defaultConcurrencyLimiter`, opt out with `null`), and thread it into `getSource`'s opts alongside `getPriority`. Lets a consumer's `getSource` forward both straight to `GeoTIFF.fromUrl` (or spread `opts`), so priority can't silently no-op when the user forgets to also pass the limiter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mosaic-layer/mosaic-layer.ts | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index a96a097e..5383ed94 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -2,7 +2,8 @@ import type { CompositeLayerProps, Layer, LayersList } from "@deck.gl/core"; import { CompositeLayer } from "@deck.gl/core"; import type { TileLayerProps } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; -import type { Priority } from "@developmentseed/geotiff"; +import type { ConcurrencyLimiter, Priority } from "@developmentseed/geotiff"; +import { defaultConcurrencyLimiter } from "../default-concurrency-limiter.js"; import type { MosaicSource } from "./mosaic-tileset-2d.js"; import { MosaicTileset2D } from "./mosaic-tileset-2d.js"; @@ -40,19 +41,38 @@ export type MosaicLayerProps< */ sources: MosaicT[]; + /** + * Caps concurrent HTTP requests for this layer's source fetches. + * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: + * 6 })` (the same instance `COGLayer` / `MultiCOGLayer` use), so all + * layers targeting one origin share one HTTP/1.1 connection pool. Pass + * your own `ConcurrencyLimiter` to override; pass `null` to disable + * gating. The layer threads this into `getSource`'s opts so consumers + * can forward it to {@link GeoTIFF.fromUrl} (or any source-opening + * call) alongside the matching `getPriority`. + */ + concurrencyLimiter?: ConcurrencyLimiter | null; + /** Fetch data for this source. */ getSource?: ( source: MosaicT, opts: { signal?: AbortSignal; + /** + * The layer's current `concurrencyLimiter` prop (default + * {@link defaultConcurrencyLimiter}). Forward to + * {@link GeoTIFF.fromUrl}'s `concurrencyLimiter` option so this + * source's fetches join the shared per-origin queue. + */ + concurrencyLimiter: ConcurrencyLimiter | null; /** * Dynamic priority for fetches related to this source. Re-invoked by * the limiter on every slot-open, so the queue re-sorts on viewport * pan. Computed from the source's `bbox` center and the layer's * current viewport: closer to viewport center ⇒ lower number ⇒ - * serviced sooner. Threaded into {@link fetchGeoTIFF} / - * `GeoTIFF.fromUrl` to bias center-of-screen rendering ahead of - * edges; passing it on is otherwise optional. + * serviced sooner. Forward to {@link GeoTIFF.fromUrl}'s + * `getPriority` option alongside `concurrencyLimiter` to bias + * center-of-screen rendering ahead of edges. */ getPriority: () => Priority; }, @@ -68,7 +88,9 @@ export type MosaicLayerProps< ) => Layer | LayersList | null; }; -const defaultProps: Partial = {}; +const defaultProps: Partial = { + concurrencyLimiter: defaultConcurrencyLimiter, +}; /** * A deck.gl layer for rendering a mosaic of raster sources. @@ -146,9 +168,22 @@ export class MosaicLayer< const dy = sourceCy - lat; return Math.hypot(dx, dy); }; + // deck.gl fills `concurrencyLimiter` from `defaultProps` when the + // user doesn't supply it; an explicit `null` is the opt-out signal + // and gets forwarded as-is so `GeoTIFF.fromUrl` skips installing + // the middleware. Only `undefined` (e.g. types-only escape hatches) + // falls back to the default here. + const concurrencyLimiter = + this.props.concurrencyLimiter === undefined + ? defaultConcurrencyLimiter + : this.props.concurrencyLimiter; const userData = this.props.getSource && - (await this.props.getSource(index, { signal, getPriority })); + (await this.props.getSource(index, { + signal, + concurrencyLimiter, + getPriority, + })); return { source: index, From 55157395794f89ae3579f06bd6d4334ecde946f2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 16:59:23 -0400 Subject: [PATCH 20/46] feat(geotiff): gate header fetches through the concurrency limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header reads were temporarily un-gated during the priority work to isolate whether the data path alone was responsible for the bottom-left load order. Now that dynamic priority is in place and ordering works as expected, restore header gating so the browser's HTTP/1.1 6-per-origin cap isn't the only thing throttling header bursts — the limiter sees them, can interleave them with data fetches, and consumers keep explicit control over the queue. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/geotiff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index da53f200..ec7dc27a 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -327,7 +327,7 @@ export class GeoTIFF { const view = new SourceView(source, [ new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize }), - // ...(limiter ? [limiter] : []), + ...(limiter ? [limiter] : []), ]); const dataSource: Pick = limiter From a4b9777291b6ae3c9c9f85249628cb0034caeecc Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 17:00:58 -0400 Subject: [PATCH 21/46] refactor(examples/naip-mosaic): forward MosaicLayer opts straight to GeoTIFF.fromUrl MosaicLayer now defaults `concurrencyLimiter` to the shared `defaultConcurrencyLimiter` and threads it into `getSource`'s opts alongside `getPriority`, so the example no longer has to import the default limiter or stitch the two together by hand. Collapse `getCachedGeoTIFF` to take an `opts` object that gets spread directly into `GeoTIFF.fromUrl`. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/naip-mosaic/src/App.tsx | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 932c4de6..0ad5cd7c 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -1,8 +1,4 @@ -import { - COGLayer, - defaultConcurrencyLimiter, - MosaicLayer, -} from "@developmentseed/deck.gl-geotiff"; +import { COGLayer, MosaicLayer } from "@developmentseed/deck.gl-geotiff"; import type { RasterModule, RenderTileResult, @@ -15,7 +11,7 @@ import { LinearRescale, } from "@developmentseed/deck.gl-raster/gpu-modules"; import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png"; -import type { Overview, Priority } from "@developmentseed/geotiff"; +import type { Overview } from "@developmentseed/geotiff"; import { GeoTIFF } from "@developmentseed/geotiff"; import type { Device, Texture } from "@luma.gl/core"; import type { ShaderModule } from "@luma.gl/shadertools"; @@ -91,20 +87,16 @@ const geotiffCache = new Map>(); function getCachedGeoTIFF( url: string, - signal?: AbortSignal, - getPriority?: () => Priority, + opts: Parameters[1], ): Promise { let promise = geotiffCache.get(url); if (!promise) { - // Pass the shared module-level limiter so every header + tile-data fetch - // through this GeoTIFF's sources is gated against the per-origin slot - // pool — and re-ordered dynamically by `getPriority` (distance from - // viewport center, computed by MosaicLayer). - promise = GeoTIFF.fromUrl(url, { - signal, - concurrencyLimiter: defaultConcurrencyLimiter, - getPriority, - }).catch((err) => { + // MosaicLayer threads its `concurrencyLimiter` and `getPriority` through + // `opts`, so spreading here forwards both to `GeoTIFF.fromUrl` — every + // header + tile-data fetch through this GeoTIFF's sources is gated + // against the shared per-origin pool and re-ordered dynamically by + // distance from viewport center. + promise = GeoTIFF.fromUrl(url, opts).catch((err) => { geotiffCache.delete(url); throw err; }); @@ -427,8 +419,8 @@ export default function App() { // the MosaicLayer's TileLayer cache so we can keep cheap header metadata // around indefinitely without pinning every parent tile (and its inner // COGLayer's in-flight tile requests) in memory. - getSource: async (source, { signal, getPriority }) => - getCachedGeoTIFF(source.assets.image.href, signal, getPriority), + getSource: async (source, opts) => + getCachedGeoTIFF(source.assets.image.href, opts), renderSource: (source, { data, signal }) => { const url = source.assets.image.href; return new COGLayer({ From 4c6703b96afd66967e7b056c7a9dacdef042255c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 19 May 2026 17:04:24 -0400 Subject: [PATCH 22/46] docs(spec): cover dynamic priority + LimiterMiddleware + MosaicLayer Update 2026-05-19-concurrency-limiter-design.md to match what's actually shipped: chunkd `LimiterMiddleware` (not the original `limitFetch` function), both header and data paths gate (cache hits short-circuit before the limiter), dynamic `getPriority` re-evaluated per slot-open with linear-scan find-min, `Priority` type (`number | readonly number[]`, lex with trailing-zero fill), and MosaicLayer's viewport-distance closure threaded through `getSource`'s opts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-concurrency-limiter-design.md | 113 ++++++++++++++---- 1 file changed, 91 insertions(+), 22 deletions(-) diff --git a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md index 9fa8839c..85a718cb 100644 --- a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md +++ b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md @@ -20,8 +20,9 @@ deck.gl's `Tileset2D` fires a tile's abort signal in exactly one place: `_pruneR 1. Cap concurrent HTTP requests **per origin**, **shared across all layers** (and source formats — COG today, Zarr or similar tomorrow) targeting that origin. 2. Signal-aware queueing: when a queued request's `signal` aborts (e.g. user panned away), the request is dropped without firing a network call. -3. Zero-config default that works out of the box (cross-layer per-origin gating on, `maxRequests = 6`), with explicit opt-out and explicit override per layer. -4. No new dependency added; no implicit module-level state hidden inside `@developmentseed/geotiff`. +3. **Dynamic priority**: callers attach a `getPriority` callback to each fetch; the limiter re-evaluates it on every slot-open so the queue re-orders when the viewport pans, surfacing newly-central sources ahead of stale edge sources. +4. Zero-config default that works out of the box (cross-layer per-origin gating on, `maxRequests = 6`), with explicit opt-out and explicit override per layer. +5. No new dependency added; no implicit module-level state hidden inside `@developmentseed/geotiff`. ## Non-goals (deferred, not removed from consideration) @@ -33,16 +34,32 @@ deck.gl's `Tileset2D` fires a tile's abort signal in exactly one place: `_pruneR ## Architecture -Three types, all in `@developmentseed/geotiff`: +Four types, all in `@developmentseed/geotiff`: ```ts +/** Priority for one queued request. Numbers compare numerically; arrays + * compare lexicographically, with missing trailing elements treated as 0 + * (so `[1]` and `[1, 0, 0]` are equivalent, and `[1, -1]` jumps ahead of + * `[1]`). Lower = serviced sooner. */ +export type Priority = number | readonly number[]; + /** The public contract a layer / source can accept. */ export interface ConcurrencyLimiter { /** Acquire a slot to perform one fetch to `url`. Resolves to a release * function (call it once when the fetch settles). If `signal` aborts while * the call is queued, the promise rejects with the signal's reason and no - * slot is consumed. */ - acquire(url: URL, signal?: AbortSignal): Promise<() => void>; + * slot is consumed. + * + * `getPriority` is re-invoked on every slot-open: on each release, the + * limiter scans pending waiters, evaluates each one's `getPriority`, and + * resumes the lowest-priority waiter. Pan the viewport between releases + * and the next scan sees the new values — no explicit re-queue. Omit it + * (or have it return a constant) for FIFO behavior. */ + acquire( + url: URL, + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void>; } /** Default implementation. Maintains one Semaphore per URL origin; new origins @@ -50,32 +67,59 @@ export interface ConcurrencyLimiter { * same origin share one cap; two layers on different origins don't compete. */ export class PerOriginSemaphore implements ConcurrencyLimiter { constructor(opts: { maxRequests: number }); - acquire(url: URL, signal?: AbortSignal): Promise<() => void>; + acquire( + url: URL, + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void>; } // Internal (not exported from index.ts): -/** The standard counting semaphore primitive — FIFO queue, signal-aware - * acquire. Used by `PerOriginSemaphore` and `limitFetch`. */ +/** Counting semaphore with signal-aware acquire and dynamic priority. On each + * release, linear-scans the pending list, evaluates each waiter's + * `getPriority`, and resumes the min. Waiters without a `getPriority` are + * treated as `+Infinity` so explicit-priority waiters always run first; ties + * break by insertion order (so it degrades to FIFO when nobody supplies + * priorities). Linear scan rather than a heap because priorities change + * under the structure between releases — a heap would have to be rebuilt + * each time anyway. */ class Semaphore { constructor(opts: { maxRequests: number }); - acquire(signal?: AbortSignal): Promise<() => void>; + acquire( + signal?: AbortSignal, + getPriority?: () => Priority, + ): Promise<() => void>; } -/** Wrap a `Source.fetch` so each call goes through `limiter.acquire(url, signal)`, - * forwarding the call's signal so a queued abort drops the request. */ -function limitFetch(fetch: Fetch, url: URL, limiter: ConcurrencyLimiter): Fetch; +/** chunkd `SourceMiddleware` class, shape matches `SourceChunk` / `SourceCache`. + * Gates one `Source.fetch` through `limiter.acquire(url, signal, getPriority)`, + * forwarding the call's signal so a queued abort drops the request. Composes + * with the header source's existing middleware stack (chunking + cache → then + * limiter), so cache hits never burn a slot. */ +class LimiterMiddleware implements SourceMiddleware { + name: "limiter"; + constructor(opts: { + url: URL; + limiter: ConcurrencyLimiter; + getPriority?: () => Priority; + }); + fetch(req: SourceRequest, next: SourceFetch): Promise; +} ``` `Semaphore` is internal because users have no reason to construct one directly — `PerOriginSemaphore` is the public class. Keeping it internal also avoids the "which one do I use?" question. Promote later if someone wants a flat (single-pool) limiter. +`LimiterMiddleware` is also internal: the limiter is wired by `GeoTIFF.fromUrl` (where chunkd's `Source` / `SourceView` types are already in scope), so callers don't need to compose middleware themselves. + ## Integration ### `@developmentseed/geotiff` -- `GeoTIFF.fromUrl(url, { …, concurrencyLimiter? })` — `concurrencyLimiter: ConcurrencyLimiter | null | undefined`. When non-null, wraps the data source's `.fetch` via `limitFetch(fetch, new URL(url), concurrencyLimiter)` before constructing the `GeoTIFF`. When `null` or `undefined`, no gating. (`fromUrl` does *not* default to a shared limiter — that's a layer-level concern; see below.) -- `GeoTIFF.open({ … })` — unchanged. Users wanting gating with `open` wrap their sources themselves before calling. -- `Pick` is the only shape the wrapper needs; no `@chunkd/*` middleware machinery, no `SourceView`. +- `GeoTIFF.fromUrl(url, { …, concurrencyLimiter?, getPriority? })` — `concurrencyLimiter: ConcurrencyLimiter | null | undefined`, `getPriority: () => Priority`. When `concurrencyLimiter` is non-null, constructs `new LimiterMiddleware({ url, limiter, getPriority })` and slots it into both the header source's `SourceView` (after `SourceChunk` + `SourceCache`, so cache hits short-circuit before the limiter) and a tiny `SourceView` wrapping the raw data source. When `null` or `undefined`, no gating; `getPriority` is then a no-op. (`fromUrl` does *not* default to a shared limiter — that's a layer-level concern; see below.) +- `GeoTIFF.open({ … })` — unchanged. Users wanting gating with `open` wire the middleware themselves before calling. + +Both header and data fetches gate so the limiter has full visibility into per-origin demand; cache hits in the header path short-circuit before reaching the limiter (the middleware order in the `SourceView` puts `SourceCache` ahead of `LimiterMiddleware`). ### `@developmentseed/deck.gl-geotiff` @@ -113,7 +157,24 @@ type COGLayerProps = … & { The layer threads its prop into `fetchGeoTIFF(url, { concurrencyLimiter })` → `GeoTIFF.fromUrl(url, { concurrencyLimiter })`. When `props.geotiff` is a pre-opened `GeoTIFF` instance, the prop is ignored (doc note: "you already wired the limiter at `fromUrl`/`open` time"). -Same module-level default is reused by `MultiCOGLayer` (and any other layer that opens a `GeoTIFF`) so cross-layer-type sharing works out of the box. +Same module-level default is reused by `MultiCOGLayer` and `MosaicLayer` (and any other layer that opens a `GeoTIFF`) so cross-layer-type sharing works out of the box. + +#### `MosaicLayer` — viewport-aware priority + +`MosaicLayer`'s `getTileData` builds a `getPriority` closure per source: euclidean distance from `source.bbox` center to `this.context.viewport.{longitude, latitude}` in degree-space (just an ordering key — great-circle isn't needed). The closure reads `this.context.viewport` lazily, so each invocation sees the *current* viewport, not the one at queue time. + +The closure plus the layer's `concurrencyLimiter` (resolved as `defaultProps`-fill with explicit `null` preserved as opt-out) are passed through `getSource(source, opts)`. A consumer's `getSource` can spread `opts` straight into `GeoTIFF.fromUrl`, so a typical usage is one line: + +```ts +new MosaicLayer({ + sources, + getSource: async (source, opts) => + getCachedGeoTIFF(source.assets.image.href, opts), + renderSource: …, +}); +``` + +`COGLayer` / `MultiCOGLayer` don't (yet) thread a `getPriority` — they only have a single source per layer, so dynamic priority doesn't reorder anything across layer boundaries. If consumers want priority across many `COGLayer`s sharing one limiter, they can construct their `GeoTIFF` outside the layer and pass `getPriority` to `fromUrl` themselves. `RasterTileLayer.props.maxRequests` is unchanged — still passed through to deck.gl's `Tileset2D`. Independent cap from the source-level one; users typically leave it at deck.gl's default 6 so `_pruneRequests` keeps firing. @@ -122,18 +183,26 @@ Same module-level default is reused by `MultiCOGLayer` (and any other layer that 1. User pans. deck.gl's `Tileset2D._pruneRequests` fires `tile.abort()` for unselected in-flight tiles (because `ongoing > maxRequests`). 2. The tile's `AbortController.signal` aborts. `getTileData(tile, { signal })` (already awaiting our chain) sees it. 3. The signal threads through `fetchTile(image, { x, y, signal })` → `dataSource.fetch(offset, length, { signal })`. -4. Our `limitFetch` wrapper passes the signal to `limiter.acquire(url, signal)`: +4. `LimiterMiddleware` passes the signal to `limiter.acquire(url, signal, getPriority)`: - Already aborted on entry → reject immediately, no slot consumed. - Aborted while queued in the inner `Semaphore` → splice from the queue, reject, no slot consumed. - Aborted in-flight (after acquiring the slot) → the underlying `fetch` itself aborts via its own signal handling; the `finally` releases the slot. +## Priority flow + +1. Layer builds a `getPriority` closure that reads the current viewport when invoked. +2. Closure threads through `getSource(opts)` → `GeoTIFF.fromUrl({ getPriority })` → `LimiterMiddleware` → `limiter.acquire(url, signal, getPriority)` → `Semaphore.acquire(signal, getPriority)`. +3. On each release, `Semaphore` linear-scans pending waiters, calls each `getPriority()` (catching synchronous throws → treat as `+Infinity` so a broken closure can't deadlock the queue), picks the min, and resumes it. Panning between releases mutates the values the next scan sees — no explicit re-queue or external "reprioritize" call. +4. We don't use a priority queue / heap here because priorities are *not* stable under the structure: a heap built from one snapshot would have to be rebuilt each time the viewport moves. A linear scan is O(N) per release and trivially correct; queue depths in practice are bounded by the number of overlapping sources visible at once (single digits to low hundreds), so the constant factor is fine. + ## Testing -- `Semaphore` (unit): FIFO ordering; `maxRequests` honored; `acquire(signal)` rejects on already-aborted; aborts while queued splice cleanly without consuming a slot; release is idempotent. -- `PerOriginSemaphore` (unit): two different-origin `acquire`s don't compete; two same-origin acquires share one pool; per-origin Semaphores are minted lazily. -- `limitFetch` (unit): forwards `offset`/`length`/`options` unmodified; releases on resolve and on throw; forwards `options.signal` to `acquire`. -- `GeoTIFF.fromUrl({ concurrencyLimiter })` (integration, with a recording counting limiter wrapping a fixture file source): with `maxRequests: 1`, `peak in-flight` never exceeds 1; the data source's `.fetch` is gated, header reads are not. -- `COGLayer.defaultProps.concurrencyLimiter` (unit): two `COGLayer` instances without explicit prop end up with the same limiter instance. +- `Semaphore` (unit): FIFO ordering when no priority is given; `maxRequests` honored; `acquire(signal)` rejects on already-aborted; aborts while queued splice cleanly without consuming a slot; release is idempotent; explicit priorities serviced lowest-first; dynamic priority — a waiter's `getPriority` return value can change between releases and the next release picks the new min; ties break by insertion order; a `getPriority` that throws is treated as `+Infinity` (no deadlock). +- `PerOriginSemaphore` (unit): two different-origin `acquire`s don't compete; two same-origin acquires share one pool; per-origin Semaphores are minted lazily; `getPriority` is forwarded to the right per-origin Semaphore. +- `comparePriorities` (unit): numbers compared numerically; arrays compared lex; missing trailing elements treated as 0; mixed number / array comparisons. +- `LimiterMiddleware` (unit): forwards `offset`/`length`/`options` unmodified to `next`; releases on resolve and on throw; forwards `options.signal` and the constructor's `getPriority` to `limiter.acquire`. +- `GeoTIFF.fromUrl({ concurrencyLimiter, getPriority })` (integration, with a recording counting limiter wrapping a fixture file source): with `maxRequests: 1`, `peak in-flight` never exceeds 1; both header (cache miss) and data source `.fetch` are gated; cache hits in the header path don't burn a slot; `getPriority` is forwarded to every `acquire`. +- `COGLayer.defaultProps.concurrencyLimiter` (unit): `COGLayer`, `MultiCOGLayer`, and `MosaicLayer` instances without explicit prop end up with the same limiter instance. ## Future work (for design context, not built here) From 13ce197972e880a1955e79eb3ebdf0d9d0a81357 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 10:27:51 -0400 Subject: [PATCH 23/46] feat(geotiff): export GeoTIFFFromUrlOptions; fix stale gating docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract fromUrl's inline options object into an exported `GeoTIFFFromUrlOptions` interface so callers can name the type instead of reaching for `Parameters[1]` (PR #557 review). Also corrects the `concurrencyLimiter` doc, which still claimed header reads weren't gated — they are now, with cache hits short-circuiting before the limiter. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/geotiff.ts | 46 ++++++++++++++++++++++----------- packages/geotiff/src/index.ts | 1 + 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index ec7dc27a..98719bdc 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -19,6 +19,35 @@ import type { DecoderPool } from "./pool/pool.js"; import type { Tile } from "./tile.js"; import { createTransform, index, xy } from "./transform.js"; +/** Options for {@link GeoTIFF.fromUrl}. */ +export interface GeoTIFFFromUrlOptions { + /** Bytes per chunk for the header cache. Defaults to 64 KiB (matches + * geotiff.js's BlockedSource). */ + chunkSize?: number; + /** Total cache size in bytes. Defaults to 8 MiB (~128 blocks at the default + * chunk size). */ + cacheSize?: number; + /** An optional {@link AbortSignal} to cancel the header reads. */ + signal?: AbortSignal; + /** When true, the returned GeoTIFF logs each tile/mask data fetch to the + * console with offset/length and a `data`/`mask` label. Off by default. */ + debug?: boolean; + /** Caps concurrent HTTP requests for both the header/metadata and tile-data + * paths. Header reads go through the cached `SourceView`, so cache hits + * short-circuit before the limiter and never consume a slot — only network + * reads gate. Pass `null` to explicitly disable; omit (or pass `undefined`) + * for the same effect — `GeoTIFF.fromUrl` does *not* default to a shared + * limiter on its own. The deck.gl-geotiff layers default to a shared + * {@link PerOriginSemaphore} via their `defaultProps`. */ + concurrencyLimiter?: ConcurrencyLimiter | null; + /** Optional dynamic priority for every fetch through this GeoTIFF's sources. + * Re-invoked by the limiter on each slot-open, so closures over dynamic + * state (e.g. layer viewport center, tile bbox) re-sort the queue when that + * state changes. Lower = serviced sooner. Only meaningful when + * `concurrencyLimiter` is set. */ + getPriority?: () => Priority; +} + /** * A high-level GeoTIFF abstraction built on * {@link https://github.com/blacha/cogeotiff | @cogeotiff/core}'s `Tiff` and @@ -266,13 +295,7 @@ export class GeoTIFF { * bypass the cache and go straight to the raw HTTP source. * * @param url The URL of the GeoTIFF to open. - * @param options Optional parameters. - * @param options.chunkSize Bytes per chunk for the header cache. Defaults to 64 KiB (matches geotiff.js's BlockedSource). - * @param options.cacheSize Total cache size in bytes. Defaults to 8 MiB (~128 blocks at the default chunk size). - * @param options.signal An optional {@link AbortSignal} to cancel the header reads. - * @param options.debug When true, the returned GeoTIFF logs each tile/mask data fetch to the console with offset/length and a `data`/`mask` label. Off by default. - * @param options.concurrencyLimiter Caps concurrent HTTP requests for the *tile data* path. Header / metadata reads (through the cached SourceView) are not gated. Pass `null` to explicitly disable; omit (or pass `undefined`) for the same effect — `GeoTIFF.fromUrl` does *not* default to a shared limiter on its own. The deck.gl-geotiff layers default to a shared {@link PerOriginSemaphore} via their `defaultProps`. - * @param options.getPriority Optional dynamic priority for every fetch through this GeoTIFF's sources. Re-invoked by the limiter on each slot-open, so closures over dynamic state (e.g. layer viewport center, tile bbox) re-sort the queue when that state changes. Lower = serviced sooner. Only meaningful when `concurrencyLimiter` is set. + * @param options Optional parameters; see {@link GeoTIFFFromUrlOptions}. * @returns A Promise that resolves to a GeoTIFF instance. */ static async fromUrl( @@ -284,14 +307,7 @@ export class GeoTIFF { debug, concurrencyLimiter, getPriority, - }: { - chunkSize?: number; - cacheSize?: number; - signal?: AbortSignal; - debug?: boolean; - concurrencyLimiter?: ConcurrencyLimiter | null; - getPriority?: () => Priority; - } = {}, + }: GeoTIFFFromUrlOptions = {}, ): Promise { const source = new SourceHttp(url, {}); diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index c87439f3..8f2b7a1f 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -16,6 +16,7 @@ export type { DecoderMetadata, } from "./decode.js"; export { DECODER_REGISTRY } from "./decode.js"; +export type { GeoTIFFFromUrlOptions } from "./geotiff.js"; export { GeoTIFF } from "./geotiff.js"; export type { CachedTags, GeoKeyDirectory } from "./ifd.js"; export type { ConcurrencyLimiter, Priority } from "./limiter.js"; From acd46d6d7b727dd31ede91f19a45be07c3075842 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 10:28:02 -0400 Subject: [PATCH 24/46] fix(deck.gl-geotiff): address PR #557 review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancel in-flight header reads: COGLayer / MultiCOGLayer now hold an AbortController per parse, aborting the prior open when the geotiff/sources prop changes and on finalizeState, so stale reads release their limiter slot immediately. fetchGeoTIFF threads the signal through to GeoTIFF.fromUrl. - MosaicLayer only supplies getPriority for geographic viewports (WebMercatorViewport / _GlobeViewport), where the source bbox and viewport center share a lon/lat space; otherwise it's omitted and the limiter falls back to FIFO. - Drop MosaicLayer's redundant `=== undefined` defaulting — defaultProps already resolves concurrencyLimiter, so forward the prop straight through. - Rename the shared module-level default to DEFAULT_CONCURRENCY_LIMITER (UPPER_CASE constant). - Example forwards MosaicLayer's opts straight into GeoTIFF.fromUrl via the new GeoTIFFFromUrlOptions type; trim the verbose comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-concurrency-limiter-design.md | 4 +- examples/naip-mosaic/src/App.tsx | 11 ++- packages/deck.gl-geotiff/src/cog-layer.ts | 43 ++++++++++-- .../src/default-concurrency-limiter.ts | 2 +- .../deck.gl-geotiff/src/geotiff/geotiff.ts | 5 ++ packages/deck.gl-geotiff/src/index.ts | 2 +- .../src/mosaic-layer/mosaic-layer.ts | 66 ++++++++++-------- .../deck.gl-geotiff/src/multi-cog-layer.ts | 68 ++++++++++++++----- .../tests/concurrency-limiter.test.ts | 15 +++- 9 files changed, 152 insertions(+), 64 deletions(-) diff --git a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md index 85a718cb..61d3404e 100644 --- a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md +++ b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md @@ -132,7 +132,7 @@ import { PerOriginSemaphore } from "@developmentseed/geotiff"; /** Shared by every COGLayer / MultiCOGLayer that doesn't override its * concurrencyLimiter prop, so multiple layers on the same origin share one * HTTP/1.1 connection pool. */ -export const defaultConcurrencyLimiter = new PerOriginSemaphore({ maxRequests: 6 }); +export const DEFAULT_CONCURRENCY_LIMITER = new PerOriginSemaphore({ maxRequests: 6 }); ``` `COGLayer`: @@ -141,7 +141,7 @@ export const defaultConcurrencyLimiter = new PerOriginSemaphore({ maxRequests: 6 class COGLayer extends RasterTileLayer { static override defaultProps = { ...RasterTileLayer.defaultProps, - concurrencyLimiter: defaultConcurrencyLimiter, + concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER, }; } diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index a11f3053..f5978328 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -11,7 +11,7 @@ import { LinearRescale, } from "@developmentseed/deck.gl-raster/gpu-modules"; import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png"; -import type { Overview } from "@developmentseed/geotiff"; +import type { GeoTIFFFromUrlOptions, Overview } from "@developmentseed/geotiff"; import { GeoTIFF } from "@developmentseed/geotiff"; import type { Device, Texture } from "@luma.gl/core"; import type { ShaderModule } from "@luma.gl/shadertools"; @@ -87,15 +87,12 @@ const geotiffCache = new Map>(); function getCachedGeoTIFF( url: string, - opts: Parameters[1], + opts: GeoTIFFFromUrlOptions, ): Promise { let promise = geotiffCache.get(url); if (!promise) { - // MosaicLayer threads its `concurrencyLimiter` and `getPriority` through - // `opts`, so spreading here forwards both to `GeoTIFF.fromUrl` — every - // header + tile-data fetch through this GeoTIFF's sources is gated - // against the shared per-origin pool and re-ordered dynamically by - // distance from viewport center. + // Forward MosaicLayer's opts (concurrencyLimiter, getPriority, signal) + // straight through to GeoTIFF.fromUrl. promise = GeoTIFF.fromUrl(url, opts).catch((err) => { geotiffCache.delete(url); throw err; diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 20fec830..bee9e52e 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -1,4 +1,4 @@ -import type { UpdateParameters } from "@deck.gl/core"; +import type { LayerContext, UpdateParameters } from "@deck.gl/core"; import type { MinimalTileData, GetTileDataOptions as RasterTileGetTileDataOptions, @@ -23,7 +23,7 @@ import { } from "@developmentseed/proj"; import type { Texture } from "@luma.gl/core"; import proj4 from "proj4"; -import { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; +import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import type { TextureDataT } from "./geotiff/render-pipeline.js"; import { inferRenderPipeline } from "./geotiff/render-pipeline.js"; @@ -168,7 +168,7 @@ export class COGLayer< static override defaultProps = { ...RasterTileLayer.defaultProps, epsgResolver, - concurrencyLimiter: defaultConcurrencyLimiter, + concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER, } as typeof RasterTileLayer.defaultProps; declare state: { @@ -176,12 +176,20 @@ export class COGLayer< tilesetDescriptor?: RasterTilesetDescriptor; defaultGetTileData?: COGLayerProps["getTileData"]; defaultRenderTile?: COGLayerProps["renderTile"]; + /** Aborts the in-flight header read when the `geotiff` prop changes or the + * layer is removed, freeing its limiter slot for fresh work. */ + abortController?: AbortController; }; override initializeState(): void { this.setState({}); } + override finalizeState(context: LayerContext): void { + this.state.abortController?.abort(); + super.finalizeState(context); + } + override updateState(params: UpdateParameters) { super.updateState(params); @@ -208,9 +216,26 @@ export class COGLayer< } async _parseGeoTIFF(): Promise { - const geotiff = await fetchGeoTIFF(this.props.geotiff, { - concurrencyLimiter: this.props.concurrencyLimiter, - }); + // Abort any header read still in flight from a previous `geotiff` prop, + // then open a fresh controller for this one. + this.state.abortController?.abort(); + const abortController = new AbortController(); + const { signal } = abortController; + this.setState({ abortController }); + + let geotiff: GeoTIFF; + try { + geotiff = await fetchGeoTIFF(this.props.geotiff, { + concurrencyLimiter: this.props.concurrencyLimiter, + signal, + }); + } catch (err) { + // A newer prop (or layer removal) aborted us; drop the stale open. + if (signal.aborted) { + return; + } + throw err; + } const crs = geotiff.crs; const sourceProjection = typeof crs === "number" @@ -269,6 +294,12 @@ export class COGLayer< inferRenderPipeline(geotiff, this.context.device)); } + // A newer prop (or layer removal) superseded this open while we were + // resolving the projection; don't clobber state with stale results. + if (signal.aborted) { + return; + } + this.setState({ geotiff, tilesetDescriptor, diff --git a/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts b/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts index 93e64533..52755645 100644 --- a/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts +++ b/packages/deck.gl-geotiff/src/default-concurrency-limiter.ts @@ -7,6 +7,6 @@ import { PerOriginSemaphore } from "@developmentseed/geotiff"; * same origin (e.g. the same S3 bucket) share *one* HTTP/1.1 connection * pool. The cap matches Chrome's default per-origin HTTP/1.1 limit. */ -export const defaultConcurrencyLimiter = new PerOriginSemaphore({ +export const DEFAULT_CONCURRENCY_LIMITER = new PerOriginSemaphore({ maxRequests: 6, }); diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index 29c7ab21..451d9e1d 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -68,12 +68,17 @@ export async function fetchGeoTIFF( * fetch through this GeoTIFF's sources. Only meaningful when * `concurrencyLimiter` is set. */ getPriority?: () => Priority; + /** Forwarded to {@link GeoTIFF.fromUrl} to cancel the header reads when + * the opening layer is removed or its source prop changes mid-flight. + * Ignored when `input` is already a `GeoTIFF` or `ArrayBuffer`. */ + signal?: AbortSignal; } = {}, ): Promise { if (typeof input === "string" || input instanceof URL) { return await GeoTIFF.fromUrl(input, { concurrencyLimiter: options.concurrencyLimiter, getPriority: options.getPriority, + signal: options.signal, }); } diff --git a/packages/deck.gl-geotiff/src/index.ts b/packages/deck.gl-geotiff/src/index.ts index 4ca2d6b6..9a6a59f1 100644 --- a/packages/deck.gl-geotiff/src/index.ts +++ b/packages/deck.gl-geotiff/src/index.ts @@ -3,7 +3,7 @@ export type { GetTileDataOptions, } from "./cog-layer.js"; export { COGLayer } from "./cog-layer.js"; -export { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; +export { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; export { addAlphaChannel } from "./geotiff/geotiff.js"; export * as texture from "./geotiff/texture.js"; export type { MosaicLayerProps } from "./mosaic-layer/mosaic-layer.js"; diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 199e99f6..1402abd3 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -5,12 +5,16 @@ import type { LayersList, UpdateParameters, } from "@deck.gl/core"; -import { CompositeLayer } from "@deck.gl/core"; +import { + _GlobeViewport, + CompositeLayer, + WebMercatorViewport, +} from "@deck.gl/core"; import type { TileLayerProps } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; import type { ConcurrencyLimiter, Priority } from "@developmentseed/geotiff"; import Flatbush from "flatbush"; -import { defaultConcurrencyLimiter } from "../default-concurrency-limiter.js"; +import { DEFAULT_CONCURRENCY_LIMITER } from "../default-concurrency-limiter.js"; import type { MosaicSource } from "./mosaic-tileset-2d.js"; import { MosaicTileset2D } from "./mosaic-tileset-2d.js"; @@ -68,11 +72,11 @@ export type MosaicLayerProps< signal?: AbortSignal; /** * The layer's current `concurrencyLimiter` prop (default - * {@link defaultConcurrencyLimiter}). Forward to + * {@link DEFAULT_CONCURRENCY_LIMITER}). Forward to * {@link GeoTIFF.fromUrl}'s `concurrencyLimiter` option so this * source's fetches join the shared per-origin queue. */ - concurrencyLimiter: ConcurrencyLimiter | null; + concurrencyLimiter?: ConcurrencyLimiter | null; /** * Dynamic priority for fetches related to this source. Re-invoked by * the limiter on every slot-open, so the queue re-sorts on viewport @@ -81,8 +85,14 @@ export type MosaicLayerProps< * serviced sooner. Forward to {@link GeoTIFF.fromUrl}'s * `getPriority` option alongside `concurrencyLimiter` to bias * center-of-screen rendering ahead of edges. + * + * Only provided for geographic viewports (`WebMercatorViewport` / + * `_GlobeViewport`), where the source bbox and viewport center share + * a lon/lat space. Omitted (`undefined`) otherwise, so the limiter + * falls back to FIFO instead of comparing mismatched coordinate + * units. */ - getPriority: () => Priority; + getPriority?: () => Priority; }, ) => Promise; @@ -122,7 +132,7 @@ export type MosaicLayerProps< }; const defaultProps: Partial = { - concurrencyLimiter: defaultConcurrencyLimiter, + concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER, sources: [], }; @@ -230,35 +240,35 @@ export class MosaicLayer< // needed). Re-evaluated on every limiter slot-open, so panning the // viewport re-sorts the queue and pulls newly-central sources to the // front of the line ahead of older edge sources. + // + // Only meaningful for geographic viewports, where the source bbox and + // the viewport center share a lon/lat space. For any other viewport we + // leave `getPriority` undefined so the limiter falls back to FIFO. const [minX, minY, maxX, maxY] = index.bbox; const sourceCx = (minX + maxX) / 2; const sourceCy = (minY + maxY) / 2; - const getPriority = (): number => { - const { viewport } = this.context; - // Viewport exposes longitude/latitude for geographic viewports; - // fall back to (0, 0) defensively if not available. - const lon = - "longitude" in viewport ? (viewport.longitude as number) : 0; - const lat = - "latitude" in viewport ? (viewport.latitude as number) : 0; - const dx = sourceCx - lon; - const dy = sourceCy - lat; - return Math.hypot(dx, dy); - }; - // deck.gl fills `concurrencyLimiter` from `defaultProps` when the - // user doesn't supply it; an explicit `null` is the opt-out signal - // and gets forwarded as-is so `GeoTIFF.fromUrl` skips installing - // the middleware. Only `undefined` (e.g. types-only escape hatches) - // falls back to the default here. - const concurrencyLimiter = - this.props.concurrencyLimiter === undefined - ? defaultConcurrencyLimiter - : this.props.concurrencyLimiter; + const isGeographic = + this.context.viewport instanceof WebMercatorViewport || + this.context.viewport instanceof _GlobeViewport; + const getPriority = isGeographic + ? (): number => { + // Re-read the viewport each call so panning re-sorts the queue. + const viewport = this.context.viewport as + | WebMercatorViewport + | _GlobeViewport; + const dx = sourceCx - viewport.longitude; + const dy = sourceCy - viewport.latitude; + return Math.hypot(dx, dy); + } + : undefined; + // `concurrencyLimiter` is filled from `defaultProps`, so the prop is + // already resolved (the shared default, a user override, or an + // explicit `null` to disable) — forward it straight through. const userData = this.props.getSource && (await this.props.getSource(index, { signal, - concurrencyLimiter, + concurrencyLimiter: this.props.concurrencyLimiter, getPriority, })); diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index f2c61d71..04b6eeb8 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -1,6 +1,7 @@ import type { CompositeLayerProps, Layer, + LayerContext, UpdateParameters, } from "@deck.gl/core"; import type { @@ -39,7 +40,7 @@ import type { RasterArray, } from "@developmentseed/geotiff"; import { assembleTiles, defaultDecoderPool } from "@developmentseed/geotiff"; -import type { EpsgResolver } from "@developmentseed/proj"; +import type { EpsgResolver, ProjectionDefinition } from "@developmentseed/proj"; import { epsgResolver as defaultEpsgResolver, makeClampedForwardTo3857, @@ -48,7 +49,7 @@ import { } from "@developmentseed/proj"; import type { Device, Texture, TextureFormat } from "@luma.gl/core"; import proj4 from "proj4"; -import { defaultConcurrencyLimiter } from "./default-concurrency-limiter.js"; +import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; @@ -275,7 +276,7 @@ const defaultProps = { ...RasterTileLayer.defaultProps, epsgResolver: { type: "accessor" as const, value: defaultEpsgResolver }, debugLevel: { type: "number" as const, value: 1 }, - concurrencyLimiter: defaultConcurrencyLimiter, + concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER, }; /** @@ -305,6 +306,9 @@ export class MultiCOGLayer extends RasterTileLayer< declare state: { sources: Map | null; multiDescriptor: MultiRasterTilesetDescriptor | null; + /** Aborts the in-flight header reads when the `sources` prop changes or the + * layer is removed, freeing their limiter slots for fresh work. */ + abortController?: AbortController; }; override initializeState(): void { @@ -314,6 +318,11 @@ export class MultiCOGLayer extends RasterTileLayer< }); } + override finalizeState(context: LayerContext): void { + this.state.abortController?.abort(); + super.finalizeState(context); + } + override updateState({ changeFlags, props, @@ -344,20 +353,41 @@ export class MultiCOGLayer extends RasterTileLayer< const { sources } = this.props; const entries = Object.entries(sources); + // Abort any header reads still in flight from a previous `sources` prop, + // then open a fresh controller for this batch. + this.state.abortController?.abort(); + const abortController = new AbortController(); + const { signal } = abortController; + this.setState({ abortController }); + // Open all COGs in parallel - const cogSources = await Promise.all( - entries.map(async ([name, config]) => { - const geotiff = await fetchGeoTIFF(config.url, { - concurrencyLimiter: this.props.concurrencyLimiter, - }); - const crs = geotiff.crs; - const sourceProjection = - typeof crs === "number" - ? await this.props.epsgResolver!(crs) - : parseWkt(crs); - return { name, geotiff, sourceProjection }; - }), - ); + let cogSources: Array<{ + name: string; + geotiff: GeoTIFF; + sourceProjection: ProjectionDefinition; + }>; + try { + cogSources = await Promise.all( + entries.map(async ([name, config]) => { + const geotiff = await fetchGeoTIFF(config.url, { + concurrencyLimiter: this.props.concurrencyLimiter, + signal, + }); + const crs = geotiff.crs; + const sourceProjection = + typeof crs === "number" + ? await this.props.epsgResolver!(crs) + : parseWkt(crs); + return { name, geotiff, sourceProjection }; + }), + ); + } catch (err) { + // A newer prop (or layer removal) aborted us; drop the stale opens. + if (signal.aborted) { + return; + } + throw err; + } // Use the first source's projection for shared projection functions // (all sources must share the same CRS) @@ -411,6 +441,12 @@ export class MultiCOGLayer extends RasterTileLayer< const multiDescriptor = createMultiRasterTilesetDescriptor(tilesetMap); + // A newer prop (or layer removal) superseded this batch while we were + // resolving projections; don't clobber state with stale results. + if (signal.aborted) { + return; + } + this.setState({ sources: sourceMap, multiDescriptor, diff --git a/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts index 67d67aba..b4660732 100644 --- a/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts +++ b/packages/deck.gl-geotiff/tests/concurrency-limiter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { COGLayer } from "../src/cog-layer.js"; -import { defaultConcurrencyLimiter } from "../src/default-concurrency-limiter.js"; +import { DEFAULT_CONCURRENCY_LIMITER } from "../src/default-concurrency-limiter.js"; +import { MosaicLayer } from "../src/mosaic-layer/mosaic-layer.js"; import { MultiCOGLayer } from "../src/multi-cog-layer.js"; describe("COGLayer default concurrencyLimiter", () => { @@ -9,7 +10,7 @@ describe("COGLayer default concurrencyLimiter", () => { // declaration site, so the field isn't visible on its static type. The // *value* is still the one we want. expect(COGLayer.defaultProps.concurrencyLimiter).toBe( - defaultConcurrencyLimiter, + DEFAULT_CONCURRENCY_LIMITER, ); }); }); @@ -18,7 +19,7 @@ describe("MultiCOGLayer default concurrencyLimiter", () => { it("defaultProps.concurrencyLimiter is the same shared instance as COGLayer's", () => { // @ts-expect-error — see COGLayer test above expect(MultiCOGLayer.defaultProps.concurrencyLimiter).toBe( - defaultConcurrencyLimiter, + DEFAULT_CONCURRENCY_LIMITER, ); // @ts-expect-error expect(MultiCOGLayer.defaultProps.concurrencyLimiter).toBe( @@ -27,3 +28,11 @@ describe("MultiCOGLayer default concurrencyLimiter", () => { ); }); }); + +describe("MosaicLayer default concurrencyLimiter", () => { + it("defaultProps.concurrencyLimiter is the same shared instance", () => { + expect(MosaicLayer.defaultProps.concurrencyLimiter).toBe( + DEFAULT_CONCURRENCY_LIMITER, + ); + }); +}); From 6d8d24186a98726e57b4ad455621fb9b2ffaaf8e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 10:48:14 -0400 Subject: [PATCH 25/46] fix(geotiff): thread signal into getTileSize header reads The tile-fetch path resolves each tile's offset/byte-count via image.getTileSize before reading the data. Those are header-source reads (lazy per-entry, served by the chunk cache, but a real network fetch on a cache miss) and now go through the concurrency limiter too. Without the signal, an aborted tile fetch left the getTileSize read running and holding its limiter slot. Forward the signal through getTile, getTiles, and the band-separate range lookup so the offset reads abort and release their slot alongside the data fetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/geotiff/src/fetch.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 88760897..4d7c98e8 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -326,6 +326,7 @@ async function findBandSeparateTileByteRanges( self: HasTiffReference, x: number, y: number, + signal?: AbortSignal, ): Promise { // TODO: error here if user-provided band-indexes are out of bounds const { x: tilesPerRow, y: tilesPerColumn } = self.image.tileCount; @@ -333,7 +334,7 @@ async function findBandSeparateTileByteRanges( const numBands = self.cachedTags.samplesPerPixel; const tileSizes = [...Array(numBands).keys()].map((band) => { const bandIdx = band * tilesPerBand + y * tilesPerRow + x; - return self.image.getTileSize(bandIdx); + return self.image.getTileSize(bandIdx, { signal }); }); return Promise.all(tileSizes); } @@ -351,7 +352,7 @@ async function fetchBandSeparateTileBytes( const debug: DebugTag | undefined = self._debug ? { label: "data" } : undefined; - const byteRanges = await findBandSeparateTileByteRanges(self, x, y); + const byteRanges = await findBandSeparateTileByteRanges(self, x, y, signal); const buffers = byteRanges.map(async ({ offset, imageSize }) => { const tile = await getBytes( self.image, @@ -429,7 +430,7 @@ async function fetchBandSeparateTileBytesMultiple( : undefined; const numBands = self.cachedTags.samplesPerPixel; const perTileRanges = await Promise.all( - xy.map(([x, y]) => findBandSeparateTileByteRanges(self, x, y)), + xy.map(([x, y]) => findBandSeparateTileByteRanges(self, x, y, signal)), ); const flatRanges = perTileRanges.flatMap((ranges) => ranges.map(({ offset, imageSize }) => ({ @@ -505,8 +506,11 @@ async function getTile( // image.getTileSize() reads TileOffsets[idx] and TileByteCounts[idx] from // the header source (cogeotiff's lazy per-entry path, served by the chunk // cache). It does NOT read tile data — only the 4–8 byte offset/count - // entries. - const { offset, imageSize } = await image.getTileSize(idx); + // entries. Thread the signal so a cache-miss read aborts (and releases its + // limiter slot) alongside the data fetch. + const { offset, imageSize } = await image.getTileSize(idx, { + signal: options?.signal, + }); // The actual tile bytes go through dataSource (uncached HTTP). return getBytes(image, offset, imageSize, dataSource, options); @@ -687,7 +691,9 @@ export async function getTiles( return idx; }); - const sizes = await Promise.all(indices.map((i) => image.getTileSize(i))); + const sizes = await Promise.all( + indices.map((i) => image.getTileSize(i, { signal: options?.signal })), + ); return getMultipleBytes( image, sizes.map((s) => ({ offset: s.offset, byteCount: s.imageSize })), From 45d13c8071cc5757ab709e448a1e3ed457a74294 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 11:28:15 -0400 Subject: [PATCH 26/46] options object --- packages/geotiff/src/fetch.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 4d7c98e8..299abc2d 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -326,7 +326,7 @@ async function findBandSeparateTileByteRanges( self: HasTiffReference, x: number, y: number, - signal?: AbortSignal, + options?: { signal?: AbortSignal }, ): Promise { // TODO: error here if user-provided band-indexes are out of bounds const { x: tilesPerRow, y: tilesPerColumn } = self.image.tileCount; @@ -334,7 +334,7 @@ async function findBandSeparateTileByteRanges( const numBands = self.cachedTags.samplesPerPixel; const tileSizes = [...Array(numBands).keys()].map((band) => { const bandIdx = band * tilesPerBand + y * tilesPerRow + x; - return self.image.getTileSize(bandIdx, { signal }); + return self.image.getTileSize(bandIdx, options); }); return Promise.all(tileSizes); } @@ -343,16 +343,15 @@ async function fetchBandSeparateTileBytes( self: HasTiffReference, x: number, y: number, - { - signal, - }: { + options: { signal?: AbortSignal; } = {}, ): Promise { + const { signal } = options; const debug: DebugTag | undefined = self._debug ? { label: "data" } : undefined; - const byteRanges = await findBandSeparateTileByteRanges(self, x, y, signal); + const byteRanges = await findBandSeparateTileByteRanges(self, x, y, options); const buffers = byteRanges.map(async ({ offset, imageSize }) => { const tile = await getBytes( self.image, @@ -419,18 +418,17 @@ async function fetchCogBytesMultiple( async function fetchBandSeparateTileBytesMultiple( self: HasTiffReference, xy: Array<[number, number]>, - { - signal, - }: { + options: { signal?: AbortSignal; } = {}, ): Promise { + const { signal } = options; const debug: DebugTag | undefined = self._debug ? { label: "data" } : undefined; const numBands = self.cachedTags.samplesPerPixel; const perTileRanges = await Promise.all( - xy.map(([x, y]) => findBandSeparateTileByteRanges(self, x, y, signal)), + xy.map(([x, y]) => findBandSeparateTileByteRanges(self, x, y, options)), ); const flatRanges = perTileRanges.flatMap((ranges) => ranges.map(({ offset, imageSize }) => ({ From 7e881b38b47d1a369319514d95477d18783c3451 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 12:36:59 -0400 Subject: [PATCH 27/46] fix(deck.gl-geotiff): drop debounceTime from MosaicLayer A non-zero debounceTime stalls all source loading in MosaicLayer: deck.gl's RequestScheduler re-checks `isSelected` when the debounce timer fires and cancels anything not selected at that instant, and the flat, zoomless MosaicTileset2D model churns selection under viewport changes, so requests are perpetually cancelled and re-scheduled and nothing loads. Remove the prop from MosaicLayerProps and stop forwarding it to the inner TileLayer. COGLayer / MultiCOGLayer keep debounceTime support (hierarchical RasterTileLayer tiles, where it works). Tracked for a real fix in #562. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 1402abd3..5d0ff364 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -24,7 +24,12 @@ export type MosaicLayerProps< > = CompositeLayerProps & Pick< TileLayerProps, - | "debounceTime" + // NOTE: `debounceTime` is intentionally NOT exposed. deck.gl's + // RequestScheduler re-checks `isSelected` when the debounce timer fires + // and cancels anything not selected at that instant; MosaicLayer's flat, + // zoomless tile model churns selection under viewport changes, so a + // non-zero debounce leaves tiles perpetually cancelled and nothing loads. + // Tracked in https://github.com/developmentseed/deck.gl-raster/issues/562 | "extent" | "maxCacheByteSize" | "maxCacheSize" @@ -192,7 +197,6 @@ export class MosaicLayer< id, minZoom, maxZoom, - debounceTime, extent, maxCacheByteSize, maxCacheSize, @@ -223,7 +227,6 @@ export class MosaicLayer< TilesetClass: MosaicTileset2DFactory, minZoom, maxZoom, - debounceTime, extent, ...(maxCacheByteSize !== undefined && { maxCacheByteSize }), maxCacheSize, From 7b18d1b1178792247d99b80c48c2568430e14fcf Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 12:39:49 -0400 Subject: [PATCH 28/46] shorter comment --- packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 5d0ff364..f65f453e 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -24,12 +24,8 @@ export type MosaicLayerProps< > = CompositeLayerProps & Pick< TileLayerProps, - // NOTE: `debounceTime` is intentionally NOT exposed. deck.gl's - // RequestScheduler re-checks `isSelected` when the debounce timer fires - // and cancels anything not selected at that instant; MosaicLayer's flat, - // zoomless tile model churns selection under viewport changes, so a - // non-zero debounce leaves tiles perpetually cancelled and nothing loads. - // Tracked in https://github.com/developmentseed/deck.gl-raster/issues/562 + // NOTE: `debounceTime` is intentionally not exposed. + // See https://github.com/developmentseed/deck.gl-raster/issues/562 | "extent" | "maxCacheByteSize" | "maxCacheSize" From 860abac3b0fe4847b58adbd562bbc018625087cb Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 13:57:33 -0400 Subject: [PATCH 29/46] refactor(deck.gl-geotiff): simplify COG layer AbortController to finalize-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #557 review: the per-parse abort-and-recreate handled the geotiff/sources prop changing mid-open, which is rare and was the "overkill" part. Drop it. Keep one controller for the layer's lifetime (created in initializeState, aborted in finalizeState) so a header read still in flight when the layer is removed is cancelled and its limiter slot freed — the one clearly useful case. MosaicLayer needs no controller of its own; it opens GeoTIFFs inside getSource during a tile load, where deck.gl already supplies a per-tile abort signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deck.gl-geotiff/src/cog-layer.ts | 22 ++++++++--------- .../deck.gl-geotiff/src/multi-cog-layer.ts | 24 +++++++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index bee9e52e..9655d96f 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -182,7 +182,10 @@ export class COGLayer< }; override initializeState(): void { - this.setState({}); + // One controller for the layer's lifetime; aborted in finalizeState so a + // header read still in flight when the layer is removed is cancelled and + // its limiter slot freed. + this.setState({ abortController: new AbortController() }); } override finalizeState(context: LayerContext): void { @@ -216,12 +219,7 @@ export class COGLayer< } async _parseGeoTIFF(): Promise { - // Abort any header read still in flight from a previous `geotiff` prop, - // then open a fresh controller for this one. - this.state.abortController?.abort(); - const abortController = new AbortController(); - const { signal } = abortController; - this.setState({ abortController }); + const signal = this.state.abortController?.signal; let geotiff: GeoTIFF; try { @@ -230,8 +228,8 @@ export class COGLayer< signal, }); } catch (err) { - // A newer prop (or layer removal) aborted us; drop the stale open. - if (signal.aborted) { + // Layer removed mid-open (finalizeState aborted the signal); drop it. + if (signal?.aborted) { return; } throw err; @@ -294,9 +292,9 @@ export class COGLayer< inferRenderPipeline(geotiff, this.context.device)); } - // A newer prop (or layer removal) superseded this open while we were - // resolving the projection; don't clobber state with stale results. - if (signal.aborted) { + // Layer was removed while we resolved the projection; don't setState on a + // finalized layer. + if (signal?.aborted) { return; } diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 04b6eeb8..ae6fb640 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -306,8 +306,8 @@ export class MultiCOGLayer extends RasterTileLayer< declare state: { sources: Map | null; multiDescriptor: MultiRasterTilesetDescriptor | null; - /** Aborts the in-flight header reads when the `sources` prop changes or the - * layer is removed, freeing their limiter slots for fresh work. */ + /** Aborts the in-flight header reads when the layer is removed, freeing + * their limiter slots for fresh work. */ abortController?: AbortController; }; @@ -315,6 +315,9 @@ export class MultiCOGLayer extends RasterTileLayer< this.setState({ sources: null, multiDescriptor: null, + // One controller for the layer's lifetime; aborted in finalizeState so + // header reads still in flight when the layer is removed are cancelled. + abortController: new AbortController(), }); } @@ -353,12 +356,7 @@ export class MultiCOGLayer extends RasterTileLayer< const { sources } = this.props; const entries = Object.entries(sources); - // Abort any header reads still in flight from a previous `sources` prop, - // then open a fresh controller for this batch. - this.state.abortController?.abort(); - const abortController = new AbortController(); - const { signal } = abortController; - this.setState({ abortController }); + const signal = this.state.abortController?.signal; // Open all COGs in parallel let cogSources: Array<{ @@ -382,8 +380,8 @@ export class MultiCOGLayer extends RasterTileLayer< }), ); } catch (err) { - // A newer prop (or layer removal) aborted us; drop the stale opens. - if (signal.aborted) { + // Layer removed mid-open (finalizeState aborted the signal); drop it. + if (signal?.aborted) { return; } throw err; @@ -441,9 +439,9 @@ export class MultiCOGLayer extends RasterTileLayer< const multiDescriptor = createMultiRasterTilesetDescriptor(tilesetMap); - // A newer prop (or layer removal) superseded this batch while we were - // resolving projections; don't clobber state with stale results. - if (signal.aborted) { + // Layer was removed while we resolved projections; don't setState on a + // finalized layer. + if (signal?.aborted) { return; } From 0d57f736329c58140a270278463db320a872cd91 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 14:19:51 -0400 Subject: [PATCH 30/46] refactor(deck.gl-geotiff): extract createGetPriorityCallback helper Per PR #557 review: the inline getPriority construction in MosaicLayer.getTileData was verbose. Pull it into a module-level createGetPriorityCallback(bbox, getViewport) helper that returns the distance-from-viewport-center callback (or undefined for non-geographic viewports). No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mosaic-layer/mosaic-layer.ts | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index f65f453e..1fdf2f40 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -4,6 +4,7 @@ import type { LayerContext, LayersList, UpdateParameters, + Viewport, } from "@deck.gl/core"; import { _GlobeViewport, @@ -137,6 +138,44 @@ const defaultProps: Partial = { sources: [], }; +/** + * Build the limiter `getPriority` callback for one mosaic source: euclidean + * distance from the source's bbox center to the current viewport center, in + * lon/lat degree-space (just an ordering key — great-circle isn't needed). + * + * `getViewport` is read on every call, so the limiter re-sorts its queue as + * the viewport pans, pulling newly-central sources ahead of edge sources. + * + * Returns `undefined` for non-geographic viewports — where the source bbox and + * viewport center don't share a coordinate space — so the limiter falls back + * to FIFO instead of comparing mismatched units. The viewport type is checked + * once here; it isn't expected to change under the layer. + */ +function createGetPriorityCallback( + bbox: readonly [number, number, number, number], + getViewport: () => Viewport, +): (() => number) | undefined { + const viewport = getViewport(); + if ( + !(viewport instanceof WebMercatorViewport) && + !(viewport instanceof _GlobeViewport) + ) { + return undefined; + } + + const [minX, minY, maxX, maxY] = bbox; + const sourceCx = (minX + maxX) / 2; + const sourceCy = (minY + maxY) / 2; + + return (): number => { + // Geographic viewport (checked above); both types expose lon/lat. + const v = getViewport() as WebMercatorViewport | _GlobeViewport; + const dx = sourceCx - v.longitude; + const dy = sourceCy - v.latitude; + return Math.hypot(dx, dy); + }; +} + /** * A deck.gl layer for rendering a mosaic of raster sources. * @@ -233,33 +272,10 @@ export class MosaicLayer< // exposes only the plain `TileIndex` here. const index = data.index as unknown as MosaicT; const { signal } = data; - // Dynamic priority for the limiter: euclidean distance from this - // source's bbox center to the layer's current viewport center, in - // degree-space (just used as an ordering key; great-circle isn't - // needed). Re-evaluated on every limiter slot-open, so panning the - // viewport re-sorts the queue and pulls newly-central sources to the - // front of the line ahead of older edge sources. - // - // Only meaningful for geographic viewports, where the source bbox and - // the viewport center share a lon/lat space. For any other viewport we - // leave `getPriority` undefined so the limiter falls back to FIFO. - const [minX, minY, maxX, maxY] = index.bbox; - const sourceCx = (minX + maxX) / 2; - const sourceCy = (minY + maxY) / 2; - const isGeographic = - this.context.viewport instanceof WebMercatorViewport || - this.context.viewport instanceof _GlobeViewport; - const getPriority = isGeographic - ? (): number => { - // Re-read the viewport each call so panning re-sorts the queue. - const viewport = this.context.viewport as - | WebMercatorViewport - | _GlobeViewport; - const dx = sourceCx - viewport.longitude; - const dy = sourceCy - viewport.latitude; - return Math.hypot(dx, dy); - } - : undefined; + const getPriority = createGetPriorityCallback( + index.bbox, + () => this.context.viewport, + ); // `concurrencyLimiter` is filled from `defaultProps`, so the prop is // already resolved (the shared default, a user override, or an // explicit `null` to disable) — forward it straight through. From 7ba675b9749449ee1b671b3151a5a248a7253688 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 14:23:10 -0400 Subject: [PATCH 31/46] refactor(deck.gl-geotiff): extract openCogSources helper Per PR #557 review: the inline "open all COGs in parallel" block in MultiCOGLayer._parseAllSources was verbose. Pull it into a module-level openCogSources(entries, opts) helper that opens each source's GeoTIFF and resolves its projection, returning null when the signal aborts mid-open so the caller bails. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deck.gl-geotiff/src/multi-cog-layer.ts | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index ae6fb640..50555e51 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -279,6 +279,49 @@ const defaultProps = { concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER, }; +/** A source's opened GeoTIFF paired with its resolved projection. */ +type OpenedCogSource = { + name: string; + geotiff: GeoTIFF; + sourceProjection: ProjectionDefinition; +}; + +/** + * Open every configured source's GeoTIFF in parallel and resolve each one's + * projection. Returns `null` when `signal` aborts mid-open (the layer was + * removed), so the caller can bail without applying stale state. + */ +async function openCogSources( + entries: [string, MultiCOGSourceConfig][], + options: { + concurrencyLimiter?: ConcurrencyLimiter | null; + epsgResolver: EpsgResolver; + signal?: AbortSignal; + }, +): Promise { + const { concurrencyLimiter, epsgResolver, signal } = options; + try { + return await Promise.all( + entries.map(async ([name, config]) => { + const geotiff = await fetchGeoTIFF(config.url, { + concurrencyLimiter, + signal, + }); + const crs = geotiff.crs; + const sourceProjection = + typeof crs === "number" ? await epsgResolver(crs) : parseWkt(crs); + return { name, geotiff, sourceProjection }; + }), + ); + } catch (err) { + // Layer removed mid-open (finalizeState aborted the signal); bail. + if (signal?.aborted) { + return null; + } + throw err; + } +} + /** * A deck.gl {@link CompositeLayer} that opens multiple Cloud-Optimized GeoTIFFs * (COGs) in parallel, builds a {@link RasterTilesetDescriptor} for each, and groups @@ -358,33 +401,14 @@ export class MultiCOGLayer extends RasterTileLayer< const signal = this.state.abortController?.signal; - // Open all COGs in parallel - let cogSources: Array<{ - name: string; - geotiff: GeoTIFF; - sourceProjection: ProjectionDefinition; - }>; - try { - cogSources = await Promise.all( - entries.map(async ([name, config]) => { - const geotiff = await fetchGeoTIFF(config.url, { - concurrencyLimiter: this.props.concurrencyLimiter, - signal, - }); - const crs = geotiff.crs; - const sourceProjection = - typeof crs === "number" - ? await this.props.epsgResolver!(crs) - : parseWkt(crs); - return { name, geotiff, sourceProjection }; - }), - ); - } catch (err) { - // Layer removed mid-open (finalizeState aborted the signal); drop it. - if (signal?.aborted) { - return; - } - throw err; + const cogSources = await openCogSources(entries, { + concurrencyLimiter: this.props.concurrencyLimiter, + epsgResolver: this.props.epsgResolver!, + signal, + }); + // Layer removed mid-open; drop the result. + if (cogSources === null) { + return; } // Use the first source's projection for shared projection functions From eb4a2b321d2205069918c9cd065824ddecc5b3aa Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 15:08:50 -0400 Subject: [PATCH 32/46] remove comment --- examples/naip-mosaic/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index f5978328..65d4fd78 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -91,8 +91,6 @@ function getCachedGeoTIFF( ): Promise { let promise = geotiffCache.get(url); if (!promise) { - // Forward MosaicLayer's opts (concurrencyLimiter, getPriority, signal) - // straight through to GeoTIFF.fromUrl. promise = GeoTIFF.fromUrl(url, opts).catch((err) => { geotiffCache.delete(url); throw err; From 2668b82fc8be1d31e03731ec2718f7cde3a14264 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 11:17:53 -0400 Subject: [PATCH 33/46] fix(geotiff): gate via source wrapper so abort-while-queued works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the internal LimiterMiddleware with an internal LimitedSource that wraps Source.fetch, composed beneath SourceChunk/SourceCache (cache hits still short-circuit before it). The middleware approach couldn't observe aborts: chunkd's SourceView doesn't forward the request signal to middleware, only to the underlying source via its terminal handler. Wrapping the source is the only layer that receives options.signal, so a request whose caller aborts while queued for a slot is now dropped before any network I/O — previously that never fired. Internal-only: GeoTIFF.fromUrl options, ConcurrencyLimiter, Priority, and PerOriginSemaphore are unchanged. Revert to a SourceMiddleware once chunkd forwards the signal (blacha/chunkd#1697); tracked in #565. Adds a LimitedSource test that aborts a queued read through the real acquire path (the gap that hid this). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-concurrency-limiter-design.md | 2 + packages/geotiff/src/geotiff.ts | 41 +++-- packages/geotiff/src/limiter.ts | 89 ++++++---- .../tests/geotiff-concurrency-limiter.test.ts | 7 +- packages/geotiff/tests/limiter.test.ts | 157 +++++++++--------- 5 files changed, 164 insertions(+), 132 deletions(-) diff --git a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md index 61d3404e..fc846e17 100644 --- a/dev-docs/specs/2026-05-19-concurrency-limiter-design.md +++ b/dev-docs/specs/2026-05-19-concurrency-limiter-design.md @@ -112,6 +112,8 @@ class LimiterMiddleware implements SourceMiddleware { `LimiterMiddleware` is also internal: the limiter is wired by `GeoTIFF.fromUrl` (where chunkd's `Source` / `SourceView` types are already in scope), so callers don't need to compose middleware themselves. +> **Implementation note (temporary).** The `SourceMiddleware` shape above is the intended design, but it doesn't work yet: chunkd's `SourceView` doesn't forward the request `signal` to middleware, so a middleware can't observe an abort (only the underlying source receives the read options via `SourceView`'s terminal handler). Until that's fixed upstream ([chunkd#1697](https://github.com/blacha/chunkd/pull/1697)), the limiter is implemented as an internal **source wrapper** — `LimitedSource` — composed *beneath* `SourceChunk` / `SourceCache` (as the `SourceView`'s source) rather than as a middleware on top. Cache hits still short-circuit in `SourceCache` before reaching it. This is internal-only; the public API is unchanged either way. Revert to `LimiterMiddleware` once chunkd ships the fix — tracked in [#565](https://github.com/developmentseed/deck.gl-raster/issues/565). + ## Integration ### `@developmentseed/geotiff` diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 98719bdc..c209e5e3 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -13,7 +13,7 @@ import { parseGDALMetadata } from "./gdal-metadata.js"; import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; import type { ConcurrencyLimiter, Priority } from "./limiter.js"; -import { LimiterMiddleware } from "./limiter.js"; +import { LimitedSource } from "./limiter.js"; import { Overview } from "./overview.js"; import type { DecoderPool } from "./pool/pool.js"; import type { Tile } from "./tile.js"; @@ -327,31 +327,30 @@ export class GeoTIFF { // unbounded length. Remove once the upstream fix lands. source.metadata = { size: Number.POSITIVE_INFINITY }; - // When a limiter is supplied, slot a LimiterMiddleware into both - // sources' middleware stacks. On the header source, it sits *after* - // SourceChunk + SourceCache so a cache hit short-circuits and never - // consumes a slot — only network reads that escape the cache are gated. - // On the data source (no caching), every fetch is gated. - const limiter = concurrencyLimiter - ? new LimiterMiddleware({ - url: new URL(url), - limiter: concurrencyLimiter, - getPriority, - }) - : null; - - const view = new SourceView(source, [ + // When a limiter is supplied, gate every network read through it by + // wrapping the raw source. The header `SourceView` composes SourceChunk + + // SourceCache *on top* of this wrapped source, so a cache hit + // short-circuits in SourceCache and never reaches — never burns a slot on + // — the limiter; only reads that escape the cache (and every data read, + // which bypasses the cache) are gated. The same wrapped source backs both + // the header view and the data source, so both share one per-origin pool. + // + // Gating here as a source wrapper rather than a chunkd SourceMiddleware is + // a workaround for chunkd not forwarding the abort signal to middleware; + // see LimitedSource. Once that's fixed upstream this can become a + // middleware again. Tracked in + // https://github.com/developmentseed/deck.gl-raster/issues/565 + const limitedSource = concurrencyLimiter + ? new LimitedSource(source, { limiter: concurrencyLimiter, getPriority }) + : source; + + const view = new SourceView(limitedSource, [ new SourceChunk({ size: chunkSize }), new SourceCache({ size: cacheSize }), - ...(limiter ? [limiter] : []), ]); - const dataSource: Pick = limiter - ? new SourceView(source, [limiter]) - : source; - return await GeoTIFF.open({ - dataSource, + dataSource: limitedSource, headerSource: view, signal, debug, diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index 50911c95..7eac19db 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -1,8 +1,4 @@ -import type { - SourceCallback, - SourceMiddleware, - SourceRequest, -} from "@chunkd/source"; +import type { Source, SourceMetadata } from "@chunkd/source"; /** * Numeric priority used to order waiters in a {@link Semaphore}'s queue. Lower @@ -55,7 +51,7 @@ interface Waiter { /** * Counting semaphore with abort-aware acquire and dynamic priority. Internal - * primitive used by {@link PerOriginSemaphore} and {@link LimiterMiddleware}. + * primitive used by {@link PerOriginSemaphore} and {@link LimitedSource}. * * Hands out up to `maxRequests` concurrent slots. Further `acquire()`s queue. * On every slot-open, the queue is searched for the lowest-priority waiter @@ -214,15 +210,12 @@ export class PerOriginSemaphore implements ConcurrencyLimiter { } } -/** Options for {@link LimiterMiddleware}. */ -interface LimiterMiddlewareOptions { - /** The URL the wrapped source is reading from. Passed to - * `limiter.acquire(url, signal?)` on every fetch — the limiter uses it for - * per-origin routing. */ - url: URL; - /** The {@link ConcurrencyLimiter} to gate through. */ +/** Options for {@link LimitedSource}. */ +interface LimitedSourceOptions { + /** The {@link ConcurrencyLimiter} to gate through. The wrapped source's + * own `url` is passed to `limiter.acquire` for per-origin routing. */ limiter: ConcurrencyLimiter; - /** Optional dynamic priority for every fetch through this middleware. The + /** Optional dynamic priority for every fetch through this source. The * limiter re-invokes this callback on each slot-open, so closures over * dynamic state (e.g. layer viewport center) re-sort the queue when that * state changes. Lower = serviced sooner. */ @@ -230,48 +223,78 @@ interface LimiterMiddlewareOptions { } /** - * chunkd middleware that holds a {@link ConcurrencyLimiter} slot for the - * duration of each underlying `fetch` — releasing on resolve, on reject, and - * never otherwise interfering. Forwards the request's `signal` to - * `limiter.acquire`, so if the caller aborts while the call is queued the - * request is dropped before any network I/O fires. + * Wraps a {@link Source} so every `fetch` holds a {@link ConcurrencyLimiter} + * slot for its duration — acquiring before the read, releasing when it settles + * (resolve or reject). Forwards the read's `signal` to `limiter.acquire`, so a + * request whose caller aborts while it is still queued for a slot is dropped + * before any network I/O fires. * - * Composed into a {@link SourceView}'s middleware list alongside the chunkd - * middlewares (`SourceChunk`, `SourceCache`, …). Place it after caching so - * cache hits don't burn a slot. + * Compose this *beneath* `SourceChunk` / `SourceCache` (i.e. as the + * `SourceView`'s underlying source), so a cache hit short-circuits in + * `SourceCache` and never reaches — never burns a slot on — the limiter: * * @example * ```ts * import { SourceView } from "@chunkd/source"; * import { SourceCache, SourceChunk } from "@chunkd/middleware"; * - * const view = new SourceView(source, [ + * const limited = new LimitedSource(source, { limiter }); + * const view = new SourceView(limited, [ * new SourceChunk({ size: 64 * 1024 }), * new SourceCache({ size: 8 * 1024 * 1024 }), - * new LimiterMiddleware({ url, limiter }), * ]); * ``` + * + * **Why a source wrapper and not a chunkd `SourceMiddleware`** (which would + * compose more naturally): chunkd's `SourceView` does not forward the request + * `signal` to its middleware, so a middleware cannot observe an abort — only + * the underlying source receives the read options (incl. `signal`) via + * `SourceView`'s terminal handler. Wrapping the source is therefore the only + * layer that can drop a queued request on abort. Revert to a `SourceMiddleware` + * once chunkd forwards the signal (https://github.com/blacha/chunkd/pull/1697); + * tracked in https://github.com/developmentseed/deck.gl-raster/issues/565. + * + * @internal */ -export class LimiterMiddleware implements SourceMiddleware { - readonly name = "limiter"; - private readonly url: URL; +export class LimitedSource implements Source { + private readonly source: Source; private readonly limiter: ConcurrencyLimiter; private readonly getPriority?: () => Priority; - constructor(opts: LimiterMiddlewareOptions) { - this.url = opts.url; + constructor(source: Source, opts: LimitedSourceOptions) { + this.source = source; this.limiter = opts.limiter; this.getPriority = opts.getPriority; } - async fetch(req: SourceRequest, next: SourceCallback): Promise { + get type(): string { + return this.source.type; + } + + get url(): URL { + return this.source.url; + } + + get metadata(): SourceMetadata | undefined { + return this.source.metadata; + } + + head(options?: { signal: AbortSignal }): Promise { + return this.source.head(options); + } + + async fetch( + offset: number, + length?: number, + options?: { signal: AbortSignal }, + ): Promise { const release = await this.limiter.acquire( - this.url, - req.signal, + this.source.url, + options?.signal, this.getPriority, ); try { - return await next(req); + return await this.source.fetch(offset, length, options); } finally { release(); } diff --git a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts index ea022662..12381172 100644 --- a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts +++ b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts @@ -1,7 +1,8 @@ /** - * Verifies that GeoTIFF.fromUrl wraps the data source's .fetch with a - * ConcurrencyLimiter when one is supplied. The header source (the cached - * SourceView) is not wrapped — the limiter caps tile/data reads only. + * Verifies that GeoTIFF.fromUrl gates network reads through a + * ConcurrencyLimiter when one is supplied. Both the header reads (cache + * misses through the SourceView) and the tile-data reads are gated; cache + * hits short-circuit in SourceCache and never reach the limiter. * * The SourceHttp stubbing pattern mirrors fromurl.test.ts. */ diff --git a/packages/geotiff/tests/limiter.test.ts b/packages/geotiff/tests/limiter.test.ts index 3e5e56da..9319214e 100644 --- a/packages/geotiff/tests/limiter.test.ts +++ b/packages/geotiff/tests/limiter.test.ts @@ -1,8 +1,8 @@ -import type { SourceCallback, SourceRequest } from "@chunkd/source"; +import type { Source } from "@chunkd/source"; import { describe, expect, it } from "vitest"; import type { ConcurrencyLimiter, Priority } from "../src/limiter.js"; import { - LimiterMiddleware, + LimitedSource, PerOriginSemaphore, Semaphore, } from "../src/limiter.js"; @@ -322,15 +322,23 @@ describe("PerOriginSemaphore", () => { }); }); -describe("LimiterMiddleware", () => { +describe("LimitedSource", () => { const URL_A = new URL("https://a.example.com/cog.tif"); - const REQ: SourceRequest = { - source: {} as never, - offset: 0, - length: 4, - }; - it("only invokes `next` after acquiring a slot, and releases after", async () => { + /** A minimal recording {@link Source} for wrapping. */ + function fakeSource( + fetchImpl: Source["fetch"] = async () => new ArrayBuffer(0), + ): Source { + return { + type: "test", + url: URL_A, + metadata: { size: 1024 }, + head: async () => ({ size: 1024 }), + fetch: fetchImpl, + }; + } + + it("acquires a slot before fetching and releases after", async () => { const order: string[] = []; const limiter: ConcurrencyLimiter = { acquire: async () => { @@ -338,105 +346,104 @@ describe("LimiterMiddleware", () => { return () => order.push("release"); }, }; - const mw = new LimiterMiddleware({ url: URL_A, limiter }); - const next: SourceCallback = async () => { - order.push("next"); - return new ArrayBuffer(0); - }; - await mw.fetch(REQ, next); - expect(order).toEqual(["acquire", "next", "release"]); + const limited = new LimitedSource( + fakeSource(async () => { + order.push("fetch"); + return new ArrayBuffer(0); + }), + { limiter }, + ); + await limited.fetch(0, 4); + expect(order).toEqual(["acquire", "fetch", "release"]); }); - it("forwards req to `next` unchanged", async () => { - const calls: SourceRequest[] = []; - const limiter: ConcurrencyLimiter = { - acquire: async () => () => {}, - }; - const mw = new LimiterMiddleware({ url: URL_A, limiter }); + it("forwards offset/length/options to the wrapped source's fetch", async () => { + const calls: Array<[number, number | undefined, unknown]> = []; + const limiter: ConcurrencyLimiter = { acquire: async () => () => {} }; const signal = new AbortController().signal; - const req: SourceRequest = { - source: {} as never, - offset: 100, - length: 200, - signal, - }; - const next: SourceCallback = async (r) => { - calls.push(r); - return new ArrayBuffer(0); - }; - await mw.fetch(req, next); - expect(calls).toEqual([req]); + const limited = new LimitedSource( + fakeSource(async (offset, length, options) => { + calls.push([offset, length, options]); + return new ArrayBuffer(0); + }), + { limiter }, + ); + await limited.fetch(100, 200, { signal }); + expect(calls).toEqual([[100, 200, { signal }]]); }); - it("releases the slot when `next` rejects (and propagates the error)", async () => { + it("releases the slot when the wrapped fetch rejects (and propagates)", async () => { const sem = new Semaphore({ maxRequests: 1 }); const limiter: ConcurrencyLimiter = { acquire: (_url, signal) => sem.acquire(signal), }; - const mw = new LimiterMiddleware({ url: URL_A, limiter }); - await expect( - mw.fetch(REQ, async () => { + const limited = new LimitedSource( + fakeSource(async () => { throw new Error("network down"); }), - ).rejects.toThrow("network down"); - // Slot was released — a second fetch must not hang. - await mw.fetch(REQ, async () => new ArrayBuffer(0)); + { limiter }, + ); + await expect(limited.fetch(0, 4)).rejects.toThrow("network down"); + // Slot was released — a second fetch (with a source that resolves) must + // not hang. + const ok = new LimitedSource(fakeSource(), { limiter }); + await ok.fetch(0, 4); }); - it("forwards req.signal to limiter.acquire so a queued abort drops the call", async () => { + it("forwards the signal to limiter.acquire so a queued abort drops the read before fetching", async () => { const sem = new Semaphore({ maxRequests: 1 }); const limiter: ConcurrencyLimiter = { acquire: (_url, signal) => sem.acquire(signal), }; // Saturate the semaphore so the next acquire queues. const hold = await sem.acquire(); - let nextCalled = false; - const mw = new LimiterMiddleware({ url: URL_A, limiter }); + let fetched = false; + const limited = new LimitedSource( + fakeSource(async () => { + fetched = true; + return new ArrayBuffer(0); + }), + { limiter }, + ); const ac = new AbortController(); - const req: SourceRequest = { - source: {} as never, - offset: 0, - length: 8, - signal: ac.signal, - }; - const pending = mw.fetch(req, async () => { - nextCalled = true; - return new ArrayBuffer(0); - }); + const pending = limited.fetch(0, 8, { signal: ac.signal }); ac.abort(new Error("pan-away")); await expect(pending).rejects.toThrow("pan-away"); - expect(nextCalled).toBe(false); + expect(fetched).toBe(false); hold(); }); - it("has the expected SourceMiddleware shape (name + fetch)", () => { - const mw = new LimiterMiddleware({ - url: URL_A, - limiter: { acquire: async () => () => {} }, - }); - expect(mw.name).toBe("limiter"); - expect(typeof mw.fetch).toBe("function"); + it("delegates type/url/metadata/head and routes acquire on the source's url", async () => { + const acquired: URL[] = []; + const limiter: ConcurrencyLimiter = { + acquire: async (url) => { + acquired.push(url); + return () => {}; + }, + }; + const source = fakeSource(); + const limited = new LimitedSource(source, { limiter }); + expect(limited.type).toBe(source.type); + expect(limited.url).toBe(source.url); + expect(limited.metadata).toBe(source.metadata); + expect(await limited.head()).toEqual({ size: 1024 }); + await limited.fetch(0, 4); + expect(acquired).toEqual([source.url]); }); - it("threads getPriority from constructor through to limiter.acquire", async () => { - const calls: Array<{ - url: URL; - signal?: AbortSignal; - priority: Priority | undefined; - }> = []; + it("threads getPriority through to limiter.acquire", async () => { + const priorities: Array = []; const limiter: ConcurrencyLimiter = { - acquire: async (url, signal, getPriority) => { - calls.push({ url, signal, priority: getPriority?.() }); + acquire: async (_url, _signal, getPriority) => { + priorities.push(getPriority?.()); return () => {}; }, }; - const mw = new LimiterMiddleware({ - url: URL_A, + const limited = new LimitedSource(fakeSource(), { limiter, getPriority: () => [2, 7], }); - await mw.fetch(REQ, async () => new ArrayBuffer(0)); - expect(calls).toHaveLength(1); - expect(calls[0]!.priority).toEqual([2, 7]); + await limited.fetch(0, 4); + expect(priorities).toEqual([[2, 7]]); }); }); From e5c3e5ca9aa066307842eea918b4482611ff5ce2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 11:33:22 -0400 Subject: [PATCH 34/46] test(geotiff): abort a queued fromUrl header read end-to-end Proves the full chain (GeoTIFF.fromUrl -> open -> cogeotiff -> LimitedSource -> limiter) drops a header read still queued for a slot when its signal aborts, without ever fetching it. Saturate a maxRequests:1 PerOriginSemaphore with a first open, queue a second open behind it, abort the second, and assert it rejects and never hits the network. Closes the coverage gap that hid the chunkd signal bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/geotiff-concurrency-limiter.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts index 12381172..b7d98b92 100644 --- a/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts +++ b/packages/geotiff/tests/geotiff-concurrency-limiter.test.ts @@ -12,6 +12,7 @@ import { SourceHttp } from "@chunkd/source-http"; import { afterEach, describe, expect, it } from "vitest"; import { GeoTIFF } from "../src/geotiff.js"; import type { ConcurrencyLimiter } from "../src/limiter.js"; +import { PerOriginSemaphore } from "../src/limiter.js"; import { fixturePath } from "./helpers.js"; const FIXTURE = readFileSync( @@ -123,4 +124,62 @@ describe("GeoTIFF.fromUrl({ concurrencyLimiter })", () => { // Reference `limiter` so it isn't flagged as unused. expect(limiter.acquire).toBeDefined(); }); + + it("drops a queued header read when its signal aborts, without fetching it", async () => { + // One slot per origin, so the second open must queue behind the first. + const limiter = new PerOriginSemaphore({ maxRequests: 1 }); + + const fetched: string[] = []; + let firstHolds!: () => void; + const firstHolding = new Promise((resolve) => { + firstHolds = resolve; + }); + let releaseFirst!: () => void; + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + + SourceHttp.fetch = (async ( + url: string | URL, + init?: { method?: string; headers?: Record }, + ) => { + const href = String(url); + fetched.push(href); + if (href.includes("first.tif")) { + // The first open holds the only slot until we release it, so the + // second open's first read must queue in the limiter. + firstHolds(); + await firstReleased; + } + const range = init?.headers?.range ?? ""; + const match = /^bytes=(\d+)-(\d+)?$/.exec(range); + const start = match ? Number(match[1]) : 0; + const end = + match?.[2] != null + ? Math.min(Number(match[2]), FIXTURE.byteLength - 1) + : FIXTURE.byteLength - 1; + return makeResponse(FIXTURE.subarray(start, end + 1)); + }) as typeof SourceHttp.fetch; + + // First open acquires the only slot and parks in its first read. + const first = GeoTIFF.fromUrl("https://ex.test/first.tif", { + concurrencyLimiter: limiter, + }); + await firstHolding; + + // Second open queues behind the first; abort it while it waits. + const ac = new AbortController(); + const second = GeoTIFF.fromUrl("https://ex.test/second.tif", { + concurrencyLimiter: limiter, + signal: ac.signal, + }); + ac.abort(new Error("pan-away")); + + await expect(second).rejects.toThrow(); + // The queued read was dropped before any network fetch for second.tif. + expect(fetched.some((u) => u.includes("second.tif"))).toBe(false); + + releaseFirst(); + await first; + }); }); From c7d2d18e7ec20432d50324dd177afd3dceb3bae4 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 12:13:26 -0400 Subject: [PATCH 35/46] fix(deck.gl-geotiff): don't forward undefined maxRequests from MosaicLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MosaicLayer forwarded `maxRequests` to the inner TileLayer unconditionally, and its defaultProps never set it — so it passed `undefined`. deck.gl's createProps copies `undefined` over the prototype, shadowing TileLayer's default of 6 with `undefined`. That left Tileset2D with maxRequests undefined, which disables BOTH request throttling and `_pruneRequests` (gated on `maxRequests > 0`) — and `_pruneRequests` is the only thing that calls `tile.abort()`. So the concurrency limiter never received a cancellation: panned-out source header reads were never dropped, starving the current viewport. Only forward `maxRequests` when defined, so TileLayer's own default applies. Now pruning fires on pan, the tile signal aborts, and the limiter drops the queued header reads. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 1fdf2f40..306337de 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -265,7 +265,14 @@ export class MosaicLayer< extent, ...(maxCacheByteSize !== undefined && { maxCacheByteSize }), maxCacheSize, - maxRequests, + // Only forward maxRequests when set: deck.gl's createProps copies + // `undefined` over the prototype, so passing `maxRequests: undefined` + // would shadow TileLayer's default of 6 with `undefined`, leaving + // Tileset2D with no request throttling AND no `_pruneRequests` (which + // gates on `maxRequests > 0` and is the only thing that aborts + // panned-out tiles — i.e. the limiter would never receive a + // cancellation). Omitting the key lets TileLayer's own default apply. + ...(maxRequests !== undefined && { maxRequests }), getTileData: async (data) => { // We hard-cast this because TilesetClass is not generic. // MosaicTileset2D returns MosaicT in `index`, but TileLayer's typing From 1c7b38248f16746ef0520bded107d358e73a8b00 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 12:54:27 -0400 Subject: [PATCH 36/46] refactor(deck.gl-geotiff): omit undefined when forwarding TileLayerProps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the maxRequests fix: forward MosaicLayer's pass-through TileLayerProps (minZoom, maxZoom, extent, maxCacheByteSize, maxCacheSize, maxRequests) through a small omitUndefined() helper rather than tracking per-prop which can safely be undefined. deck.gl's createProps copies an explicit `undefined` over the prototype, so an unset prop forwarded as `undefined` shadows TileLayer's own default instead of falling back to it. Omitting undefined keys lets TileLayer's defaults apply uniformly, so we don't have to remember which props tolerate undefined. Behavior-neutral except the already-fixed maxRequests: the only other prop whose resolved value changes is minZoom (undefined -> TileLayer's 0), and that just bounds the tileset at viewport zoom >= 0 — the whole-world view and in — which is the normal range anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mosaic-layer/mosaic-layer.ts | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 306337de..e64746a7 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -176,6 +176,27 @@ function createGetPriorityCallback( }; } +/** + * Drop keys whose value is `undefined`. + * + * MosaicLayer forwards a subset of `TileLayerProps` to its inner `TileLayer`. + * deck.gl's `createProps` copies an explicit `undefined` over the prototype, so + * forwarding an *unset* prop as `undefined` would shadow TileLayer's own + * default rather than fall back to it — e.g. `maxRequests: 6` becomes + * `undefined`, which silently disables request throttling and + * `_pruneRequests` (the only thing that aborts panned-out tiles). Forwarding + * only the defined props lets TileLayer's defaults apply. + */ +function omitUndefined(obj: T): Partial { + const result: Partial = {}; + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} + /** * A deck.gl layer for rendering a mosaic of raster sources. * @@ -260,19 +281,17 @@ export class MosaicLayer< }>({ id: `mosaic-layer-${id}`, TilesetClass: MosaicTileset2DFactory, - minZoom, - maxZoom, - extent, - ...(maxCacheByteSize !== undefined && { maxCacheByteSize }), - maxCacheSize, - // Only forward maxRequests when set: deck.gl's createProps copies - // `undefined` over the prototype, so passing `maxRequests: undefined` - // would shadow TileLayer's default of 6 with `undefined`, leaving - // Tileset2D with no request throttling AND no `_pruneRequests` (which - // gates on `maxRequests > 0` and is the only thing that aborts - // panned-out tiles — i.e. the limiter would never receive a - // cancellation). Omitting the key lets TileLayer's own default apply. - ...(maxRequests !== undefined && { maxRequests }), + // Forward only the TileLayerProps the caller actually set — an + // `undefined` here would clobber TileLayer's own default (see + // omitUndefined). + ...omitUndefined({ + minZoom, + maxZoom, + extent, + maxCacheByteSize, + maxCacheSize, + maxRequests, + }), getTileData: async (data) => { // We hard-cast this because TilesetClass is not generic. // MosaicTileset2D returns MosaicT in `index`, but TileLayer's typing From 63eecbf0e6088356f09315491b33e97be62e5266 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:00:13 -0400 Subject: [PATCH 37/46] cleaner comment --- .../src/mosaic-layer/mosaic-layer.ts | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index e64746a7..dfdb204f 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -176,27 +176,6 @@ function createGetPriorityCallback( }; } -/** - * Drop keys whose value is `undefined`. - * - * MosaicLayer forwards a subset of `TileLayerProps` to its inner `TileLayer`. - * deck.gl's `createProps` copies an explicit `undefined` over the prototype, so - * forwarding an *unset* prop as `undefined` would shadow TileLayer's own - * default rather than fall back to it — e.g. `maxRequests: 6` becomes - * `undefined`, which silently disables request throttling and - * `_pruneRequests` (the only thing that aborts panned-out tiles). Forwarding - * only the defined props lets TileLayer's defaults apply. - */ -function omitUndefined(obj: T): Partial { - const result: Partial = {}; - for (const key in obj) { - if (obj[key] !== undefined) { - result[key] = obj[key]; - } - } - return result; -} - /** * A deck.gl layer for rendering a mosaic of raster sources. * @@ -281,9 +260,6 @@ export class MosaicLayer< }>({ id: `mosaic-layer-${id}`, TilesetClass: MosaicTileset2DFactory, - // Forward only the TileLayerProps the caller actually set — an - // `undefined` here would clobber TileLayer's own default (see - // omitUndefined). ...omitUndefined({ minZoom, maxZoom, @@ -374,3 +350,18 @@ export class MosaicLayer< return this.renderTileLayer(renderSource); } } + +/** + * Drop keys whose value is `undefined`. + * + * Passing down an explicit `undefined` will override any default prop values. + */ +function omitUndefined(obj: T): Partial { + const result: Partial = {}; + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} From a655b4b4d4ed6f5d7a7e5a2d84a5fd65d0451cad Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:10:37 -0400 Subject: [PATCH 38/46] concise --- .../src/mosaic-layer/mosaic-layer.ts | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index dfdb204f..2b082978 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -57,13 +57,11 @@ export type MosaicLayerProps< /** * Caps concurrent HTTP requests for this layer's source fetches. - * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: - * 6 })` (the same instance `COGLayer` / `MultiCOGLayer` use), so all - * layers targeting one origin share one HTTP/1.1 connection pool. Pass - * your own `ConcurrencyLimiter` to override; pass `null` to disable - * gating. The layer threads this into `getSource`'s opts so consumers - * can forward it to {@link GeoTIFF.fromUrl} (or any source-opening - * call) alongside the matching `getPriority`. + * + * Defaults to a maximum of 6 concurrent requests per origin, which aligns + * with browser limits of 6 HTTP/1.1 requests per origin. If your sources + * support HTTP/2 or HTTP/3, you may want to increase this limit or disable + * it entirely by passing `null`. */ concurrencyLimiter?: ConcurrencyLimiter | null; @@ -80,19 +78,11 @@ export type MosaicLayerProps< */ concurrencyLimiter?: ConcurrencyLimiter | null; /** - * Dynamic priority for fetches related to this source. Re-invoked by - * the limiter on every slot-open, so the queue re-sorts on viewport - * pan. Computed from the source's `bbox` center and the layer's - * current viewport: closer to viewport center ⇒ lower number ⇒ - * serviced sooner. Forward to {@link GeoTIFF.fromUrl}'s - * `getPriority` option alongside `concurrencyLimiter` to bias - * center-of-screen rendering ahead of edges. + * Callback that provides dynamic priority for fetches related to this + * source. * - * Only provided for geographic viewports (`WebMercatorViewport` / - * `_GlobeViewport`), where the source bbox and viewport center share - * a lon/lat space. Omitted (`undefined`) otherwise, so the limiter - * falls back to FIFO instead of comparing mismatched coordinate - * units. + * This is designed to re-sort the limiter's queue on viewport pan, + * preferring sources closer to the viewport center. */ getPriority?: () => Priority; }, From 92e65ca48cb6dd5e7bd4360c0876554a5c39d405 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:12:26 -0400 Subject: [PATCH 39/46] concsie --- .../deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 2b082978..a6865ab7 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -220,12 +220,13 @@ export class MosaicLayer< ): TileLayer { const { id, - minZoom, - maxZoom, + concurrencyLimiter, extent, maxCacheByteSize, maxCacheSize, maxRequests, + maxZoom, + minZoom, onSourceLoad, onSourceError, onSourceUnload, @@ -268,14 +269,11 @@ export class MosaicLayer< index.bbox, () => this.context.viewport, ); - // `concurrencyLimiter` is filled from `defaultProps`, so the prop is - // already resolved (the shared default, a user override, or an - // explicit `null` to disable) — forward it straight through. const userData = this.props.getSource && (await this.props.getSource(index, { signal, - concurrencyLimiter: this.props.concurrencyLimiter, + concurrencyLimiter, getPriority, })); From 3d825a5dcccce5bd622c9427cdb7b51238dda79c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:14:49 -0400 Subject: [PATCH 40/46] concise --- packages/deck.gl-geotiff/src/cog-layer.ts | 11 ++++++----- .../deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts | 3 +-- packages/deck.gl-geotiff/src/multi-cog-layer.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 9655d96f..24abedcc 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -141,11 +141,12 @@ export type COGLayerProps = Omit< signal?: AbortSignal; /** - * Caps concurrent HTTP requests for this layer's tile-data fetches. - * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: - * 6 })` so multiple `COGLayer`s targeting the same origin (e.g. the - * same S3 bucket) share one HTTP/1.1 connection pool. Pass your own - * `ConcurrencyLimiter` to override; pass `null` to disable gating. + * Caps concurrent HTTP requests for this layer's source fetches. + * + * Defaults to a maximum of 6 concurrent requests per origin, which aligns + * with browser limits of 6 HTTP/1.1 requests per origin. If your sources + * support HTTP/2 or HTTP/3, you may want to increase this limit or disable + * it entirely by passing `null`. * * Ignored when `geotiff` is a pre-opened `GeoTIFF` instance — wire the * limiter via {@link GeoTIFF.fromUrl} at construction time instead. diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index a6865ab7..79084286 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -71,8 +71,7 @@ export type MosaicLayerProps< opts: { signal?: AbortSignal; /** - * The layer's current `concurrencyLimiter` prop (default - * {@link DEFAULT_CONCURRENCY_LIMITER}). Forward to + * The layer's current `concurrencyLimiter` prop. Forward to * {@link GeoTIFF.fromUrl}'s `concurrencyLimiter` option so this * source's fetches join the shared per-origin queue. */ diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 50555e51..630ef39c 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -262,12 +262,12 @@ export type MultiCOGLayerProps = CompositeLayerProps & debugLevel?: 1 | 2 | 3; /** - * Caps concurrent HTTP requests for this layer's tile-data fetches. - * Defaults to a shared module-level `PerOriginSemaphore({ maxRequests: - * 6 })` (the *same* instance COGLayer uses by default), so a - * COGLayer and a MultiCOGLayer hitting the same origin share one - * HTTP/1.1 connection pool. Pass your own `ConcurrencyLimiter` to - * override; pass `null` to disable gating. + * Caps concurrent HTTP requests for this layer's source fetches. + * + * Defaults to a maximum of 6 concurrent requests per origin, which aligns + * with browser limits of 6 HTTP/1.1 requests per origin. If your sources + * support HTTP/2 or HTTP/3, you may want to increase this limit or disable + * it entirely by passing `null`. */ concurrencyLimiter?: ConcurrencyLimiter | null; }; From db7cff39bd75f3e288fb96afaedbd0a165e3ec77 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:20:20 -0400 Subject: [PATCH 41/46] concise --- packages/deck.gl-geotiff/src/cog-layer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 24abedcc..087de35e 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -178,14 +178,12 @@ export class COGLayer< defaultGetTileData?: COGLayerProps["getTileData"]; defaultRenderTile?: COGLayerProps["renderTile"]; /** Aborts the in-flight header read when the `geotiff` prop changes or the - * layer is removed, freeing its limiter slot for fresh work. */ + * layer is removed + */ abortController?: AbortController; }; override initializeState(): void { - // One controller for the layer's lifetime; aborted in finalizeState so a - // header read still in flight when the layer is removed is cancelled and - // its limiter slot freed. this.setState({ abortController: new AbortController() }); } From 07efc3e40816fd9fa5646927eee29956a2e3f1a0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:25:33 -0400 Subject: [PATCH 42/46] move up abort signal check --- packages/deck.gl-geotiff/src/cog-layer.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 087de35e..bf68f090 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -239,6 +239,10 @@ export class COGLayer< ? await this.props.epsgResolver!(crs) : parseWkt(crs); + if (signal?.aborted) { + return; + } + // @ts-expect-error - proj4 typings are incomplete and don't support // wkt-parser input const converter4326 = proj4(sourceProjection, "EPSG:4326"); @@ -291,12 +295,6 @@ export class COGLayer< inferRenderPipeline(geotiff, this.context.device)); } - // Layer was removed while we resolved the projection; don't setState on a - // finalized layer. - if (signal?.aborted) { - return; - } - this.setState({ geotiff, tilesetDescriptor, From 26435d12da3c37b44431fc515007f0c3dae3d9aa Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:28:24 -0400 Subject: [PATCH 43/46] doc comment --- packages/deck.gl-geotiff/src/geotiff/geotiff.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index 451d9e1d..071645e1 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -69,8 +69,9 @@ export async function fetchGeoTIFF( * `concurrencyLimiter` is set. */ getPriority?: () => Priority; /** Forwarded to {@link GeoTIFF.fromUrl} to cancel the header reads when - * the opening layer is removed or its source prop changes mid-flight. - * Ignored when `input` is already a `GeoTIFF` or `ArrayBuffer`. */ + * the opening layer is removed (the COG layers abort this on + * `finalizeState`). Ignored when `input` is already a `GeoTIFF` or + * `ArrayBuffer`. */ signal?: AbortSignal; } = {}, ): Promise { From 1316bc8ce476ea995844cb14919046eb762c23a4 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:39:57 -0400 Subject: [PATCH 44/46] fix(deck.gl-geotiff,geotiff): address round-2 review nits - comparePriorities: extract a normalizePriorityValue() helper that maps undefined/NaN to 0, so a getPriority returning NaN sorts as un-prioritized instead of silently comparing as a tie with everything (NaN < x and NaN > x are both false). - MultiCOGLayer._parseAllSources: bail when there are no sources, before the cogSources[0] (first source's projection) access that would throw on an empty `sources`. - naip-mosaic example: reword the maxCacheSize: 0 comment (it disables the cache, not "smaller"), and drop a leftover `window.data` debug assignment. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/naip-mosaic/src/App.tsx | 6 +++--- packages/deck.gl-geotiff/src/multi-cog-layer.ts | 4 ++++ packages/geotiff/src/limiter.ts | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 65d4fd78..6f0dc050 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -354,7 +354,6 @@ export default function App() { async function wrappedFetchSTACItems() { try { const data = STAC_DATA as unknown as STACFeatureCollection; - (window as any).data = data; setStacItems(data.features); } catch (err) { console.error("Error fetching STAC items:", err); @@ -437,8 +436,9 @@ export default function App() { signal, }); }, - // Smaller cache for MosaicLayer cache, since it caches full COGLayer - // instances + // Disable the MosaicLayer tile cache: each cached tile is a full + // COGLayer instance, and opened GeoTIFFs are already kept in the + // module-level `geotiffCache`, so there's nothing cheap to retain here. maxCacheSize: 0, // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps // doesn't know about it. diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 630ef39c..3f9fc360 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -399,6 +399,10 @@ export class MultiCOGLayer extends RasterTileLayer< const { sources } = this.props; const entries = Object.entries(sources); + if (entries.length === 0) { + return; + } + const signal = this.state.abortController?.signal; const cogSources = await openCogSources(entries, { diff --git a/packages/geotiff/src/limiter.ts b/packages/geotiff/src/limiter.ts index 7eac19db..06083473 100644 --- a/packages/geotiff/src/limiter.ts +++ b/packages/geotiff/src/limiter.ts @@ -9,6 +9,18 @@ import type { Source, SourceMetadata } from "@chunkd/source"; */ export type Priority = number | readonly number[]; +/** + * Normalize priority value: `undefined` or `NaN` becomes 0, so it sorts as un-prioritized. + * + * Coercing `NaN` matters because `NaN < x` and `NaN > x` both resolve to false, + * so a `getPriority` that returns `NaN` (e.g. a distance from a degenerate + * viewport) would otherwise compare as a tie with everything and silently + * mis-sort, rather than falling back to FIFO. + */ +function normalizePriority(value: number | undefined): number { + return value === undefined || Number.isNaN(value) ? 0 : value; +} + /** * Compare two priorities. Returns negative if `a` should be serviced before * `b`, positive if `b` should go first, 0 on tie (queue then breaks the tie @@ -19,8 +31,8 @@ function comparePriorities(a: Priority, b: Priority): number { const arrB = typeof b === "number" ? [b] : b; const len = Math.max(arrA.length, arrB.length); for (let i = 0; i < len; i++) { - const ai = arrA[i] ?? 0; - const bi = arrB[i] ?? 0; + const ai = normalizePriority(arrA[i]); + const bi = normalizePriority(arrB[i]); if (ai < bi) { return -1; } From 369a2809f512eb6050ba1ee446417557daa65247 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:45:26 -0400 Subject: [PATCH 45/46] cleaner options --- packages/deck.gl-geotiff/src/geotiff/geotiff.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index 071645e1..1f128373 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -59,28 +59,13 @@ export function addAlphaChannel(rgbImage: RasterArray): RasterArray { export async function fetchGeoTIFF( input: GeoTIFF | string | URL | ArrayBuffer, options: { - /** Forwarded to {@link GeoTIFF.fromUrl} when `input` is a URL or string. - * Ignored when `input` is already a `GeoTIFF` instance or an - * `ArrayBuffer` (there's no network to gate, and a pre-opened GeoTIFF - * has already had its limiter wired at construction time). */ concurrencyLimiter?: ConcurrencyLimiter | null; - /** Forwarded to {@link GeoTIFF.fromUrl} as the dynamic priority for every - * fetch through this GeoTIFF's sources. Only meaningful when - * `concurrencyLimiter` is set. */ getPriority?: () => Priority; - /** Forwarded to {@link GeoTIFF.fromUrl} to cancel the header reads when - * the opening layer is removed (the COG layers abort this on - * `finalizeState`). Ignored when `input` is already a `GeoTIFF` or - * `ArrayBuffer`. */ signal?: AbortSignal; } = {}, ): Promise { if (typeof input === "string" || input instanceof URL) { - return await GeoTIFF.fromUrl(input, { - concurrencyLimiter: options.concurrencyLimiter, - getPriority: options.getPriority, - signal: options.signal, - }); + return await GeoTIFF.fromUrl(input, options); } if (input instanceof ArrayBuffer) { From 38dd4d53d921b2a91bc4e567626e69a6af3921d7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 13:47:20 -0400 Subject: [PATCH 46/46] concise --- packages/deck.gl-geotiff/src/multi-cog-layer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 3f9fc360..8472054e 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -410,7 +410,6 @@ export class MultiCOGLayer extends RasterTileLayer< epsgResolver: this.props.epsgResolver!, signal, }); - // Layer removed mid-open; drop the result. if (cogSources === null) { return; }