From 92d6188045ba18aa36fcdefad4ed9867a82086ac Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:56:17 -0800 Subject: [PATCH] Stop a moved-on view from cancelling the GeoParquet index the next view needs The browser shared one partition-index build per entry but ran it under the first view's signal. A camera move cancels the old view before the next one starts, so the next view was handed the same build, already cancelled, and drew nothing (the view layer takes an AbortError for its own cancellation) until the camera moved again. EPC hit this on its first view after any quick move. The build now belongs to no view: each view stops waiting on its own signal, and the build runs on for the next view and every view after it. --- src/library/parquet.test.mjs | 48 ++++++++++++++++++++++++++++++++++++ src/library/source.js | 40 +++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs index 5dba367d5..ae0d91695 100644 --- a/src/library/parquet.test.mjs +++ b/src/library/parquet.test.mjs @@ -211,6 +211,54 @@ test('a view over an incomplete index reads PARTIAL', async () => { assert.equal(partial.truncated, true); }); +test('a view that moves on does not cancel the index build the next view waits for', async () => { + const home = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-1.2, 53.3] }, + properties: { CURRENT_ENERGY_RATING: 'C', UPRN: '1' }, + }; + let release; + let buildSignal = null; + let builds = 0; + const source = createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: null, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async (_entry, { signal }) => { + builds += 1; + buildSignal = signal; + await new Promise((resolve) => { + release = resolve; + }); + signal?.throwIfAborted(); + return { + partitions: [ + { url: 'https://h/A.parquet', bbox: [-1.5, 53.2, -1.1, 53.5] }, + ], + complete: true, + }; + }, + readGeoParquetFeatures: async () => [home], + }), + }); + const box = [-1.21, 53.29, -1.19, 53.31]; + const tick = () => new Promise((resolve) => setImmediate(resolve)); + const first = new AbortController(); + const a = source.loadDataset(epc, { bbox: box, signal: first.signal }); + await tick(); + first.abort(); + await assert.rejects(a); + const b = source.loadDataset(epc, { bbox: box, signal: new AbortController().signal }); + await tick(); + release(); + const view = await b; + assert.equal(view.features.length, 1); + assert.equal(builds, 1, 'the second view reused the build'); + assert.equal(buildSignal, undefined, 'no view owns the build'); +}); + test('a footer that keeps failing leaves the index incomplete, and a retry recovers', async () => { const { buildPartitionIndex } = await import('./parquet.js'); const entry = { diff --git a/src/library/source.js b/src/library/source.js index f4062afa5..1c6f544a6 100644 --- a/src/library/source.js +++ b/src/library/source.js @@ -176,9 +176,30 @@ export function createLibrarySource({ * shared), else built here and kept in this browser for `cacheSeconds`. */ function partitionIndex(entry, { signal } = {}) { - const memo = indexes.get(entry.id); - if (memo && (memo.retryAt === null || now() < memo.retryAt)) - return memo.promise; + let memo = indexes.get(entry.id); + if (!memo || (memo.retryAt !== null && now() >= memo.retryAt)) + memo = startPartitionIndex(entry); + return waitForIndex(entry, memo, signal); + } + + /** + * One caller's wait on the shared index build. The build belongs to no + * view: a view that moves on stops waiting, while the build runs on for + * the next view (which a camera move starts right after cancelling the + * last) and is kept for every view after it. + */ + function waitForIndex(entry, memo, signal) { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal?.addEventListener?.('abort', onAbort, { once: true }); + memo.promise + .then(resolve, reject) + .finally(() => signal?.removeEventListener?.('abort', onAbort)); + }); + } + + function startPartitionIndex(entry) { const promise = (async () => { const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; const stored = storedJson(storage, key); @@ -190,7 +211,7 @@ export function createLibrarySource({ try { const response = await fetchImpl( `${base}/index/${encodeURIComponent(entry.id)}`, - { signal, headers: { Accept: 'application/json' } }, + { headers: { Accept: 'application/json' } }, ); if (response.ok) { const body = await response.json(); @@ -200,8 +221,8 @@ export function createLibrarySource({ partial = body.partitions; } } - } catch (error) { - if (signal?.aborted) throw error; + } catch { + /* no server index: build one here */ } if ( storedComplete && @@ -210,7 +231,7 @@ export function createLibrarySource({ return { partitions: storedComplete.partitions, complete: true }; // The server's index is incomplete, or there is no server: build here. const { buildPartitionIndex } = await loadParquet(); - const built = await buildPartitionIndex(entry, { fetchImpl, signal }); + const built = await buildPartitionIndex(entry, { fetchImpl }); if (built.complete) { storeJson(storage, key, { savedAt: now(), @@ -244,8 +265,9 @@ export function createLibrarySource({ throw error; }, ); - indexes.set(entry.id, { promise, retryAt: null }); - return promise; + const memo = { promise, retryAt: null }; + indexes.set(entry.id, memo); + return memo; } /** One partition's features, decoded once and kept for a few views. */