@lde/search-pipeline 0.19.0 → 0.20.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
|
|
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),
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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,
|
|
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),
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
118
|
-
// blocks nor wipes another – the `datasets` index goes live even if
|
|
119
|
-
// label collection cannot. Record each collection that
|
|
120
|
-
// the abort that follows a failed commit never drops
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
128
|
-
//
|
|
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]) =>
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.20.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.
|
|
28
|
+
"@lde/search": "^0.20.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.
|
|
35
|
+
"@lde/search-typesense": "^0.23.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.
|
|
43
|
+
"@lde/pipeline": "^0.36.0"
|
|
44
44
|
}
|
|
45
45
|
}
|