-
Notifications
You must be signed in to change notification settings - Fork 2
feat(sources): client helpers for the batched-tiles fast path [sc-556605] #302
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: main
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,115 @@ | ||
| // Client helpers for the batched-tiles fast path (server: maps-api sc-556575). | ||
| // | ||
| // deck.gl requests a viewport's tiles in one burst; per-tile serving turns that | ||
| // into N warehouse queries β ruinous on job-based engines. When the source is | ||
| // batch-capable the tilejson carries a `tiles_batch` URL; the client coalesces | ||
| // the burst into ONE request to it (`&tiles=z/x/y,z/x/y,β¦`, sorted so a repeated | ||
| // viewport is a repeated URL β CDN-cacheable) and splits the CDTB container the | ||
| // server returns, handing each payload to the format's normal tile decoder. | ||
| // | ||
| // These are the pure pieces (detect / build request / split response); the | ||
| // request coalescing itself lives in the deck.gl layer. | ||
|
|
||
| import type {TilejsonResult} from './types.js'; | ||
|
|
||
| export interface BatchTileIndex { | ||
| z: number; | ||
| x: number; | ||
| y: number; | ||
| } | ||
|
|
||
| /** | ||
| * A source is batch-capable when the server advertised a `tiles_batch` endpoint | ||
| * in the tilejson (PostgreSQL/BigQuery, binary/geojson). Absent β the consumer | ||
| * must use the per-tile `tiles` template. | ||
| */ | ||
| export function isBatchTilesTilejson(tilejson: TilejsonResult): boolean { | ||
| return Boolean(tilejson.tiles_batch); | ||
| } | ||
|
|
||
| /** | ||
| * Build the URL for a batch of tiles: the advertised `tiles_batch` endpoint with | ||
| * `&tiles=z/x/y,β¦` appended. Tiles are sorted (z, then x, then y) so the same | ||
| * viewport always yields the same URL β batches stay as CDN-cacheable as single | ||
| * tiles. The caller is responsible for keeping the batch within `tiles_batch_max`. | ||
| */ | ||
| export function buildBatchTilesRequest( | ||
| tilejson: TilejsonResult, | ||
| tiles: BatchTileIndex[] | ||
| ): string { | ||
| if (!tilejson.tiles_batch) { | ||
| throw new Error('tilejson has no batch endpoint (tiles_batch)'); | ||
| } | ||
| if (tiles.length === 0) { | ||
| throw new Error('Cannot build a batch request with zero tiles'); | ||
| } | ||
| const list = [...tiles] | ||
| .sort((a, b) => a.z - b.z || a.x - b.x || a.y - b.y) | ||
| .map((t) => `${t.z}/${t.x}/${t.y}`) | ||
| .join(','); | ||
| const separator = tilejson.tiles_batch.includes('?') ? '&' : '?'; | ||
| return `${tilejson.tiles_batch}${separator}tiles=${list}`; | ||
| } | ||
|
|
||
| const CDTB_MAGIC = [0x43, 0x44, 0x54, 0x42]; // "CDTB" | ||
|
|
||
| /** Key a tile by its `z/x/y` (matches the strings in a batch request). */ | ||
| export function batchTileKey(tile: BatchTileIndex): string { | ||
| return `${tile.z}/${tile.x}/${tile.y}`; | ||
| } | ||
|
|
||
| /** | ||
| * Split the CDTB container the batch endpoint returns into per-tile payloads, | ||
| * keyed by `z/x/y`. Each payload is the same bytes a single-tile request would | ||
| * have returned, ready for the format's normal decoder. | ||
| * | ||
| * Container layout (little-endian): | ||
| * "CDTB" | u32 tileCount | per tile: u8 z | u32 x | u32 y | u32 len | bytes | ||
| */ | ||
| export function splitBatchTilesResponse( | ||
| data: ArrayBuffer | ||
| ): Map<string, ArrayBuffer> { | ||
| const bytes = new Uint8Array(data); | ||
| if ( | ||
| bytes.length < 8 || | ||
| bytes[0] !== CDTB_MAGIC[0] || | ||
| bytes[1] !== CDTB_MAGIC[1] || | ||
| bytes[2] !== CDTB_MAGIC[2] || | ||
| bytes[3] !== CDTB_MAGIC[3] | ||
| ) { | ||
| throw new Error('Invalid CDTB batch container'); | ||
| } | ||
| const view = new DataView(data); | ||
| let offset = 4; | ||
| const tileCount = view.getUint32(offset, true); | ||
| offset += 4; | ||
| const tiles = new Map<string, ArrayBuffer>(); | ||
| // Each tile header is 13 bytes (u8 z + u32 x + u32 y + u32 len). Bounds-check | ||
| // before every read and before slicing the payload so a truncated or | ||
| // malformed container throws a clear error instead of a generic DataView | ||
| // RangeError or a silently truncated payload. | ||
| const TILE_HEADER_BYTES = 13; | ||
| for (let i = 0; i < tileCount; i++) { | ||
| if (offset + TILE_HEADER_BYTES > data.byteLength) { | ||
| throw new Error( | ||
| 'Malformed CDTB batch container: unexpected end of metadata' | ||
| ); | ||
| } | ||
| const z = view.getUint8(offset); | ||
| offset += 1; | ||
| const x = view.getUint32(offset, true); | ||
| offset += 4; | ||
| const y = view.getUint32(offset, true); | ||
| offset += 4; | ||
| const length = view.getUint32(offset, true); | ||
| offset += 4; | ||
| if (offset + length > data.byteLength) { | ||
| throw new Error( | ||
| 'Malformed CDTB batch container: unexpected end of payload' | ||
| ); | ||
| } | ||
| tiles.set(`${z}/${x}/${y}`, data.slice(offset, offset + length)); | ||
| offset += length; | ||
| } | ||
|
Comment on lines
+92
to
+113
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. The binary parser in for (let i = 0; i < tileCount; i++) {
if (offset + 13 > data.byteLength) {
throw new Error('Malformed CDTB batch container: unexpected end of metadata');
}
const z = view.getUint8(offset);
offset += 1;
const x = view.getUint32(offset, true);
offset += 4;
const y = view.getUint32(offset, true);
offset += 4;
const length = view.getUint32(offset, true);
offset += 4;
if (offset + length > data.byteLength) {
throw new Error('Malformed CDTB batch container: unexpected end of payload');
}
tiles.set(`${z}/${x}/${y}`, data.slice(offset, offset + length));
offset += length;
} |
||
| return tiles; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import {describe, test, expect} from 'vitest'; | ||
| import { | ||
| isBatchTilesTilejson, | ||
| buildBatchTilesRequest, | ||
| splitBatchTilesResponse, | ||
| batchTileKey, | ||
| } from '@carto/api-client'; | ||
| import type {TilejsonResult} from '@carto/api-client'; | ||
|
|
||
| // Build a CDTB container exactly as the maps-api endpoint does, so the splitter | ||
| // is tested against the real wire layout (little-endian): | ||
| // "CDTB" | u32 count | per tile: u8 z | u32 x | u32 y | u32 len | bytes | ||
| function makeCDTB( | ||
| tiles: {z: number; x: number; y: number; payload: number[]}[] | ||
| ): ArrayBuffer { | ||
| let size = 8; | ||
| for (const t of tiles) size += 13 + t.payload.length; | ||
| const buf = new ArrayBuffer(size); | ||
| const view = new DataView(buf); | ||
| const bytes = new Uint8Array(buf); | ||
| bytes.set([0x43, 0x44, 0x54, 0x42], 0); // "CDTB" | ||
| let off = 4; | ||
| view.setUint32(off, tiles.length, true); | ||
| off += 4; | ||
| for (const t of tiles) { | ||
| view.setUint8(off, t.z); | ||
| off += 1; | ||
| view.setUint32(off, t.x, true); | ||
| off += 4; | ||
| view.setUint32(off, t.y, true); | ||
| off += 4; | ||
| view.setUint32(off, t.payload.length, true); | ||
| off += 4; | ||
| bytes.set(t.payload, off); | ||
| off += t.payload.length; | ||
| } | ||
| return buf; | ||
| } | ||
|
|
||
| const batchTilejson = { | ||
| tiles: ['https://x/{z}/{x}/{y}?name=t&formatTiles=binary'], | ||
| tiles_batch: | ||
| 'https://x/table/tiles?name=t&geomType=points&formatTiles=binary', | ||
| tiles_batch_max: 32, | ||
| } as unknown as TilejsonResult; | ||
|
|
||
| describe('batch-tiler', () => { | ||
| describe('isBatchTilesTilejson', () => { | ||
| test('true when tiles_batch is advertised', () => { | ||
| expect(isBatchTilesTilejson(batchTilejson)).toBe(true); | ||
| }); | ||
| test('false when absent', () => { | ||
| expect( | ||
| isBatchTilesTilejson({ | ||
| tiles: ['https://x/{z}/{x}/{y}'], | ||
| } as unknown as TilejsonResult) | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('buildBatchTilesRequest', () => { | ||
| test('sorts tiles (z,x,y) and appends &tiles=', () => { | ||
| const url = buildBatchTilesRequest(batchTilejson, [ | ||
| {z: 10, x: 5, y: 9}, | ||
| {z: 8, x: 1, y: 1}, | ||
| {z: 10, x: 5, y: 1}, | ||
| {z: 10, x: 2, y: 0}, | ||
| ]); | ||
| expect(url).toBe( | ||
| 'https://x/table/tiles?name=t&geomType=points&formatTiles=binary&tiles=8/1/1,10/2/0,10/5/1,10/5/9' | ||
| ); | ||
| }); | ||
| test('a repeated viewport (any order) yields the same URL', () => { | ||
| const a = buildBatchTilesRequest(batchTilejson, [ | ||
| {z: 1, x: 0, y: 0}, | ||
| {z: 1, x: 1, y: 0}, | ||
| ]); | ||
| const b = buildBatchTilesRequest(batchTilejson, [ | ||
| {z: 1, x: 1, y: 0}, | ||
| {z: 1, x: 0, y: 0}, | ||
| ]); | ||
| expect(a).toEqual(b); | ||
| }); | ||
| test('throws when the source is not batch-capable', () => { | ||
| expect(() => | ||
| buildBatchTilesRequest({tiles: []} as unknown as TilejsonResult, [ | ||
| {z: 0, x: 0, y: 0}, | ||
| ]) | ||
| ).toThrow(/tiles_batch/); | ||
| }); | ||
| test('throws when given zero tiles', () => { | ||
| expect(() => buildBatchTilesRequest(batchTilejson, [])).toThrow( | ||
| /zero tiles/ | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('splitBatchTilesResponse', () => { | ||
| test('splits the CDTB container into per-tile payloads keyed by z/x/y', () => { | ||
| const buffer = makeCDTB([ | ||
| {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, | ||
| {z: 10, x: 523, y: 350, payload: [9, 8, 7, 6]}, | ||
| ]); | ||
| const tiles = splitBatchTilesResponse(buffer); | ||
| expect([...tiles.keys()]).toEqual(['0/0/0', '10/523/350']); | ||
| expect([...new Uint8Array(tiles.get('0/0/0'))]).toEqual([1, 2, 3]); | ||
| expect([ | ||
| ...new Uint8Array(tiles.get(batchTileKey({z: 10, x: 523, y: 350}))), | ||
| ]).toEqual([9, 8, 7, 6]); | ||
| }); | ||
| test('handles an empty (zero-payload) tile', () => { | ||
| const tiles = splitBatchTilesResponse( | ||
| makeCDTB([{z: 5, x: 1, y: 1, payload: []}]) | ||
| ); | ||
| expect(tiles.get('5/1/1').byteLength).toEqual(0); | ||
| }); | ||
| test('throws on a non-CDTB buffer', () => { | ||
| const bad = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0, 0, 0, 0]).buffer; | ||
| expect(() => splitBatchTilesResponse(bad)).toThrow(/CDTB/); | ||
| }); | ||
|
Comment on lines
+117
to
+120
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. To ensure the new bounds checks in test('throws on a non-CDTB buffer', () => {
const bad = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0, 0, 0, 0]).buffer;
expect(() => splitBatchTilesResponse(bad)).toThrow(/CDTB/);
});
test('throws on truncated header or payload', () => {
const truncatedHeader = makeCDTB([
{z: 0, x: 0, y: 0, payload: [1, 2, 3]},
]).slice(0, 15);
expect(() => splitBatchTilesResponse(truncatedHeader)).toThrow(/unexpected end of metadata/);
const truncatedPayload = makeCDTB([
{z: 0, x: 0, y: 0, payload: [1, 2, 3]},
]).slice(0, 20);
expect(() => splitBatchTilesResponse(truncatedPayload)).toThrow(/unexpected end of payload/);
}); |
||
| test('throws on a truncated tile header', () => { | ||
| // Valid container, then cut mid-header (count says 1 tile, header is 13B | ||
| // starting at offset 8 β slice to 15 leaves only 7 of the 13). | ||
| const truncated = makeCDTB([ | ||
| {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, | ||
| ]).slice(0, 15); | ||
| expect(() => splitBatchTilesResponse(truncated)).toThrow( | ||
| /unexpected end of metadata/ | ||
| ); | ||
| }); | ||
| test('throws on a truncated payload', () => { | ||
| // Full header (through offset 21) but the 3-byte payload is cut off. | ||
| const truncated = makeCDTB([ | ||
| {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, | ||
| ]).slice(0, 22); | ||
| expect(() => splitBatchTilesResponse(truncated)).toThrow( | ||
| /unexpected end of payload/ | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
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.
Building a batch request with an empty list of tiles is invalid and would result in an unnecessary network request with an empty
tiles=query parameter. Adding a defensive check to throw an error whentilesis empty prevents this scenario.