Skip to content

feat: per-origin HTTP concurrency limiter for COG layers#557

Merged
kylebarron merged 48 commits into
mainfrom
kyle/getTileData-coalesce-v2
May 22, 2026
Merged

feat: per-origin HTTP concurrency limiter for COG layers#557
kylebarron merged 48 commits into
mainfrom
kyle/getTileData-coalesce-v2

Conversation

@kylebarron
Copy link
Copy Markdown
Member

@kylebarron kylebarron commented May 19, 2026

Huge reduction in network requests with the MosaicLayer

before, scrolling around generates many many requests that aren't cancelled when panning around the map. You can see we fetch almost 300MB of data before loading the data in the end viewport location

Screen.Recording.2026-05-19.at.5.20.43.PM-clip.mov

after we've only fetched 150MB of data even after a ton of scrolling. The request cancellation is working much better:

Screen.Recording.2026-05-19.at.5.16.37.PM.mov

Closes #555


Summary

Adds a shared, per-origin HTTP concurrency limiter that caps concurrent tile-data fetches per host across all COGLayer / MultiCOGLayer / MosaicLayer instances on a page, with dynamic, viewport-aware priority so center-of-screen sources jump the queue.

The problem this addresses. Most COGs we target live on AWS S3 (or similar), which serves HTTP/1.1 only. Browsers cap concurrent HTTP/1.1 connections per origin at ~6 (Chrome). deck.gl's Tileset2D ships a RequestScheduler({ maxRequests: 6 }) per-TileLayer instance — so two COGLayers on the same S3 bucket can each schedule 6 → ≥12 in flight → the browser queues the excess. Worse, those queued requests stick around when the viewport pans, blocking fresh requests behind stale ones. The cap has to be per origin, shared across layers, and the queue has to re-order on pan so newly-central sources are serviced before stale edge sources.

What's in this PR

In @developmentseed/geotiff:

  • ConcurrencyLimiter interfaceacquire(url, signal?, getPriority?: () => Priority): Promise<() => void>. Signal-aware: a queued request whose signal aborts (e.g. user panned away) is dropped before any network I/O fires, no slot burned.
  • Priority typenumber | readonly number[]. 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.
  • Dynamic prioritygetPriority is a callback, re-invoked on every slot-open. Panning the viewport re-sorts the queue without any explicit "reprioritize" call. A linear-scan find-min on release is the right primitive here (a heap can't represent priorities that move under it).
  • PerOriginSemaphore class — default impl. Maintains Map<origin, Semaphore> internally; one PerOriginSemaphore({ maxRequests: 6 }) shared across layers gates each origin to 6 in flight, independently.
  • LimiterMiddleware class — chunkd SourceMiddleware, shape matches SourceChunk / SourceCache. Slotted into the existing header-source SourceView after the cache, so cache hits short-circuit and never burn a slot — only uncached network reads gate. Data source path gets a tiny SourceView of its own with just the middleware, so every tile-data fetch gates.
  • GeoTIFF.fromUrl(url, { concurrencyLimiter?, getPriority? }) — accepts a limiter (non-null → installed; null or omitted → no gating) and an optional priority callback forwarded to the limiter for every fetch through this GeoTIFF's sources.
  • Semaphore (the FIFO + priority primitive) stays internal. Public surface: ConcurrencyLimiter (type), Priority (type), PerOriginSemaphore (class).

In @developmentseed/deck.gl-geotiff:

  • defaultConcurrencyLimiter — a module-level new PerOriginSemaphore({ maxRequests: 6 }). Referenced from COGLayer, MultiCOGLayer, and MosaicLayer's defaultProps.concurrencyLimiter, so any two layers on the same origin share one cap out of the box, zero user setup.
  • concurrencyLimiter?: ConcurrencyLimiter | null prop on COGLayer, MultiCOGLayer, and MosaicLayer. Pass your own to override; pass null to disable.
  • MosaicLayer priority — computes a getPriority per source (euclidean distance from source.bbox center to the current viewport center, in degree-space) and threads it, along with the layer's concurrencyLimiter, through getSource's opts. A consumer's getSource can spread opts straight into GeoTIFF.fromUrl so both wire up automatically.
  • fetchGeoTIFF accepts the options and forwards them to GeoTIFF.fromUrl.

Cancellation flow

  1. deck.gl pans → Tileset2D._pruneRequests aborts unselected in-flight tiles.
  2. Each tile's AbortSignal propagates through getTileDatafetchTiledataSource.fetch(..., { signal }).
  3. LimiterMiddleware forwards the signal to limiter.acquire(url, signal, getPriority):
    • Already aborted → reject immediately, no slot consumed.
    • Aborted while queued in the inner Semaphore → splice from queue, reject, no slot consumed.
    • Aborted in-flight (after acquire) → underlying fetch aborts via its own signal handling; finally releases the slot.

Priority flow

  1. MosaicLayer.getTileData builds a getPriority closure that reads this.context.viewport lazily, so each invocation sees the current viewport (not the one at queue time).
  2. The closure is passed to getSource(opts); example app spreads opts into GeoTIFF.fromUrl.
  3. LimiterMiddleware forwards it to every acquire call.
  4. Semaphore.release linear-scans pending waiters, calls each one's getPriority(), picks the min, and resumes it. Panning the viewport between releases mutates the values the next scan sees — no explicit re-queue.

Out of scope (deferred)

  • Multi-tile request coalescing (the TileBatcher / getMultiTileData thread from the design discussion). The interaction with deck.gl's _pruneRequests (which only fires when ongoing > maxRequests) made it more involved than expected — gating + priority first is cleanly mergeable and unblocks the cross-layer pain point; coalescing follows.
  • Subclassing Tileset2D to fire abort signals on onTileUnload regardless of maxRequests.
  • Upstream deck.gl proposals (getTileDataBatched, public pruneRequests, etc.) — captured in the spec.
  • A standalone @developmentseed/concurrency package — the limiter primitives live in @developmentseed/geotiff for now; extractable later when/if a non-geotiff source format wants to share an instance.

Spec

dev-docs/specs/2026-05-19-concurrency-limiter-design.md.

Test plan

  • pnpm --filter @developmentseed/geotiff test — passes. New: Semaphore (FIFO + signal + dynamic priority) tests, PerOriginSemaphore tests, LimiterMiddleware tests, fromUrl({ concurrencyLimiter, getPriority }) integration tests with SourceHttp.fetch stubbed. The 17 pre-existing integration-rasterio.test.ts failures are unrelated (missing local .npy fixtures).
  • pnpm --filter @developmentseed/deck.gl-geotiff test — passes. concurrency-limiter.test.ts confirms COGLayer / MultiCOGLayer / MosaicLayer all default concurrencyLimiter to the same module-level PerOriginSemaphore instance.
  • pnpm --filter @developmentseed/geotiff typecheck + pnpm --filter @developmentseed/deck.gl-geotiff typecheck — clean.
  • pnpm biome check packages/geotiff packages/deck.gl-geotiff examples/naip-mosaic — clean.
  • Manual: naip-mosaic example with ~100 COGs in view — first rendered tile is now near viewport center (was bottom-left), time-to-first-render dropped from ~5s to sub-second.

Part of #273 (the multi-tile coalescing other half is a follow-up).

🤖 Generated with Claude Code

kylebarron and others added 5 commits May 12, 2026 18:37
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<origin, Semaphore>. 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment thread packages/geotiff/src/limiter.ts
…class

Semaphore and limitFetch stay internal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/geotiff/src/limiter.ts
Comment thread packages/geotiff/src/limiter.ts
kylebarron and others added 2 commits May 19, 2026 14:15
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment thread packages/geotiff/src/limiter.ts
kylebarron and others added 2 commits May 19, 2026 14:18
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment thread packages/geotiff/src/geotiff.ts Outdated
Comment on lines +319 to +325
const dataSource: Pick<Source, "fetch"> = concurrencyLimiter
? {
fetch: limitFetch(
source.fetch.bind(source),
new URL(url),
concurrencyLimiter,
),
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner if we defined a middleware class similar to SourceChunk and SourceCache that we could slot in, rather than overriding the fetch like this.

Also what's the benefit of not limiting header fetches? Those still count too, right?

kylebarron and others added 3 commits May 19, 2026 14:31
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@kylebarron kylebarron changed the title Kyle/get tile data coalesce v2 feat: per-origin HTTP concurrency limiter for COG layers May 19, 2026
@github-actions github-actions Bot added the feat label May 19, 2026
@kylebarron kylebarron marked this pull request as ready for review May 19, 2026 19:00
kylebarron and others added 9 commits May 19, 2026 16:11
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…er as getPriority

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…an fire

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
kylebarron and others added 3 commits May 21, 2026 12:36
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) <noreply@anthropic.com>
…lize-only

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) <noreply@anthropic.com>
Comment on lines +236 to +262
// 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;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we put this into a helper function instead of having it inline? It's verbose.

Maybe something like function createGetPriorityCallback?

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) <noreply@anthropic.com>
Comment on lines +362 to +388
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;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be clearer as a helper function

kylebarron and others added 3 commits May 21, 2026 14:23
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) <noreply@anthropic.com>
kylebarron and others added 14 commits May 22, 2026 11:17
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…Layer

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
@kylebarron
Copy link
Copy Markdown
Member Author

Ok after a LOT of back and forth I think this PR is ready to merge!

@kylebarron kylebarron enabled auto-merge (squash) May 22, 2026 17:48
@kylebarron kylebarron merged commit e1a8fef into main May 22, 2026
3 checks passed
@kylebarron kylebarron deleted the kyle/getTileData-coalesce-v2 branch May 22, 2026 17:50
kylebarron added a commit that referenced this pull request May 26, 2026
…572)

chunkd#1697 shipped (@chunkd/source 11.4.1, @chunkd/middleware 11.3.1),
so SourceView now forwards the request signal to middleware and
SourceChunk forwards it on multi-chunk sub-requests. That removes the
reason for the LimitedSource source-wrapper workaround (#557): a
middleware can now observe an abort, so a request whose caller aborts
while queued for a slot is dropped before any network I/O.

Replace the internal LimitedSource with LimiterMiddleware again,
composed into the header SourceView *after* SourceChunk/SourceCache (so
cache hits short-circuit and never burn a slot) and into a tiny
SourceView wrapping the data source. Bump the @chunkd/source,
@chunkd/source-http, and @chunkd/middleware floors to the fixed
releases.

Internal-only: GeoTIFF.fromUrl options, ConcurrencyLimiter, Priority,
and PerOriginSemaphore are unchanged. The integration test that aborts a
queued header read through the real fromUrl -> SourceView -> limiter
path is unchanged and still passes, proving the signal now reaches the
middleware end to end.

Closes #565.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MosaicLayer: Connect RequestScheduler of the top-level layer with all sub layers

1 participant