-
Notifications
You must be signed in to change notification settings - Fork 49
perf(map): raise tile concurrency and cancel superseded vector tile loads #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { expect, describe, it } from 'vitest'; | ||
| import { | ||
| bucketForZoom, | ||
| releaseController, | ||
| type PendingByZoom | ||
| } from './tile-loader-abort'; | ||
|
|
||
| function makePending(): PendingByZoom { | ||
| return new Map(); | ||
| } | ||
|
|
||
| describe('bucketForZoom', () => { | ||
| it('returns the same bucket on repeated calls at the same zoom', () => { | ||
| const pending = makePending(); | ||
| const bucket1 = bucketForZoom(pending, 10); | ||
| const bucket2 = bucketForZoom(pending, 10); | ||
| expect(bucket1).toBe(bucket2); | ||
| }); | ||
|
|
||
| it('does not abort any controller when the zoom is unchanged', () => { | ||
| const pending = makePending(); | ||
| const bucket = bucketForZoom(pending, 10); | ||
| const controller = new AbortController(); | ||
| bucket.add(controller); | ||
|
|
||
| bucketForZoom(pending, 10); | ||
|
|
||
| expect(controller.signal.aborted).toBe(false); | ||
| }); | ||
|
|
||
| it('aborts all controllers from a superseded zoom', () => { | ||
| const pending = makePending(); | ||
| const bucket = bucketForZoom(pending, 8); | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| bucket.add(c1); | ||
| bucket.add(c2); | ||
|
|
||
| bucketForZoom(pending, 12); | ||
|
|
||
| expect(c1.signal.aborted).toBe(true); | ||
| expect(c2.signal.aborted).toBe(true); | ||
| }); | ||
|
|
||
| it('removes the old zoom bucket after superseding', () => { | ||
| const pending = makePending(); | ||
| const oldBucket = bucketForZoom(pending, 8); | ||
| const c = new AbortController(); | ||
| oldBucket.add(c); | ||
|
|
||
| bucketForZoom(pending, 12); | ||
|
|
||
| expect(pending.has(8)).toBe(false); | ||
| expect(pending.has(12)).toBe(true); | ||
| }); | ||
|
|
||
| it('aborts controllers across multiple superseded zooms in one call', () => { | ||
| const pending = makePending(); | ||
| const c5 = new AbortController(); | ||
| const c7 = new AbortController(); | ||
| pending.set(5, new Set([c5])); | ||
| pending.set(7, new Set([c7])); | ||
|
|
||
| bucketForZoom(pending, 10); | ||
|
|
||
| expect(c5.signal.aborted).toBe(true); | ||
| expect(c7.signal.aborted).toBe(true); | ||
| expect(pending.has(5)).toBe(false); | ||
| expect(pending.has(7)).toBe(false); | ||
| expect(pending.has(10)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('releaseController', () => { | ||
| it('deletes the bucket when its last controller is removed', () => { | ||
| const pending = makePending(); | ||
| const bucket = bucketForZoom(pending, 9); | ||
| const controller = new AbortController(); | ||
| bucket.add(controller); | ||
|
|
||
| releaseController(pending, 9, controller); | ||
|
|
||
| expect(pending.has(9)).toBe(false); | ||
| }); | ||
|
|
||
| it('leaves the bucket intact when other controllers remain', () => { | ||
| const pending = makePending(); | ||
| const bucket = bucketForZoom(pending, 9); | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| bucket.add(c1); | ||
| bucket.add(c2); | ||
|
|
||
| releaseController(pending, 9, c1); | ||
|
|
||
| expect(pending.has(9)).toBe(true); | ||
| expect(pending.get(9)?.has(c2)).toBe(true); | ||
| expect(pending.get(9)?.has(c1)).toBe(false); | ||
| }); | ||
|
|
||
| it('is a no-op when the zoom bucket has already been removed', () => { | ||
| const pending = makePending(); | ||
| const controller = new AbortController(); | ||
|
|
||
| expect(() => releaseController(pending, 9, controller)).not.toThrow(); | ||
| expect(pending.has(9)).toBe(false); | ||
| }); | ||
|
|
||
| it('is a no-op when the controller is not in the bucket', () => { | ||
| const pending = makePending(); | ||
| const bucket = bucketForZoom(pending, 9); | ||
| const resident = new AbortController(); | ||
| bucket.add(resident); | ||
| const stranger = new AbortController(); | ||
|
|
||
| expect(() => releaseController(pending, 9, stranger)).not.toThrow(); | ||
| expect(pending.has(9)).toBe(true); | ||
| expect(pending.get(9)?.has(resident)).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import TileState from 'ol/TileState'; | ||
| import type VectorTile from 'ol/VectorTile'; | ||
| import type { FeatureLike } from 'ol/Feature'; | ||
| import type { Extent } from 'ol/extent'; | ||
| import type Projection from 'ol/proj/Projection'; | ||
|
|
||
| export type PendingByZoom = Map<number, Set<AbortController>>; | ||
|
|
||
| /** | ||
| * Abort and drop controllers held under zoom levels other than `z`, then | ||
| * return (or create) the controller bucket for `z`. Keeps the vector | ||
| * loader's per-zoom bookkeeping in lockstep. | ||
| */ | ||
| export function bucketForZoom( | ||
| pending: PendingByZoom, | ||
| z: number | ||
| ): Set<AbortController> { | ||
| for (const [oldZ, controllers] of pending) { | ||
| if (oldZ !== z) { | ||
| for (const c of controllers) c.abort(); | ||
| pending.delete(oldZ); | ||
| } | ||
| } | ||
| let set = pending.get(z); | ||
| if (!set) { | ||
| set = new Set(); | ||
| pending.set(z, set); | ||
| } | ||
| return set; | ||
| } | ||
|
|
||
| export function releaseController( | ||
| pending: PendingByZoom, | ||
| z: number, | ||
| controller: AbortController | ||
| ) { | ||
| const s = pending.get(z); | ||
| if (s) { | ||
| s.delete(controller); | ||
| if (s.size === 0) pending.delete(z); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build an OL VectorTile `tileLoadFunction` that aborts in-flight loads from | ||
| * superseded zoom levels. When a new tile request arrives at zoom Z, any | ||
| * outstanding controllers for zoom !== Z are aborted so they free up the | ||
| * browser's connection pool for the now-visible level. The fetch is wrapped | ||
| * inside `tile.setLoader` because OL's VectorTile contract has the tile own | ||
| * its decoded features. | ||
| */ | ||
| export function createAbortableVectorTileLoader(): ( | ||
| tile: VectorTile<FeatureLike>, | ||
| src: string | ||
| ) => void { | ||
| const pending: PendingByZoom = new Map(); | ||
|
|
||
| return function loader(tile: VectorTile<FeatureLike>, src: string) { | ||
| const z = tile.getTileCoord()[0]; | ||
| const bucket = bucketForZoom(pending, z); | ||
|
|
||
| tile.setLoader( | ||
| (extent: Extent, _resolution: number, projection: Projection) => { | ||
| const controller = new AbortController(); | ||
| bucket.add(controller); | ||
|
|
||
| tile.setState(TileState.LOADING); | ||
| fetch(src, { signal: controller.signal }) | ||
| .then((r) => r.arrayBuffer()) | ||
| .then((data) => { | ||
| const format = tile.getFormat(); | ||
| const features = format.readFeatures(data, { | ||
| extent, | ||
| featureProjection: projection | ||
| }) as FeatureLike[]; | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| (tile as any).setFeatures(features); | ||
| tile.setState(TileState.LOADED); | ||
| }) | ||
| .catch((err) => { | ||
| if (err?.name !== 'AbortError') { | ||
| tile.setState(TileState.ERROR); | ||
| } | ||
| }) | ||
| .finally(() => { | ||
| releaseController(pending, z, controller); | ||
|
Comment on lines
+80
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 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:
💡 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:
🌐 Web query:
💡 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:
Set aborted tiles to 🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
| ); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Expose a teardown hook for pending controllers.
pendingis trapped inside this factory, soVectorChartLayerComponent.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