@@ -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).
849851lite_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 = {
22832422filteredClustersReady = {
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;
0 commit comments