@lde/search-pipeline 0.3.1 → 0.4.1

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.
package/README.md CHANGED
@@ -11,11 +11,13 @@ The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transa
11
11
  - [`@lde/search`](../search) owns the engine-agnostic projection (framed
12
12
  document + field model) and stays pipeline-free;
13
13
  - `@lde/search-<engine>` (e.g. [`@lde/search-typesense`](../search-typesense))
14
- is the engine adapter: a transactional `Writer` consuming projected
15
- documents, owning the run lifecycle (Blue/green alias swap or In-place
16
- sweeps, cross-pod lock);
17
- - **this package** composes them: `searchIndexWriter` is the one
18
- type-changing step (quad → document), shared across engines.
14
+ is the engine adapter: a transactional, **single-collection** `Writer`
15
+ consuming projected documents, owning the run lifecycle (Blue/green alias swap
16
+ or In-place sweeps, cross-pod lock);
17
+ - **this package** composes them: `searchIndexWriter` is the one type-changing
18
+ step (quad → document), shared across engines, and – since one schema declares
19
+ several root types – it fans each document out to the engine writer for **its**
20
+ type’s collection ([ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md)).
19
21
 
20
22
  ## Usage
21
23
 
@@ -25,20 +27,41 @@ import { searchSchema } from '@lde/search';
25
27
  import { BlueGreenRebuild } from '@lde/search-typesense';
26
28
  import { searchIndexWriter } from '@lde/search-pipeline';
27
29
 
28
- const schema = searchSchema({
29
- name: 'dataset',
30
- type: 'http://www.w3.org/ns/dcat#Dataset',
31
- fields: [
32
- {
33
- name: 'title',
34
- kind: 'text',
35
- path: 'http://purl.org/dc/terms/title',
36
- locales: ['und'],
37
- output: true,
38
- searchable: true,
39
- },
40
- ],
41
- });
30
+ // One schema, several root types: the `datasets` catalog plus the Organization
31
+ // label collection its references resolve against.
32
+ const schema = searchSchema(
33
+ {
34
+ name: 'Dataset',
35
+ type: 'http://www.w3.org/ns/dcat#Dataset',
36
+ fields: [
37
+ {
38
+ name: 'title',
39
+ kind: 'text',
40
+ path: 'http://purl.org/dc/terms/title',
41
+ locales: ['und'],
42
+ output: true,
43
+ searchable: { weight: 5 },
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ name: 'Organization',
49
+ type: 'http://xmlns.com/foaf/0.1/Organization',
50
+ fields: [
51
+ {
52
+ name: 'label',
53
+ kind: 'text',
54
+ path: 'http://xmlns.com/foaf/0.1/name',
55
+ locales: ['und'],
56
+ output: true,
57
+ searchable: { weight: 1 },
58
+ },
59
+ ],
60
+ },
61
+ );
62
+
63
+ // Each type maps to its own collection: an independent blue/green rebuild.
64
+ const collectionName = { Dataset: 'datasets', Organization: 'organizations' };
42
65
 
43
66
  const pipeline = new Pipeline({
44
67
  datasetSelector,
@@ -50,13 +73,10 @@ const pipeline = new Pipeline({
50
73
  ],
51
74
  writers: searchIndexWriter({
52
75
  schema,
53
- writer: new BlueGreenRebuild(
54
- typesenseClient,
55
- schema.get('http://www.w3.org/ns/dcat#Dataset')!,
56
- {
57
- name: 'datasets',
58
- },
59
- ),
76
+ writerFor: (searchType) =>
77
+ new BlueGreenRebuild(typesenseClient, searchType, {
78
+ name: collectionName[searchType.name],
79
+ }),
60
80
  }),
61
81
  });
62
82
 
@@ -64,12 +84,31 @@ await pipeline.run();
64
84
  ```
65
85
 
66
86
  Each dataset’s extracted CONSTRUCT quads are buffered until the dataset
67
- completes, then framed by root type and projected into flat search documents
68
- (`@lde/search`’s `projectGraph`), and written to the engine run – before the
69
- engine acts on the dataset’s completion, so an In-place stale sweep never
70
- races its own documents. The run lifecycle (run context, per-dataset flush
71
- outcome, commit/abort) passes through unchanged: the engine writer’s update
72
- mode governs, and the pipeline never branches on it.
87
+ completes, then projected once over the **whole** schema (`@lde/search`’s
88
+ `projectGraph`) into one mixed, type-tagged stream, and each document is
89
+ dispatched to the engine run for its type’s collection – before the engine acts
90
+ on the dataset’s completion, so an In-place stale sweep never races its own
91
+ documents. The run lifecycle (run context, per-dataset flush outcome,
92
+ commit/abort) passes through unchanged: the engine writer’s update mode governs,
93
+ and the pipeline never branches on it, nor on how many collections there are.
94
+
95
+ ## Per-collection isolation
96
+
97
+ Each root type is an independent engine run – its own collection, alias and
98
+ cross-pod lock – so the collections commit, sweep and fail in isolation:
99
+
100
+ - a type whose projection is empty this run affects only its own collection,
101
+ never another’s – in particular the `datasets` index still goes live;
102
+ - `commit` finalizes every collection independently and, if any fails, throws an
103
+ `AggregateError` _after_ attempting them all, so a non-critical
104
+ label-collection failure never blocks the collections that did commit, while
105
+ the failure is still surfaced (the run is marked failed);
106
+ - because the pipeline aborts a run whose `commit` throws, `abort` finalizes only
107
+ the collections that did **not** already go live (aborting a committed
108
+ blue/green rebuild would drop its now-live collection), dropping the half-built
109
+ ones and releasing their locks.
110
+
111
+ See [ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md).
73
112
 
74
113
  ## Memory
75
114
 
@@ -1,31 +1,55 @@
1
1
  import type { Quad } from '@rdfjs/types';
2
2
  import type { Writer } from '@lde/pipeline';
3
- import { type SearchDocument, type SearchSchema } from '@lde/search';
3
+ import { type SearchDocument, type SearchSchema, type SearchType } from '@lde/search';
4
4
  /** Options for {@link searchIndexWriter}. */
5
5
  export interface SearchIndexWriterOptions {
6
6
  /**
7
7
  * The declarative schema driving the projection: one {@link SearchType} per
8
- * root type, each mapping framed RDF to flat search-document fields.
8
+ * root type, each mapping framed RDF to flat search-document fields. The
9
+ * writer opens one engine run per type in it.
9
10
  */
10
11
  schema: SearchSchema;
11
12
  /**
12
- * The engine adapter the projected documents are written to – e.g.
13
- * `@lde/search-typesense`’s `BlueGreenRebuild` or `InPlaceRebuild`. The
14
- * run lifecycle (context, per-dataset flush outcome, commit/abort) is
15
- * forwarded unchanged, so the engine writer’s update mode governs.
13
+ * The engine writer that owns a given root type’s collection – e.g. a
14
+ * `@lde/search-typesense` `BlueGreenRebuild` bound to that type and the
15
+ * collection name it keeps its alias on. Called once per {@link SearchType}
16
+ * in the schema when the writer is built.
17
+ *
18
+ * A single-collection deployment returns one writer for its one type; a
19
+ * multi-collection deployment (the Dataset Register’s `datasets` plus its
20
+ * Organization / Class / TerminologySource label collections) returns a
21
+ * distinct writer per type, each an independent blue/green rebuild with its
22
+ * own collection, alias and cross-pod lock. The projection is whole-schema
23
+ * either way; the per-collection fan-out is this writer’s job.
16
24
  */
17
- writer: Writer<SearchDocument>;
25
+ writerFor: (searchType: SearchType) => Writer<SearchDocument>;
18
26
  }
19
27
  /**
20
- * The projection step of a search-indexing pipeline, as a quad `Writer`: it
21
- * turns each dataset’s extracted CONSTRUCT quads into engine-agnostic search
22
- * documents ({@link projectGraph}: frame by root type, then project each node
23
- * with its type’s declaration) and feeds them to the engine writer. This is
24
- * the one type-changing step (quad → document), shared across engines –
25
- * which is why it lives here and not inside an engine adapter.
28
+ * The projection step of a search-indexing pipeline, as a quad `Writer`, fanned
29
+ * out across a type’s collections. It turns each dataset’s extracted CONSTRUCT
30
+ * quads into engine-agnostic search documents ({@link projectGraph}: frame by
31
+ * root type, then project each node with its type’s declaration) and dispatches
32
+ * each document to the engine run for **its** type. This is the one
33
+ * type-changing step (quad → document), shared across engines – which is why it
34
+ * lives here and not inside an engine adapter.
35
+ *
36
+ * Each root type is an independent engine run (its own collection, alias and
37
+ * lock), so the collections commit, sweep and fail in isolation:
38
+ *
39
+ * - a type whose projection is empty this run affects only its own collection,
40
+ * never another’s – in particular the `datasets` index still goes live;
41
+ * - `commit` finalizes every collection independently and, if any fails, throws
42
+ * an `AggregateError` *after* attempting them all, so a non-critical
43
+ * label-collection failure never blocks the collections that did commit,
44
+ * while the failure is still surfaced (the pipeline marks the run failed).
45
+ * Because the pipeline then calls {@link RunWriter.abort}, `abort` finalizes
46
+ * only the collections that did **not** already go live – aborting a
47
+ * committed blue/green rebuild would drop its now-live collection;
48
+ * - `abort` (a run failure, or a partial commit) drops every half-built
49
+ * collection that has not committed and leaves the live ones untouched.
26
50
  *
27
51
  * A dataset’s quads are buffered until its flush and projected then, so the
28
- * documents land before the engine acts on the dataset’s completion (e.g. an
52
+ * documents land before every engine acts on the dataset’s completion (e.g. an
29
53
  * In-place stale sweep). Memory is bounded by one dataset’s extraction, and
30
54
  * released at each flush – nothing accumulates across datasets. Streaming
31
55
  * bounded entity batches *within* one huge dataset needs the two-level
@@ -1 +1 @@
1
- {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,KAAK,EAIV,MAAM,EACP,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AAErB,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;OAKG;IACH,MAAM,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC;CAChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,IAAI,CAAC,CA8Dd"}
1
+ {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,KAAK,EAIV,MAAM,EACP,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,SAAS,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC,cAAc,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,IAAI,CAAC,CA2Id"}
@@ -1,34 +1,90 @@
1
1
  import { projectGraph, } from '@lde/search';
2
2
  /**
3
- * The projection step of a search-indexing pipeline, as a quad `Writer`: it
4
- * turns each dataset’s extracted CONSTRUCT quads into engine-agnostic search
5
- * documents ({@link projectGraph}: frame by root type, then project each node
6
- * with its type’s declaration) and feeds them to the engine writer. This is
7
- * the one type-changing step (quad → document), shared across engines –
8
- * which is why it lives here and not inside an engine adapter.
3
+ * The projection step of a search-indexing pipeline, as a quad `Writer`, fanned
4
+ * out across a type’s collections. It turns each dataset’s extracted CONSTRUCT
5
+ * quads into engine-agnostic search documents ({@link projectGraph}: frame by
6
+ * root type, then project each node with its type’s declaration) and dispatches
7
+ * each document to the engine run for **its** type. This is the one
8
+ * type-changing step (quad → document), shared across engines – which is why it
9
+ * lives here and not inside an engine adapter.
10
+ *
11
+ * Each root type is an independent engine run (its own collection, alias and
12
+ * lock), so the collections commit, sweep and fail in isolation:
13
+ *
14
+ * - a type whose projection is empty this run affects only its own collection,
15
+ * never another’s – in particular the `datasets` index still goes live;
16
+ * - `commit` finalizes every collection independently and, if any fails, throws
17
+ * an `AggregateError` *after* attempting them all, so a non-critical
18
+ * label-collection failure never blocks the collections that did commit,
19
+ * while the failure is still surfaced (the pipeline marks the run failed).
20
+ * Because the pipeline then calls {@link RunWriter.abort}, `abort` finalizes
21
+ * only the collections that did **not** already go live – aborting a
22
+ * committed blue/green rebuild would drop its now-live collection;
23
+ * - `abort` (a run failure, or a partial commit) drops every half-built
24
+ * collection that has not committed and leaves the live ones untouched.
9
25
  *
10
26
  * A dataset’s quads are buffered until its flush and projected then, so the
11
- * documents land before the engine acts on the dataset’s completion (e.g. an
27
+ * documents land before every engine acts on the dataset’s completion (e.g. an
12
28
  * In-place stale sweep). Memory is bounded by one dataset’s extraction, and
13
29
  * released at each flush – nothing accumulates across datasets. Streaming
14
30
  * bounded entity batches *within* one huge dataset needs the two-level
15
31
  * iteration (dataset → entity-URI batches) and is not implemented yet.
16
32
  */
17
33
  export function searchIndexWriter(options) {
18
- const { schema, writer } = options;
34
+ const { schema, writerFor } = options;
35
+ // One engine writer per root type, built once; each run opens them all. Keyed
36
+ // by the type IRI, which is also how a projected document names its type.
37
+ const writers = new Map([...schema.values()].map((searchType) => [
38
+ searchType.type,
39
+ writerFor(searchType),
40
+ ]));
19
41
  return {
20
42
  async openRun(context) {
21
- const run = await writer.openRun(context);
43
+ const runs = new Map();
44
+ try {
45
+ for (const [typeIri, writer] of writers) {
46
+ runs.set(typeIri, await writer.openRun(context));
47
+ }
48
+ }
49
+ catch (error) {
50
+ // One engine run failed to open (e.g. its lock is held); roll the
51
+ // already-opened ones back so no collection or lock is left dangling.
52
+ await Promise.allSettled([...runs.values()].map((run) => run.abort(error)));
53
+ throw error;
54
+ }
55
+ // The collections that have gone live, so `abort` never re-finalizes one
56
+ // (a committed blue/green rebuild’s abort drops its now-live collection).
57
+ const committed = new Set();
22
58
  // One buffered pass per dataset, keyed by IRI. The pipeline processes
23
- // datasets sequentially, but keeping the key explicit makes a write
24
- // after another dataset's flush safe too.
59
+ // datasets sequentially, but keeping the key explicit makes a write after
60
+ // another dataset's flush safe too.
25
61
  const passes = new Map();
26
62
  const project = async (pass) => {
27
63
  passes.delete(pass.dataset.iri.toString());
28
64
  if (pass.quads.length === 0) {
29
65
  return;
30
66
  }
31
- await run.write(pass.dataset, projectGraph(pass.quads, schema));
67
+ // Whole-schema projection into one mixed stream, split by type here so
68
+ // each type’s documents reach its own collection’s run.
69
+ const byType = new Map();
70
+ for await (const { searchType, document } of projectGraph(pass.quads, schema)) {
71
+ const documents = byType.get(searchType.type);
72
+ if (documents === undefined) {
73
+ byType.set(searchType.type, [document]);
74
+ }
75
+ else {
76
+ documents.push(document);
77
+ }
78
+ }
79
+ // Every yielded type is a schema type, so it has a run; iterate the
80
+ // runs and write each the documents projected for its type (none, for a
81
+ // type absent from this pass).
82
+ for (const [typeIri, run] of runs) {
83
+ const documents = byType.get(typeIri);
84
+ if (documents !== undefined) {
85
+ await run.write(pass.dataset, stream(documents));
86
+ }
87
+ }
32
88
  };
33
89
  return {
34
90
  write: async (dataset, quads) => {
@@ -47,24 +103,66 @@ export function searchIndexWriter(options) {
47
103
  if (pass !== undefined) {
48
104
  await project(pass);
49
105
  }
50
- await run.flush?.(dataset, outcome);
106
+ // Flush every collection independently: one collection’s flush failure
107
+ // (a rollback, or an In-place stale sweep) must not skip another’s –
108
+ // the pipeline isolates a flush error per dataset and still commits,
109
+ // so a skipped collection would swap live with a dataset it should
110
+ // have rolled back or swept. Every collection is flushed, not just the
111
+ // ones that received documents this dataset: a collection an earlier
112
+ // run held documents for still needs its sweep to reconcile.
113
+ await settleAll(runs.values(), 'flush', (run) => run.flush?.(dataset, outcome));
51
114
  },
52
115
  reset: async (dataset) => {
53
- // Discard the buffered pass so the re-run replaces it, and let the
54
- // engine writer discard whatever it already holds for the dataset.
116
+ // Discard the buffered pass so the re-run replaces it, and let every
117
+ // collection’s run discard whatever it already holds for the dataset –
118
+ // independently, so one collection’s reset failure never leaves
119
+ // another holding the discarded pass’s documents into the re-run.
55
120
  passes.delete(dataset.iri.toString());
56
- await run.reset?.(dataset);
121
+ await settleAll(runs.values(), 'reset', (run) => run.reset?.(dataset));
57
122
  },
58
123
  commit: async () => {
59
- // Safety net: project passes that were never flushed, so a
60
- // committed run never silently drops written quads.
124
+ // Safety net: project passes that were never flushed, so a committed
125
+ // run never silently drops written quads.
61
126
  for (const pass of [...passes.values()]) {
62
127
  await project(pass);
63
128
  }
64
- await run.commit();
129
+ // Commit every collection independently, so one failure neither
130
+ // blocks nor wipes another – the `datasets` index goes live even if a
131
+ // label collection cannot. Record each collection that went live, so
132
+ // the abort that follows a failed commit never drops it.
133
+ await settleAll(runs, 'commit', async ([typeIri, run]) => {
134
+ await run.commit();
135
+ committed.add(typeIri);
136
+ });
137
+ },
138
+ abort: async (error) => {
139
+ // Finalize only the collections that have not gone live: aborting a
140
+ // committed blue/green rebuild would drop its now-live collection.
141
+ // Best-effort – cleanup failures must not mask the original error.
142
+ await Promise.allSettled([...runs]
143
+ .filter(([typeIri]) => !committed.has(typeIri))
144
+ .map(([, run]) => run.abort(error)));
65
145
  },
66
- abort: (error) => run.abort(error),
67
146
  };
68
147
  },
69
148
  };
70
149
  }
150
+ /**
151
+ * Run `operation` on every collection concurrently and independently, so one
152
+ * collection’s failure never skips another’s (a flush’s rollback, a reset’s
153
+ * discard, a commit’s alias swap). Surface the failures together once all have
154
+ * been attempted, as an `AggregateError` – the run is still marked failed, but
155
+ * every collection got its chance to reconcile or go live first.
156
+ */
157
+ async function settleAll(items, verb, operation) {
158
+ const targets = [...items];
159
+ const outcomes = await Promise.allSettled(targets.map((item) => operation(item)));
160
+ const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
161
+ if (failures.length > 0) {
162
+ throw new AggregateError(failures, `${failures.length} of ${targets.length} search collections failed to ${verb}`);
163
+ }
164
+ }
165
+ /** Yield the given documents as an async iterable, like a streaming source. */
166
+ async function* stream(items) {
167
+ yield* items;
168
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lde/search-pipeline",
3
- "version": "0.3.1",
4
- "description": "Search indexing as an @lde/pipeline instance: a Writer<Quad> that applies the @lde/search projection to extracted RDF and streams the documents to a transactional search-engine writer. Glue between the pipeline quad world and the search-engine document world; owns no projection or engine logic itself.",
3
+ "version": "0.4.1",
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",
7
7
  "directory": "packages/search-pipeline"
@@ -25,15 +25,18 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.4.1",
28
+ "@lde/search": "^0.5.0",
29
29
  "@rdfjs/types": "^2.0.1",
30
30
  "tslib": "^2.3.0"
31
31
  },
32
32
  "devDependencies": {
33
- "n3": "^2.1.1"
33
+ "@lde/search-typesense": "^0.7.1",
34
+ "n3": "^2.1.1",
35
+ "testcontainers": "^12.0.3",
36
+ "typesense": "^3.0.6"
34
37
  },
35
38
  "peerDependencies": {
36
39
  "@lde/dataset": "^0.7.8",
37
- "@lde/pipeline": "^0.34.0"
40
+ "@lde/pipeline": "^0.34.1"
38
41
  }
39
42
  }