@lde/search-typesense 0.3.0 → 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 +72 -27
- package/dist/blue-green-rebuild.d.ts +46 -0
- package/dist/blue-green-rebuild.d.ts.map +1 -0
- package/dist/blue-green-rebuild.js +119 -0
- package/dist/import.d.ts +31 -0
- package/dist/import.d.ts.map +1 -0
- package/dist/import.js +60 -0
- package/dist/in-place-rebuild.d.ts +62 -0
- package/dist/in-place-rebuild.d.ts.map +1 -0
- package/dist/in-place-rebuild.js +140 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/lock.d.ts +42 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +134 -0
- package/dist/rebuild-support.d.ts +34 -0
- package/dist/rebuild-support.d.ts.map +1 -0
- package/dist/rebuild-support.js +33 -0
- package/dist/sweep.d.ts +50 -0
- package/dist/sweep.d.ts.map +1 -0
- package/dist/sweep.js +87 -0
- package/package.json +5 -1
- package/dist/adapter.d.ts +0 -56
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -214
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ domain-agnostic** – you supply a `SearchType`; this package never names your
|
|
|
6
6
|
domain. It is the Typesense implementation of the `SearchEngine` port: it derives
|
|
7
7
|
a collection schema from the field model, compiles the neutral `SearchQuery` into
|
|
8
8
|
Typesense search params, runs it, reconstructs the engine-neutral `SearchResult`,
|
|
9
|
-
and manages the search index lifecycle
|
|
9
|
+
and manages the search index lifecycle as transactional `@lde/pipeline`
|
|
10
|
+
writers (Blue/green Rebuild and In-place Rebuild).
|
|
10
11
|
|
|
11
12
|
## Collection schema and engine
|
|
12
13
|
|
|
@@ -39,56 +40,100 @@ direct use and testing.
|
|
|
39
40
|
|
|
40
41
|
## Indexing
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
Indexing runs through two transactional writers, one per update mode – the
|
|
44
|
+
[NDE Stack](https://docs.nde.nl/stack/patterns) patterns of the same names:
|
|
45
|
+
|
|
46
|
+
- [**Blue/green Rebuild**](#bluegreen-rebuild: build a fresh index, then swap to it atomically;
|
|
47
|
+
- [**In-place Rebuild**](#in-place-rebuild): update the live index directly by
|
|
48
|
+
upserting changed sources and sweeping the rest.
|
|
49
|
+
|
|
50
|
+
Both implement `@lde/pipeline`’s `Writer` – each run is
|
|
51
|
+
`openRun(context)` → `write` per dataset → `commit()` or `abort(error)` – so
|
|
52
|
+
an `@lde/pipeline` `Pipeline` drives them without branching on the mode. Both
|
|
53
|
+
derive the collection schema from your `SearchType` (via
|
|
54
|
+
`buildCollectionSchema`), and their options accept everything
|
|
55
|
+
`buildCollectionSchema` does (`defaultLocale`, `defaultSortingField`,
|
|
56
|
+
`synonymSets`) plus the tuning knobs (`batchSize`, `lockTtlMs`).
|
|
57
|
+
|
|
58
|
+
### Blue/green Rebuild
|
|
59
|
+
|
|
60
|
+
`BlueGreenRebuild` rebuilds the index from zero and goes live atomically:
|
|
61
|
+
`openRun` creates a fresh versioned collection (`${name}_<timestamp>`),
|
|
62
|
+
`write` streams documents into it in batches (each stamped with its `source`
|
|
63
|
+
dataset IRI), and `commit` atomically repoints the `name` alias and drops the
|
|
64
|
+
collection it superseded. Until commit, the live alias never points at a
|
|
65
|
+
partial build; `abort` drops the half-built collection. Deletion is implicit –
|
|
66
|
+
whatever a run does not write does not exist in the new collection. A dataset
|
|
67
|
+
that fails (or is reset before a dump re-run) is rolled back out of the
|
|
68
|
+
not-yet-live collection by `source`, so the swap never ships a half-processed
|
|
69
|
+
dataset. Right-sized for small collections (e.g. one document per dataset
|
|
70
|
+
description).
|
|
49
71
|
|
|
50
72
|
```ts
|
|
51
73
|
import { Client } from 'typesense';
|
|
52
|
-
import {
|
|
74
|
+
import { BlueGreenRebuild } from '@lde/search-typesense';
|
|
53
75
|
|
|
54
76
|
const client = new Client({
|
|
55
77
|
nodes: [{ host, port, protocol: 'https' }],
|
|
56
78
|
apiKey,
|
|
57
79
|
});
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
const writer = new BlueGreenRebuild(client, DATASET, { name: 'datasets' });
|
|
82
|
+
// Standalone use; under @lde/pipeline the Pipeline drives this lifecycle.
|
|
83
|
+
const run = await writer.openRun(context);
|
|
84
|
+
await run.write(dataset, documents);
|
|
85
|
+
await run.commit();
|
|
63
86
|
```
|
|
64
87
|
|
|
65
|
-
|
|
66
|
-
`defaultSortingField`, `synonymSets`) plus the rebuild knobs (`batchSize`,
|
|
67
|
-
`lockTtlMs`).
|
|
88
|
+
### In-place Rebuild
|
|
68
89
|
|
|
69
|
-
`
|
|
90
|
+
`InPlaceRebuild` maintains one long-lived collection with per-source
|
|
91
|
+
atomicity – no swap, no staging – for large, mostly-static corpora (e.g.
|
|
92
|
+
millions of objects across many datasets, where a daily run touches only the
|
|
93
|
+
changed ones). Every document is stamped with its `source` (the dataset IRI)
|
|
94
|
+
and `last_seen` (the run id); deletion is a sweep, never special-cased:
|
|
95
|
+
|
|
96
|
+
- a successful dataset flush deletes the source’s documents the run did not
|
|
97
|
+
rewrite (`source = dataset && last_seen != runId`); a failed dataset is not
|
|
98
|
+
swept – its output is incomplete – and the next successful run reconciles;
|
|
99
|
+
- `commit` deletes every document whose source left the run’s selection (the
|
|
100
|
+
registry-membership sweep over `RunContext.selectedSources()`, which
|
|
101
|
+
includes datasets skipped as unchanged);
|
|
102
|
+
- `abort` only releases the lock: upserts are idempotent, so whatever landed
|
|
103
|
+
stays until the next run reconciles.
|
|
104
|
+
|
|
105
|
+
Document ids must be unique per (source, entity) – the caller keys them.
|
|
106
|
+
|
|
107
|
+
Both writers take a `Client` the caller owns (and reuses for queries), so this
|
|
70
108
|
package adds no connection or document type of its own – any object with an `id`
|
|
71
109
|
is a valid document, including the `SearchDocument`s `@lde/search` produces.
|
|
72
110
|
|
|
73
111
|
## Concurrency
|
|
74
112
|
|
|
75
|
-
|
|
113
|
+
Rebuilds are **single-flight per index**: `openRun` takes a lock (a marker
|
|
76
114
|
document in a `rebuild_locks` collection, created on demand) via Typesense’s
|
|
77
|
-
atomic create, so concurrent
|
|
78
|
-
once
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
115
|
+
atomic create, so concurrent runs across pods never rebuild the same index at
|
|
116
|
+
once – a run opened while another holds the lock throws
|
|
117
|
+
`RebuildAlreadyRunning` (catch it to treat a concurrent rebuild as a graceful
|
|
118
|
+
skip). This keeps blue/green safe under replication: without it, two
|
|
119
|
+
same-millisecond rebuilds would collide on the versioned collection name and
|
|
120
|
+
one would delete the other’s in-flight build.
|
|
82
121
|
|
|
83
122
|
Limitations to design around:
|
|
84
123
|
|
|
85
124
|
- **Advisory, not a strict mutex.** The lock is built on Typesense, not a
|
|
86
125
|
consensus store. Under a TTL-reclaim race two rebuilds can briefly run at
|
|
87
126
|
once; this is safe because blue/green is idempotent (worst case: redundant
|
|
88
|
-
work and a transient orphaned collection)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
work and a transient orphaned collection) and in-place upserts are
|
|
128
|
+
idempotent.
|
|
129
|
+
- **Single-flight, not coalescing.** A run refused with `RebuildAlreadyRunning`
|
|
130
|
+
is _not_ queued. If you must capture state that changed mid-build,
|
|
131
|
+
re-trigger after the running rebuild finishes.
|
|
92
132
|
- **Lock TTL.** A rebuild running longer than `lockTtlMs` (default 10 minutes)
|
|
93
133
|
can be reclaimed by another caller and run concurrently; size the TTL above
|
|
94
134
|
your longest rebuild.
|
|
135
|
+
- **Membership-sweep cap.** The in-place membership sweep enumerates distinct
|
|
136
|
+
sources via a single facet, capped at `maxSweepableSources` (default
|
|
137
|
+
10 000); beyond that the commit throws rather than sweeping blind. Raise the
|
|
138
|
+
option (up to the engine’s `max_facet_values` limit) before an index nears
|
|
139
|
+
the cap so it stays a tunable guard rather than a hard wall.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import type { SearchType } from '@lde/search';
|
|
3
|
+
import type { RunContext, RunWriter, Writer } from '@lde/pipeline';
|
|
4
|
+
import { type RebuildOptions } from './rebuild-support.js';
|
|
5
|
+
/** {@link BlueGreenRebuild} options: the collection-schema options (`name` is
|
|
6
|
+
* the logical index name the alias is kept on) plus the rebuild tuning knobs. */
|
|
7
|
+
export type BlueGreenRebuildOptions = RebuildOptions;
|
|
8
|
+
/**
|
|
9
|
+
* Blue/green Rebuild (build a fresh index alongside the live one, then swap to
|
|
10
|
+
* it atomically) as a transactional `Writer`: each run builds the search
|
|
11
|
+
* index `options.name` from zero in a fresh versioned collection and goes
|
|
12
|
+
* live atomically on commit. Deletion is implicit – whatever the run does not
|
|
13
|
+
* write does not exist in the new collection. The name is the NDE Stack’s
|
|
14
|
+
* pattern ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
|
|
15
|
+
*
|
|
16
|
+
* - `openRun` takes the single-flight cross-pod lock ({@link openLockedRun},
|
|
17
|
+
* throwing `RebuildAlreadyRunning` when another rebuild holds it) and
|
|
18
|
+
* creates the versioned collection (`${name}_<timestamp>`) with the schema
|
|
19
|
+
* derived from the {@link SearchType}.
|
|
20
|
+
* - `write` streams documents into the fresh collection, batched across write
|
|
21
|
+
* calls, each stamped with its `source` (the dataset IRI).
|
|
22
|
+
* - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
|
|
23
|
+
* discard), roll that dataset’s documents back out of the not-yet-live
|
|
24
|
+
* collection by `source`, so a swap never ships a half-processed dataset;
|
|
25
|
+
* a successful flush leaves the streamed documents in place.
|
|
26
|
+
* - `commit` imports the remainder, atomically repoints the `name` alias to
|
|
27
|
+
* the new collection, drops the collection it superseded, and releases the
|
|
28
|
+
* lock. Until commit, the live alias never points at a partial build.
|
|
29
|
+
* - `abort` drops the half-built collection and releases the lock; the live
|
|
30
|
+
* index is untouched.
|
|
31
|
+
*
|
|
32
|
+
* The caller passes only the logical index `name`; the versioned collection
|
|
33
|
+
* name and the alias are managed here.
|
|
34
|
+
*/
|
|
35
|
+
export declare class BlueGreenRebuild<TDocument extends {
|
|
36
|
+
id: string;
|
|
37
|
+
}> implements Writer<TDocument> {
|
|
38
|
+
private readonly client;
|
|
39
|
+
private readonly searchType;
|
|
40
|
+
private readonly options;
|
|
41
|
+
constructor(client: Client, searchType: SearchType, options: BlueGreenRebuildOptions);
|
|
42
|
+
openRun(context: RunContext): Promise<RunWriter<TDocument>>;
|
|
43
|
+
/** The collection an alias currently points at, or `undefined` if unset. */
|
|
44
|
+
private aliasTarget;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=blue-green-rebuild.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAMvB,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAE9B;kFACkF;AAClF,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,uBAAuB;IAM7C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAoFjE,4EAA4E;YAC9D,WAAW;CAW1B"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { buildCollectionSchema } from './collection-schema.js';
|
|
2
|
+
import { BatchImporter } from './import.js';
|
|
3
|
+
import { httpStatus, openLockedRun, releaseLock } from './lock.js';
|
|
4
|
+
import { SOURCE_FIELD, sourceDocumentsFilter } from './sweep.js';
|
|
5
|
+
import { assertNoReservedFields, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
|
|
6
|
+
/**
|
|
7
|
+
* Blue/green Rebuild (build a fresh index alongside the live one, then swap to
|
|
8
|
+
* it atomically) as a transactional `Writer`: each run builds the search
|
|
9
|
+
* index `options.name` from zero in a fresh versioned collection and goes
|
|
10
|
+
* live atomically on commit. Deletion is implicit – whatever the run does not
|
|
11
|
+
* write does not exist in the new collection. The name is the NDE Stack’s
|
|
12
|
+
* pattern ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
|
|
13
|
+
*
|
|
14
|
+
* - `openRun` takes the single-flight cross-pod lock ({@link openLockedRun},
|
|
15
|
+
* throwing `RebuildAlreadyRunning` when another rebuild holds it) and
|
|
16
|
+
* creates the versioned collection (`${name}_<timestamp>`) with the schema
|
|
17
|
+
* derived from the {@link SearchType}.
|
|
18
|
+
* - `write` streams documents into the fresh collection, batched across write
|
|
19
|
+
* calls, each stamped with its `source` (the dataset IRI).
|
|
20
|
+
* - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
|
|
21
|
+
* discard), roll that dataset’s documents back out of the not-yet-live
|
|
22
|
+
* collection by `source`, so a swap never ships a half-processed dataset;
|
|
23
|
+
* a successful flush leaves the streamed documents in place.
|
|
24
|
+
* - `commit` imports the remainder, atomically repoints the `name` alias to
|
|
25
|
+
* the new collection, drops the collection it superseded, and releases the
|
|
26
|
+
* lock. Until commit, the live alias never points at a partial build.
|
|
27
|
+
* - `abort` drops the half-built collection and releases the lock; the live
|
|
28
|
+
* index is untouched.
|
|
29
|
+
*
|
|
30
|
+
* The caller passes only the logical index `name`; the versioned collection
|
|
31
|
+
* name and the alias are managed here.
|
|
32
|
+
*/
|
|
33
|
+
export class BlueGreenRebuild {
|
|
34
|
+
client;
|
|
35
|
+
searchType;
|
|
36
|
+
options;
|
|
37
|
+
constructor(client, searchType, options) {
|
|
38
|
+
this.client = client;
|
|
39
|
+
this.searchType = searchType;
|
|
40
|
+
this.options = options;
|
|
41
|
+
// `source` is stamped on every document for per-dataset rollback.
|
|
42
|
+
assertNoReservedFields(searchType, [SOURCE_FIELD]);
|
|
43
|
+
}
|
|
44
|
+
async openRun(context) {
|
|
45
|
+
const { name, batchSize, lockTtlMs, schemaOptions } = resolveRebuildOptions(this.options);
|
|
46
|
+
return openLockedRun(this.client, name, lockTtlMs, async () => {
|
|
47
|
+
// Create the fresh (blue) collection up front, so a failure surfaces
|
|
48
|
+
// before any dataset is processed. startedAt orders the versioned names;
|
|
49
|
+
// concurrent same-name runs are excluded by the lock.
|
|
50
|
+
const collection = `${name}_${Date.parse(context.startedAt)}`;
|
|
51
|
+
const previous = await this.aliasTarget(name);
|
|
52
|
+
const schema = buildCollectionSchema(this.searchType, schemaOptions);
|
|
53
|
+
await this.client.collections().create({
|
|
54
|
+
...schema,
|
|
55
|
+
name: collection,
|
|
56
|
+
fields: [
|
|
57
|
+
...(schema.fields ?? []),
|
|
58
|
+
{ name: SOURCE_FIELD, type: 'string' },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
const importer = new BatchImporter(this.client, collection, batchSize);
|
|
62
|
+
// Drop a dataset’s streamed documents back out of the not-yet-live
|
|
63
|
+
// collection (a failed dataset, or a reset before the dump re-run). Any
|
|
64
|
+
// still buffered must land first, so the delete filter sees them.
|
|
65
|
+
const rollback = async (dataset) => {
|
|
66
|
+
await importer.flush();
|
|
67
|
+
await deleteByFilter(this.client, collection, sourceDocumentsFilter(dataset.iri.toString()));
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
write: async (dataset, documents) => importer.add(stampDocuments(documents, {
|
|
71
|
+
[SOURCE_FIELD]: dataset.iri.toString(),
|
|
72
|
+
})),
|
|
73
|
+
flush: async (dataset, outcome) => {
|
|
74
|
+
// A successful dataset keeps its streamed documents (they go live at
|
|
75
|
+
// the swap); a failed one is rolled back so the swap never ships it.
|
|
76
|
+
if (outcome !== 'success') {
|
|
77
|
+
await rollback(dataset);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
reset: async (dataset) => rollback(dataset),
|
|
81
|
+
commit: async () => {
|
|
82
|
+
await importer.flush();
|
|
83
|
+
await this.client
|
|
84
|
+
.aliases()
|
|
85
|
+
.upsert(name, { collection_name: collection });
|
|
86
|
+
if (previous !== undefined && previous !== collection) {
|
|
87
|
+
await this.client
|
|
88
|
+
.collections(previous)
|
|
89
|
+
.delete()
|
|
90
|
+
.catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
await releaseLock(this.client, name);
|
|
93
|
+
},
|
|
94
|
+
abort: async () => {
|
|
95
|
+
// The live alias is untouched; just drop the orphaned half-built
|
|
96
|
+
// collection rather than let it accumulate.
|
|
97
|
+
await this.client
|
|
98
|
+
.collections(collection)
|
|
99
|
+
.delete()
|
|
100
|
+
.catch(() => undefined);
|
|
101
|
+
await releaseLock(this.client, name);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** The collection an alias currently points at, or `undefined` if unset. */
|
|
107
|
+
async aliasTarget(alias) {
|
|
108
|
+
try {
|
|
109
|
+
const { collection_name } = await this.client.aliases(alias).retrieve();
|
|
110
|
+
return collection_name;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (httpStatus(error) === 404) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/import.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
/** Documents imported per Typesense request unless a writer is told otherwise. */
|
|
3
|
+
export declare const DEFAULT_BATCH_SIZE = 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Accumulates documents across writes and upserts them into a collection in
|
|
6
|
+
* fixed-size batches, so many small writes (one dataset at a time) still land
|
|
7
|
+
* as few Typesense requests. Call {@link flush} to import the remainder.
|
|
8
|
+
*/
|
|
9
|
+
export declare class BatchImporter<TDocument extends {
|
|
10
|
+
id: string;
|
|
11
|
+
}> {
|
|
12
|
+
private readonly client;
|
|
13
|
+
private readonly collection;
|
|
14
|
+
private readonly batchSize;
|
|
15
|
+
private buffer;
|
|
16
|
+
constructor(client: Client, collection: string, batchSize: number);
|
|
17
|
+
/** Add a stream of documents, importing a batch whenever one fills up. */
|
|
18
|
+
add(documents: AsyncIterable<TDocument>): Promise<void>;
|
|
19
|
+
/** Import any remaining documents. */
|
|
20
|
+
flush(): Promise<void>;
|
|
21
|
+
private importBuffer;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
|
|
25
|
+
* individual document fails (Typesense’s bulk import otherwise reports
|
|
26
|
+
* per-document failures without rejecting).
|
|
27
|
+
*/
|
|
28
|
+
export declare function importBatch(client: Client, collection: string, batch: readonly {
|
|
29
|
+
id: string;
|
|
30
|
+
}[]): Promise<void>;
|
|
31
|
+
//# sourceMappingURL=import.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../src/import.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAC;AAExD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;GAIG;AACH,qBAAa,aAAa,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAIvD,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAL5B,OAAO,CAAC,MAAM,CAAmB;gBAGd,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM;IAGpC,0EAA0E;IACpE,GAAG,CAAC,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS7D,sCAAsC;IAChC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAMd,YAAY;CAK3B;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC,CAkBf"}
|
package/dist/import.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Documents imported per Typesense request unless a writer is told otherwise. */
|
|
2
|
+
export const DEFAULT_BATCH_SIZE = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Accumulates documents across writes and upserts them into a collection in
|
|
5
|
+
* fixed-size batches, so many small writes (one dataset at a time) still land
|
|
6
|
+
* as few Typesense requests. Call {@link flush} to import the remainder.
|
|
7
|
+
*/
|
|
8
|
+
export class BatchImporter {
|
|
9
|
+
client;
|
|
10
|
+
collection;
|
|
11
|
+
batchSize;
|
|
12
|
+
buffer = [];
|
|
13
|
+
constructor(client, collection, batchSize) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.collection = collection;
|
|
16
|
+
this.batchSize = batchSize;
|
|
17
|
+
}
|
|
18
|
+
/** Add a stream of documents, importing a batch whenever one fills up. */
|
|
19
|
+
async add(documents) {
|
|
20
|
+
for await (const document of documents) {
|
|
21
|
+
this.buffer.push(document);
|
|
22
|
+
if (this.buffer.length >= this.batchSize) {
|
|
23
|
+
await this.importBuffer();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Import any remaining documents. */
|
|
28
|
+
async flush() {
|
|
29
|
+
if (this.buffer.length > 0) {
|
|
30
|
+
await this.importBuffer();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async importBuffer() {
|
|
34
|
+
const batch = this.buffer;
|
|
35
|
+
this.buffer = [];
|
|
36
|
+
await importBatch(this.client, this.collection, batch);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
|
|
41
|
+
* individual document fails (Typesense’s bulk import otherwise reports
|
|
42
|
+
* per-document failures without rejecting).
|
|
43
|
+
*/
|
|
44
|
+
export async function importBatch(client, collection, batch) {
|
|
45
|
+
const results = (await client
|
|
46
|
+
.collections(collection)
|
|
47
|
+
.documents()
|
|
48
|
+
.import(batch, {
|
|
49
|
+
action: 'upsert',
|
|
50
|
+
// Collect per-document outcomes instead of throwing the client’s opaque
|
|
51
|
+
// ImportError, so we can report which documents failed and why.
|
|
52
|
+
throwOnFail: false,
|
|
53
|
+
}));
|
|
54
|
+
const failures = results.filter((result) => !result.success);
|
|
55
|
+
if (failures.length > 0) {
|
|
56
|
+
throw new Error(`Typesense upsert into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures
|
|
57
|
+
.map((failure) => failure.error)
|
|
58
|
+
.join('; ')}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import type { SearchType } from '@lde/search';
|
|
3
|
+
import type { RunContext, RunWriter, Writer } from '@lde/pipeline';
|
|
4
|
+
import { type RebuildOptions } from './rebuild-support.js';
|
|
5
|
+
/** {@link InPlaceRebuild} options: the shared rebuild options plus the
|
|
6
|
+
* membership-sweep ceiling. */
|
|
7
|
+
export interface InPlaceRebuildOptions extends RebuildOptions {
|
|
8
|
+
/** Most distinct sources the membership sweep may enumerate before it throws
|
|
9
|
+
* rather than risk missing departed sources (default 10 000). */
|
|
10
|
+
readonly maxSweepableSources?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* In-place Rebuild (update the live index directly – upsert changed sources,
|
|
14
|
+
* sweep the rest – rather than swap in a fresh one) as a transactional
|
|
15
|
+
* `Writer`: documents are upserted into one long-lived collection with
|
|
16
|
+
* per-source atomicity – no swap, no staging. Every document is stamped with
|
|
17
|
+
* its `source` (the dataset IRI) and `last_seen` (the run id); deletion is a
|
|
18
|
+
* sweep, never special-cased:
|
|
19
|
+
*
|
|
20
|
+
* - a **successful dataset flush** deletes the source’s documents the run did
|
|
21
|
+
* not rewrite (`source = dataset && last_seen != runId`). A failed dataset
|
|
22
|
+
* is not swept – its output is incomplete, and the next successful run
|
|
23
|
+
* reconciles;
|
|
24
|
+
* - **reset** (the pipeline’s dump-fallback discard) deletes only *this run’s*
|
|
25
|
+
* writes for the dataset (`source = dataset && last_seen = runId`), so the
|
|
26
|
+
* dump re-run rebuilds it cleanly while the source’s prior-run documents are
|
|
27
|
+
* left for the success sweep to reconcile;
|
|
28
|
+
* - **commit** deletes every document whose source left the run’s selection
|
|
29
|
+
* (registry-membership sweep over {@link RunContext.selectedSources}, which
|
|
30
|
+
* includes datasets skipped as unchanged) and releases the lock;
|
|
31
|
+
* - **abort** only releases the lock: upserts are idempotent, so whatever
|
|
32
|
+
* landed stays until the next run reconciles.
|
|
33
|
+
*
|
|
34
|
+
* `openRun` takes the single-flight cross-pod lock (throwing
|
|
35
|
+
* `RebuildAlreadyRunning` when another rebuild holds it) and creates
|
|
36
|
+
* the collection on demand from the {@link SearchType} plus the two
|
|
37
|
+
* bookkeeping fields.
|
|
38
|
+
*
|
|
39
|
+
* Document ids must be unique per (source, entity) – the caller keys them –
|
|
40
|
+
* or documents from different sources overwrite each other.
|
|
41
|
+
*
|
|
42
|
+
* The name is the NDE Stack’s pattern
|
|
43
|
+
* ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
|
|
44
|
+
*/
|
|
45
|
+
export declare class InPlaceRebuild<TDocument extends {
|
|
46
|
+
id: string;
|
|
47
|
+
}> implements Writer<TDocument> {
|
|
48
|
+
private readonly client;
|
|
49
|
+
private readonly searchType;
|
|
50
|
+
private readonly options;
|
|
51
|
+
constructor(client: Client, searchType: SearchType, options: InPlaceRebuildOptions);
|
|
52
|
+
openRun(context: RunContext): Promise<RunWriter<TDocument>>;
|
|
53
|
+
/**
|
|
54
|
+
* The distinct sources present in the collection, via a single `source`
|
|
55
|
+
* facet. Requests one bucket beyond `maxSources` so genuine truncation is
|
|
56
|
+
* distinguishable from an exactly-full result: `maxSources` buckets are
|
|
57
|
+
* returned intact, `maxSources + 1` proves more exist and the sweep would
|
|
58
|
+
* miss some, so it throws rather than delete blind.
|
|
59
|
+
*/
|
|
60
|
+
private indexedSources;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=in-place-rebuild.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAYvB,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,qBAAqB;IAK3C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAuFjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { buildCollectionSchema } from './collection-schema.js';
|
|
2
|
+
import { BatchImporter } from './import.js';
|
|
3
|
+
import { ensureCollectionExists, openLockedRun, releaseLock } from './lock.js';
|
|
4
|
+
import { LAST_SEEN_FIELD, SOURCE_FIELD, departedSources, membershipSweepFilters, staleDocumentsFilter, thisRunDocumentsFilter, } from './sweep.js';
|
|
5
|
+
import { assertNoReservedFields, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
|
|
6
|
+
/**
|
|
7
|
+
* Default ceiling on the distinct sources the membership sweep enumerates via
|
|
8
|
+
* a single `source` facet. The sweep needs the complete source set to spot
|
|
9
|
+
* departed ones, so a truncated facet would silently miss deletions;
|
|
10
|
+
* {@link InPlaceRebuild} throws instead. Raise it (up to the engine’s
|
|
11
|
+
* `max_facet_values` limit) with {@link InPlaceRebuildOptions.maxSweepableSources}
|
|
12
|
+
* before an index approaches it, so the ceiling is a tunable guard rather than
|
|
13
|
+
* a hard wall.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* In-place Rebuild (update the live index directly – upsert changed sources,
|
|
18
|
+
* sweep the rest – rather than swap in a fresh one) as a transactional
|
|
19
|
+
* `Writer`: documents are upserted into one long-lived collection with
|
|
20
|
+
* per-source atomicity – no swap, no staging. Every document is stamped with
|
|
21
|
+
* its `source` (the dataset IRI) and `last_seen` (the run id); deletion is a
|
|
22
|
+
* sweep, never special-cased:
|
|
23
|
+
*
|
|
24
|
+
* - a **successful dataset flush** deletes the source’s documents the run did
|
|
25
|
+
* not rewrite (`source = dataset && last_seen != runId`). A failed dataset
|
|
26
|
+
* is not swept – its output is incomplete, and the next successful run
|
|
27
|
+
* reconciles;
|
|
28
|
+
* - **reset** (the pipeline’s dump-fallback discard) deletes only *this run’s*
|
|
29
|
+
* writes for the dataset (`source = dataset && last_seen = runId`), so the
|
|
30
|
+
* dump re-run rebuilds it cleanly while the source’s prior-run documents are
|
|
31
|
+
* left for the success sweep to reconcile;
|
|
32
|
+
* - **commit** deletes every document whose source left the run’s selection
|
|
33
|
+
* (registry-membership sweep over {@link RunContext.selectedSources}, which
|
|
34
|
+
* includes datasets skipped as unchanged) and releases the lock;
|
|
35
|
+
* - **abort** only releases the lock: upserts are idempotent, so whatever
|
|
36
|
+
* landed stays until the next run reconciles.
|
|
37
|
+
*
|
|
38
|
+
* `openRun` takes the single-flight cross-pod lock (throwing
|
|
39
|
+
* `RebuildAlreadyRunning` when another rebuild holds it) and creates
|
|
40
|
+
* the collection on demand from the {@link SearchType} plus the two
|
|
41
|
+
* bookkeeping fields.
|
|
42
|
+
*
|
|
43
|
+
* Document ids must be unique per (source, entity) – the caller keys them –
|
|
44
|
+
* or documents from different sources overwrite each other.
|
|
45
|
+
*
|
|
46
|
+
* The name is the NDE Stack’s pattern
|
|
47
|
+
* ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
|
|
48
|
+
*/
|
|
49
|
+
export class InPlaceRebuild {
|
|
50
|
+
client;
|
|
51
|
+
searchType;
|
|
52
|
+
options;
|
|
53
|
+
constructor(client, searchType, options) {
|
|
54
|
+
this.client = client;
|
|
55
|
+
this.searchType = searchType;
|
|
56
|
+
this.options = options;
|
|
57
|
+
assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
|
|
58
|
+
}
|
|
59
|
+
async openRun(context) {
|
|
60
|
+
const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = this.options;
|
|
61
|
+
const { name, batchSize, lockTtlMs, schemaOptions } = resolveRebuildOptions(rebuildOptions);
|
|
62
|
+
return openLockedRun(this.client, name, lockTtlMs, async () => {
|
|
63
|
+
// Create the collection on demand: SearchType schema + the bookkeeping
|
|
64
|
+
// fields, `source` faceted so the membership sweep can enumerate the
|
|
65
|
+
// distinct sources.
|
|
66
|
+
await ensureCollectionExists(this.client, name, () => {
|
|
67
|
+
const schema = buildCollectionSchema(this.searchType, schemaOptions);
|
|
68
|
+
const bookkeeping = [
|
|
69
|
+
{ name: SOURCE_FIELD, type: 'string', facet: true },
|
|
70
|
+
{ name: LAST_SEEN_FIELD, type: 'string' },
|
|
71
|
+
];
|
|
72
|
+
return {
|
|
73
|
+
...schema,
|
|
74
|
+
fields: [...(schema.fields ?? []), ...bookkeeping],
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
const importer = new BatchImporter(this.client, name, batchSize);
|
|
78
|
+
return {
|
|
79
|
+
write: async (dataset, documents) => importer.add(stampDocuments(documents, {
|
|
80
|
+
[SOURCE_FIELD]: dataset.iri.toString(),
|
|
81
|
+
[LAST_SEEN_FIELD]: context.runId,
|
|
82
|
+
})),
|
|
83
|
+
flush: async (dataset, outcome) => {
|
|
84
|
+
// Land the buffered documents first, so the sweep below never
|
|
85
|
+
// deletes what this run just rewrote.
|
|
86
|
+
await importer.flush();
|
|
87
|
+
if (outcome !== 'success') {
|
|
88
|
+
// A failed dataset’s output is incomplete: sweeping against it
|
|
89
|
+
// would delete documents the run never got to rewrite. Leave the
|
|
90
|
+
// stale ones for the next successful run to reconcile.
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await deleteByFilter(this.client, name, staleDocumentsFilter(dataset.iri.toString(), context.runId));
|
|
94
|
+
},
|
|
95
|
+
reset: async (dataset) => {
|
|
96
|
+
// Discard only this run’s partial writes for the source (the failed
|
|
97
|
+
// endpoint attempt) so the dump re-run rebuilds it cleanly; the
|
|
98
|
+
// source’s prior-run documents stay for the success sweep.
|
|
99
|
+
await importer.flush();
|
|
100
|
+
await deleteByFilter(this.client, name, thisRunDocumentsFilter(dataset.iri.toString(), context.runId));
|
|
101
|
+
},
|
|
102
|
+
commit: async () => {
|
|
103
|
+
await importer.flush();
|
|
104
|
+
const departed = departedSources(await this.indexedSources(name, maxSweepableSources), context.selectedSources());
|
|
105
|
+
for (const filter of membershipSweepFilters(departed)) {
|
|
106
|
+
await deleteByFilter(this.client, name, filter);
|
|
107
|
+
}
|
|
108
|
+
await releaseLock(this.client, name);
|
|
109
|
+
},
|
|
110
|
+
abort: async () => {
|
|
111
|
+
await releaseLock(this.client, name);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The distinct sources present in the collection, via a single `source`
|
|
118
|
+
* facet. Requests one bucket beyond `maxSources` so genuine truncation is
|
|
119
|
+
* distinguishable from an exactly-full result: `maxSources` buckets are
|
|
120
|
+
* returned intact, `maxSources + 1` proves more exist and the sweep would
|
|
121
|
+
* miss some, so it throws rather than delete blind.
|
|
122
|
+
*/
|
|
123
|
+
async indexedSources(name, maxSources) {
|
|
124
|
+
const response = await this.client
|
|
125
|
+
.collections(name)
|
|
126
|
+
.documents()
|
|
127
|
+
.search({
|
|
128
|
+
q: '*',
|
|
129
|
+
query_by: SOURCE_FIELD,
|
|
130
|
+
per_page: 0,
|
|
131
|
+
facet_by: SOURCE_FIELD,
|
|
132
|
+
max_facet_values: maxSources + 1,
|
|
133
|
+
});
|
|
134
|
+
const counts = response.facet_counts?.[0]?.counts ?? [];
|
|
135
|
+
if (counts.length > maxSources) {
|
|
136
|
+
throw new Error(`Membership sweep cannot see beyond ${maxSources} distinct sources in “${name}”; raise maxSweepableSources or departed sources might be missed`);
|
|
137
|
+
}
|
|
138
|
+
return counts.map((count) => count.value);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type {
|
|
1
|
+
export { BlueGreenRebuild } from './blue-green-rebuild.js';
|
|
2
|
+
export type { BlueGreenRebuildOptions } from './blue-green-rebuild.js';
|
|
3
|
+
export { InPlaceRebuild } from './in-place-rebuild.js';
|
|
4
|
+
export type { InPlaceRebuildOptions } from './in-place-rebuild.js';
|
|
5
|
+
export { RebuildAlreadyRunning } from './lock.js';
|
|
6
|
+
export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
|
|
3
7
|
export { buildCollectionSchema } from './collection-schema.js';
|
|
4
8
|
export type { CollectionSchemaOptions } from './collection-schema.js';
|
|
5
9
|
export { buildSearchParams } from './query-compiler.js';
|
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,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { BlueGreenRebuild } from './blue-green-rebuild.js';
|
|
2
|
+
export { InPlaceRebuild } from './in-place-rebuild.js';
|
|
3
|
+
export { RebuildAlreadyRunning } from './lock.js';
|
|
4
|
+
export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
|
|
2
5
|
export { buildCollectionSchema } from './collection-schema.js';
|
|
3
6
|
export { buildSearchParams } from './query-compiler.js';
|
|
4
7
|
export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';
|