Skip to content

Commit aec3a2b

Browse files
rdhyeeclaude
andcommitted
explorer + perf-smoke: review fixes (#173)
Self-review fixes against the originally-opened PR. Two files affected: explorer.qmd - Capture per-URL byte data (seen_urls list) in the structured search log so analysis can post-hoc filter concurrent fetches that aren't attributable to the search itself, rather than only summed bytes. tests/test_search_perf.py - Replace deprecated dt.datetime.utcnow() with dt.datetime.now(UTC). - Replace fixed 800ms wait_for_timeout after facet changes with a poll on '.facet-count.recomputing' clearing — the cross-filter handler has a 250ms debounce plus an async cluster reload that can exceed 1s cold; fixed waits flake on slow networks. - Add a composed-source-material query that exercises the facetFilterSQL() pid-IN-subquery path, not just sourceFilterSQL(). First material checkbox is used to avoid hard-coding URIs that may rotate between data snapshots. - Tighten the final assertion: refuse to treat a benchmark with any silent failures as valid (was '>= half'). Partial JSON still written for diagnosis. Refs #165, #167. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 90e26a8 commit aec3a2b

2 files changed

Lines changed: 95 additions & 19 deletions

File tree

explorer.qmd

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,16 +1877,24 @@ zoomWatcher = {
18771877
try { performance.measure(`search-${searchId}`, markStart, markEnd); } catch (e) {}
18781878
const elapsedMs = performance.now() - tStart;
18791879
1880-
// Sum bytes transferred from data.isamples.org during the search
1880+
// Per-URL byte data from data.isamples.org during the search
18811881
// window. transferSize is 0 cross-origin without Timing-Allow-Origin;
1882-
// fall back to encodedBodySize so we still report something.
1882+
// encodedBodySize is reported as a fallback. Per-URL detail (rather
1883+
// than just summed bytes) lets analysis post-hoc-filter concurrent
1884+
// fetches that are not actually attributable to the search.
1885+
const seenUrls = [];
18831886
let transferBytes = 0;
18841887
let bodyBytes = 0;
18851888
try {
18861889
const entries = performance.getEntriesByType('resource');
18871890
for (const e of entries) {
18881891
if (!e.name.startsWith(R2_BASE)) continue;
18891892
if (e.startTime < tStart || e.startTime > tStart + elapsedMs) continue;
1893+
seenUrls.push({
1894+
name: e.name,
1895+
transfer_size: e.transferSize || 0,
1896+
body_size: e.encodedBodySize || 0,
1897+
});
18901898
transferBytes += (e.transferSize || 0);
18911899
bodyBytes += (e.encodedBodySize || 0);
18921900
}
@@ -1903,6 +1911,7 @@ zoomWatcher = {
19031911
elapsed_ms: Math.round(elapsedMs),
19041912
bytes_transfer: transferBytes,
19051913
bytes_body: bodyBytes,
1914+
seen_urls: seenUrls,
19061915
has_source_filter: getActiveSources().length !== SOURCE_VALUES.length,
19071916
has_facet_filter: hasFacetFilters(),
19081917
error: errorMessage,

tests/test_search_perf.py

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@
5656
"term": "pottery",
5757
"filters": {"source_only": ["OPENCONTEXT"]},
5858
},
59+
{
60+
# Pairs source restriction with a material-facet selection so the
61+
# benchmark exercises the facetFilterSQL() pid-IN-subquery path,
62+
# not just sourceFilterSQL(). The first material checkbox is used
63+
# to keep the test stable across data refreshes (don't hard-code a
64+
# URI that may disappear between snapshots).
65+
"label": "composed-source-material",
66+
"term": "pottery",
67+
"filters": {
68+
"source_only": ["OPENCONTEXT"],
69+
"material_first_n": 1,
70+
},
71+
},
5972
]
6073

6174

@@ -72,17 +85,50 @@ def _wait_for_explorer_ready(page, timeout_ms: int = 90_000) -> None:
7285
)
7386

7487

88+
def _wait_for_facet_settle(page, timeout_ms: int = 30_000) -> None:
89+
"""Block until no .facet-count is in the .recomputing transient state.
90+
91+
The cross-filter handler debounces 250ms before issuing the query
92+
(explorer.qmd:1610-1612), and the H3 cluster reload triggered by source
93+
filter changes is async and can exceed 1 s cold. Polling the DOM for
94+
"no recomputing class" is more reliable than a fixed wait.
95+
"""
96+
page.wait_for_function(
97+
"""() => {
98+
const recomputing = document.querySelectorAll('.facet-count.recomputing');
99+
return recomputing.length === 0;
100+
}""",
101+
timeout=timeout_ms,
102+
)
103+
104+
75105
def _apply_source_filter(page, sources_to_keep_checked: list[str]) -> None:
76106
"""Uncheck source checkboxes that aren't in the keep list."""
77107
all_sources = ["SESAR", "OPENCONTEXT", "GEOME", "SMITHSONIAN"]
108+
changed = False
78109
for src in all_sources:
79110
cb = page.locator(f"#sourceFilter input[type='checkbox'][value='{src}']")
80111
is_checked = cb.is_checked()
81112
should_be_checked = src in sources_to_keep_checked
82113
if is_checked != should_be_checked:
83114
cb.click()
84-
# Let the change handler debounce settle.
85-
page.wait_for_timeout(800)
115+
changed = True
116+
if changed:
117+
_wait_for_facet_settle(page)
118+
119+
120+
def _apply_material_first_n(page, n: int) -> None:
121+
"""Check the first n material-facet checkboxes (avoids hard-coding URIs)."""
122+
if n <= 0:
123+
return
124+
boxes = page.locator("#materialFilterBody input[type='checkbox']")
125+
boxes.first.wait_for(state="attached", timeout=15_000)
126+
total = boxes.count()
127+
for i in range(min(n, total)):
128+
cb = boxes.nth(i)
129+
if not cb.is_checked():
130+
cb.click()
131+
_wait_for_facet_settle(page)
86132

87133

88134
def _run_search(page, term: str, *, captured: list, expected_id_after: int) -> dict:
@@ -133,8 +179,11 @@ def _measure_one_query(browser, query: dict) -> dict:
133179
page.goto(EXPLORER_URL, wait_until="domcontentloaded", timeout=60_000)
134180
_wait_for_explorer_ready(page)
135181

136-
if "source_only" in query["filters"]:
137-
_apply_source_filter(page, query["filters"]["source_only"])
182+
filters = query["filters"]
183+
if "source_only" in filters:
184+
_apply_source_filter(page, filters["source_only"])
185+
if "material_first_n" in filters:
186+
_apply_material_first_n(page, filters["material_first_n"])
138187

139188
cold = _run_search(page, query["term"], captured=captured, expected_id_after=0)
140189
warm = _run_search(
@@ -151,16 +200,27 @@ def _measure_one_query(browser, query: dict) -> dict:
151200
}
152201

153202

203+
def _utc_now() -> dt.datetime:
204+
"""Aware UTC datetime; replaces the deprecated dt.datetime.utcnow()."""
205+
return dt.datetime.now(dt.timezone.utc)
206+
207+
154208
@pytest.fixture(scope="session")
155-
def baseline_output_path() -> pathlib.Path:
156-
today = dt.datetime.utcnow().strftime("%Y-%m-%d")
157-
path = pathlib.Path(__file__).parent / f"search_baseline_{today}.json"
209+
def benchmark_run_started_at() -> dt.datetime:
210+
return _utc_now()
211+
212+
213+
@pytest.fixture(scope="session")
214+
def baseline_output_path(benchmark_run_started_at) -> pathlib.Path:
215+
stamp = benchmark_run_started_at.strftime("%Y-%m-%d")
216+
path = pathlib.Path(__file__).parent / f"search_baseline_{stamp}.json"
158217
return path
159218

160219

161-
def test_record_search_baseline(browser, baseline_output_path):
220+
def test_record_search_baseline(browser, benchmark_run_started_at, baseline_output_path):
162221
"""Run the canonical query set, dump JSON. Single test = one benchmark run."""
163222
results = []
223+
failures = []
164224
for query in CANONICAL_QUERIES:
165225
try:
166226
record = _measure_one_query(browser, query)
@@ -171,26 +231,33 @@ def test_record_search_baseline(browser, baseline_output_path):
171231
"filters": query["filters"],
172232
"error": f"{type(exc).__name__}: {exc}",
173233
}
234+
failures.append(record)
174235
results.append(record)
175236
# Stream to stdout so partial runs are still useful.
176237
print(json.dumps(record, indent=2))
177238

178239
payload = {
179240
"site_url": SITE_URL,
180-
"captured_at_utc": dt.datetime.utcnow().isoformat() + "Z",
241+
"captured_at_utc": benchmark_run_started_at.isoformat(),
181242
"schema_version": 1,
182243
"field_subset": "label+place_name (samples_map_lite.parquet)",
183244
"queries": results,
184245
}
185246
baseline_output_path.write_text(json.dumps(payload, indent=2) + "\n")
186247
print(f"\nWrote baseline to {baseline_output_path}")
187248

188-
# Light sanity check: at least half the queries produced a usable cold record.
189-
completed = sum(
190-
1 for r in results
191-
if "cold" in r and r["cold"].get("elapsed_ms") is not None
192-
)
193-
assert completed >= len(CANONICAL_QUERIES) // 2, (
194-
f"Only {completed}/{len(CANONICAL_QUERIES)} queries completed cleanly; "
195-
f"see {baseline_output_path} for partial data"
249+
# A benchmark with silent failures is a poisoned baseline — refuse to
250+
# treat it as valid. Partial data is still on disk for diagnosis.
251+
incomplete = [
252+
r for r in results
253+
if "error" in r
254+
or "cold" not in r
255+
or r.get("cold", {}).get("elapsed_ms") is None
256+
or "warm" not in r
257+
or r.get("warm", {}).get("elapsed_ms") is None
258+
]
259+
assert not incomplete, (
260+
f"{len(incomplete)}/{len(CANONICAL_QUERIES)} queries did not complete cleanly. "
261+
f"Failed labels: {[r['label'] for r in incomplete]}. "
262+
f"Partial data at {baseline_output_path}."
196263
)

0 commit comments

Comments
 (0)