Skip to content

fix(search)!: fan out a qualified edge into one entry per tuple - #801

Open
ddeboer wants to merge 2 commits into
mainfrom
worktree-weld-fanout
Open

fix(search)!: fan out a qualified edge into one entry per tuple#801
ddeboer wants to merge 2 commits into
mainfrom
worktree-weld-fanout

Conversation

@ddeboer

@ddeboer ddeboer commented Sep 2, 2026

Copy link
Copy Markdown
Member

Fix #798.

A welded co-element filter – this agent in this role – hangs Typesense 30.2 indefinitely whenever the nested entry’s leaves hold arrays, which is every document a qualified relation produced in practice. Through the GraphQL surface that reached a consumer as Unexpected error.

What this changes, and why it is not a workaround

The hang is why this got noticed. It is not why the shape changes.

A weld asks whether one entry satisfies every condition. An entry holding { role: ["etser", "etcher"], creator_id: ["p1", "p3"] } stands for four (role, agent) pairs at once, so it answers the weld with none of them – and the weld silently degenerates into the cross-product it exists to exclude. That entry is not a valid input the engine mishandles; it is a shape that never had a meaning, and one ADR 24’s own words – one entry per edge – already exclude.

So: a weldable nested leaf is single-valued, and multiplicity moves to the entry list.

  • searchSchema refuses a nested field declaring both filterable and array. output-only leaves are untouched – nothing welds them, so they may carry a list for display.
  • The projection emits one entry per combination of an edge’s weldable leaves. Nothing is dropped; each entry now answers the weld.
  • A nested identity companion is written under the arity of its reference. That companion is the leaf a weld actually names, so this was the load-bearing half: fan-out alone still left creator_id: ["p1"] inside each entry, which is exactly what hangs 30.2. Top-level companions stand for the whole document and are unchanged.
  • maxEntries (default 100) bounds the entries one document stores, per ADR 12: a cartesian product over an edge’s values is a bound in the data’s own units, which is not a bound.

Full reasoning, measurements and the rejected alternatives are in the new ADR 26.

Measured

Against live containers, 1 000 000 documents, same data and same query:

welded filter, p50 role alone facet
30.2 + fan-out (this PR) 14–19 ms 15.9 ms 30 ms
31.0 + arrays 13–16 ms 15.8 ms 64 ms
30.2 + arrays (before) hangs 15.8 ms 30 ms
31.0 + fan-out 13–16 ms 16.1 ms 28 ms

Two things that decided the design. The upgrade unwinds nothing: the fanned-out shape is no slower on 31.0 than the array shape it replaces, so when 31.0 goes stable this is routine maintenance rather than a migration back. And facets stay on the real fields: the other shape that works on 30.2 is an index-time composite role|agent key, faster at 3 ms but faceting into 2 142 125 buckets in 1.4 s against 15 buckets in 48 ms.

Cost: indexing is ~15 % slower (54 s against 47 s per 1 000 000 documents) for ~50 % more entries. A batch cost, not a request cost.

Upstream

Typesense fixed this in the 31.0 release candidates – 31.0.rc1 hangs, rc5 through rc14 answer correctly – but 31.0 has no stable release. Bisect and trigger posted to the two open reports, typesense#2469 (where the reporter had been told it was the expected cost of &&) and typesense#2964.

Notes for review

  • Breaking: a nested field may no longer declare both filterable and array. Three search-pipeline fixtures carried exactly that shape and are updated here – the same edit a deployment makes.
  • packages/search-typesense/vite.config.ts moves a coverage threshold up (95.69 → 95.82) from a full-package run.
  • Two things are deleted rather than adapted: arrayValueType’s case 'string[]' became unreachable once indexed nested leaves are single-valued, and a collection-definition test covering a combination the schema now refuses (the refusal itself is asserted in schema.test.ts).
  • welded-filter.integration.test.ts is the test that would have caught this: it asserts the weld returns strictly less than the same conditions unwelded, and pins the array-shaped hang so a future engine bump reports when it is gone.

A weld asks whether ONE entry satisfies every condition. An entry whose leaves
hold arrays stands for every combination at once, so it answers the weld with
none of them – and Typesense 30.2, the current stable release, does not answer
at all: the search hangs indefinitely, which reaches a consumer as an
unexplained error.

- refuse a nested field declaring both filterable and array, so a leaf a weld
  can name states one value per entry
- fan an edge out into one entry per combination of its weldable leaves, on the
  framed node before projection, so each leaf still passes through transform,
  folding and the facet companion unchanged
- write a nested identity companion under the arity of its reference, since that
  companion is the leaf a weld actually names; a top-level companion stands for
  the whole document and is unchanged
- bound the fan-out with maxEntries (default 100), per ADR 12: a cartesian
  product over an edge's values is a bound in the data's own units
- pin the engine guarantee against a real container, the array-shaped hang
  included, so a future engine bump reports when it is gone

Measured at 1,000,000 documents: the welded filter answers in 14–19 ms where it
previously never returned, and the fanned-out shape is no slower than the array
shape on 31.0, so the eventual upgrade unwinds nothing. Indexing is ~15 % slower
for ~50 % more entries.

BREAKING CHANGE: a nested field may no longer declare both “filterable” and
“array”; searchSchema refuses it. Declare the leaf single-valued – the
projection now emits one entry per combination.
Three defects found reviewing the fan-out, two of them introduced by it and
each invisible without a live engine.

- restore the already-a-list case when widening a nested leaf. Only Reference
  Types are held to the single-valued rule, and the same path declares the
  fields of the Root Type a local lookup nests, where array and filterable meet
  on an ordinary facet – the leaf was emitted with no type at all, which the
  engine refuses outright
- declare a nested identity companion by what its path yields rather than
  always as a list. Under a single-valued edge the parent is an object, nothing
  flattens, and Typesense rejects the scalar the projection writes against a
  declared list: the whole import fails
- expand every tuple the cap keeps. Stopping mid-expansion left the remaining
  weldable leaves holding their lists, and a single-valued leaf then kept the
  first value and dropped the rest – losing data quietly rather than fanning it
  out

Two existing tests asserted the arity rule inverted, stating that a companion is
always a list and that a single value is what the engine rejects. A live import
says the opposite, so both now read the other way.

An identity on a Reference Type's field is already refused, and a nested
filterable lookup can no longer be an array, so a nested companion never holds
more than one id; no change was needed there. The entry budget is per node
carrying the reference rather than per document, so nested edges compound it –
documented rather than changed.
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.

A welded co-element filter hangs Typesense when the nested fields hold arrays

1 participant