@lde/search-pipeline 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { type Writer } from '@lde/pipeline';
2
- import type { RootType, SearchDocument, SearchSchema } from '@lde/search';
2
+ import { type RootType, type SearchDocument, type SearchSchema } from '@lde/search';
3
3
  import type { TypedSearchDocument } from './typed-search-document.js';
4
4
  /** Options for {@link searchIndexWriter}. */
5
5
  export interface SearchIndexWriterOptions {
@@ -38,17 +38,27 @@ export interface SearchIndexWriterOptions {
38
38
  * made it.
39
39
  *
40
40
  * Each root type is an independent engine run (its own collection, alias and
41
- * lock), so the collections commit, sweep and fail in isolation:
41
+ * lock), and the unit those runs are isolated **by** is the join component –
42
+ * the group of types that reference one another through a
43
+ * {@link ReferenceField.joinable} reference and therefore cannot go live apart
44
+ * ([ADR 19](https://github.com/ldelements/lde/blob/main/docs/decisions/0019-filter-across-collections-through-declared-joins.md)).
45
+ * A type with no joinable reference is a singleton component, so a schema
46
+ * without joins behaves exactly as it did:
42
47
  *
43
48
  * - a type whose projection is empty this run affects only its own collection,
44
49
  * never another’s – in particular the `datasets` index still goes live;
45
- * - `commit` finalizes every collection independently and, if any fails, throws
50
+ * - runs are **opened** in join order, referenced first: an engine cannot
51
+ * create a collection whose reference names a collection that does not exist
52
+ * yet;
53
+ * - `commit` finalizes every component independently and, if any fails, throws
46
54
  * an `AggregateError` *after* attempting them all, so a non-critical
47
- * label-collection failure never blocks the collections that did commit,
55
+ * label-collection failure never blocks the components that did commit,
48
56
  * while the failure is still surfaced (the pipeline marks the run failed).
49
- * Because the pipeline then calls {@link RunWriter.abort}, `abort` finalizes
50
- * only the collections that did **not** already go live – aborting a
51
- * committed blue/green rebuild would drop its now-live collection;
57
+ * Within a component the commits are sequential, referrers first, and stop
58
+ * at the first failure – so a component ships whole or not at all. Because
59
+ * the pipeline then calls {@link RunWriter.abort}, `abort` finalizes only the
60
+ * collections that did **not** already go live – aborting a committed
61
+ * blue/green rebuild would drop its now-live collection;
52
62
  * - `abort` (a run failure, or a partial commit) drops every half-built
53
63
  * collection that has not committed and leaves the live ones untouched.
54
64
  *
@@ -1 +1 @@
1
- {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,SAAS,EAAE,CACT,UAAU,EAAE,QAAQ,EACpB,MAAM,EAAE,YAAY,KACjB,MAAM,CAAC,cAAc,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,mBAAmB,CAAC,CAoI7B"}
1
+ {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,SAAS,EAAE,CACT,UAAU,EAAE,QAAQ,EACpB,MAAM,EAAE,YAAY,KACjB,MAAM,CAAC,cAAc,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,mBAAmB,CAAC,CA+M7B"}
@@ -1,4 +1,5 @@
1
1
  import { AsyncQueue, } from '@lde/pipeline';
2
+ import { joinGraph, } from '@lde/search';
2
3
  /**
3
4
  * The single terminal of a search-indexing pipeline: an engine-agnostic router
4
5
  * that fans already-projected documents out across a type’s collections. The
@@ -12,17 +13,27 @@ import { AsyncQueue, } from '@lde/pipeline';
12
13
  * made it.
13
14
  *
14
15
  * Each root type is an independent engine run (its own collection, alias and
15
- * lock), so the collections commit, sweep and fail in isolation:
16
+ * lock), and the unit those runs are isolated **by** is the join component –
17
+ * the group of types that reference one another through a
18
+ * {@link ReferenceField.joinable} reference and therefore cannot go live apart
19
+ * ([ADR 19](https://github.com/ldelements/lde/blob/main/docs/decisions/0019-filter-across-collections-through-declared-joins.md)).
20
+ * A type with no joinable reference is a singleton component, so a schema
21
+ * without joins behaves exactly as it did:
16
22
  *
17
23
  * - a type whose projection is empty this run affects only its own collection,
18
24
  * never another’s – in particular the `datasets` index still goes live;
19
- * - `commit` finalizes every collection independently and, if any fails, throws
25
+ * - runs are **opened** in join order, referenced first: an engine cannot
26
+ * create a collection whose reference names a collection that does not exist
27
+ * yet;
28
+ * - `commit` finalizes every component independently and, if any fails, throws
20
29
  * an `AggregateError` *after* attempting them all, so a non-critical
21
- * label-collection failure never blocks the collections that did commit,
30
+ * label-collection failure never blocks the components that did commit,
22
31
  * while the failure is still surfaced (the pipeline marks the run failed).
23
- * Because the pipeline then calls {@link RunWriter.abort}, `abort` finalizes
24
- * only the collections that did **not** already go live – aborting a
25
- * committed blue/green rebuild would drop its now-live collection;
32
+ * Within a component the commits are sequential, referrers first, and stop
33
+ * at the first failure – so a component ships whole or not at all. Because
34
+ * the pipeline then calls {@link RunWriter.abort}, `abort` finalizes only the
35
+ * collections that did **not** already go live – aborting a committed
36
+ * blue/green rebuild would drop its now-live collection;
26
37
  * - `abort` (a run failure, or a partial commit) drops every half-built
27
38
  * collection that has not committed and leaves the live ones untouched.
28
39
  *
@@ -38,12 +49,25 @@ export function searchIndexWriter(options) {
38
49
  searchType.class,
39
50
  writerFor(searchType, schema),
40
51
  ]));
52
+ // The declared join components – the unit a rebuild opens and commits by.
53
+ // A type with no joinable reference is a singleton here, so a schema without
54
+ // joins keeps per-collection isolation exactly as before.
55
+ const components = joinGraph(schema).components;
56
+ // Referenced first, across every component: the order the collections may be
57
+ // created in.
58
+ const openOrder = components.flat();
41
59
  return {
42
60
  async openRun(context) {
43
61
  const runs = new Map();
44
62
  try {
45
- for (const [typeIri, writer] of writers) {
46
- runs.set(typeIri, await writer.openRun(context));
63
+ // Open in join order: a collection whose reference names a peer’s
64
+ // collection cannot be created before that peer’s exists, so a
65
+ // component’s referenced types open first. Locking needs no change –
66
+ // this is still the single deterministic pass that takes every lock in
67
+ // a fixed order, which is what keeps lock-ordering deadlock impossible.
68
+ for (const searchType of openOrder) {
69
+ const writer = writers.get(searchType.class);
70
+ runs.set(searchType.class, await writer.openRun(context));
47
71
  }
48
72
  }
49
73
  catch (error) {
@@ -55,6 +79,11 @@ export function searchIndexWriter(options) {
55
79
  // The collections that have gone live, so `abort` never re-finalizes one
56
80
  // (a committed blue/green rebuild’s abort drops its now-live collection).
57
81
  const committed = new Set();
82
+ // The components with at least one collection live. Their UNCOMMITTED
83
+ // members are the ones whose build the live collection references, so
84
+ // they are abandoned rather than aborted. Tracked per component because
85
+ // that is the unit that hazard lives at.
86
+ const live = new Set();
58
87
  return {
59
88
  write: async (dataset, items) => {
60
89
  // Route each document to its type’s run, streaming. A stage writes
@@ -105,48 +134,93 @@ export function searchIndexWriter(options) {
105
134
  // have rolled back or swept. Every collection is flushed, not just the
106
135
  // ones that received documents this dataset: a collection an earlier
107
136
  // run held documents for still needs its sweep to reconcile.
108
- await settleAll(runs.values(), 'flush', (run) => run.flush?.(dataset, outcome));
137
+ await settleAll(runs.values(), 'flush', 'collection', (run) => run.flush?.(dataset, outcome));
109
138
  },
110
139
  reset: async (dataset) => {
111
140
  // Let every collection’s run discard whatever it already holds for the
112
141
  // dataset – independently, so one collection’s reset failure never
113
142
  // leaves another holding the discarded documents into the re-run.
114
- await settleAll(runs.values(), 'reset', (run) => run.reset?.(dataset));
143
+ await settleAll(runs.values(), 'reset', 'collection', (run) => run.reset?.(dataset));
115
144
  },
116
145
  commit: async () => {
117
- // Commit every collection independently, so one failure neither
118
- // blocks nor wipes another – the `datasets` index goes live even if a
119
- // label collection cannot. Record each collection that went live, so
120
- // the abort that follows a failed commit never drops it.
121
- await settleAll(runs, 'commit', async ([typeIri, run]) => {
122
- await run.commit();
123
- committed.add(typeIri);
146
+ // Commit every join COMPONENT independently, so one failure neither
147
+ // blocks nor wipes another – the `datasets` index goes live even if
148
+ // an unrelated label collection cannot. Record each collection that
149
+ // went live, so the abort that follows a failed commit never drops
150
+ // it.
151
+ //
152
+ // Within a component the commits are sequential and in REVERSE join
153
+ // order – referrers before the types they reference. A blue/green
154
+ // commit drops the collection it supersedes, so committing the
155
+ // referent first would delete a collection the still-live referrer’s
156
+ // documents point at. Going the other way, the referrer swaps onto
157
+ // the fresh build (whose references already name the peer’s fresh
158
+ // collection) before the peer’s old collection is dropped, and the
159
+ // only inconsistency left is the alias-flip window Typesense gives no
160
+ // way to close.
161
+ //
162
+ // Sequential, and stopping at the first failure, is what makes the
163
+ // component the unit: a member that cannot commit leaves the rest of
164
+ // its component un-swapped rather than shipping half a rebuild.
165
+ await settleAll(components, 'commit', 'join component', async (component) => {
166
+ for (const searchType of [...component].reverse()) {
167
+ const run = runs.get(searchType.class);
168
+ await run.commit();
169
+ committed.add(searchType.class);
170
+ // Note the component as touched BEFORE the next member’s
171
+ // commit can throw, so the abort that follows knows this
172
+ // component has something live in it.
173
+ live.add(component);
174
+ }
124
175
  });
125
176
  },
126
177
  abort: async (error) => {
127
- // Finalize only the collections that have not gone live: aborting a
128
- // committed blue/green rebuild would drop its now-live collection.
178
+ // Three outcomes, not two, because a join component makes “undo”
179
+ // destructive in a way it never was per collection:
180
+ //
181
+ // - a collection that WENT LIVE is left alone: aborting a committed
182
+ // blue/green rebuild would drop its now-live collection;
183
+ // - a collection in a component that went PARTLY live is
184
+ // `abandon`ed – finalized WITHOUT dropping what it built. Its
185
+ // half-built collection is precisely what the member that did
186
+ // commit now references by concrete name, so dropping it would
187
+ // leave the live referrer with a dangling reference and every join
188
+ // through it failing, permanently. Abandoning keeps the collection
189
+ // (orphaned until a later run supersedes it) while still releasing
190
+ // the cross-pod lock – skipping it entirely would hold that lock
191
+ // for its whole TTL and fail the NEXT run before it starts;
192
+ // - everything else is aborted as before: nothing in its component is
193
+ // live, so there is no reference to protect.
194
+ //
129
195
  // Best-effort – cleanup failures must not mask the original error.
196
+ const partlyLive = new Set([...live].flatMap((component) => component.map((searchType) => searchType.class)));
130
197
  await Promise.allSettled([...runs]
131
198
  .filter(([typeIri]) => !committed.has(typeIri))
132
- .map(([, run]) => run.abort(error)));
199
+ .map(([typeIri, run]) => partlyLive.has(typeIri)
200
+ ? // A writer that does not implement `abandon` has an `abort`
201
+ // that keeps what it built anyway – that is the contract.
202
+ (run.abandon ?? run.abort).call(run, error)
203
+ : run.abort(error)));
133
204
  },
134
205
  };
135
206
  },
136
207
  };
137
208
  }
138
209
  /**
139
- * Run `operation` on every collection concurrently and independently, so one
140
- * collection’s failure never skips another’s (a flush’s rollback, a reset’s
141
- * discard, a commit’s alias swap). Surface the failures together once all have
142
- * been attempted, as an `AggregateError` – the run is still marked failed, but
143
- * every collection got its chance to reconcile or go live first.
210
+ * Run `operation` on every item concurrently and independently, so one
211
+ * failure never skips another’s work (a flush’s rollback, a reset’s discard, a
212
+ * commit’s alias swap). Surface the failures together once all have been
213
+ * attempted, as an `AggregateError` – the run is still marked failed, but every
214
+ * item got its chance to reconcile or go live first.
215
+ *
216
+ * `unit` names what was iterated, because that differs by phase: a flush and a
217
+ * reset are per collection, a commit is per join component.
144
218
  */
145
- async function settleAll(items, verb, operation) {
219
+ async function settleAll(items, verb, unit, operation) {
146
220
  const targets = [...items];
147
221
  const outcomes = await Promise.allSettled(targets.map((item) => operation(item)));
148
222
  const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
149
223
  if (failures.length > 0) {
150
- throw new AggregateError(failures, `${failures.length} of ${targets.length} search collections failed to ${verb}`);
224
+ throw new AggregateError(failures, `${failures.length} of ${targets.length} search ${unit}s failed to ${verb}`);
151
225
  }
152
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-pipeline",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Search indexing as an @lde/pipeline instance: a Writer<Quad> that applies the @lde/search projection to extracted RDF and fans each document out to the transactional engine writer for its type's collection. Glue between the pipeline quad world and the search-engine document world; owns no projection or engine logic itself.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -25,14 +25,14 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.19.0",
28
+ "@lde/search": "^0.21.0",
29
29
  "@traqula/generator-sparql-1-1": "^1.1.8",
30
30
  "@traqula/parser-sparql-1-1": "^1.2.0",
31
31
  "@traqula/rules-sparql-1-1": "^1.2.0",
32
32
  "tslib": "^2.3.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@lde/search-typesense": "^0.22.0",
35
+ "@lde/search-typesense": "^0.24.0",
36
36
  "@rdfjs/types": "^2.0.1",
37
37
  "n3": "^2.1.2",
38
38
  "testcontainers": "^12.0.3",
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@lde/dataset": "^0.7.9",
43
- "@lde/pipeline": "^0.35.6"
43
+ "@lde/pipeline": "^0.36.0"
44
44
  }
45
45
  }