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.
This commit is contained in:
Sameh Khamis committed 2026-11-12 17:56:17 -08:00
1 parent 8cca78bcd5
commit 92d6188045
2 files changed
+79 -9

No files matched your search

+48
View File
@@ -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 = {
+31 -9
View File
@@ -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. */