9. Route a whole-schema projection to per-type collections
Date: 2026-07-10
Status
Accepted
Extends the writer model of ADR 6 (Make the Writer transaction-aware) and the reference model of ADR 8 (Resolve reference labels from per-reference label sources); updates the catalog grain of search as a Configurable Pipeline instance (#534) from one collection to several (#590).
Partly superseded by ADR 12 (Bound memory by the unit of work, not the input) and ADR 13 (Project inside the batch, per root type), which reverse this ADR by halves:
Still current – the fan-out’s placement: composed in
@lde/search-pipelineover N single-collection engine writers (option (a) below), not a multi-collection writer inside an engine adapter; each collection committing, sweeping and failing in isolation;abortfinalizing only the collections that did not go live. That is what this ADR set out to decide, and it stands.Still current – the per-document
searchType(TypedSearchDocument). N stages still write to one terminal (source → transform → sink), andwrite(dataset, items)carries no stage identity, so the type must travel with the item. What changes is that a stage mints it – it was constructed for one type – rather thanprojectGraphdiscovering it fromrdf:type.searchIndexWriterremains the router this ADR made it; it loses projection and buffering, nothing else.Superseded – the projection mechanism: the whole-schema single-scan mixed stream, and the buffer-until-flush. Both are structures sized by the input rather than by a bounded unit. Projection happens per type, per batch, over roots the selector supplied (ADR 13).
Implemented by #606.
For the record: the buffering was never this ADR’s subject. It arrived two PRs earlier, in @lde/search-pipeline’s first commit (#565), as a documented deferral of “bounded entity batches within one huge dataset”; this ADR inherited it as background and restated it while deciding the fan-out. Which is why the Consequences below never weigh memory – memory was not on the table.
Context
One validated SearchSchema declares several root types. The Dataset Register indexes four: datasets plus the Organization / Class / TerminologySource label collections its references resolve against (ADR 8). Each type derives its own Typesense collection definition, so the types cannot share a collection – they are four independent blue/green rebuilds, each with its own versioned collection, alias and single-flight lock.
ADR 6 made the engine Writer a transactional, single-collection rebuild (BlueGreenRebuild / InPlaceRebuild), bound to one SearchType. projectGraph already projects the whole schema in one pass – a single scan of the quads builds the subject index every type frames off – yielding one mixed stream of documents. Nothing joined the two: a consumer that wanted several collections had to forge a per-type schema brand, hand-roll a per-type rebuild loop and cast a per-type projection. The per-collection fan-out had no home.
Two placement options: (a) a composition in @lde/search-pipeline over N single-collection engine writers, or (b) a new multi-collection writer inside @lde/search-typesense. (a) keeps the engine adapter a single-collection concern and keeps the fan-out engine-agnostic (an OpenSearch adapter would reuse it unchanged), so the routing is a pipeline-glue concern, not an engine one.
Decision
The projection tags each document with its type.
projectGraphyields{ searchType, document }(TypedSearchDocument), not a bareSearchDocument. The whole schema still projects into one mixed, single-scan stream; the tag is what lets the write side route each document to the collection for its type without re-deriving the type from the document. A single-collection consumer just readsdocument.@lde/search-pipeline’ssearchIndexWriterfans out (option a). It takes the schema and awriterFor(searchType)factory, builds one engine writer per root type once, and on each run opens one engine run per type. A dataset’s quads are buffered until its flush, projected once (whole schema), then split by type and dispatched to each type’s run. The pipeline still drives one uniformopenRun → write* → commit/abortand never branches on the multi-collection shape – consistent with ADR 6’s single lifecycle. A single-collection deployment is just the N = 1 case.Each collection commits, sweeps and fails in isolation. A type whose projection is empty this run affects only its own collection, never another’s; the empty-selection guard (#569) is therefore per collection by construction, not a single global gate.
commitfinalizes every collection independently and, if any fails, throws anAggregateErrorafter attempting them all: a non-critical label-collection failure never blocks the collections that did commit – in particular thedatasetsindex still goes live – while the failure is still surfaced, so a stale collection is never silent.abortfinalizes only the collections that did not go live. The pipeline aborts a run whosecommitthrows (ADR 6), so a partial commit reachesabort. Because a committed blue/green rebuild’sabortwould drop its now-live collection,abortskips the collections that already committed and drops only the half-built ones (and releases their locks). A failure while opening the per-type runs rolls the already-opened ones back the same way.
Consequences
The Dataset Register consumer collapses: project the schema, hand the stream to
searchIndexWriterwith awriterForthat returns aBlueGreenRebuildper type. The forged schema brand, the per-type projection casts and the hand-rolled per-type rebuild loop all go away.Breaking for
@lde/search:projectGraphyieldsTypedSearchDocument, notSearchDocument. Breaking for@lde/search-pipeline:searchIndexWritertakeswriterFor(per type) instead of a singlewriter.The partial-failure policy is fail-the-run-but-commit-what-can: the safest default (no silent staleness) that still honours the isolation requirement. A skip-and-report or critical/non-critical-per-type policy can layer on later without changing the routing, if a deployment needs the
datasetsindex to go live without the run being marked failed.The fan-out is engine-agnostic: it composes any transactional
Writer<SearchDocument>, so a second engine adapter reuses it unchanged.