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. */