perf(map): raise tile concurrency and cancel superseded vector tile loads#387
perf(map): raise tile concurrency and cancel superseded vector tile loads#387NearlCrews wants to merge 2 commits into
Conversation
|
According to Openlayers documentation |
|
Good catch, confirmed against the OL 10.7 docs: I'll split this: land the |
…oads Raise OL maxTilesLoading from the default 16 to 32 so zoom-out tile bursts keep the browser's HTTP/1.1 connection pool fed. Add an abortable VectorTile tileLoadFunction that cancels in-flight loads from superseded zoom levels, freeing the connection pool for the now-visible level. Raster-source cancellation is intentionally left out: OL deprecates tileLoadFunction on raster (UrlTile) sources in favor of an ImageTile loader, which is a larger rework for a follow-up.
|
Trimmed to the safe parts and rebased onto master: kept the |
9228386 to
df0f5e1
Compare
|
Thanks for trimming this down — splitting off the deprecated raster My Claude asked your Claude for this: # Verification — PR #387 (vector tile abort + maxTilesLoading)
Context: this PR replaces OpenLayers' built-in VectorTile load path with a
custom `tileLoadFunction` (`createAbortableVectorTileLoader` in
`src/app/modules/map/ol/lib/charts/tile-loader-abort.ts`) that cancels
in-flight tile loads from superseded zoom levels, and bumps
`maxTilesLoading` from 16 to 32 in `map.component.ts`.
The current "Tested" evidence is `tsc --noEmit` + `build:web` — compile only.
There are two gaps to close: (1) a unit test proving the abort bookkeeping,
and (2) runtime proof the vector charts still render and requests cancel.
---
## Part 1 — Add a vitest spec for the abort bookkeeping
FSK runs vitest with co-located `*.spec.ts`. Add
`src/app/modules/map/ol/lib/charts/tile-loader-abort.spec.ts` proving the
per-zoom supersede logic. The pure helpers (`bucketForZoom`,
`releaseController`) are the cleanest seam — export them from the module
(keep `createAbortableVectorTileLoader` as the public entry) so they can be
tested directly without mocking the whole OL VectorTile.
Cases to cover:
- **Same-zoom reuse:** two calls at zoom Z return the *same* controller set;
no controllers are aborted.
- **Supersede aborts others:** with controllers held under zoom Z1, a call
for zoom Z2 aborts every Z1 controller (assert `.signal.aborted === true`)
and removes the Z1 bucket from the map.
- **Release cleanup:** removing the last controller for a zoom deletes that
zoom's bucket entirely; removing one of several leaves the rest intact.
- **Release is zoom-safe:** releasing a controller under a zoom that's
already been superseded/deleted is a no-op, not a throw.
Run `ng test` (or the vitest invocation this repo uses) and confirm green.
---
## Part 2 — Runtime verification
### Setup
1. Check out the PR branch (`perf/chart-tile-throughput`) and `ng serve`.
2. Point FSK at a Signal K server with a **vector (MVT) chart source**
served over **HTTP/1.1** (the connection-cap condition this PR targets —
confirm in DevTools the tile responses are h1, not h2).
3. Open FSK in Chrome → DevTools → Network, filter to the tile host,
enable "Disable cache".
### Checks (capture an artifact for each)
A. **Vector charts still render correctly.**
Load the vector chart; pan and zoom across a few levels. Confirm features
draw with no missing/blank tiles and no console errors.
→ Artifact: screenshot of the rendered vector chart + clean console.
B. **Superseded requests actually cancel.**
Zoom out across 2–3 levels rapidly (faster than tiles finish). In the
Network panel, confirm tile requests from the abandoned zoom level(s)
show status **(canceled)**.
→ Artifact: Network-waterfall screenshot showing (canceled) rows for the
superseded zoom, plus the in-flight count for the visible level.
C. **No regression in the steady state.**
Zoom to a level and let it fully settle. Confirm every tile reaches
LOADED and nothing is stuck in (canceled) or error.
→ Artifact: waterfall of a clean settle (all 200/LOADED).
D. **maxTilesLoading effect (optional).**
On the same HTTP/1.1 source, compare time-to-visually-settle after a
zoom-out burst on this branch vs. master, same hardware.
→ Artifact: before/after settle time (or a short screen capture).
### Edge cases to spot-check
- Cross-origin source: does plain `fetch(src)` still load (CORS /
credentials) the same as OL's default loader did? No CORS errors for a
remote chart host.
- Rapid pinch-zoom through intermediate levels: the final level renders and
you don't end up with a level whose tiles were all aborted and never
re-requested.
---
## Output
- Confirm the new spec passes (`ng test`).
- Post the runtime artifacts on PR #387, one line each on what they show.
- If any check fails, note which and the console/network detail. |
Add a vitest spec for the per-zoom AbortController bookkeeping behind the superseded-tile cancellation: same-zoom bucket reuse, supersede aborting and dropping stale-zoom controllers, and release cleanup including the zoom-safe no-op paths. Export bucketForZoom, releaseController, and the PendingByZoom type as the test seam, with no runtime behavior change.
📝 WalkthroughWalkthroughAdds ChangesAbortable Vector Tile Loading
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Both parts are in. Part 1: unit spec. Added
Green at Part 2: runtime. I drove the actual Results (request log, 63 tile requests):
This is a synthetic harness exercising the real loader code path, not FSK driven by hand against a Signal K vector source. If you would rather see it against a live SK chart source, or want the render and waterfall screenshots and the harness script, say the word and I will post them. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/app/modules/map/ol/lib/charts/tile-loader-abort.spec.ts (1)
12-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd specs for
createAbortableVectorTileLoader().These tests only cover the helper map/set bookkeeping. The new behavior that actually ships here—fetch cancellation,
AbortErrorhandling, tile state transitions, and cleanup after settle—is still untested, so the main regression surface from this PR would slip through.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.spec.ts` around lines 12 - 120, The current spec only covers bucket bookkeeping in bucketForZoom and releaseController, but not the actual loader behavior introduced by createAbortableVectorTileLoader(). Add tests around createAbortableVectorTileLoader that verify an in-flight fetch is canceled via AbortController, AbortError is handled without failing the tile, tile state transitions happen correctly on success/failure, and cleanup occurs after the request settles. Use the existing helper patterns in the spec to locate and exercise the loader entry points directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.ts`:
- Around line 52-58: Expose a teardown hook from createAbortableVectorTileLoader
so callers can abort all pending controllers held in the pending map. Update the
loader factory and the createAbortableVectorTileLoader return type to include a
cleanup/teardown function, and use it from
VectorChartLayerComponent.ngOnDestroy() to cancel any still-running tile fetches
before the layer is removed.
- Around line 80-86: The abort handling in tile-loader-abort should put
superseded tiles into a terminal state instead of leaving them stuck in LOADING.
Update the abort path around the tile load promise and the tile state logic in
tile-loader-abort so AbortError is treated as a terminal outcome and the tile is
set to ERROR via tile.setState, while keeping EMPTY reserved for intentionally
blank/disposed tiles. Use the existing tile.setState, TileState.ERROR, and abort
catch/finally flow to locate the fix.
---
Nitpick comments:
In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.spec.ts`:
- Around line 12-120: The current spec only covers bucket bookkeeping in
bucketForZoom and releaseController, but not the actual loader behavior
introduced by createAbortableVectorTileLoader(). Add tests around
createAbortableVectorTileLoader that verify an in-flight fetch is canceled via
AbortController, AbortError is handled without failing the tile, tile state
transitions happen correctly on success/failure, and cleanup occurs after the
request settles. Use the existing helper patterns in the spec to locate and
exercise the loader entry points directly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b7783283-a22a-400f-9956-6672af3a1421
📒 Files selected for processing (4)
src/app/modules/map/ol/lib/charts/layer-vector-chart.component.tssrc/app/modules/map/ol/lib/charts/tile-loader-abort.spec.tssrc/app/modules/map/ol/lib/charts/tile-loader-abort.tssrc/app/modules/map/ol/lib/map.component.ts
| export function createAbortableVectorTileLoader(): ( | ||
| tile: VectorTile<FeatureLike>, | ||
| src: string | ||
| ) => void { | ||
| const pending: PendingByZoom = new Map(); | ||
|
|
||
| return function loader(tile: VectorTile<FeatureLike>, src: string) { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Expose a teardown hook for pending controllers.
pending is trapped inside this factory, so VectorChartLayerComponent.ngOnDestroy() has no way to abort still-running tile fetches when the layer is removed. Those requests keep consuming the shared tile budget until the network settles.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.ts` around lines 52 - 58,
Expose a teardown hook from createAbortableVectorTileLoader so callers can abort
all pending controllers held in the pending map. Update the loader factory and
the createAbortableVectorTileLoader return type to include a cleanup/teardown
function, and use it from VectorChartLayerComponent.ngOnDestroy() to cancel any
still-running tile fetches before the layer is removed.
| .catch((err) => { | ||
| if (err?.name !== 'AbortError') { | ||
| tile.setState(TileState.ERROR); | ||
| } | ||
| }) | ||
| .finally(() => { | ||
| releaseController(pending, z, controller); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for OpenLayers TileState docs/usages in repo
rg -n "TileState|abort.*tile|AbortError|setState\\(TileState\\.(EMPTY|ERROR|LOADING|LOADED)\\)" . -S --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'
# Inspect package versions if present
if [ -f package.json ]; then
nl -ba package.json | sed -n '1,220p'
fi
if [ -f pnpm-lock.yaml ]; then
rg -n "^ ol@|/ol/|name: ol|version:" pnpm-lock.yaml -S | sed -n '1,120p'
fi
if [ -f package-lock.json ]; then
rg -n '"ol"|TileState' package-lock.json -S | sed -n '1,120p'
fi
if [ -f yarn.lock ]; then
rg -n '(^ol@|/ol@|TileState)' yarn.lock -S | sed -n '1,120p'
fiRepository: SignalK/freeboard-sk
Length of output: 714
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the target file region with line numbers using sed (no nl dependency)
sed -n '1,180p' src/app/modules/map/ol/lib/charts/tile-loader-abort.ts
# Inspect package metadata for the OpenLayers version if available
if [ -f package.json ]; then
sed -n '1,240p' package.json
fiRepository: SignalK/freeboard-sk
Length of output: 6452
🌐 Web query:
OpenLayers 10.7 TileState abort EMPTY ERROR loading tile image loader semantics
💡 Result:
In OpenLayers, TileState is an enumeration used to track the lifecycle of a tile. The states relevant to your query are defined in the ol/TileState module [1]: IDLE (0): Initial state [1]. LOADING (1): The tile is currently being fetched [1]. LOADED (2): The tile data has been successfully loaded [1]. ERROR (3): The tile loading process failed [1]. EMPTY (4): The tile is empty or has been disposed of [1][2]. Regarding tile loader semantics and state management: Tile Loading and Errors: When implementing a custom tileLoadFunction, it is critical to explicitly call tile.setState(TileState.ERROR) if the tile fails to load [3][4]. If this is not done, the tile will remain in a loading state, blocking the tile queue and preventing other requests from being processed [3][4]. Handling EMPTY State: A tile is assigned the TileState.EMPTY state when it is disposed of or is intentionally empty [2]. Recent improvements to OpenLayers ensure that an EMPTY tile remains in that state [2]. The internal setState method now returns early if a tile is already marked as EMPTY, preventing late-arriving load or error callbacks from incorrectly updating the state of a disposed tile [2]. Future Directions: OpenLayers is shifting away from the legacy UrlTile API (which uses tileLoadFunction) toward the DataTile and ImageTile sources using a loader function [5]. When using these newer loaders, if a tile request fails, you have the option to resolve the loader with empty data (e.g., an empty image) to treat the failure as an empty tile rather than an error, providing more granular control over how missing data is handled [5].
Citations:
- 1: https://openlayers.org/en/latest/apidoc/module-ol_TileState.html
- 2: An EMPTY tile stays EMPTY openlayers/openlayers#16513
- 3: https://openlayers.org/en/latest/apidoc/module-ol_ImageTile-ImageTile.html
- 4: https://openlayers.org/en/latest/apidoc/module-ol_Tile-Tile.html
- 5:
useInterimTilesOnErrordeprecated, but what is the replacement? openlayers/openlayers#16618
🌐 Web query:
site:openlayers.org ol TileState EMPTY disposed intentionally empty abort loading setState ERROR
💡 Result:
In OpenLayers, TileState is an enumeration used to track the lifecycle status of a tile [1][2]. The states are: IDLE (0): The tile is initialized but not yet loading [1][2]. LOADING (1): The tile is currently being fetched [1][2]. LOADED (2): The tile has been successfully loaded [1][2]. ERROR (3): The tile failed to load [1][2]. EMPTY (4): The tile is intentionally empty (e.g., no data exists for this tile coordinate) [1][2]. When implementing a custom tile load function, you are responsible for updating the state of the tile [3][4]. It is critical to set the state to ERROR if the loading process fails, as failing to do so will leave the tile in a pending state, potentially causing the tile queue to block subsequent requests [3][4]. The EMPTY state is typically used when a tile is intentionally left blank because no data is available for that specific location or zoom level, distinguishing it from an error state where a retrieval attempt failed. Disposed tiles or those whose loading has been aborted are handled by the library's internal management, often triggering a change in the tile's state or removal from the active tile queue [5].
Citations:
- 1: https://openlayers.org/en/latest/apidoc/module-ol_TileState.html
- 2: https://openlayers.org/en/v8.2.0/apidoc/module-ol_TileState.html
- 3: https://openlayers.org/en/latest/apidoc/module-ol_Tile-Tile.html
- 4: https://openlayers.org/en/v8.2.0/apidoc/module-ol_Tile-Tile.html
- 5: https://openlayers.org/en/latest/apidoc/module-ol_source_Tile.TileSourceEvent.html
Set aborted tiles to ERROR, not leave them LOADING. AbortError currently skips every terminal tile.setState(...) path, so superseded tiles can remain active in OL’s queue. EMPTY is for intentionally blank/disposed tiles.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.ts` around lines 80 - 86,
The abort handling in tile-loader-abort should put superseded tiles into a
terminal state instead of leaving them stuck in LOADING. Update the abort path
around the tile load promise and the tile state logic in tile-loader-abort so
AbortError is treated as a terminal outcome and the tile is set to ERROR via
tile.setState, while keeping EMPTY reserved for intentionally blank/disposed
tiles. Use the existing tile.setState, TileState.ERROR, and abort catch/finally
flow to locate the fix.
|
This is exactly the evidence I was after — thank you for the thorough turnaround. The vitest cases nail the supersede bookkeeping, and the headless run over a real HTTP/1.1 + latency path is a clean way to prove the two things I couldn't take on faith from a green build: vector charts still render, and superseded loads actually cancel (the 15 CodeRabbit has since flagged two things I'd treat as substantive rather than style, and I'd like both addressed before merge:
Its third note (test Since you're on Claude Code: CodeRabbit drops a "🤖 Prompt for AI Agents" block under each finding, plus a combined "Prompt for all review comments with AI agents" at the end of its review — those are written to paste straight into your agent, so you don't have to translate the comments by hand. Get those two items sorted and I think this is ready to land. |
Summary
Two related changes to chart tile loading throughput, kept in one PR
because they pair under the same root cause (browser HTTP/1.1 per-host
connection cap):
maxTilesLoadingfrom the default 16 to 32 so OL keeps thebrowser connection pool fully fed during zoom-out bursts.
that cancels superseded zoom-level requests when the user zooms again
before the prior level finishes.
Root cause
On a zoom-out the active raster / WMTS layer typically requests 30 to 50
new tiles. The browser caps concurrent HTTP/1.1 requests per host at six.
With OL's queue capped at 16, the browser sees the requests in waves rather
than as a steady stream, and on rapid zoom the prior level's pending
requests keep occupying the pool because OL does not cancel them.
Fix
map.component.ts: bumpmaxTilesLoading: 32in thenew Map(...)constructor options. Why 32: covers the typical zoom-out burst while
leaving headroom for adjacent layers without exhausting the browser pool.
tile-loader-abort.ts(new): two factories. Raster factory takes(src, options)and returns atileLoadFunctionforXYZ,WMTS,TileWMS, andTileJSON. Vector factory mirrors it forVectorTile.Each factory closes over
Map<zoom, Set<AbortController>>; on every newloader invocation, controllers for other zoom levels are aborted before
the new request starts. Self-cleans via
.finally().layer-tilejson-chart,layer-vector-chart,layer-wms-chart,layer-wmts-chart, plus thevector-source path in the same file).
Caveats
that does not accept an
AbortSignal; adding equivalent cancellationrequires a wrapper better landed separately.
maxTilesLoadingwin applies only on HTTP/1.1 chart hosts. HTTP/2sources multiplex over one TCP connection so the per-host cap does not
apply.
Tested
npx tsc --noEmit -p tsconfig.app.jsonclean.npm run build:webclean. Bundle delta negligible.Summary by CodeRabbit
New Features
Bug Fixes