@lde/search-pipeline 0.7.1 → 0.8.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.
- package/README.md +72 -26
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/search-index-writer.d.ts +22 -22
- package/dist/search-index-writer.d.ts.map +1 -1
- package/dist/search-index-writer.js +56 -72
- package/dist/search-stages.d.ts +82 -0
- package/dist/search-stages.d.ts.map +1 -0
- package/dist/search-stages.js +72 -0
- package/dist/typed-search-document.d.ts +23 -0
- package/dist/typed-search-document.d.ts.map +1 -0
- package/dist/typed-search-document.js +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ and a search engine’s document world, so indexing reuses the existing spine
|
|
|
6
6
|
Discovery → Selection → Iteration → change-gate (skip-unchanged) – instead of
|
|
7
7
|
rebuilding it per consumer.
|
|
8
8
|
|
|
9
|
-
The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transaction-aware.md)
|
|
9
|
+
The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transaction-aware.md),
|
|
10
|
+
[ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)):
|
|
10
11
|
|
|
11
12
|
- [`@lde/search`](../search) owns the engine-agnostic projection (framed
|
|
12
13
|
document + field model) and stays pipeline-free;
|
|
@@ -14,18 +15,32 @@ The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transa
|
|
|
14
15
|
is the engine adapter: a transactional, **single-collection** `Writer`
|
|
15
16
|
consuming projected documents, owning the run lifecycle (Blue/green alias swap
|
|
16
17
|
or In-place sweeps, cross-pod lock);
|
|
17
|
-
- **this package** composes them:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- **this package** composes them:
|
|
19
|
+
- `searchStages` builds one projecting `Stage` per root type. Each stage
|
|
20
|
+
selects its own roots, extracts each root’s quads, and projects the
|
|
21
|
+
**root-complete batch** into documents paired with their `SearchType`
|
|
22
|
+
(`TypedSearchDocument`) – so projection happens inside the batch and memory
|
|
23
|
+
is bounded by `batchSize` roots, not the dataset
|
|
24
|
+
([ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md));
|
|
25
|
+
- `searchIndexWriter` is the pipeline’s single terminal: an engine-agnostic
|
|
26
|
+
router that dispatches each document to the engine writer for **its**
|
|
27
|
+
type’s collection ([ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md)).
|
|
28
|
+
It owns no projection and buffers nothing.
|
|
29
|
+
|
|
30
|
+
So a search pipeline is **one terminal, N stages**: `new Pipeline<TypedSearchDocument>({ datasetSelector, stages: searchStages(...), writers: searchIndexWriter(...) })`.
|
|
21
31
|
|
|
22
32
|
## Usage
|
|
23
33
|
|
|
24
34
|
```typescript
|
|
25
|
-
import { Pipeline,
|
|
35
|
+
import { Pipeline, SparqlConstructReader } from '@lde/pipeline';
|
|
26
36
|
import { searchSchema } from '@lde/search';
|
|
27
37
|
import { BlueGreenRebuild } from '@lde/search-typesense';
|
|
28
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
searchStages,
|
|
40
|
+
selectByClass,
|
|
41
|
+
searchIndexWriter,
|
|
42
|
+
type TypedSearchDocument,
|
|
43
|
+
} from '@lde/search-pipeline';
|
|
29
44
|
|
|
30
45
|
// One schema, several root types: the `datasets` catalog plus the Organization
|
|
31
46
|
// label collection its references resolve against.
|
|
@@ -60,14 +75,33 @@ const schema = searchSchema(
|
|
|
60
75
|
},
|
|
61
76
|
);
|
|
62
77
|
|
|
63
|
-
const
|
|
78
|
+
const dataset = schema.get('http://www.w3.org/ns/dcat#Dataset')!;
|
|
79
|
+
const organization = schema.get('http://xmlns.com/foaf/0.1/Organization')!;
|
|
80
|
+
|
|
81
|
+
const pipeline = new Pipeline<TypedSearchDocument>({
|
|
64
82
|
datasetSelector,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
// One stage per root type: each selects its own roots (here by class –
|
|
84
|
+
// `selectByClass` is a convenience for the object grain; a deployment writes
|
|
85
|
+
// its own selector where the entry point is a domain fact) and extracts each
|
|
86
|
+
// root’s quads. `rootVariable` couples the selector’s projected variable to
|
|
87
|
+
// what the projection reads; it must not be `dataset` (reserved by the reader).
|
|
88
|
+
stages: searchStages({
|
|
89
|
+
schema,
|
|
90
|
+
types: [
|
|
91
|
+
{
|
|
92
|
+
searchType: dataset,
|
|
93
|
+
rootVariable: 'root',
|
|
94
|
+
itemSelector: selectByClass(dataset),
|
|
95
|
+
readers: new SparqlConstructReader({ query: '…' }),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
searchType: organization,
|
|
99
|
+
rootVariable: 'root',
|
|
100
|
+
itemSelector: selectByClass(organization),
|
|
101
|
+
readers: new SparqlConstructReader({ query: '…' }),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
71
105
|
// Each type gets its own collection – an independent blue/green rebuild –
|
|
72
106
|
// named by the adapter from the type itself (`Dataset` → `datasets`,
|
|
73
107
|
// `Organization` → `organizations`), so no naming map is passed here and the
|
|
@@ -101,14 +135,21 @@ Whatever the writers are named, the engine reading these collections must be
|
|
|
101
135
|
given the same names (`collections`), or it reads the derived ones and finds an
|
|
102
136
|
empty index.
|
|
103
137
|
|
|
104
|
-
Each
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
Each stage projects its own root-complete batches (`@lde/search`’s
|
|
139
|
+
`projectRoots`) into documents paired with its `SearchType`, and the terminal
|
|
140
|
+
dispatches each to the engine run for its type’s collection as it arrives –
|
|
141
|
+
before the engine acts on the dataset’s completion, so an In-place stale sweep
|
|
142
|
+
never races its own documents. The run lifecycle (run context, per-dataset flush
|
|
143
|
+
outcome, commit/abort) passes through unchanged: the engine writer’s update mode
|
|
144
|
+
governs, and the pipeline never branches on it, nor on how many collections
|
|
145
|
+
there are.
|
|
146
|
+
|
|
147
|
+
**Root selection is the deployment’s contract.** A selector that does not bind
|
|
148
|
+
the CONSTRUCT’s subject yields a batch that is not root-complete, and projection
|
|
149
|
+
will then silently emit partial documents – a promise `@lde/pipeline` cannot
|
|
150
|
+
check ([ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
|
|
151
|
+
`selectByClass(searchType)` is a convenience for the object grain (where the
|
|
152
|
+
type’s `class` really is the source class), **not** a default.
|
|
112
153
|
|
|
113
154
|
## Per-collection isolation
|
|
114
155
|
|
|
@@ -130,7 +171,12 @@ See [ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-typ
|
|
|
130
171
|
|
|
131
172
|
## Memory
|
|
132
173
|
|
|
133
|
-
Memory is bounded by
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
Memory is bounded by **`batchSize` roots**, not by the input
|
|
175
|
+
([ADR 12](../../docs/decisions/0012-bound-memory-by-the-unit-of-work-not-the-input.md),
|
|
176
|
+
[ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
|
|
177
|
+
Projection runs inside each root-complete batch and streams straight through the
|
|
178
|
+
terminal to the engine run, which holds only its own batch – nothing accumulates
|
|
179
|
+
across a dataset or across datasets, so the same pipeline indexes a 15-quad
|
|
180
|
+
catalog entry and a dataset of millions of objects. The one irreducible atom is a
|
|
181
|
+
single root’s quads: unbounded for a pathological root, and nothing here changes
|
|
182
|
+
that.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { searchStages, selectByClass } from './search-stages.js';
|
|
2
|
+
export type { SearchStagesOptions, SearchStageType } from './search-stages.js';
|
|
1
3
|
export { searchIndexWriter } from './search-index-writer.js';
|
|
2
4
|
export type { SearchIndexWriterOptions } from './search-index-writer.js';
|
|
5
|
+
export type { TypedSearchDocument } from './typed-search-document.js';
|
|
3
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACjE,YAAY,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
1
|
+
import { type Writer } from '@lde/pipeline';
|
|
2
|
+
import type { SearchDocument, SearchSchema, SearchType } from '@lde/search';
|
|
3
|
+
import type { TypedSearchDocument } from './typed-search-document.js';
|
|
4
4
|
/** Options for {@link searchIndexWriter}. */
|
|
5
5
|
export interface SearchIndexWriterOptions {
|
|
6
6
|
/**
|
|
7
|
-
* The declarative schema
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The declarative schema: one {@link SearchType} per root type. The writer
|
|
8
|
+
* opens one engine run per type in it and routes each document to its type’s
|
|
9
|
+
* run. Must be the same schema the stages project through.
|
|
10
10
|
*/
|
|
11
11
|
schema: SearchSchema;
|
|
12
12
|
/**
|
|
@@ -19,19 +19,22 @@ export interface SearchIndexWriterOptions {
|
|
|
19
19
|
* multi-collection deployment (the Dataset Register’s `datasets` plus its
|
|
20
20
|
* Organization / Class / TerminologySource label collections) returns a
|
|
21
21
|
* distinct writer per type, each an independent blue/green rebuild with its
|
|
22
|
-
* own collection, alias and cross-pod lock. The
|
|
23
|
-
*
|
|
22
|
+
* own collection, alias and cross-pod lock. The per-collection fan-out is this
|
|
23
|
+
* writer’s job.
|
|
24
24
|
*/
|
|
25
25
|
writerFor: (searchType: SearchType) => Writer<SearchDocument>;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* The
|
|
29
|
-
* out across a type’s collections.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* The single terminal of a search-indexing pipeline: an engine-agnostic router
|
|
29
|
+
* that fans already-projected documents out across a type’s collections. The
|
|
30
|
+
* per-type {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | stages}
|
|
31
|
+
* project inside the batch and pair each document with its {@link SearchType}
|
|
32
|
+
* ({@link TypedSearchDocument}); this writer dispatches each document to the
|
|
33
|
+
* engine run for **its** type by `searchType.class`. It **owns no projection**
|
|
34
|
+
* (that moved into the stages) and **buffers nothing** (documents stream through
|
|
35
|
+
* to the run as they arrive) – it is purely the per-collection fan-out
|
|
36
|
+
* {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}
|
|
37
|
+
* made it.
|
|
35
38
|
*
|
|
36
39
|
* Each root type is an independent engine run (its own collection, alias and
|
|
37
40
|
* lock), so the collections commit, sweep and fail in isolation:
|
|
@@ -48,12 +51,9 @@ export interface SearchIndexWriterOptions {
|
|
|
48
51
|
* - `abort` (a run failure, or a partial commit) drops every half-built
|
|
49
52
|
* collection that has not committed and leaves the live ones untouched.
|
|
50
53
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* released at each flush – nothing accumulates across datasets. Streaming
|
|
55
|
-
* bounded entity batches *within* one huge dataset needs the two-level
|
|
56
|
-
* iteration (dataset → entity-URI batches) and is not implemented yet.
|
|
54
|
+
* Memory is bounded by one batch of documents per type, not the dataset:
|
|
55
|
+
* `write` routes each document to its type’s run through a bounded queue and
|
|
56
|
+
* never accumulates them.
|
|
57
57
|
*/
|
|
58
|
-
export declare function searchIndexWriter(options: SearchIndexWriterOptions): Writer<
|
|
58
|
+
export declare function searchIndexWriter(options: SearchIndexWriterOptions): Writer<TypedSearchDocument>;
|
|
59
59
|
//# sourceMappingURL=search-index-writer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"
|
|
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,cAAc,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,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,mBAAmB,CAAC,CAoI7B"}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncQueue, } from '@lde/pipeline';
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
4
|
-
* out across a type’s collections.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
3
|
+
* The single terminal of a search-indexing pipeline: an engine-agnostic router
|
|
4
|
+
* that fans already-projected documents out across a type’s collections. The
|
|
5
|
+
* per-type {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | stages}
|
|
6
|
+
* project inside the batch and pair each document with its {@link SearchType}
|
|
7
|
+
* ({@link TypedSearchDocument}); this writer dispatches each document to the
|
|
8
|
+
* engine run for **its** type by `searchType.class`. It **owns no projection**
|
|
9
|
+
* (that moved into the stages) and **buffers nothing** (documents stream through
|
|
10
|
+
* to the run as they arrive) – it is purely the per-collection fan-out
|
|
11
|
+
* {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}
|
|
12
|
+
* made it.
|
|
10
13
|
*
|
|
11
14
|
* Each root type is an independent engine run (its own collection, alias and
|
|
12
15
|
* lock), so the collections commit, sweep and fail in isolation:
|
|
@@ -23,17 +26,14 @@ import { projectGraph, } from '@lde/search';
|
|
|
23
26
|
* - `abort` (a run failure, or a partial commit) drops every half-built
|
|
24
27
|
* collection that has not committed and leaves the live ones untouched.
|
|
25
28
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* released at each flush – nothing accumulates across datasets. Streaming
|
|
30
|
-
* bounded entity batches *within* one huge dataset needs the two-level
|
|
31
|
-
* iteration (dataset → entity-URI batches) and is not implemented yet.
|
|
29
|
+
* Memory is bounded by one batch of documents per type, not the dataset:
|
|
30
|
+
* `write` routes each document to its type’s run through a bounded queue and
|
|
31
|
+
* never accumulates them.
|
|
32
32
|
*/
|
|
33
33
|
export function searchIndexWriter(options) {
|
|
34
34
|
const { schema, writerFor } = options;
|
|
35
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
|
|
36
|
+
// by the type IRI, which is also how a paired document names its type.
|
|
37
37
|
const writers = new Map([...schema.values()].map((searchType) => [
|
|
38
38
|
searchType.class,
|
|
39
39
|
writerFor(searchType),
|
|
@@ -55,54 +55,49 @@ export function searchIndexWriter(options) {
|
|
|
55
55
|
// The collections that have gone live, so `abort` never re-finalizes one
|
|
56
56
|
// (a committed blue/green rebuild’s abort drops its now-live collection).
|
|
57
57
|
const committed = new Set();
|
|
58
|
-
// One buffered pass per dataset, keyed by IRI. The pipeline processes
|
|
59
|
-
// datasets sequentially, but keeping the key explicit makes a write after
|
|
60
|
-
// another dataset's flush safe too.
|
|
61
|
-
const passes = new Map();
|
|
62
|
-
const project = async (pass) => {
|
|
63
|
-
passes.delete(pass.dataset.iri.toString());
|
|
64
|
-
if (pass.quads.length === 0) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
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.class);
|
|
72
|
-
if (documents === undefined) {
|
|
73
|
-
byType.set(searchType.class, [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
|
-
}
|
|
88
|
-
};
|
|
89
58
|
return {
|
|
90
|
-
write: async (dataset,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
59
|
+
write: async (dataset, items) => {
|
|
60
|
+
// Route each document to its type’s run, streaming. A stage writes
|
|
61
|
+
// one type, but the terminal carries no stage identity, so it routes
|
|
62
|
+
// per item by its `searchType`. Each type gets one `run.write`, fed a
|
|
63
|
+
// bounded queue the run drains concurrently – so memory stays O(batch)
|
|
64
|
+
// per type, never O(dataset). Almost always one lane per call.
|
|
65
|
+
const lanes = new Map();
|
|
66
|
+
const laneFor = (searchType) => {
|
|
67
|
+
let lane = lanes.get(searchType.class);
|
|
68
|
+
if (lane === undefined) {
|
|
69
|
+
const run = runs.get(searchType.class);
|
|
70
|
+
if (run === undefined) {
|
|
71
|
+
throw new Error(`No engine run for search type “${searchType.name}” (${searchType.class}); it is not in this writer’s schema.`);
|
|
72
|
+
}
|
|
73
|
+
const queue = new AsyncQueue();
|
|
74
|
+
const done = run.write(dataset, queue);
|
|
75
|
+
// If the run stops consuming (its write rejects), unblock the
|
|
76
|
+
// producer so a full queue cannot deadlock the push loop below.
|
|
77
|
+
done.catch((error) => queue.abort(error));
|
|
78
|
+
lane = { queue, done };
|
|
79
|
+
lanes.set(searchType.class, lane);
|
|
80
|
+
}
|
|
81
|
+
return lane;
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
for await (const { searchType, document } of items) {
|
|
85
|
+
await laneFor(searchType).queue.push(document);
|
|
86
|
+
}
|
|
87
|
+
for (const lane of lanes.values()) {
|
|
88
|
+
lane.queue.close();
|
|
89
|
+
}
|
|
90
|
+
await Promise.all([...lanes.values()].map((lane) => lane.done));
|
|
96
91
|
}
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
catch (error) {
|
|
93
|
+
for (const lane of lanes.values()) {
|
|
94
|
+
lane.queue.abort(error);
|
|
95
|
+
}
|
|
96
|
+
await Promise.allSettled([...lanes.values()].map((lane) => lane.done));
|
|
97
|
+
throw error;
|
|
99
98
|
}
|
|
100
99
|
},
|
|
101
100
|
flush: async (dataset, outcome) => {
|
|
102
|
-
const pass = passes.get(dataset.iri.toString());
|
|
103
|
-
if (pass !== undefined) {
|
|
104
|
-
await project(pass);
|
|
105
|
-
}
|
|
106
101
|
// Flush every collection independently: one collection’s flush failure
|
|
107
102
|
// (a rollback, or an In-place stale sweep) must not skip another’s –
|
|
108
103
|
// the pipeline isolates a flush error per dataset and still commits,
|
|
@@ -113,19 +108,12 @@ export function searchIndexWriter(options) {
|
|
|
113
108
|
await settleAll(runs.values(), 'flush', (run) => run.flush?.(dataset, outcome));
|
|
114
109
|
},
|
|
115
110
|
reset: async (dataset) => {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// another holding the discarded pass’s documents into the re-run.
|
|
120
|
-
passes.delete(dataset.iri.toString());
|
|
111
|
+
// Let every collection’s run discard whatever it already holds for the
|
|
112
|
+
// dataset – independently, so one collection’s reset failure never
|
|
113
|
+
// leaves another holding the discarded documents into the re-run.
|
|
121
114
|
await settleAll(runs.values(), 'reset', (run) => run.reset?.(dataset));
|
|
122
115
|
},
|
|
123
116
|
commit: async () => {
|
|
124
|
-
// Safety net: project passes that were never flushed, so a committed
|
|
125
|
-
// run never silently drops written quads.
|
|
126
|
-
for (const pass of [...passes.values()]) {
|
|
127
|
-
await project(pass);
|
|
128
|
-
}
|
|
129
117
|
// Commit every collection independently, so one failure neither
|
|
130
118
|
// blocks nor wipes another – the `datasets` index goes live even if a
|
|
131
119
|
// label collection cannot. Record each collection that went live, so
|
|
@@ -162,7 +150,3 @@ async function settleAll(items, verb, operation) {
|
|
|
162
150
|
throw new AggregateError(failures, `${failures.length} of ${targets.length} search collections failed to ${verb}`);
|
|
163
151
|
}
|
|
164
152
|
}
|
|
165
|
-
/** Yield the given documents as an async iterable, like a streaming source. */
|
|
166
|
-
async function* stream(items) {
|
|
167
|
-
yield* items;
|
|
168
|
-
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Stage, type ItemSelector, type StageReaders } from '@lde/pipeline';
|
|
2
|
+
import { type SearchSchema, type SearchType } from '@lde/search';
|
|
3
|
+
import type { TypedSearchDocument } from './typed-search-document.js';
|
|
4
|
+
/** One root type’s stage in a search pipeline. */
|
|
5
|
+
export interface SearchStageType {
|
|
6
|
+
/**
|
|
7
|
+
* The {@link SearchType} this stage projects. Must belong to
|
|
8
|
+
* {@link SearchStagesOptions.schema} (matched by `class`); the stage projects
|
|
9
|
+
* with the schema’s own declaration object, so `assertTypeInSchema`’s identity
|
|
10
|
+
* check inside {@link projectRoots} always holds.
|
|
11
|
+
*/
|
|
12
|
+
searchType: SearchType;
|
|
13
|
+
/**
|
|
14
|
+
* The selector variable that binds this type’s roots – the CONSTRUCT subject
|
|
15
|
+
* the batch is complete for. Must **not** be `dataset`: `?dataset` is
|
|
16
|
+
* substituted with the dataset IRI by the SPARQL reader, so a root bound to it
|
|
17
|
+
* would never reach the projection. {@link selectByClass} defaults to `root`.
|
|
18
|
+
*/
|
|
19
|
+
rootVariable: string;
|
|
20
|
+
/**
|
|
21
|
+
* Selects this type’s roots, one binding of {@link rootVariable} per root.
|
|
22
|
+
* Root selection is the deployment’s concern; {@link selectByClass} is a
|
|
23
|
+
* convenience for the object grain, not a default.
|
|
24
|
+
*/
|
|
25
|
+
itemSelector: ItemSelector;
|
|
26
|
+
/** Reader(s) that extract each selected root’s quads. */
|
|
27
|
+
readers: StageReaders;
|
|
28
|
+
/**
|
|
29
|
+
* Roots (and so documents) per batch – the memory bound. Under a root-bound
|
|
30
|
+
* selector it moves memory and request count, never output.
|
|
31
|
+
* @default 10
|
|
32
|
+
*/
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
/** Maximum concurrent in-flight SPARQL queries for this stage. @default 10 */
|
|
35
|
+
maxConcurrency?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Capacity of the bounded queue funnelling this stage’s projected documents
|
|
38
|
+
* into the write. A projected document is far heavier than a quad, so lower it
|
|
39
|
+
* where documents are large. @default 128
|
|
40
|
+
*/
|
|
41
|
+
queueCapacity?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Options for {@link searchStages}. */
|
|
44
|
+
export interface SearchStagesOptions {
|
|
45
|
+
/**
|
|
46
|
+
* The declarative schema driving projection: one {@link SearchType} per root
|
|
47
|
+
* type. Every {@link SearchStageType.searchType} must be a member of it.
|
|
48
|
+
*/
|
|
49
|
+
schema: SearchSchema;
|
|
50
|
+
/** One entry per root type to index, each its own stage. */
|
|
51
|
+
types: readonly SearchStageType[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compose one projecting {@link Stage} per root type – the source side of a
|
|
55
|
+
* search pipeline. Each stage selects its own roots, extracts each root’s quads,
|
|
56
|
+
* and projects the root-complete batch into {@link TypedSearchDocument}s
|
|
57
|
+
* ({@link projectRoots} + the `searchType` pair), which the pipeline’s single
|
|
58
|
+
* {@link searchIndexWriter} terminal routes to that type’s collection. Projection
|
|
59
|
+
* happens **inside the batch**, so memory is bounded by `batchSize` roots, never
|
|
60
|
+
* by the dataset
|
|
61
|
+
* ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
|
|
62
|
+
*
|
|
63
|
+
* Wire the result as `new Pipeline<TypedSearchDocument>({ datasetSelector,
|
|
64
|
+
* stages: searchStages(...), writers: searchIndexWriter(...) })` – one terminal,
|
|
65
|
+
* N stages.
|
|
66
|
+
*/
|
|
67
|
+
export declare function searchStages(options: SearchStagesOptions): Stage<TypedSearchDocument>[];
|
|
68
|
+
/**
|
|
69
|
+
* An {@link ItemSelector} that selects every instance of a root type’s source
|
|
70
|
+
* class: `SELECT ?‹rootVariable› WHERE { ?‹rootVariable› a <class> }`. A
|
|
71
|
+
* convenience for the **object grain**, where {@link SearchType.class} really is
|
|
72
|
+
* the source class – **not** a default: root selection is a deployment concern,
|
|
73
|
+
* and three of the Dataset Register’s four catalog types have no source class at
|
|
74
|
+
* all (their entry point – “registered, newest registration, has a title” – is a
|
|
75
|
+
* deployment fact no schema states).
|
|
76
|
+
*
|
|
77
|
+
* `rootVariable` defaults to `root` and must match the stage’s
|
|
78
|
+
* {@link SearchStageType.rootVariable}; it must not be `dataset` (reserved by the
|
|
79
|
+
* SPARQL reader).
|
|
80
|
+
*/
|
|
81
|
+
export declare function selectByClass(searchType: SearchType, rootVariable?: string): ItemSelector;
|
|
82
|
+
//# sourceMappingURL=search-stages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-stages.d.ts","sourceRoot":"","sources":["../src/search-stages.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,EACL,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAgB,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,UAAU,EAAE,UAAU,CAAC;IACvB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,EAAE,YAAY,CAAC;IAC3B,yDAAyD;IACzD,OAAO,EAAE,YAAY,CAAC;IACtB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wCAAwC;AACxC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB,4DAA4D;IAC5D,KAAK,EAAE,SAAS,eAAe,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,mBAAmB,GAC3B,KAAK,CAAC,mBAAmB,CAAC,EAAE,CA8C9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,UAAU,EAAE,UAAU,EACtB,YAAY,SAAS,GACpB,YAAY,CAId"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { SparqlItemSelector, Stage, } from '@lde/pipeline';
|
|
2
|
+
import { projectRoots } from '@lde/search';
|
|
3
|
+
/**
|
|
4
|
+
* Compose one projecting {@link Stage} per root type – the source side of a
|
|
5
|
+
* search pipeline. Each stage selects its own roots, extracts each root’s quads,
|
|
6
|
+
* and projects the root-complete batch into {@link TypedSearchDocument}s
|
|
7
|
+
* ({@link projectRoots} + the `searchType` pair), which the pipeline’s single
|
|
8
|
+
* {@link searchIndexWriter} terminal routes to that type’s collection. Projection
|
|
9
|
+
* happens **inside the batch**, so memory is bounded by `batchSize` roots, never
|
|
10
|
+
* by the dataset
|
|
11
|
+
* ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
|
|
12
|
+
*
|
|
13
|
+
* Wire the result as `new Pipeline<TypedSearchDocument>({ datasetSelector,
|
|
14
|
+
* stages: searchStages(...), writers: searchIndexWriter(...) })` – one terminal,
|
|
15
|
+
* N stages.
|
|
16
|
+
*/
|
|
17
|
+
export function searchStages(options) {
|
|
18
|
+
const { schema, types } = options;
|
|
19
|
+
return types.map((type) => {
|
|
20
|
+
// Project with the schema’s OWN declaration object, whatever the caller
|
|
21
|
+
// passed: `assertTypeInSchema` (inside `projectRoots`) is an identity check,
|
|
22
|
+
// and re-resolving here makes a class-equal lookalike work too.
|
|
23
|
+
const searchType = schema.get(type.searchType.class);
|
|
24
|
+
if (searchType === undefined) {
|
|
25
|
+
throw new Error(`Search type “${type.searchType.name}” (class ${type.searchType.class}) is not in the schema; searchStages projects only types the schema declares.`);
|
|
26
|
+
}
|
|
27
|
+
const { rootVariable } = type;
|
|
28
|
+
return new Stage({
|
|
29
|
+
name: searchType.name,
|
|
30
|
+
readers: type.readers,
|
|
31
|
+
itemSelector: type.itemSelector,
|
|
32
|
+
batchSize: type.batchSize,
|
|
33
|
+
maxConcurrency: type.maxConcurrency,
|
|
34
|
+
queueCapacity: type.queueCapacity,
|
|
35
|
+
project: async function* (quads, context) {
|
|
36
|
+
// The batch is root-complete by construction: `context.bindings` are the
|
|
37
|
+
// selector rows the readers ran with, so these are exactly this batch’s
|
|
38
|
+
// roots. Project them, then re-attach the type the stage was built for.
|
|
39
|
+
const roots = context.bindings.map((binding) => {
|
|
40
|
+
const term = binding[rootVariable];
|
|
41
|
+
if (term === undefined) {
|
|
42
|
+
// The selector projected a different variable than the stage reads:
|
|
43
|
+
// a config mismatch. Fail loudly rather than deref `undefined`.
|
|
44
|
+
throw new Error(`Stage “${searchType.name}”: selector did not bind ?${rootVariable} – the stage’s rootVariable must match the selector’s projected variable.`);
|
|
45
|
+
}
|
|
46
|
+
return term.value;
|
|
47
|
+
});
|
|
48
|
+
for await (const document of projectRoots(quads, roots, schema, searchType)) {
|
|
49
|
+
yield { searchType, document };
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* An {@link ItemSelector} that selects every instance of a root type’s source
|
|
57
|
+
* class: `SELECT ?‹rootVariable› WHERE { ?‹rootVariable› a <class> }`. A
|
|
58
|
+
* convenience for the **object grain**, where {@link SearchType.class} really is
|
|
59
|
+
* the source class – **not** a default: root selection is a deployment concern,
|
|
60
|
+
* and three of the Dataset Register’s four catalog types have no source class at
|
|
61
|
+
* all (their entry point – “registered, newest registration, has a title” – is a
|
|
62
|
+
* deployment fact no schema states).
|
|
63
|
+
*
|
|
64
|
+
* `rootVariable` defaults to `root` and must match the stage’s
|
|
65
|
+
* {@link SearchStageType.rootVariable}; it must not be `dataset` (reserved by the
|
|
66
|
+
* SPARQL reader).
|
|
67
|
+
*/
|
|
68
|
+
export function selectByClass(searchType, rootVariable = 'root') {
|
|
69
|
+
return new SparqlItemSelector({
|
|
70
|
+
query: `SELECT ?${rootVariable} WHERE { ?${rootVariable} a <${searchType.class}> }`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SearchDocument, SearchType } from '@lde/search';
|
|
2
|
+
/**
|
|
3
|
+
* A projected {@link SearchDocument} paired with the {@link SearchType} it was
|
|
4
|
+
* projected from.
|
|
5
|
+
*
|
|
6
|
+
* A search pipeline is N per-type stages writing to one terminal, and
|
|
7
|
+
* {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}’s
|
|
8
|
+
* `searchIndexWriter` receives `write(dataset, items)` with no stage identity –
|
|
9
|
+
* so the type travels with the item and the writer routes each document to the
|
|
10
|
+
* engine run for its type by `searchType.class`. A stage mints the pair itself
|
|
11
|
+
* (it was constructed for one type), so nothing is ever re-derived from the
|
|
12
|
+
* document.
|
|
13
|
+
*
|
|
14
|
+
* It lives in `@lde/search-pipeline`, not `@lde/search`, because it exists only
|
|
15
|
+
* because a pipeline terminal routes: that is glue, not projection. `@lde/search`
|
|
16
|
+
* yields a bare {@link SearchDocument} and stays pipeline-free
|
|
17
|
+
* ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
|
|
18
|
+
*/
|
|
19
|
+
export interface TypedSearchDocument {
|
|
20
|
+
readonly searchType: SearchType;
|
|
21
|
+
readonly document: SearchDocument;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=typed-search-document.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typed-search-document.d.ts","sourceRoot":"","sources":["../src/typed-search-document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9D;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;CACnC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,12 +25,12 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
29
|
-
"@rdfjs/types": "^2.0.1",
|
|
28
|
+
"@lde/search": "^0.9.0",
|
|
30
29
|
"tslib": "^2.3.0"
|
|
31
30
|
},
|
|
32
31
|
"devDependencies": {
|
|
33
|
-
"@lde/search-typesense": "^0.
|
|
32
|
+
"@lde/search-typesense": "^0.11.0",
|
|
33
|
+
"@rdfjs/types": "^2.0.1",
|
|
34
34
|
"n3": "^2.1.1",
|
|
35
35
|
"testcontainers": "^12.0.3",
|
|
36
36
|
"typesense": "^3.0.6"
|