@lde/search-typesense 0.28.0 → 0.29.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/dist/blue-green-rebuild.d.ts +7 -8
- package/dist/blue-green-rebuild.d.ts.map +1 -1
- package/dist/blue-green-rebuild.js +27 -41
- package/dist/documents.d.ts +147 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +529 -0
- package/dist/in-place-rebuild.d.ts +30 -35
- package/dist/in-place-rebuild.d.ts.map +1 -1
- package/dist/in-place-rebuild.js +56 -128
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/rebuild-support.d.ts +1 -41
- package/dist/rebuild-support.d.ts.map +1 -1
- package/dist/rebuild-support.js +3 -76
- package/package.json +1 -1
- package/dist/import.d.ts +0 -31
- package/dist/import.d.ts.map +0 -1
- package/dist/import.js +0 -60
- package/dist/sweep.d.ts +0 -70
- package/dist/sweep.d.ts.map +0 -1
- package/dist/sweep.js +0 -113
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// The documents a run writes into one collection, and every decision about
|
|
2
|
+
// which of them leave it. Owns the private bookkeeping columns, the batching,
|
|
3
|
+
// and the two regimes a collection can be in – a document belongs to one
|
|
4
|
+
// dataset, or several reference it – so a writer states *what* should go and
|
|
5
|
+
// never *how* it is stored.
|
|
6
|
+
import { datasetField, isInternalField, physicalFields, } from '@lde/search/adapter';
|
|
7
|
+
import { escapeFilterValue } from './query-compiler.js';
|
|
8
|
+
/** The private field carrying the dataset IRI a document came from, in a
|
|
9
|
+
* collection whose documents each belong to one dataset. */
|
|
10
|
+
const SOURCE_FIELD = 'source';
|
|
11
|
+
/** The private field carrying the id of the run that last wrote a document,
|
|
12
|
+
* beside {@link SOURCE_FIELD}. */
|
|
13
|
+
const LAST_SEEN_FIELD = 'last_seen';
|
|
14
|
+
/** The private field carrying the datasets that reference a document, in a
|
|
15
|
+
* collection keyed on a canonical identifier: one `{ dataset, run }` element
|
|
16
|
+
* per referring dataset. */
|
|
17
|
+
const REFERENCED_BY_FIELD = 'referenced_by';
|
|
18
|
+
/** Documents read back per round-trip while retracting a dataset – the unit of
|
|
19
|
+
* work the sweep’s memory is bounded by, and Typesense’s `per_page` ceiling. */
|
|
20
|
+
const RETRACTION_PAGE = 250;
|
|
21
|
+
/** Ids per `multi_search` entry when reading membership back before a write. */
|
|
22
|
+
const LOOKUP_BATCH = 200;
|
|
23
|
+
/** Entries per `multi_search` request: the engine’s own default ceiling
|
|
24
|
+
* (`max-searches-in-multi-search`), which a large `batchSize` would exceed. */
|
|
25
|
+
const MAX_SEARCHES_PER_REQUEST = 50;
|
|
26
|
+
/** Stay well under Typesense’s ~4000-char URL query-string limit per delete. */
|
|
27
|
+
const MAX_FILTER_VALUES_LENGTH = 3000;
|
|
28
|
+
/**
|
|
29
|
+
* Default ceiling on the distinct datasets a collection may hold before the
|
|
30
|
+
* membership sweep refuses to guess. The sweep needs the complete set to spot
|
|
31
|
+
* the ones that left, so a truncated facet would silently miss deletions.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
|
|
34
|
+
/**
|
|
35
|
+
* Open a run’s documents on a collection.
|
|
36
|
+
*
|
|
37
|
+
* Two regimes, chosen once from the type and never surfaced again:
|
|
38
|
+
*
|
|
39
|
+
* - a type with no {@link RootType.key} keys every document on its own node
|
|
40
|
+
* IRI, so a document belongs to exactly one dataset. It is stamped with that
|
|
41
|
+
* dataset and the run id, and a drop is a delete.
|
|
42
|
+
* - a **canonically keyed** type keys documents on an identifier several
|
|
43
|
+
* datasets can share – two datasets referencing the same GeoNames place
|
|
44
|
+
* produce one document. Membership is therefore a set: a write **adds** the
|
|
45
|
+
* dataset to `referenced_by`, a drop **removes** it, and the document is
|
|
46
|
+
* deleted when the last referring dataset goes. That is what stops one
|
|
47
|
+
* dataset’s sweep from deleting what another still points at, without any
|
|
48
|
+
* special case for a dataset skipped as unchanged – its membership simply
|
|
49
|
+
* stays, untouched by anyone else’s sweep.
|
|
50
|
+
*
|
|
51
|
+
* The run is recorded on a keyed collection as `Date.parse(startedAt)` rather
|
|
52
|
+
* than as the run id, because *this dataset did not write it this run* has to
|
|
53
|
+
* be asked of one element of a nested array, and Typesense correlates a
|
|
54
|
+
* comparison within an element but **not** a negation – `run:<thisRun`
|
|
55
|
+
* correlates, `run:!=thisRun` silently answers at document level and sweeps
|
|
56
|
+
* nothing. See `test/membership-filters.integration.test.ts`, where both are
|
|
57
|
+
* pinned against the engine.
|
|
58
|
+
*/
|
|
59
|
+
export function openDocuments(client, collection, options) {
|
|
60
|
+
return keyOf(options.searchType) === undefined
|
|
61
|
+
? ownedDocuments(client, collection, options)
|
|
62
|
+
: sharedDocuments(client, collection, options);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The collection definition plus the bookkeeping a writer keeps on it: the
|
|
66
|
+
* private membership columns, and the nesting a keyed collection’s
|
|
67
|
+
* `referenced_by` needs enabled.
|
|
68
|
+
*
|
|
69
|
+
* A single-dataset collection carries `source` only when the type declares no
|
|
70
|
+
* dataset field of its own; when it does, that field already holds the same
|
|
71
|
+
* IRI ({@link provenanceFieldOf}) and a second column would be the same value
|
|
72
|
+
* twice, free to drift.
|
|
73
|
+
*/
|
|
74
|
+
export function withBookkeeping(definition, searchType) {
|
|
75
|
+
const fields = [...(definition.fields ?? [])];
|
|
76
|
+
if (keyOf(searchType) !== undefined) {
|
|
77
|
+
return {
|
|
78
|
+
...definition,
|
|
79
|
+
enable_nested_fields: true,
|
|
80
|
+
fields: [
|
|
81
|
+
...fields,
|
|
82
|
+
{ name: REFERENCED_BY_FIELD, type: 'object[]' },
|
|
83
|
+
{
|
|
84
|
+
name: `${REFERENCED_BY_FIELD}.dataset`,
|
|
85
|
+
type: 'string[]',
|
|
86
|
+
facet: true,
|
|
87
|
+
},
|
|
88
|
+
{ name: `${REFERENCED_BY_FIELD}.run`, type: 'int64[]' },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...definition,
|
|
94
|
+
fields: [
|
|
95
|
+
...fields,
|
|
96
|
+
...(provenanceFieldOf(searchType) === SOURCE_FIELD
|
|
97
|
+
? [{ name: SOURCE_FIELD, type: 'string', facet: true }]
|
|
98
|
+
: []),
|
|
99
|
+
{ name: LAST_SEEN_FIELD, type: 'string' },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reject a {@link SearchType} whose declaration cannot carry this module’s
|
|
105
|
+
* bookkeeping, at writer construction – so it fails before a run touches the
|
|
106
|
+
* index rather than at the sweep, after the writes.
|
|
107
|
+
*
|
|
108
|
+
* A **canonically keyed** type may not declare a field over the dataset. Such a
|
|
109
|
+
* field asks *which dataset is this document’s*, and a document several
|
|
110
|
+
* datasets share has no answer: it would hold whichever wrote last, and a user
|
|
111
|
+
* filtering by one of the others would silently miss it.
|
|
112
|
+
*
|
|
113
|
+
* A single-dataset type declaring one makes it the collection’s provenance
|
|
114
|
+
* column, so it has to answer that question the way the private `source` did:
|
|
115
|
+
* not `array` (Typesense reads `field:=[a,b]` over a `string[]` as *contains
|
|
116
|
+
* any*, so a departed dataset would take every document merely mentioning it);
|
|
117
|
+
* no `transform` (the sweep matches the stored value against the run’s raw
|
|
118
|
+
* dataset IRIs); and `facetable` on its own name where the writer enumerates
|
|
119
|
+
* the indexed datasets by faceting it – a reference inheriting a facet policy
|
|
120
|
+
* facets a companion holding only the admitted keys, so enumerating through it
|
|
121
|
+
* would miss every dataset the policy excludes.
|
|
122
|
+
*
|
|
123
|
+
* @param requireFacetable Whether this writer enumerates the indexed datasets
|
|
124
|
+
* ({@link RunDocuments.dropUnselected}); a writer that only ever filters by a
|
|
125
|
+
* known IRI does not need the column facetable.
|
|
126
|
+
*/
|
|
127
|
+
export function assertWritableType(searchType, options) {
|
|
128
|
+
const keyed = keyOf(searchType) !== undefined;
|
|
129
|
+
const reserved = keyed
|
|
130
|
+
? [REFERENCED_BY_FIELD]
|
|
131
|
+
: [SOURCE_FIELD, LAST_SEEN_FIELD];
|
|
132
|
+
const clashing = searchType.fields.filter((field) => reserved.includes(field.name));
|
|
133
|
+
if (clashing.length > 0) {
|
|
134
|
+
throw new Error(`SearchType “${searchType.name}” declares reserved bookkeeping field(s) ${clashing
|
|
135
|
+
.map((field) => `“${field.name}”`)
|
|
136
|
+
.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
const declared = datasetField(searchType);
|
|
139
|
+
if (declared === undefined || isInternalField(declared)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (keyed) {
|
|
143
|
+
throw new Error(`SearchType “${searchType.name}” is keyed on “${searchType.key?.field}”, so several datasets can share one document, and “${declared.name}” over the indexed dataset would hold whichever of them wrote it last – a filter on any of the others would then miss the document. Drop the field: a keyed collection records every dataset that references a document.`);
|
|
144
|
+
}
|
|
145
|
+
const problems = [];
|
|
146
|
+
if (declared.array === true) {
|
|
147
|
+
problems.push('it is an array, and a membership sweep over one would delete every document merely mentioning a departed dataset');
|
|
148
|
+
}
|
|
149
|
+
if (declared.transform !== undefined) {
|
|
150
|
+
problems.push('it declares a transform, and the sweep matches the stored value against the run’s raw dataset IRIs');
|
|
151
|
+
}
|
|
152
|
+
if (options.requireFacetable && declared.facetable !== true) {
|
|
153
|
+
problems.push('it is not facetable, and the membership sweep enumerates the indexed datasets by faceting it');
|
|
154
|
+
}
|
|
155
|
+
else if (options.requireFacetable &&
|
|
156
|
+
physicalFields(declared, options.schema).facet !== declared.name) {
|
|
157
|
+
problems.push('it inherits a facet policy from the type it names, so the engine facets only the datasets the policy admits, and the membership sweep enumerates the indexed datasets by faceting it');
|
|
158
|
+
}
|
|
159
|
+
if (problems.length > 0) {
|
|
160
|
+
throw new Error(`SearchType “${searchType.name}” declares “${declared.name}” over the indexed dataset, which this writer keeps its provenance bookkeeping on, but ${problems.join('; and ')}.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fail an EXISTING collection that predates its type being canonically keyed,
|
|
165
|
+
* naming the two steps that fix it.
|
|
166
|
+
*
|
|
167
|
+
* The field cannot be added: nesting is a create-time collection setting, and
|
|
168
|
+
* even where it could be it would be **empty** on every document already
|
|
169
|
+
* indexed. Membership is only written by a dataset that reprocesses, and
|
|
170
|
+
* skip-unchanged means most do not – so those documents would match no
|
|
171
|
+
* dataset, never be retracted and never be collected, while looking perfectly
|
|
172
|
+
* healthy. Dropping the collection alone has the same hole: a rebuilt
|
|
173
|
+
* collection is repopulated only by datasets that changed, so the pipeline
|
|
174
|
+
* version has to rotate with it to force every dataset through.
|
|
175
|
+
*/
|
|
176
|
+
export function assertBookkeepingPresent(searchType, existing, collection) {
|
|
177
|
+
if (keyOf(searchType) === undefined) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const names = new Set(existing.map((field) => field.name));
|
|
181
|
+
if (names.has(`${REFERENCED_BY_FIELD}.dataset`)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Typesense collection “${collection}” exists without the “${REFERENCED_BY_FIELD}” field its keyed SearchType “${searchType.name}” records membership in, so nothing in it can be swept. It cannot be added to a live collection, and would be empty on every document already indexed: drop “${collection}” (and the collections that join to it) and rotate the pipeline version, so the next run reprocesses every dataset and rebuilds them.`);
|
|
185
|
+
}
|
|
186
|
+
/** The dataset column a single-dataset collection carries: the type’s declared
|
|
187
|
+
* dataset field, or the private `source` when it declares none. */
|
|
188
|
+
function provenanceFieldOf(searchType) {
|
|
189
|
+
const declared = datasetField(searchType);
|
|
190
|
+
return declared === undefined || isInternalField(declared)
|
|
191
|
+
? SOURCE_FIELD
|
|
192
|
+
: declared.name;
|
|
193
|
+
}
|
|
194
|
+
/** A type’s {@link RootType.key}, for the types that can have one. */
|
|
195
|
+
function keyOf(searchType) {
|
|
196
|
+
return searchType.key;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A collection whose documents each belong to one dataset: stamped with it and
|
|
200
|
+
* the run, dropped by deleting them. What every collection did before keying
|
|
201
|
+
* existed, and still the regime for every type that declares no `key`.
|
|
202
|
+
*/
|
|
203
|
+
function ownedDocuments(client, collection, options) {
|
|
204
|
+
const { searchType, runId, batchSize } = options;
|
|
205
|
+
const field = provenanceFieldOf(searchType);
|
|
206
|
+
const buffer = new Batch(batchSize);
|
|
207
|
+
const run = escapeFilterValue(runId);
|
|
208
|
+
const of = (dataset) => `${field}:=${escapeFilterValue(dataset.iri.toString())}`;
|
|
209
|
+
const land = async () => {
|
|
210
|
+
const batch = buffer.take();
|
|
211
|
+
if (batch.length > 0) {
|
|
212
|
+
await importDocuments(client, collection, batch, 'upsert');
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const drop = async (filter) => {
|
|
216
|
+
await land();
|
|
217
|
+
await deleteByFilter(client, collection, filter);
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
// Stamping the dataset column here – rather than trusting the projection to
|
|
221
|
+
// have filled a declared one – is what keeps the sweep total: a caller
|
|
222
|
+
// projecting without a dataset context, or a document reaching the writer
|
|
223
|
+
// any other way, still lands sweepable. Where the projection did fill it,
|
|
224
|
+
// the value is the same IRI, so the stamp only reasserts it.
|
|
225
|
+
//
|
|
226
|
+
// It reasserts the sweep’s column only, never the physical fanout a
|
|
227
|
+
// declared field also produces (the folded `${name}_search` companion of a
|
|
228
|
+
// searchable one). Folding is the projection’s convention, and restating it
|
|
229
|
+
// here would put it in two places. So a document that reached the writer
|
|
230
|
+
// unprojected is sweepable but not full-text findable by dataset: absent,
|
|
231
|
+
// never wrong.
|
|
232
|
+
add: async (dataset, documents) => {
|
|
233
|
+
const stamp = {
|
|
234
|
+
[field]: dataset.iri.toString(),
|
|
235
|
+
[LAST_SEEN_FIELD]: runId,
|
|
236
|
+
};
|
|
237
|
+
for await (const document of documents) {
|
|
238
|
+
buffer.push({ ...document, ...stamp });
|
|
239
|
+
if (buffer.full()) {
|
|
240
|
+
await land();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
flush: land,
|
|
245
|
+
dropUnwritten: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:!=${run}`),
|
|
246
|
+
dropWrittenThisRun: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:=${run}`),
|
|
247
|
+
dropAll: async (dataset) => drop(of(dataset)),
|
|
248
|
+
dropUnselected: async (selectedDatasets) => {
|
|
249
|
+
await land();
|
|
250
|
+
const departed = await departedDatasets(client, collection, field, selectedDatasets, options.maxSweepableSources ?? DEFAULT_MAX_SWEEPABLE_SOURCES);
|
|
251
|
+
for (const chunk of chunked(departed.map(escapeFilterValue))) {
|
|
252
|
+
await deleteByFilter(client, collection, `${field}:=[${chunk.join(',')}]`);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A collection keyed on a canonical identifier, where one document can be
|
|
259
|
+
* reached from several datasets. Membership is the set of datasets referencing
|
|
260
|
+
* it; a drop retracts one dataset’s entry and deletes only what nobody is left
|
|
261
|
+
* referencing.
|
|
262
|
+
*/
|
|
263
|
+
function sharedDocuments(client, collection, options) {
|
|
264
|
+
const { batchSize } = options;
|
|
265
|
+
const run = Date.parse(options.startedAt);
|
|
266
|
+
const facet = `${REFERENCED_BY_FIELD}.dataset`;
|
|
267
|
+
const buffer = new Batch(batchSize);
|
|
268
|
+
const referencing = (datasets) => `${facet}:=[${datasets.map(escapeFilterValue).join(',')}]`;
|
|
269
|
+
/**
|
|
270
|
+
* Land a batch, adding each document’s dataset to the membership already
|
|
271
|
+
* stored. Typesense replaces a whole document on upsert and has no way to
|
|
272
|
+
* append to an array, so the set has to be read back first – one
|
|
273
|
+
* `multi_search` per batch, against the ids the batch carries.
|
|
274
|
+
*/
|
|
275
|
+
const land = async () => {
|
|
276
|
+
const batch = buffer.take();
|
|
277
|
+
if (batch.length === 0) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Two nodes of one dataset, or two datasets, can key onto one document
|
|
281
|
+
// within a single batch. Later content wins, as a merged document always
|
|
282
|
+
// does – but membership is the union, or the second would erase the first.
|
|
283
|
+
const merged = new Map();
|
|
284
|
+
for (const { document, dataset } of batch) {
|
|
285
|
+
const seen = merged.get(document.id);
|
|
286
|
+
merged.set(document.id, {
|
|
287
|
+
document,
|
|
288
|
+
datasets: seen === undefined
|
|
289
|
+
? [dataset]
|
|
290
|
+
: seen.datasets.includes(dataset)
|
|
291
|
+
? seen.datasets
|
|
292
|
+
: [...seen.datasets, dataset],
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const stored = await storedMembership(client, collection, [
|
|
296
|
+
...merged.keys(),
|
|
297
|
+
]);
|
|
298
|
+
await importDocuments(client, collection, [...merged.values()].map(({ document, datasets }) => ({
|
|
299
|
+
...document,
|
|
300
|
+
[REFERENCED_BY_FIELD]: [
|
|
301
|
+
...(stored.get(document.id) ?? []).filter((referrer) => !datasets.includes(referrer.dataset)),
|
|
302
|
+
...datasets.map((dataset) => ({ dataset, run })),
|
|
303
|
+
],
|
|
304
|
+
})), 'upsert');
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Take the datasets out of every document the filter selects, deleting the
|
|
308
|
+
* documents left with no referrer.
|
|
309
|
+
*
|
|
310
|
+
* The filter is re-asked from the first page each round rather than
|
|
311
|
+
* paginated: a document this pass touches stops matching it, so the loop
|
|
312
|
+
* drains, its memory is one page whatever the collection holds, and deep
|
|
313
|
+
* pagination never comes into it. A round that changes nothing would spin,
|
|
314
|
+
* so it throws instead.
|
|
315
|
+
*/
|
|
316
|
+
const retract = async (filter, datasets) => {
|
|
317
|
+
await land();
|
|
318
|
+
for (;;) {
|
|
319
|
+
const page = await search(client, collection, {
|
|
320
|
+
filter_by: filter,
|
|
321
|
+
include_fields: `id,${REFERENCED_BY_FIELD}`,
|
|
322
|
+
per_page: RETRACTION_PAGE,
|
|
323
|
+
});
|
|
324
|
+
const hits = (page.hits ?? []).map((hit) => hit.document);
|
|
325
|
+
if (hits.length === 0) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const keeping = [];
|
|
329
|
+
const emptied = [];
|
|
330
|
+
for (const document of hits) {
|
|
331
|
+
const referrers = referrersOf(document);
|
|
332
|
+
const remaining = referrers.filter((referrer) => !datasets.includes(referrer.dataset));
|
|
333
|
+
if (remaining.length === referrers.length) {
|
|
334
|
+
// Selected, yet carries none of the datasets being retracted: writing
|
|
335
|
+
// it back would change nothing and it would match again next round.
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (remaining.length === 0) {
|
|
339
|
+
emptied.push(String(document.id));
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
keeping.push({
|
|
343
|
+
id: document.id,
|
|
344
|
+
[REFERENCED_BY_FIELD]: remaining,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (keeping.length === 0 && emptied.length === 0) {
|
|
349
|
+
throw new Error(`Retracting ${datasets.join(', ')} from “${collection}” selected ${hits.length} document(s) carrying none of them, so the sweep cannot make progress.`);
|
|
350
|
+
}
|
|
351
|
+
if (keeping.length > 0) {
|
|
352
|
+
await importDocuments(client, collection, keeping, 'update');
|
|
353
|
+
}
|
|
354
|
+
for (const chunk of chunked(emptied.map(escapeFilterValue))) {
|
|
355
|
+
await deleteByFilter(client, collection, `id:=[${chunk.join(',')}]`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
return {
|
|
360
|
+
add: async (dataset, documents) => {
|
|
361
|
+
const iri = dataset.iri.toString();
|
|
362
|
+
for await (const document of documents) {
|
|
363
|
+
buffer.push({ document, dataset: iri });
|
|
364
|
+
if (buffer.full()) {
|
|
365
|
+
await land();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
flush: land,
|
|
370
|
+
dropUnwritten: async (dataset) => {
|
|
371
|
+
const iri = dataset.iri.toString();
|
|
372
|
+
await retract(`${REFERENCED_BY_FIELD}.{dataset:=${escapeFilterValue(iri)} && run:<${run}}`, [iri]);
|
|
373
|
+
},
|
|
374
|
+
dropWrittenThisRun: async (dataset) => {
|
|
375
|
+
const iri = dataset.iri.toString();
|
|
376
|
+
await retract(`${REFERENCED_BY_FIELD}.{dataset:=${escapeFilterValue(iri)} && run:=${run}}`, [iri]);
|
|
377
|
+
},
|
|
378
|
+
dropAll: async (dataset) => {
|
|
379
|
+
const iri = dataset.iri.toString();
|
|
380
|
+
await retract(referencing([iri]), [iri]);
|
|
381
|
+
},
|
|
382
|
+
dropUnselected: async (selectedDatasets) => {
|
|
383
|
+
await land();
|
|
384
|
+
const departed = await departedDatasets(client, collection, facet, selectedDatasets, options.maxSweepableSources ?? DEFAULT_MAX_SWEEPABLE_SOURCES);
|
|
385
|
+
// Every departed dataset at once: one document can be referenced by
|
|
386
|
+
// several of them, and retracting them together spares it a second pass.
|
|
387
|
+
for (const chunk of chunked(departed.map(escapeFilterValue))) {
|
|
388
|
+
await retract(`${facet}:=[${chunk.join(',')}]`, departed);
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The membership stored for these ids, read back before a write adds to it. */
|
|
394
|
+
async function storedMembership(client, collection, ids) {
|
|
395
|
+
const searches = [];
|
|
396
|
+
for (let start = 0; start < ids.length; start += LOOKUP_BATCH) {
|
|
397
|
+
const batch = ids.slice(start, start + LOOKUP_BATCH);
|
|
398
|
+
searches.push({
|
|
399
|
+
collection,
|
|
400
|
+
q: '*',
|
|
401
|
+
filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
|
|
402
|
+
include_fields: `id,${REFERENCED_BY_FIELD}`,
|
|
403
|
+
per_page: batch.length,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const membership = new Map();
|
|
407
|
+
// A batch wide enough to need more entries than the engine accepts per
|
|
408
|
+
// request travels as several requests: `batchSize` is the caller's knob, and
|
|
409
|
+
// it must not decide whether a write succeeds.
|
|
410
|
+
for (let start = 0; start < searches.length; start += MAX_SEARCHES_PER_REQUEST) {
|
|
411
|
+
const { results } = (await client.multiSearch.perform({
|
|
412
|
+
searches: searches.slice(start, start + MAX_SEARCHES_PER_REQUEST),
|
|
413
|
+
}));
|
|
414
|
+
for (const result of results) {
|
|
415
|
+
// multi_search reports a failed entry inline instead of rejecting. A lost
|
|
416
|
+
// entry would silently drop the membership of everything it covered, so
|
|
417
|
+
// it is raised rather than skipped.
|
|
418
|
+
if (result.error !== undefined) {
|
|
419
|
+
throw new Error(`Reading membership from “${collection}” failed: ${result.error}`);
|
|
420
|
+
}
|
|
421
|
+
for (const hit of result.hits ?? []) {
|
|
422
|
+
membership.set(String(hit.document.id), referrersOf(hit.document));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return membership;
|
|
427
|
+
}
|
|
428
|
+
/** The referrers stored on a document, ignoring anything malformed. */
|
|
429
|
+
function referrersOf(document) {
|
|
430
|
+
const stored = document[REFERENCED_BY_FIELD];
|
|
431
|
+
return (Array.isArray(stored) ? stored : []).filter((referrer) => typeof referrer === 'object' &&
|
|
432
|
+
referrer !== null &&
|
|
433
|
+
typeof referrer.dataset === 'string');
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The datasets a collection holds that the run’s selection does not, via a
|
|
437
|
+
* single facet over its membership column.
|
|
438
|
+
*
|
|
439
|
+
* Asks for one bucket beyond the ceiling so genuine truncation is
|
|
440
|
+
* distinguishable from an exactly-full result: `maxDatasets` buckets are
|
|
441
|
+
* returned intact, one more proves the sweep would miss some, so it throws
|
|
442
|
+
* rather than delete blind.
|
|
443
|
+
*/
|
|
444
|
+
async function departedDatasets(client, collection, facetField, selectedDatasets, maxDatasets) {
|
|
445
|
+
const response = await search(client, collection, {
|
|
446
|
+
per_page: 0,
|
|
447
|
+
facet_by: facetField,
|
|
448
|
+
max_facet_values: maxDatasets + 1,
|
|
449
|
+
});
|
|
450
|
+
const counts = response.facet_counts?.[0]?.counts ?? [];
|
|
451
|
+
if (counts.length > maxDatasets) {
|
|
452
|
+
throw new Error(`Membership sweep cannot see beyond ${maxDatasets} distinct datasets in “${collection}”; raise maxSweepableSources or departed datasets might be missed`);
|
|
453
|
+
}
|
|
454
|
+
const selected = new Set(selectedDatasets);
|
|
455
|
+
return counts
|
|
456
|
+
.map((count) => count.value)
|
|
457
|
+
.filter((dataset) => !selected.has(dataset));
|
|
458
|
+
}
|
|
459
|
+
/** Escaped values grouped into filters that fit a URL query string. */
|
|
460
|
+
function* chunked(values) {
|
|
461
|
+
let chunk = [];
|
|
462
|
+
let length = 0;
|
|
463
|
+
for (const value of values) {
|
|
464
|
+
if (length + value.length > MAX_FILTER_VALUES_LENGTH && chunk.length > 0) {
|
|
465
|
+
yield chunk;
|
|
466
|
+
chunk = [];
|
|
467
|
+
length = 0;
|
|
468
|
+
}
|
|
469
|
+
chunk.push(value);
|
|
470
|
+
length += value.length + 1;
|
|
471
|
+
}
|
|
472
|
+
if (chunk.length > 0) {
|
|
473
|
+
yield chunk;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** Accumulates items until a batch is full. */
|
|
477
|
+
class Batch {
|
|
478
|
+
size;
|
|
479
|
+
items = [];
|
|
480
|
+
constructor(size) {
|
|
481
|
+
this.size = size;
|
|
482
|
+
}
|
|
483
|
+
push(item) {
|
|
484
|
+
this.items.push(item);
|
|
485
|
+
}
|
|
486
|
+
full() {
|
|
487
|
+
return this.items.length >= this.size;
|
|
488
|
+
}
|
|
489
|
+
/** The accumulated items, emptying the batch. */
|
|
490
|
+
take() {
|
|
491
|
+
const items = this.items;
|
|
492
|
+
this.items = [];
|
|
493
|
+
return items;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Import one batch, throwing if any individual document fails – Typesense’s
|
|
498
|
+
* bulk import otherwise reports per-document failures without rejecting, which
|
|
499
|
+
* would turn a rejected write into silently missing data.
|
|
500
|
+
*/
|
|
501
|
+
async function importDocuments(client, collection, batch, action) {
|
|
502
|
+
const results = (await client
|
|
503
|
+
.collections(collection)
|
|
504
|
+
.documents()
|
|
505
|
+
.import(batch, {
|
|
506
|
+
action,
|
|
507
|
+
throwOnFail: false,
|
|
508
|
+
}));
|
|
509
|
+
const failures = results.filter((result) => !result.success);
|
|
510
|
+
if (failures.length > 0) {
|
|
511
|
+
throw new Error(`Typesense ${action} into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures
|
|
512
|
+
.map((failure) => failure.error)
|
|
513
|
+
.join('; ')}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** A whole-collection search; `q: '*'` needs no field to query by. */
|
|
517
|
+
async function search(client, collection, parameters) {
|
|
518
|
+
return (await client
|
|
519
|
+
.collections(collection)
|
|
520
|
+
.documents()
|
|
521
|
+
.search({ q: '*', ...parameters }));
|
|
522
|
+
}
|
|
523
|
+
/** Delete a collection’s documents matching a Typesense filter. */
|
|
524
|
+
async function deleteByFilter(client, collection, filterBy) {
|
|
525
|
+
await client
|
|
526
|
+
.collections(collection)
|
|
527
|
+
.documents()
|
|
528
|
+
.delete({ filter_by: filterBy });
|
|
529
|
+
}
|
|
@@ -5,27 +5,33 @@ import { type RebuildOptions } from './rebuild-support.js';
|
|
|
5
5
|
/** {@link InPlaceRebuild} options: the shared rebuild options plus the
|
|
6
6
|
* membership-sweep ceiling. */
|
|
7
7
|
export interface InPlaceRebuildOptions extends RebuildOptions {
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
8
|
+
/**
|
|
9
|
+
* Most distinct datasets the membership sweep may enumerate before it throws
|
|
10
|
+
* rather than risk missing departed ones (default 10 000). The sweep needs
|
|
11
|
+
* the complete set to spot which datasets left, so a truncated facet would
|
|
12
|
+
* silently miss deletions. Raise it (up to the engine’s `max_facet_values`
|
|
13
|
+
* limit) before an index approaches it, so the ceiling is a tunable guard
|
|
14
|
+
* rather than a hard wall.
|
|
15
|
+
*/
|
|
10
16
|
readonly maxSweepableSources?: number;
|
|
11
17
|
}
|
|
12
18
|
/**
|
|
13
19
|
* In-place Rebuild (update the live index directly – upsert changed sources,
|
|
14
20
|
* sweep the rest – rather than swap in a fresh one) as a transactional
|
|
15
21
|
* `Writer`: documents are upserted into one long-lived collection with
|
|
16
|
-
* per-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
22
|
+
* per-dataset atomicity – no swap, no staging. Deletion is a sweep, never
|
|
23
|
+
* special-cased, and {@link openDocuments} decides what a deletion physically
|
|
24
|
+
* is – for a canonically keyed type, dropping one dataset’s membership of a
|
|
25
|
+
* document several datasets share, and the document itself only with the last
|
|
26
|
+
* of them:
|
|
19
27
|
*
|
|
20
|
-
* - a **successful dataset flush**
|
|
21
|
-
* not rewrite
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* left for the success sweep to reconcile;
|
|
28
|
-
* - **commit** deletes every document whose source left the run’s selection
|
|
28
|
+
* - a **successful dataset flush** drops what the dataset had and this run did
|
|
29
|
+
* not rewrite. A failed dataset is not swept – its output is incomplete, and
|
|
30
|
+
* the next successful run reconciles;
|
|
31
|
+
* - **reset** (the pipeline’s dump-fallback discard) drops only *this run’s*
|
|
32
|
+
* writes for the dataset, so the dump re-run rebuilds it cleanly while the
|
|
33
|
+
* dataset’s prior-run documents are left for the success sweep to reconcile;
|
|
34
|
+
* - **commit** drops what belongs to datasets that left the run’s selection
|
|
29
35
|
* (registry-membership sweep over {@link RunContext.selectedSources}, which
|
|
30
36
|
* includes datasets skipped as unchanged) and releases the lock;
|
|
31
37
|
* - **abort** only releases the lock: upserts are idempotent, so whatever
|
|
@@ -33,15 +39,17 @@ export interface InPlaceRebuildOptions extends RebuildOptions {
|
|
|
33
39
|
*
|
|
34
40
|
* `openRun` takes the single-flight cross-pod lock (throwing
|
|
35
41
|
* `RebuildAlreadyRunning` when another rebuild holds it) and creates
|
|
36
|
-
* the collection on demand from the {@link SearchType} plus the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* ({@link
|
|
42
|
+
* the collection on demand from the {@link SearchType} plus the bookkeeping
|
|
43
|
+
* fields. A collection that already exists is used as it is, with two
|
|
44
|
+
* exceptions: it must carry every reference field the declaration asks for and
|
|
45
|
+
* every facet companion its facets read
|
|
46
|
+
* ({@link assertReferencesAndFacetCompanionsPresent}), and the membership field
|
|
47
|
+
* a keyed type records in ({@link assertBookkeepingPresent}) – each failing
|
|
48
|
+
* with the rebuild that fixes it.
|
|
42
49
|
*
|
|
43
|
-
*
|
|
44
|
-
* or documents from different
|
|
50
|
+
* Unless the type is canonically keyed, document ids must be unique per
|
|
51
|
+
* (dataset, entity) – the caller keys them – or documents from different
|
|
52
|
+
* datasets overwrite each other.
|
|
45
53
|
*
|
|
46
54
|
* The name is the NDE Stack’s pattern
|
|
47
55
|
* ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
|
|
@@ -60,20 +68,7 @@ export declare class InPlaceRebuild<TDocument extends {
|
|
|
60
68
|
readonly collectionName: string;
|
|
61
69
|
private readonly maxSweepableSources;
|
|
62
70
|
private readonly resolved;
|
|
63
|
-
/** The column this collection carries its documents’ dataset IRI in: the
|
|
64
|
-
* type’s declared dataset field, or the private `source` when it declares
|
|
65
|
-
* none. Everything below – the stamp, every sweep filter, the source
|
|
66
|
-
* enumeration – reads this one name. */
|
|
67
|
-
private readonly sourceField;
|
|
68
71
|
constructor(client: Client, searchType: SearchType, options?: InPlaceRebuildOptions);
|
|
69
72
|
openRun(context: RunContext): Promise<RunWriter<TDocument>>;
|
|
70
|
-
/**
|
|
71
|
-
* The distinct sources present in the collection, via a single facet over its
|
|
72
|
-
* {@link provenanceField}. Requests one bucket beyond `maxSources` so genuine truncation is
|
|
73
|
-
* distinguishable from an exactly-full result: `maxSources` buckets are
|
|
74
|
-
* returned intact, `maxSources + 1` proves more exist and the sweep would
|
|
75
|
-
* miss some, so it throws rather than delete blind.
|
|
76
|
-
*/
|
|
77
|
-
private indexedSources;
|
|
78
73
|
}
|
|
79
74
|
//# sourceMappingURL=in-place-rebuild.d.ts.map
|
|
@@ -1 +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,EAAgB,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;
|
|
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,EAAgB,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AASvB,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;;;;;;;OAOG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAY1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAZ7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAe/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;CAuElE"}
|