Skip to content

Commit f0f47bf

Browse files
authored
Merge pull request #352 from rdhyee/fix/351-map-lite-single-fetch
#351: fetch samples_map_lite once, serve every lite query from an in-memory copy
2 parents e9b2d58 + a46edfc commit f0f47bf

3 files changed

Lines changed: 336 additions & 11 deletions

File tree

explorer.qmd

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,8 @@ h3_res8_url = `${R2_BASE}/isamples_202608_h3_summary_res8.parquet`
846846
// min/max-pid verified identical against the live wide before rebuilding),
847847
// so this is a pure column-content fix, not a data-vintage change. Same
848848
// immutable-cache reasoning as _v2: new filename, never overwrite.
849+
// #351: queries name this URL, but the db cell's query wrapper serves them from
850+
// an in-memory copy fetched once by the liteFile cell (see both).
849851
lite_url = `${R2_BASE}/isamples_202608_samples_map_lite_v3.parquet`
850852
// Explicit versioned wide (#272: OC concept-enriched — popups read material/
851853
// object-type from this file). The stable alias `current/wide.parquet` still
@@ -2062,11 +2064,66 @@ db = {
20622064
// issuing (see whenConnectionIdle / loadRes). _inFlight is read there.
20632065
const origQuery = instance.query.bind(instance);
20642066
let inFlight = 0;
2065-
instance.query = (...args) => {
2067+
// #351: samples_map_lite is the one file the explorer always reads END TO
2068+
// END (the samples table's COUNT + page scans, the #300 filtered-cluster
2069+
// aggregation, search-result JOINs), and with #345's range requests working
2070+
// each concurrent scan re-fetches the whole 63 MB through DuckDB-WASM's
2071+
// per-read ranged GETs (~120-134 MB measured for a 63 MB file). The liteFile
2072+
// cell below fetches it ONCE and registers the bytes as an in-memory DuckDB
2073+
// file; this wrapper resolves every `read_parquet('<lite_url>')` to that
2074+
// in-memory name. Queries that mention the lite URL wait for that fetch to
2075+
// settle (buffer registered, or fetch failed → keep the URL and range-read
2076+
// as before). Facet/summary files keep their range reads. The waiting query
2077+
// counts as in flight, and the #300 load waits for the buffer before its
2078+
// idle wait (see _liteSettled), so the heavy aggregation still runs after
2079+
// the boot scans rather than alongside them.
2080+
//
2081+
// Liveness (Codex rounds 1-2, 5): the shared liteReady gate is bounded —
2082+
// a lite query waits at most LITE_WAIT_CAP_MS from the FIRST lite demand
2083+
// (whether liteFile has not started because facetIndexReady stalled, or
2084+
// the fetch/registration itself is dragging), then reads the URL while
2085+
// the background fetch continues; queries issued after the buffer lands
2086+
// use it.
2087+
// 120 s keeps a normal slow link (facets settling at ~80 s, then a fetch of
2088+
// a minute or so) on the single-fetch path; slower than that degrades to
2089+
// today's ranged reads for the queries issued early. The cap bounds only
2090+
// this gate — the query's own execution/ranged GETs have no deadline, as
2091+
// before.
2092+
let resolveLite;
2093+
const liteReady = new Promise(r => { resolveLite = r; }); // string name | null
2094+
let liteSettled = false;
2095+
instance._resolveLiteSource = (name) => { liteSettled = true; resolveLite(name); };
2096+
const LITE_WAIT_CAP_MS = 120000;
2097+
// One shared deadline, armed by the first lite demand: every waiter races
2098+
// the same timer, so a caller that waits, proceeds, and queries again does
2099+
// not pay the cap twice (Codex round-5 P2), and after the deadline all
2100+
// queries read the URL until the buffer lands.
2101+
let liteDeadline = null;
2102+
const liteSource = () => {
2103+
if (liteSettled) return liteReady;
2104+
if (!liteDeadline) liteDeadline = new Promise(r => setTimeout(() => r(null), LITE_WAIT_CAP_MS));
2105+
return Promise.race([liteReady, liteDeadline]);
2106+
};
2107+
// For callers that want to sequence AFTER the buffer lands (the #300
2108+
// filtered-cluster load does this before its idle wait, so the heavy
2109+
// aggregation doesn't burst into DuckDB together with the lite scans that
2110+
// were released at the same instant — Codex round-4 P1). Same cap.
2111+
instance._liteSettled = () => liteSource();
2112+
// Only table reads are redirected: metadata probes such as
2113+
// parquet_schema('<lite_url>') (the #300 readiness preflight) stay on the
2114+
// URL — they read the footer only and must not wait for the 63 MB fetch.
2115+
const liteLiteral = `read_parquet('${lite_url}')`;
2116+
instance.query = async (...args) => {
20662117
inFlight++;
2067-
const p = origQuery(...args);
2068-
p.then(() => {}, () => {}).finally(() => { inFlight--; });
2069-
return p;
2118+
try {
2119+
if (typeof args[0] === 'string' && args[0].includes(liteLiteral)) {
2120+
const name = await liteSource();
2121+
if (name) args[0] = args[0].split(liteLiteral).join(`read_parquet('${name}')`);
2122+
}
2123+
return await origQuery(...args);
2124+
} finally {
2125+
inFlight--;
2126+
}
20702127
};
20712128
instance._inFlight = () => inFlight;
20722129
return instance;
@@ -2266,6 +2323,88 @@ facetIndexReady = {
22662323
}
22672324
```
22682325

2326+
```{ojs}
2327+
//| echo: false
2328+
//| output: false
2329+
// #351: fetch samples_map_lite ONCE and register it as an in-memory DuckDB
2330+
// file. Sequenced after facetIndexReady so the boot-critical facet chain
2331+
// (#345: ~3.5 MB to a usable sidebar) never competes with this 63 MB fetch;
2332+
// facetIndexReady never rejects (its body is fully try/catch'd), so this cell
2333+
// always runs and the db wrapper's liteReady promise always settles. Measured
2334+
// (tests/playwright/measure_parquet_ranges.py, cold cache, 200 s): the lite
2335+
// file went from 132.8 MB in 65 ranged GETs to one 62.9 MB GET, total parquet
2336+
// traffic 156 MB → 86 MB, and the samples table appeared at 63 s instead of
2337+
// 122 s (two scans from memory beat two scans over ranged reads). The file is
2338+
// served immutable/1-yr, but a warm reload in headless Chromium re-fetched it
2339+
// (likely above the per-entry cache limit), so no caching win is claimed.
2340+
// Memory: steady state is one copy in
2341+
// the DuckDB WASM heap (the footprint the pre-#345 "full HTTP read" fallback
2342+
// had); transiently ~2 copies (~126 MB) while the bytes are assembled and
2343+
// handed to the worker (registerFileBuffer transfers the ArrayBuffer, the
2344+
// worker copies it into the heap). On any failure — HTTP error, short read,
2345+
// or no bytes for STALL_MS (the watchdog below aborts a stalled fetch) —
2346+
// queries keep reading the URL with ranged GETs (pre-#351 behaviour):
2347+
// slower, never wrong. Query consumers always proceed regardless: their
2348+
// shared gate in the db wrapper expires on its own after 120 s.
2349+
liteFile = {
2350+
const _ = facetIndexReady;
2351+
const name = 'samples_map_lite_v3.parquet'; // DuckDB virtual filename
2352+
const STALL_MS = 30000;
2353+
const ctrl = new AbortController();
2354+
let watchdog;
2355+
const arm = () => { clearTimeout(watchdog); watchdog = setTimeout(() => ctrl.abort(new Error(`no bytes for ${STALL_MS} ms`)), STALL_MS); };
2356+
try {
2357+
performance.mark('lite-fetch-start');
2358+
arm();
2359+
const resp = await fetch(lite_url, { signal: ctrl.signal });
2360+
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
2361+
const total = Number(resp.headers.get('content-length')) || 0;
2362+
const reader = resp.body.getReader();
2363+
const chunks = [];
2364+
let got = 0;
2365+
for (;;) {
2366+
const { done, value } = await reader.read();
2367+
if (done) break;
2368+
chunks.push(value); got += value.byteLength; arm();
2369+
}
2370+
clearTimeout(watchdog);
2371+
// The data host serves parquet unencoded, so Content-Length is the byte
2372+
// count we should have; a mismatch (short read, or an encoded response
2373+
// whose decoded size differs) is a safe failure, never a corrupt buffer.
2374+
if (total && got !== total) throw new Error(`short read: ${got} of ${total} bytes`);
2375+
const bytes = new Uint8Array(got);
2376+
let off = 0;
2377+
for (const c of chunks) { bytes.set(c, off); off += c.byteLength; }
2378+
chunks.length = 0;
2379+
await db._db.registerFileBuffer(name, bytes); // transfers bytes.buffer to the worker (detaches it)
2380+
// Validate before any query is redirected to it: a body that ended
2381+
// cleanly but early (no Content-Length to catch it) would otherwise be
2382+
// served as truth. parquet_metadata() decodes the footer (one row per
2383+
// column chunk), which fails on a body cut before the footer; on
2384+
// failure the name is dropped so the URL path is the only one left.
2385+
// (Not a content check — a smaller *valid* parquet under this URL
2386+
// would pass; that needs an expected size/hash, out of scope.)
2387+
try {
2388+
const meta = await db.query(`SELECT COUNT(*) AS n FROM parquet_metadata('${name}')`);
2389+
if (!(Number(Array.from(meta)[0]?.n) > 0)) throw new Error('empty parquet metadata');
2390+
} catch (err) {
2391+
await db._db.dropFile(name).catch(() => {});
2392+
throw new Error(`in-memory parquet failed validation: ${err && err.message || err}`);
2393+
}
2394+
performance.mark('lite-fetch-end');
2395+
performance.measure('lite_fetch', 'lite-fetch-start', 'lite-fetch-end');
2396+
db._resolveLiteSource(name);
2397+
window.__liteFile = { src: name, bytes: got }; // test/diagnostic hook
2398+
} catch (err) {
2399+
clearTimeout(watchdog);
2400+
console.warn('#351: whole-file fetch of samples_map_lite failed; falling back to ranged reads of the URL:', err);
2401+
db._resolveLiteSource(null);
2402+
window.__liteFile = { src: lite_url, error: String(err) };
2403+
}
2404+
return window.__liteFile;
2405+
}
2406+
```
2407+
22692408
```{ojs}
22702409
//| echo: false
22712410
//| output: false
@@ -2283,7 +2422,20 @@ facetIndexReady = {
22832422
filteredClustersReady = {
22842423
window.__filteredClustersReady = false;
22852424
try {
2286-
await db.query(`SELECT h3_res4, h3_res6 FROM read_parquet('${lite_url}') LIMIT 1`);
2425+
// #351: a footer-only schema probe (parquet_schema reads the file
2426+
// metadata, a few hundred KB via ranged GETs) instead of a LIMIT 1 row
2427+
// read, so readiness flips at ~10 s as before and never waits for the
2428+
// liteFile whole-file fetch — the db wrapper only redirects
2429+
// read_parquet('<lite_url>') table reads. For this flat, lowercase
2430+
// schema it answers the same question as the old probe: both
2431+
// top-level columns present → true; either missing → false. (The
2432+
// name match is exact-case, and a data-page fault the old row read
2433+
// would have hit is not seen here — neither applies to v3.)
2434+
const cols = await db.query(`
2435+
SELECT name FROM parquet_schema('${lite_url}')
2436+
WHERE name IN ('h3_res4', 'h3_res6')`);
2437+
const found = new Set(Array.from(cols).map(r => r.name));
2438+
if (!(found.has('h3_res4') && found.has('h3_res6'))) throw new Error(`lite columns present: ${[...found].join(',') || 'none'}`);
22872439
window.__filteredClustersReady = true;
22882440
if (typeof window.__onFilteredClustersReady === 'function') window.__onFilteredClustersReady();
22892441
return true;
@@ -3600,7 +3752,15 @@ zoomWatcher = {
36003752
performance.mark(`r${res}-s`);
36013753
// #300: gate the heavy filtered aggregation on an idle connection
36023754
// (deadlock-avoidance); no-op for the light summary path.
3603-
if (filtered) await whenConnectionIdle();
3755+
// #351: first let the in-memory lite buffer land (or the wait cap
3756+
// expire). The boot-time table scans are released at that same
3757+
// instant, so an idle wait taken BEFORE it would have expired
3758+
// (20 s cap) during the fetch and let this aggregation burst in
3759+
// with them; taken after, it sees them executing and waits.
3760+
if (filtered) {
3761+
if (typeof db._liteSettled === 'function') await db._liteSettled();
3762+
await whenConnectionIdle();
3763+
}
36043764
// Re-check supersession after the idle wait (a newer load or a filter
36053765
// change may have landed while we waited).
36063766
if (gen !== loadResGen || sig !== desiredClusterSig()) return false;

tests/playwright/filtered-clusters-300.spec.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
/**
22
* #300 verification [data]: filtered H3 clusters at world zoom.
33
*
4-
* Runs against a LOCAL data mirror (dev_server.py on :8099) whose
5-
* samples_map_lite carries h3_res4/h3_res6, so window.__filteredClustersReady
6-
* becomes true and the feature ACTIVATES. (Production data lacks res4/res6, so
7-
* this can't run against data.isamples.org yet — that's the pending republish.)
4+
* Runs against a data host whose samples_map_lite carries h3_res4/h3_res6, so
5+
* window.__filteredClustersReady becomes true and the feature ACTIVATES.
6+
* Default is a LOCAL mirror (dev_server.py on :8099); since the _v3 lite
7+
* republish production carries res4/res6 too, so
8+
* DATA_BASE=https://data.isamples.org npx playwright test filtered-clusters-300
9+
* runs it against live data (used to verify #351).
810
*
911
* Pass DATA_BASE=http://localhost:8099 (default below).
1012
*/
1113
const { test, expect } = require('@playwright/test');
14+
const { explorerUrl } = require('./helpers/url');
1215

1316
const DATA_BASE = process.env.DATA_BASE || 'http://localhost:8099';
1417
const MATERIAL = 'https://w3id.org/isample/vocabulary/material/1.0/anyanthropogenicmaterial';
1518
const WORLD_ALT = 18000000; // world zoom, well above EXIT_POINT_ALT
1619

1720
function url(extraHash = '') {
1821
const qs = new URLSearchParams({ data_base: DATA_BASE, material: MATERIAL }).toString();
19-
return `/explorer.html?${qs}#v=1&lat=10.0000&lng=0.0000&alt=${WORLD_ALT}${extraHash}`;
22+
// explorerUrl() keeps TEST_URL's sub-path (fork staging lives under
23+
// /isamplesorg.github.io/); a bare '/explorer.html' would replace it and
24+
// silently test a 404 page. See helpers/url.js.
25+
return explorerUrl(`?${qs}#v=1&lat=10.0000&lng=0.0000&alt=${WORLD_ALT}${extraHash}`);
2026
}
2127

2228
// Read an OJS cell value (viewer, db, lite_url, facetFilterSQL, ...) the same way

0 commit comments

Comments
 (0)