feat: per-origin HTTP concurrency limiter for COG layers#557
Merged
Conversation
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>
kylebarron
commented
May 19, 2026
…class Semaphore and limitFetch stay internal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron
commented
May 19, 2026
kylebarron
commented
May 19, 2026
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>
kylebarron
commented
May 19, 2026
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>
kylebarron
commented
May 19, 2026
Comment on lines
+319
to
+325
| const dataSource: Pick<Source, "fetch"> = concurrencyLimiter | ||
| ? { | ||
| fetch: limitFetch( | ||
| source.fetch.bind(source), | ||
| new URL(url), | ||
| concurrencyLimiter, | ||
| ), |
Member
Author
There was a problem hiding this comment.
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?
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>
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>
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>
kylebarron
commented
May 21, 2026
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; |
Member
Author
There was a problem hiding this comment.
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>
kylebarron
commented
May 21, 2026
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; | ||
| } |
Member
Author
There was a problem hiding this comment.
I think this might be clearer as a helper function
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>
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>
Member
Author
|
Ok after a LOT of back and forth I think this PR is ready to merge! |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/MosaicLayerinstances 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
Tileset2Dships aRequestScheduler({ maxRequests: 6 })per-TileLayerinstance — so twoCOGLayers 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:ConcurrencyLimiterinterface —acquire(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.Prioritytype —number | 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.getPriorityis 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).PerOriginSemaphoreclass — default impl. MaintainsMap<origin, Semaphore>internally; onePerOriginSemaphore({ maxRequests: 6 })shared across layers gates each origin to 6 in flight, independently.LimiterMiddlewareclass — chunkdSourceMiddleware, shape matchesSourceChunk/SourceCache. Slotted into the existing header-sourceSourceViewafter the cache, so cache hits short-circuit and never burn a slot — only uncached network reads gate. Data source path gets a tinySourceViewof its own with just the middleware, so every tile-data fetch gates.GeoTIFF.fromUrl(url, { concurrencyLimiter?, getPriority? })— accepts a limiter (non-null → installed;nullor 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-levelnew PerOriginSemaphore({ maxRequests: 6 }). Referenced fromCOGLayer,MultiCOGLayer, andMosaicLayer'sdefaultProps.concurrencyLimiter, so any two layers on the same origin share one cap out of the box, zero user setup.concurrencyLimiter?: ConcurrencyLimiter | nullprop onCOGLayer,MultiCOGLayer, andMosaicLayer. Pass your own to override; passnullto disable.MosaicLayerpriority — computes agetPriorityper source (euclidean distance fromsource.bboxcenter to the current viewport center, in degree-space) and threads it, along with the layer'sconcurrencyLimiter, throughgetSource'sopts. A consumer'sgetSourcecan spreadoptsstraight intoGeoTIFF.fromUrlso both wire up automatically.fetchGeoTIFFaccepts the options and forwards them toGeoTIFF.fromUrl.Cancellation flow
Tileset2D._pruneRequestsaborts unselected in-flight tiles.AbortSignalpropagates throughgetTileData→fetchTile→dataSource.fetch(..., { signal }).LimiterMiddlewareforwards the signal tolimiter.acquire(url, signal, getPriority):Semaphore→ splice from queue, reject, no slot consumed.fetchaborts via its own signal handling;finallyreleases the slot.Priority flow
MosaicLayer.getTileDatabuilds agetPriorityclosure that readsthis.context.viewportlazily, so each invocation sees the current viewport (not the one at queue time).getSource(opts); example app spreadsoptsintoGeoTIFF.fromUrl.LimiterMiddlewareforwards it to everyacquirecall.Semaphore.releaselinear-scans pending waiters, calls each one'sgetPriority(), 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)
TileBatcher/getMultiTileDatathread from the design discussion). The interaction with deck.gl's_pruneRequests(which only fires whenongoing > maxRequests) made it more involved than expected — gating + priority first is cleanly mergeable and unblocks the cross-layer pain point; coalescing follows.Tileset2Dto fire abort signals ononTileUnloadregardless ofmaxRequests.getTileDataBatched, publicpruneRequests, etc.) — captured in the spec.@developmentseed/concurrencypackage — the limiter primitives live in@developmentseed/geotifffor 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,PerOriginSemaphoretests,LimiterMiddlewaretests,fromUrl({ concurrencyLimiter, getPriority })integration tests withSourceHttp.fetchstubbed. The 17 pre-existingintegration-rasterio.test.tsfailures are unrelated (missing local.npyfixtures).pnpm --filter @developmentseed/deck.gl-geotiff test— passes.concurrency-limiter.test.tsconfirmsCOGLayer/MultiCOGLayer/MosaicLayerall defaultconcurrencyLimiterto the same module-levelPerOriginSemaphoreinstance.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.naip-mosaicexample 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