Match species/approach/technique against assetsSummary instead of per-asset metadata - #2882
Conversation
|
demo on local seed data: Screen.Recording.2026-08-05.at.3.33.30.PM.mov |
There was a problem hiding this comment.
I ran this on a local instance seeded with the version metadata of 220 real dandisets pulled from production, and drove it through both the API and the browser. The core change is correct and the performance argument holds — approve from me.
Details below; nothing blocking.
Performance, measured
You noted latency wasn't re-measured. I measured it. The plan is a seq scan on api_version; I cloned the table to 10,304 rows to approximate production scale:
Seq Scan on ver_scale (actual rows=3744 loops=1)
Filter: jsonb_path_exists(metadata, '$.assetsSummary.species[*].name ? (@ like_regex "mouse" flag "i")')
Rows Removed by Filter: 6560
Execution Time: 196.773 ms
197 ms versus the 13.4 s you measured for the old path. Version metadata averages 2.5 KB (max 9.5 KB), so there's no TOAST detoasting cost hiding in there. The row-count argument is sound.
It's linear in version count, though — if published versions grow an order of magnitude this comes back. The follow-up would be a generated text column plus a trgm GIN index, roughly what the comment you removed suggested.
On dropping the user parameter
Agreed it's safe, but I'd correct the reasoning slightly, because the stated justification doesn't cover every path.
get_queryset() returns get_visible_dandisets only for list and search; detail routes get bare Dandiset.objects (dandiset.py:191). DRF runs filter backends inside get_object() (generics.py:87), so on retrieve/users/uploads/star these filters do run against a queryset containing embargoed dandisets. Confirmed against an embargoed dandiset whose assetsSummary I seeded with mouse/LFP/NWB, anonymous:
GET /api/dandisets/<embargoed>/?search=species:musculus -> 401
GET /api/dandisets/<embargoed>/?search=species:zzzznomatch -> 404
That's a substring oracle over embargoed assetsSummary. It doesn't expose anything that wasn't already exposed, though, and I don't think it should hold the PR up — the same oracle already exists on master through two paths you didn't touch:
?search=Secret -> 401 free text, dandiset.py:170-177,
?search=zzzznomatch -> 404 Version.objects with no visibility filter at all
?search=created_after:2000-01-01 -> 401 date operators, filter the Dandiset qs directly
?search=created_after:2099-01-01 -> 404
The free-text path casts the whole metadata blob to text, so it already probes assetsSummary and strictly more besides. And no data escapes either way — dandiset.py:277 re-checks embargo_status before returning the object. So dropping user= widens nothing.
The cleanest support for the change is further down the same file: DandisetViewSet.search has been doing AssetSearch.objects.all() intersected with get_visible_dandisets (dandiset.py:376-380) all along. This PR just adopts the pattern the search endpoint already used.
Worth swapping that in, because the current wording isn't only in the PR description — it's in the source at filters.py:142-144:
# ... and the view's base queryset is already limited to dandisets visible to the user, so embargoed dandisets can't surface.
That comment will outlive the PR description, and it's the one someone will cite in a year before adding a sixth operator or reusing apply_search_filters somewhere new.
Suggestions
1. _jsonpath_match's new column parameter. It's only ever called with the literal 'metadata' (filters.py:164). Master had the column hardcoded; making it an f-string argument (filters.py:82) turns a fixed piece of raw SQL into a second interpolation point protected only by a docstring convention. The path argument has to stay dynamic, but the column doesn't — I'd drop the parameter.
2. Recall changes worth knowing about, in both directions. assetsSummary is aggregated only over status=VALID assets (services/metadata/__init__.py:99), whereas asset_search included every asset. Assets that fail validation are now invisible to these operators. Going the other way, asset_search INNER JOINs AssetBlob (search/migrations/0002:24-25), so Zarr assets (blob IS NULL) were never in it and now are. Probably a net win, but neither the tests nor my instance can surface it, since both seed assetsSummary directly. Might deserve a line in the description next to the staleness caveat you already flagged.
3. Operator lists (pre-existing, and you've improved it). Master carried _DATE_OPS/_ASSET_OPS/_NAME_PATH_OPS/_FILE_TYPE_ALIASES; this consolidates to _DATE_OPS/_SUMMARY_OPS/_SUMMARY_PATHS, alongside OPERATOR_KEYS and operatorHelp. The remaining sharp edge, also pre-existing: apply_search_filters is if key in _DATE_OPS / elif key in _SUMMARY_OPS with no else (filters.py:153-168), so a key added to OPERATOR_KEYS but not the filter sets is silently ignored — unfiltered results rather than a 400. Since _SUMMARY_OPS has exactly one use site, deleting it and testing key in _SUMMARY_PATHS removes one list for free. A test asserting OPERATOR_KEYS == _DATE_OPS | frozenset(_SUMMARY_PATHS) would pin three of the four; operatorHelp is TS and stays manual, and the e2e toHaveCount(11) is a hardcoded literal rather than derived from it.
4. file_type: removal has no fallback message. Verified there's no near-miss suggestion — get_close_matches('file_type', OPERATOR_KEYS, cutoff=0.6) returns [], best ratio is modified_after at 0.522 — so the user gets a dead end:
Unknown search operator "file_type". Wrap the term in double quotes (e.g. "foo:bar") to search for it as text.
To be clear, the removal itself looks right and not really optional: AssetsSummary has no encodingFormat or MIME field at all, so keeping file_type: would have meant keeping the AssetSearch dependency and the scan you're removing. And the faceted sidebar parameter still covers the use case (I checked, it's untouched). The exposure window is small too, since this shipped in v1.0.0 yesterday. It's just that a one-line special case pointing at standard: and the sidebar would land better than a generic unknown-operator error, and a test pinning the 400 would be cheap.
5. Nits. The suggestion dropdown is max-height: 320px against a scrollHeight of 536px, and onKeydown doesn't scroll the highlighted item into view — arrowing to variable: marks it active while the card stays at scrollTop: 0. Pre-existing (file_type: sat below the fold too) and mostly theoretical, since typing v or stan filters the list to one entry, but the two entries you're adding are the two at the bottom. Needs a ref on the v-list-item v-for plus scrollIntoView({ block: 'nearest' }), not just a watcher. Separately, test_search_parser.py has no standard:/variable: cases.
What else I checked (all clean)
- All 22
advanced_searchtests pass locally. - Operators against real production metadata:
species:mouse(67),approach:electrophysiological(91),technique:"spike sorting"(61),standard:nwb(137),standard:bids(8),variable:LFP(22).variable:LFPreturns exactly the right dandisets — sharp-wave ripple, Neuropixels, "Local Field Potential Recordings…". - Injection:
species:'); DROP TABLE api_version; --,species:.*,species:%,species:\,species:[pilot]all return 0 rows, table intact. There.escape+ parameterization holds. - Malformed
assetsSummary(null, string, int, object-instead-of-array, non-string name, null array element, missing key) inserted via raw SQL: all queries 200, no 500s. Lax jsonpath mode is tolerant, so a single bad legacy row can't break search. variableMeasuredbeing bare strings is guaranteed bydandischema(Optional[List[str]]), and a 100-dandiset production sample across schema 0.4.4/0.6.0/0.6.4 found no rows using the olderPropertyValueshape, so thevariable:path won't silently miss historical versions.
Generated by Claude Code
|
Followed up on suggestions 1–3 from my review. I intended to send these as a PR against this branch, but the automation account I'm running as has read + review permissions only — no branch, push, or fork access to this repo — so here are the patches inline instead. All three are rebased onto the current head (884d1e9), not the head I originally reviewed. Verified locally: 1. Drop the
|
… metadata The species:, approach:, and technique: operators matched per-asset metadata in the asset_search materialized view with a jsonpath built at runtime. A runtime-built jsonpath cannot use the view's GIN indexes, so each query sequential-scanned one row per asset and evaluated a regex against every row's metadata blob, taking 13-15 seconds in production. The visible_to() embargo check added a correlated subquery per row on top of that. These operators now match the version-level assetsSummary aggregation, which rolls up exactly these three fields. The scan moves from one row per asset to one row per dandiset version, three to four orders of magnitude fewer rows. No visibility subquery is needed on this path: the filter only ever restricts the caller's queryset, list and search responses are built from get_visible_dandisets, and detail routes re-check embargo_status in get_object. file_type: is untouched and still matches per-asset metadata through asset_search; it was not one of the slow operators. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Per Yarik's request I have pared this PR down to the minimal fix for the slow queries and moved everything else out. The branch is rewritten on top of master, so it no longer sits on the advanced-search stack, and it now contains a single commit repointing Of the review follow-ups: the corrected visibility reasoning is folded into the comment on 🤖 Generated with Claude Code |
884d1e9 to
b1db40f
Compare
yarikoptic
left a comment
There was a problem hiding this comment.
I approve as I was told it to solve a real performance problem. But review pointed again to IMHO pretty much reducing SNR in our code with all that unverified or unnecessary documentation and hypothetical comments.
| Each clause is an (operator, value) pair. All clauses are AND'd on a | ||
| SINGLE Version row, so `species:mouse approach:ephys` returns dandisets | ||
| with some version whose summary lists BOTH — a dandiset-level AND, since | ||
| the mouse assets and the ephys assets may be different assets. Repeated | ||
| keys (`species:mouse species:rat`) likewise require one version's summary | ||
| to contain both. Any version of the dandiset may match, mirroring the | ||
| free-text filter in DandisetSearchFilter. | ||
|
|
||
| No visibility filtering happens here: this subquery only ever *restricts* | ||
| the caller's queryset, list/search responses are built from | ||
| `get_visible_dandisets`, and detail routes re-check `embargo_status` in | ||
| `get_object` before returning anything — the same posture as the | ||
| free-text and date-operator paths. | ||
| """ | ||
| version_qs = Version.objects.all() | ||
| for operator, value in clauses: | ||
| where, params = _jsonpath_name_match(_SUMMARY_PATH_OPS[operator], value) | ||
| # `where` interpolates only an allowlisted jsonpath; the user value | ||
| # is bound via params (and re-escaped against regex injection). | ||
| return queryset.extra(where=[where], params=params) # noqa: S610 | ||
| if operator == 'file_type': | ||
| version_qs = version_qs.extra(where=[where], params=params) # noqa: S610 | ||
| # NOTE perf: jsonb_path_exists with a runtime-built jsonpath cannot use | ||
| # a GIN index, so this scans the Version table — linear in the number of | ||
| # versions (one to a few rows per dandiset). If version count grows | ||
| # enough for this to hurt, the fix is a denormalized generated text | ||
| # column per field + trgm_ops index. | ||
| return queryset.filter(id__in=version_qs.values_list('dandiset_id', flat=True).distinct()) |
There was a problem hiding this comment.
IMHO this is an example on where AI makes code less readable with all kinds of either duplicated (here in docstring of a short 5 lines function; better if just brief to the point inline commends like .all() # AND chaining of queries)) or hypothetical (here is this # NOTE perf which might or not be true, since nobody really confirms it or assessed .. I do not even know what GIN index is which was added as a term in ca7f283 .
I think we need to improve our instructions to agents to be more concise and not add hypotheticals....
There was a problem hiding this comment.
Fair point, and agreed that this kind of context belongs in the PR thread rather than in the code. 11e9901 trims the file back down: the docstrings are one to three lines, the perf note is gone, and what remains in the code is only what a future editor needs to avoid breaking it (the jsonpaths must stay trusted constants, the user value is parameterized and regex-escaped, and the metadata column has to stay unqualified).
Recording the removed context here instead, since it is review material rather than code:
- The perf claim was measured rather than hypothetical: on a clone of
api_versionat production scale (10,304 rows),species:mouseruns in 197 ms on this branch versus 13.4 s for the old per-asset path. The scan is linear in version count, so if published versions grow by an order of magnitude the follow-up would be a generated text column per field with a trigram index. (GIN is Postgres's index type for jsonb columns; the point of the removed note was that this query shape cannot use one.) - Visibility: this filter only ever restricts the caller's queryset. List and search responses are built from
get_visible_dandisets, and detail routes re-checkembargo_statusinget_object, which is the same posture as the free-text and date-operator paths. - Semantics: clauses are AND'd on a single Version row, so
species:mouse approach:ephysmatches a dandiset whose summary lists both even when the mouse assets and the ephys assets are different files. The tests pin this.
On the broader point, I have updated my agent instructions to keep code comments to constraints the code cannot show and to put discussion like the above in the PR instead.
Per review feedback, keep code comments to the constraints the code cannot show (trusted-constant jsonpaths, parameterized values, the unqualified column) and move the performance and semantics discussion to the PR thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve conflicts with the assetsSummary search refactor (dandi#2882): - filters.py: keep master's _SUMMARY_PATH_OPS/file_type split and re-add the owner branch on top of it; extract _parse_date to stay under the C901 complexity limit. - serializers.py: describe species/approach/technique as matching assetsSummary (master) while keeping the owner operator docs.
Problem
The advanced search operators released in v1.0.0 are slow. Timed against production:
species:mouse created_after:2026-01-01species:mousecreated_after:2026-01-01mouse(free text)All of the cost is in
species:,approach:, andtechnique:. Those matched per-asset metadata in theasset_searchmaterialized view withjsonb_path_existsand a jsonpath built at runtime. A runtime-built jsonpath cannot use any of the view's GIN indexes, so each query sequential-scanned one row per asset and evaluated a regex against every row's metadata blob. Thevisible_to()embargo check added a correlated subquery per row on top of that. A comment infilters.pyhad flagged this risk when the operators were written.Change
Per review feedback this PR is now pared down to the minimal fix for the slow queries, and rebased onto master so it no longer depends on the unmerged operator stack (#2821, #2822, #2827).
species,approach, andtechniquenow match the version-levelassetsSummaryaggregation, which already rolls up exactly these three fields. The scan moves from one row per asset to one row per dandiset version, three to four orders of magnitude fewer rows. On @yarikoptic-gitmate's production-scale replica this measured 197 ms against the 13.4 s above. No visibility subquery runs on this path: the filter only ever restricts the caller's queryset, list and search responses are built fromget_visible_dandisets, and detail routes re-checkembargo_statusinget_object, which is the same posture as the free-text and date-operator paths.file_type:is untouched here and still matches per-asset metadata throughAssetSearch; it was not one of the slow operators. Its removal in favor ofstandard:is proposed separately in #2884.Consequences to be aware of:
species:mouse approach:electrophysiological. The tests pin the new semantics.assetsSummaryis recomputed during metadata aggregation rather than on every asset write, so results can lag slightly for a dandiset whose assets changed very recently.assetsSummaryis aggregated only over assets withstatus=VALID, whereasasset_searchincluded every asset, so assets that fail validation are invisible to these operators. In the other direction,asset_searchexcluded Zarr assets (its inner join onAssetBlobskips rows withblob IS NULL), and the summary includes them.Follow-Ups
The features that were previously part of this PR now have their own PRs, stacked on this branch: #2884 re-adds
standard:and removesfile_type:, and #2885 addsvariable:.🤖 Generated with Claude Code