Skip to content

feat(search)!: batch facet searches into a single multi_search#554

Merged
ddeboer merged 4 commits into
mainfrom
feat/batch-facet-searches
Jul 6, 2026
Merged

feat(search)!: batch facet searches into a single multi_search#554
ddeboer merged 4 commits into
mainfrom
feat/batch-facet-searches

Conversation

@ddeboer

@ddeboer ddeboer commented Jul 6, 2026

Copy link
Copy Markdown
Member

Fix #532

The keyed DatasetFacets surface resolved each selected facet through its own engine.search call, so a typical listing page fanned out into 1 listing search + N facet searches. This PR batches the whole facet selection into one engine round-trip, restoring the pre-migration single-multi_search behaviour: a typical page now costs the listing search plus one batched facet search (with the label cache on, exactly 2 Typesense round-trips). Recorded as ADR 5.

Port (@lde/search) — breaking

  • SearchEngine gains the batch entry point searchFacets(searchType, queries), returning one FacetsOutcome per query, positionally: { facets } or { error } (an allSettled-style union). A failure of one query is reported in place and never discards its siblings’ facets; the promise rejects only for batch-level failures (foreign type, invalid query, transport). Engines answer every query facet-only — as if limit: 0 and without ordering, whatever the query carries — so hits are never transferred. Existing SearchEngine implementations must add the method.
  • The executable port-contract suite (@lde/search/testing) covers the new method: foreign-type rejection, per-query validation, positional alignment (including a non-zero-limit query), and the empty batch.

Typesense adapter (@lde/search-typesense)

  • searchFacets compiles each query with the existing buildSearchParams (normalized to per_page: 0, no sort_by) and sends the whole batch as one multi_search.
  • A failed multi_search entry (reported inline by Typesense) becomes a per-query error outcome naming the affected facet fields, so its siblings’ facets survive; fetchLabels now also throws on an inline error entry, engaging the label degradation path (onLabelError, id-only references) instead of silently missing every label.
  • Reference-facet labels for the entire batch resolve in one bundled lookup, or from the in-memory cache when labelCacheTtlMs is set; the label-degradation logic is shared between search and searchFacets.

GraphQL surface (@lde/search-api-graphql)

  • Selected facet fields load through a per-request DataLoader (facet-batch.ts): same-tick loads are collected, grouped by their effective where after skip-own-filter — facets whose own field is unfiltered share one query, so the unfiltered browse collapses to a single facet query; each own-filtered facet gets its own variant — and dispatched as one engine.searchFacets call.
  • Facet counts, skip-own-filter semantics, per-facet failure isolation (onFacetError fires only for the fields of the query that actually failed), reference labels, and the public SDL are unchanged. Facet-only queries drop the listing orderBy (nothing to order).
  • New direct dependency: dataloader (GraphQL Foundation’s reference batching implementation) instead of a hand-rolled microtask batcher.

Deliberately not done

  • Riding same-where facets on the main listing search via facet_by for a 1-round-trip browse — needs GraphQLResolveInfo selection-walking; inside one multi_search the difference is negligible (see ADR 5).
  • Overlapping the listing search with the facet dispatch (pre-existing sequencing; the facet queries derive only from the resolved query, so this is a possible follow-up).

ddeboer added 4 commits July 6, 2026 10:08
- add searchFacets(searchType, queries) to the SearchEngine port: facet-only
  queries answered positionally, one facet map per query
- Typesense adapter sends the whole batch as a single multi_search, compiles
  every entry facet-only (per_page 0), surfaces failed entries as rejections,
  and shares one label lookup (or the label cache) across the batch
- GraphQL surface: selected facet fields load through a per-request DataLoader
  that groups them by effective where (skip-own-filter) and dispatches ONE
  searchFacets call; the unfiltered browse collapses to a single facet query
- facet-only queries drop the listing orderBy; a failed batch degrades every
  selected facet to an empty list, reported per field via onFacetError
- extend the port contract suite in @lde/search/testing to cover searchFacets

BREAKING CHANGE: SearchEngine implementations must add the searchFacets method.
- searchFacets returns one FacetsOutcome ({ facets } or { error }) per query,
  so one failed query no longer discards its siblings' facets: the surface
  degrades exactly the failed query's facets and reports each via onFacetError
- the Typesense adapter passes a failed multi_search entry through as an
  in-place error naming the query's facet fields, and normalizes orderBy
  away alongside limit/offset (facet-only compiles carry no sort)
- fetchLabels now throws on an inline multi_search error entry, engaging the
  label degradation path instead of silently missing every label
- a missing outcome (port-contract breach) is reported, not read as empty
- restore the lazy iris thunk so the cached-label path skips collecting them
- record the decision as ADR 5 and amend the port snippet in ADR 3

BREAKING CHANGE: searchFacets returns FacetsOutcome[] instead of FacetMap[].
- one configurable fake (search, export, multi_search; recorded performs and
  export calls) replaces the five bespoke fakes in parse-response.test.ts
- labelLookup() shares the filter_by id-list answering between the fetchLabels
  and bundled-label-lookup tests
- re-anchor coverage thresholds: the helper's defensive guards are uncovered
@ddeboer ddeboer merged commit 84a67f6 into main Jul 6, 2026
2 checks passed
@ddeboer ddeboer deleted the feat/batch-facet-searches branch July 6, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Batch facet searches into a single multi_search (per-facet engine.search fan-out)

1 participant