@lde/search-typesense 0.36.1 → 0.38.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 +3 -0
- package/dist/blue-green-rebuild.d.ts.map +1 -1
- package/dist/blue-green-rebuild.js +21 -4
- package/dist/documents.d.ts +19 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +147 -25
- package/dist/in-place-rebuild.d.ts +4 -0
- package/dist/in-place-rebuild.d.ts.map +1 -1
- package/dist/in-place-rebuild.js +17 -6
- package/dist/rebuild-support.d.ts +28 -0
- package/dist/rebuild-support.d.ts.map +1 -1
- package/dist/rebuild-support.js +40 -1
- package/dist/search.d.ts +2 -2
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +262 -78
- package/package.json +3 -3
|
@@ -20,6 +20,9 @@ export type BlueGreenRebuildOptions = RebuildOptions;
|
|
|
20
20
|
* derived from the {@link SearchType}.
|
|
21
21
|
* - `write` streams documents into the fresh collection, batched across write
|
|
22
22
|
* calls, each recording the dataset it came from ({@link openDocuments}).
|
|
23
|
+
* - `write` leaves out a document Typesense refuses or that lacks a required
|
|
24
|
+
* field; `flush` reports those through {@link RunContext.itemsRejected} and
|
|
25
|
+
* fails the dataset when they exceed `maxRejectedShare`.
|
|
23
26
|
* - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
|
|
24
27
|
* discard), roll that dataset back out of the not-yet-live collection, so a
|
|
25
28
|
* swap never ships a half-processed dataset; a successful flush leaves the
|
|
@@ -1 +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;AASvB,OAAO,
|
|
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;AASvB,OAAO,EAGL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAa1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAb7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAUjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA2HjE,4EAA4E;YAC9D,WAAW;CAW1B"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildCollectionDefinition } from './collection-definition.js';
|
|
2
2
|
import { httpStatus, openLockedRun, releaseLock } from './lock.js';
|
|
3
3
|
import { assertWritableType, openDocuments, withBookkeeping, } from './documents.js';
|
|
4
|
-
import { resolveRebuildOptions, } from './rebuild-support.js';
|
|
4
|
+
import { resolveRebuildOptions, settleDataset, } from './rebuild-support.js';
|
|
5
5
|
/**
|
|
6
6
|
* Blue/green Rebuild (build a fresh index alongside the live one, then swap to
|
|
7
7
|
* it atomically) as a transactional `Writer`: each run builds the search
|
|
@@ -16,6 +16,9 @@ import { resolveRebuildOptions, } from './rebuild-support.js';
|
|
|
16
16
|
* derived from the {@link SearchType}.
|
|
17
17
|
* - `write` streams documents into the fresh collection, batched across write
|
|
18
18
|
* calls, each recording the dataset it came from ({@link openDocuments}).
|
|
19
|
+
* - `write` leaves out a document Typesense refuses or that lacks a required
|
|
20
|
+
* field; `flush` reports those through {@link RunContext.itemsRejected} and
|
|
21
|
+
* fails the dataset when they exceed `maxRejectedShare`.
|
|
19
22
|
* - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
|
|
20
23
|
* discard), roll that dataset back out of the not-yet-live collection, so a
|
|
21
24
|
* swap never ships a half-processed dataset; a successful flush leaves the
|
|
@@ -58,7 +61,7 @@ export class BlueGreenRebuild {
|
|
|
58
61
|
this.resolved.definitionOptions.collectionNameFor(searchType);
|
|
59
62
|
}
|
|
60
63
|
async openRun(context) {
|
|
61
|
-
const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
|
|
64
|
+
const { batchSize, lockTtlMs, maxRejectedShare, definitionOptions } = this.resolved;
|
|
62
65
|
const name = this.collectionName;
|
|
63
66
|
return openLockedRun(this.client, name, lockTtlMs, async () => {
|
|
64
67
|
// Create the fresh (blue) collection up front, so a failure surfaces
|
|
@@ -89,6 +92,7 @@ export class BlueGreenRebuild {
|
|
|
89
92
|
runId: context.runId,
|
|
90
93
|
startedAt: context.startedAt,
|
|
91
94
|
batchSize,
|
|
95
|
+
schema: definitionOptions.schema,
|
|
92
96
|
});
|
|
93
97
|
// Roll a dataset back out of the not-yet-live collection (a failed
|
|
94
98
|
// dataset, or a reset before the dump re-run). On a keyed collection
|
|
@@ -99,12 +103,25 @@ export class BlueGreenRebuild {
|
|
|
99
103
|
write: async (dataset, items) => documents.add(dataset, items),
|
|
100
104
|
flush: async (dataset, outcome) => {
|
|
101
105
|
// A successful dataset keeps its streamed documents (they go live at
|
|
102
|
-
// the swap); a failed one
|
|
106
|
+
// the swap); a failed one – or one that left too much out – is
|
|
107
|
+
// rolled back so the swap never ships it.
|
|
108
|
+
try {
|
|
109
|
+
await settleDataset(documents, dataset, outcome, context, this.searchType, maxRejectedShare);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
await rollback(dataset);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
103
115
|
if (outcome !== 'success') {
|
|
104
116
|
await rollback(dataset);
|
|
105
117
|
}
|
|
106
118
|
},
|
|
107
|
-
reset: async (dataset) =>
|
|
119
|
+
reset: async (dataset) => {
|
|
120
|
+
// The re-run writes the dataset afresh, so what this attempt left
|
|
121
|
+
// out is no longer the dataset’s to answer for.
|
|
122
|
+
await documents.settle(dataset);
|
|
123
|
+
await rollback(dataset);
|
|
124
|
+
},
|
|
108
125
|
commit: async () => {
|
|
109
126
|
await documents.flush();
|
|
110
127
|
// The alias swap is the commit point: once it lands the new
|
package/dist/documents.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Client, CollectionCreateSchema } from 'typesense';
|
|
2
2
|
import type { CollectionFieldSchema } from 'typesense/lib/Typesense/Collection.js';
|
|
3
3
|
import type { Dataset } from '@lde/dataset';
|
|
4
|
+
import type { ItemsRejected } from '@lde/pipeline';
|
|
4
5
|
import type { SearchSchema, SearchType } from '@lde/search';
|
|
5
6
|
/**
|
|
6
7
|
* Default ceiling on the distinct datasets a collection may hold before the
|
|
@@ -19,6 +20,9 @@ export interface RunDocumentsOptions {
|
|
|
19
20
|
readonly startedAt: string;
|
|
20
21
|
/** Documents per Typesense import request. */
|
|
21
22
|
readonly batchSize: number;
|
|
23
|
+
/** The schema the type belongs to, which a required field of an inline
|
|
24
|
+
* reference’s referent is declared in. */
|
|
25
|
+
readonly schema?: SearchSchema;
|
|
22
26
|
/** Most distinct datasets {@link RunDocuments.dropUnselected} may enumerate
|
|
23
27
|
* before it throws rather than risk missing one that left. */
|
|
24
28
|
readonly maxSweepableSources?: number;
|
|
@@ -36,10 +40,21 @@ export interface RunDocumentsOptions {
|
|
|
36
40
|
export interface RunDocuments<TDocument extends {
|
|
37
41
|
id: string;
|
|
38
42
|
}> {
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Write a dataset’s documents, batched. A document Typesense refuses, or
|
|
45
|
+
* that lacks a required field Typesense cannot check
|
|
46
|
+
* ({@link missingRequiredFields}), is left out and counted against its
|
|
47
|
+
* dataset rather than failing the write – see {@link settle}.
|
|
48
|
+
*/
|
|
40
49
|
add(dataset: Dataset, documents: AsyncIterable<TDocument>): Promise<void>;
|
|
41
50
|
/** Land whatever is still buffered. */
|
|
42
51
|
flush(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Land whatever is still buffered and account for the dataset’s documents
|
|
54
|
+
* since it was last settled: how many it wrote, and which were left out and
|
|
55
|
+
* why. Starts the dataset’s count afresh.
|
|
56
|
+
*/
|
|
57
|
+
settle(dataset: Dataset): Promise<DatasetWrites>;
|
|
43
58
|
/**
|
|
44
59
|
* Drop what this dataset had and this run did not rewrite – the sweep after
|
|
45
60
|
* a dataset succeeds. A document another dataset still references survives,
|
|
@@ -63,6 +78,9 @@ export interface RunDocuments<TDocument extends {
|
|
|
63
78
|
*/
|
|
64
79
|
dropUnselected(selectedDatasets: Iterable<string>): Promise<void>;
|
|
65
80
|
}
|
|
81
|
+
/** A dataset’s documents in one collection, as {@link RunDocuments.settle}
|
|
82
|
+
* counts them. */
|
|
83
|
+
export type DatasetWrites = Omit<ItemsRejected, 'target'>;
|
|
66
84
|
/**
|
|
67
85
|
* Open a run’s documents on a collection.
|
|
68
86
|
*
|
package/dist/documents.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../src/documents.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AACnF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAY,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../src/documents.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AACnF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAmB,MAAM,eAAe,CAAC;AACpE,OAAO,KAAK,EAAY,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA2CtE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAS,CAAC;AAEpD,wEAAwE;AACxE,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;qCACiC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;+CAC2C;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;mEAC+D;IAC/D,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAC5D;;;;;OAKG;IACH,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,uCAAuC;IACvC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACjD;;;;OAIG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C;;;OAGG;IACH,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,yEAAyE;IACzE,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,cAAc,CAAC,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE;AAED;mBACmB;AACnB,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAC5D,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,mBAAmB,GAC3B,YAAY,CAAC,SAAS,CAAC,CAIzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,sBAAsB,EAClC,UAAU,EAAE,UAAU,GACrB,sBAAsB,CA4BxB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IACP,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC,GACA,IAAI,CAoDN;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,qBAAqB,EAAE,EAC1C,UAAU,EAAE,MAAM,GACjB,IAAI,CAWN"}
|
package/dist/documents.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and the two regimes a collection can be in – a document belongs to one
|
|
4
4
|
// dataset, or several reference it – so a writer states *what* should go and
|
|
5
5
|
// never *how* it is stored.
|
|
6
|
-
import { datasetField, isInternalField, physicalFields, } from '@lde/search/adapter';
|
|
6
|
+
import { datasetField, isInternalField, missingRequiredFields, physicalFields, } from '@lde/search/adapter';
|
|
7
7
|
import { escapeFilterValue } from './query-compiler.js';
|
|
8
8
|
/** The private field carrying the dataset IRI a document came from, in a
|
|
9
9
|
* collection whose documents each belong to one dataset. */
|
|
@@ -25,6 +25,11 @@ const LOOKUP_BATCH = 200;
|
|
|
25
25
|
const MAX_SEARCHES_PER_REQUEST = 50;
|
|
26
26
|
/** Stay well under Typesense’s ~4000-char URL query-string limit per delete. */
|
|
27
27
|
const MAX_FILTER_VALUES_LENGTH = 3000;
|
|
28
|
+
/** Distinct rejection reasons kept per dataset; the rest are counted under
|
|
29
|
+
* one, so a reason that varies per document cannot grow the tally unbounded. */
|
|
30
|
+
const MAX_REJECTION_REASONS = 10;
|
|
31
|
+
/** Ids kept per rejection reason, as examples to look up. */
|
|
32
|
+
const MAX_REJECTION_EXAMPLES = 3;
|
|
28
33
|
/**
|
|
29
34
|
* Default ceiling on the distinct datasets a collection may hold before the
|
|
30
35
|
* membership sweep refuses to guess. The sweep needs the complete set to spot
|
|
@@ -204,13 +209,21 @@ function ownedDocuments(client, collection, options) {
|
|
|
204
209
|
const { searchType, runId, batchSize } = options;
|
|
205
210
|
const field = provenanceFieldOf(searchType);
|
|
206
211
|
const buffer = new Batch(batchSize);
|
|
212
|
+
const writes = new WriteTally();
|
|
207
213
|
const run = escapeFilterValue(runId);
|
|
208
214
|
const of = (dataset) => `${field}:=${escapeFilterValue(dataset.iri.toString())}`;
|
|
209
215
|
const land = async () => {
|
|
210
216
|
const batch = buffer.take();
|
|
211
|
-
if (batch.length
|
|
212
|
-
|
|
217
|
+
if (batch.length === 0) {
|
|
218
|
+
return;
|
|
213
219
|
}
|
|
220
|
+
const errors = await upsertDocuments(client, collection, batch);
|
|
221
|
+
errors.forEach((error, index) => {
|
|
222
|
+
if (error !== undefined) {
|
|
223
|
+
const document = batch[index];
|
|
224
|
+
writes.reject(String(document[field]), String(document.id), error);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
214
227
|
};
|
|
215
228
|
const drop = async (filter) => {
|
|
216
229
|
await land();
|
|
@@ -230,18 +243,25 @@ function ownedDocuments(client, collection, options) {
|
|
|
230
243
|
// unprojected is sweepable but not full-text findable by dataset: absent,
|
|
231
244
|
// never wrong.
|
|
232
245
|
add: async (dataset, documents) => {
|
|
246
|
+
const iri = dataset.iri.toString();
|
|
233
247
|
const stamp = {
|
|
234
|
-
[field]:
|
|
248
|
+
[field]: iri,
|
|
235
249
|
[LAST_SEEN_FIELD]: runId,
|
|
236
250
|
};
|
|
237
251
|
for await (const document of documents) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
if (writes.admit(iri, document, searchType, options.schema)) {
|
|
253
|
+
buffer.push({ ...document, ...stamp });
|
|
254
|
+
if (buffer.full()) {
|
|
255
|
+
await land();
|
|
256
|
+
}
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
259
|
},
|
|
244
260
|
flush: land,
|
|
261
|
+
settle: async (dataset) => {
|
|
262
|
+
await land();
|
|
263
|
+
return writes.take(dataset.iri.toString());
|
|
264
|
+
},
|
|
245
265
|
dropUnwritten: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:!=${run}`),
|
|
246
266
|
dropWrittenThisRun: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:=${run}`),
|
|
247
267
|
dropAll: async (dataset) => drop(of(dataset)),
|
|
@@ -261,7 +281,8 @@ function ownedDocuments(client, collection, options) {
|
|
|
261
281
|
* referencing.
|
|
262
282
|
*/
|
|
263
283
|
function sharedDocuments(client, collection, options) {
|
|
264
|
-
const { batchSize } = options;
|
|
284
|
+
const { searchType, batchSize } = options;
|
|
285
|
+
const writes = new WriteTally();
|
|
265
286
|
const run = Date.parse(options.startedAt);
|
|
266
287
|
const facet = `${REFERENCED_BY_FIELD}.dataset`;
|
|
267
288
|
const buffer = new Batch(batchSize);
|
|
@@ -280,6 +301,8 @@ function sharedDocuments(client, collection, options) {
|
|
|
280
301
|
// Two nodes of one dataset, or two datasets, can key onto one document
|
|
281
302
|
// within a single batch. Later content wins, as a merged document always
|
|
282
303
|
// does – but membership is the union, or the second would erase the first.
|
|
304
|
+
// `writes` keeps every dataset a write came from, repeats included, so a
|
|
305
|
+
// refusal counts once per document the datasets wrote – as `admit` did.
|
|
283
306
|
const merged = new Map();
|
|
284
307
|
for (const { document, dataset } of batch) {
|
|
285
308
|
const seen = merged.get(document.id);
|
|
@@ -290,18 +313,29 @@ function sharedDocuments(client, collection, options) {
|
|
|
290
313
|
: seen.datasets.includes(dataset)
|
|
291
314
|
? seen.datasets
|
|
292
315
|
: [...seen.datasets, dataset],
|
|
316
|
+
writes: [...(seen?.writes ?? []), dataset],
|
|
293
317
|
});
|
|
294
318
|
}
|
|
295
319
|
const stored = await storedMembership(client, collection, [
|
|
296
320
|
...merged.keys(),
|
|
297
321
|
]);
|
|
298
|
-
|
|
322
|
+
const entries = [...merged.values()];
|
|
323
|
+
const errors = await upsertDocuments(client, collection, entries.map(({ document, datasets }) => ({
|
|
299
324
|
...document,
|
|
300
325
|
[REFERENCED_BY_FIELD]: [
|
|
301
326
|
...(stored.get(document.id) ?? []).filter((referrer) => !datasets.includes(referrer.dataset)),
|
|
302
327
|
...datasets.map((dataset) => ({ dataset, run })),
|
|
303
328
|
],
|
|
304
|
-
}))
|
|
329
|
+
})));
|
|
330
|
+
// A refused document is refused for every write merged into it.
|
|
331
|
+
errors.forEach((error, index) => {
|
|
332
|
+
if (error !== undefined) {
|
|
333
|
+
const { document, writes: merges } = entries[index];
|
|
334
|
+
for (const dataset of merges) {
|
|
335
|
+
writes.reject(dataset, document.id, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
305
339
|
};
|
|
306
340
|
/**
|
|
307
341
|
* Take the datasets out of every document the filter selects, deleting the
|
|
@@ -349,7 +383,7 @@ function sharedDocuments(client, collection, options) {
|
|
|
349
383
|
throw new Error(`Retracting ${datasets.join(', ')} from “${collection}” selected ${hits.length} document(s) carrying none of them, so the sweep cannot make progress.`);
|
|
350
384
|
}
|
|
351
385
|
if (keeping.length > 0) {
|
|
352
|
-
await
|
|
386
|
+
await updateDocuments(client, collection, keeping);
|
|
353
387
|
}
|
|
354
388
|
for (const chunk of chunked(emptied.map(escapeFilterValue))) {
|
|
355
389
|
await deleteByFilter(client, collection, `id:=[${chunk.join(',')}]`);
|
|
@@ -360,13 +394,19 @@ function sharedDocuments(client, collection, options) {
|
|
|
360
394
|
add: async (dataset, documents) => {
|
|
361
395
|
const iri = dataset.iri.toString();
|
|
362
396
|
for await (const document of documents) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
397
|
+
if (writes.admit(iri, document, searchType, options.schema)) {
|
|
398
|
+
buffer.push({ document, dataset: iri });
|
|
399
|
+
if (buffer.full()) {
|
|
400
|
+
await land();
|
|
401
|
+
}
|
|
366
402
|
}
|
|
367
403
|
}
|
|
368
404
|
},
|
|
369
405
|
flush: land,
|
|
406
|
+
settle: async (dataset) => {
|
|
407
|
+
await land();
|
|
408
|
+
return writes.take(dataset.iri.toString());
|
|
409
|
+
},
|
|
370
410
|
dropUnwritten: async (dataset) => {
|
|
371
411
|
const iri = dataset.iri.toString();
|
|
372
412
|
await retract(`${REFERENCED_BY_FIELD}.{dataset:=${escapeFilterValue(iri)} && run:<${run}}`, [iri]);
|
|
@@ -494,24 +534,106 @@ class Batch {
|
|
|
494
534
|
}
|
|
495
535
|
}
|
|
496
536
|
/**
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
537
|
+
* Each dataset’s documents since it was last settled: how many it wrote, and
|
|
538
|
+
* which were left out and why. Bounded per dataset by
|
|
539
|
+
* {@link MAX_REJECTION_REASONS} and {@link MAX_REJECTION_EXAMPLES}, however
|
|
540
|
+
* many documents are rejected.
|
|
541
|
+
*/
|
|
542
|
+
class WriteTally {
|
|
543
|
+
datasets = new Map();
|
|
544
|
+
/**
|
|
545
|
+
* Count a document the dataset wrote, answering whether it may be imported:
|
|
546
|
+
* one lacking a required field is rejected here, before Typesense sees it.
|
|
547
|
+
*/
|
|
548
|
+
admit(dataset, document, searchType, schema) {
|
|
549
|
+
this.of(dataset).written += 1;
|
|
550
|
+
const missing = missingRequiredFields(document, searchType, schema);
|
|
551
|
+
if (missing.length === 0) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
this.reject(dataset, document.id, `missing required ${missing.length === 1 ? 'field' : 'fields'} ${missing
|
|
555
|
+
.map((name) => `“${name}”`)
|
|
556
|
+
.join(', ')}`);
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
/** Count a document of the dataset as left out, for `reason`. */
|
|
560
|
+
reject(dataset, id, reason) {
|
|
561
|
+
const tally = this.of(dataset);
|
|
562
|
+
tally.rejected += 1;
|
|
563
|
+
const key = tally.reasons.has(reason) || tally.reasons.size < MAX_REJECTION_REASONS
|
|
564
|
+
? reason
|
|
565
|
+
: 'other reasons';
|
|
566
|
+
const entry = tally.reasons.get(key) ?? { count: 0, examples: [] };
|
|
567
|
+
entry.count += 1;
|
|
568
|
+
if (entry.examples.length < MAX_REJECTION_EXAMPLES) {
|
|
569
|
+
entry.examples.push(id);
|
|
570
|
+
}
|
|
571
|
+
tally.reasons.set(key, entry);
|
|
572
|
+
}
|
|
573
|
+
/** The dataset’s count, most frequent reason first, starting it afresh. */
|
|
574
|
+
take(dataset) {
|
|
575
|
+
const tally = this.datasets.get(dataset);
|
|
576
|
+
this.datasets.delete(dataset);
|
|
577
|
+
const reasons = [...(tally?.reasons ?? [])]
|
|
578
|
+
.map(([reason, { count, examples }]) => ({ reason, count, examples }))
|
|
579
|
+
.sort((left, right) => right.count - left.count);
|
|
580
|
+
return {
|
|
581
|
+
written: tally?.written ?? 0,
|
|
582
|
+
rejected: tally?.rejected ?? 0,
|
|
583
|
+
reasons,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
of(dataset) {
|
|
587
|
+
let tally = this.datasets.get(dataset);
|
|
588
|
+
if (tally === undefined) {
|
|
589
|
+
tally = { written: 0, rejected: 0, reasons: new Map() };
|
|
590
|
+
this.datasets.set(dataset, tally);
|
|
591
|
+
}
|
|
592
|
+
return tally;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Upsert one batch, answering each document Typesense refused with its error –
|
|
597
|
+
* `undefined` for one written – in batch order. Typesense writes the valid
|
|
598
|
+
* documents of a batch and reports the rest per document without rejecting,
|
|
599
|
+
* so a caller that ignored the answer would turn a refusal into silently
|
|
600
|
+
* missing data.
|
|
601
|
+
*
|
|
602
|
+
* Only a client error (4xx) refuses the document itself. Any other failure –
|
|
603
|
+
* the engine out of memory or disk, not ready – says nothing about the data,
|
|
604
|
+
* so it throws: counted as a rejection, it would let a dataset succeed short
|
|
605
|
+
* of documents, and an In-place sweep then delete their indexed versions.
|
|
500
606
|
*/
|
|
501
|
-
async function
|
|
502
|
-
const results =
|
|
607
|
+
async function upsertDocuments(client, collection, batch) {
|
|
608
|
+
const results = await importBatch(client, collection, batch, 'upsert');
|
|
609
|
+
const failed = results.find((result) => !result.success && !(result.code >= 400 && result.code < 500));
|
|
610
|
+
if (failed !== undefined && !failed.success) {
|
|
611
|
+
throw new Error(`Typesense upsert into “${collection}” failed: ${failed.error} (HTTP ${failed.code})`);
|
|
612
|
+
}
|
|
613
|
+
return results.map((result) => (result.success ? undefined : result.error));
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Update one batch, throwing if any document fails. An update only rewrites
|
|
617
|
+
* bookkeeping on documents already stored, so a refusal means broken
|
|
618
|
+
* bookkeeping, not bad source data, and must not be skipped.
|
|
619
|
+
*/
|
|
620
|
+
async function updateDocuments(client, collection, batch) {
|
|
621
|
+
const results = await importBatch(client, collection, batch, 'update');
|
|
622
|
+
const failures = results.flatMap((result) => result.success ? [] : [result.error]);
|
|
623
|
+
if (failures.length > 0) {
|
|
624
|
+
throw new Error(`Typesense update into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures.join('; ')}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Import one batch, answering Typesense’s result per document in batch
|
|
628
|
+
* order. */
|
|
629
|
+
async function importBatch(client, collection, batch, action) {
|
|
630
|
+
return (await client
|
|
503
631
|
.collections(collection)
|
|
504
632
|
.documents()
|
|
505
633
|
.import(batch, {
|
|
506
634
|
action,
|
|
507
635
|
throwOnFail: false,
|
|
508
636
|
}));
|
|
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
637
|
}
|
|
516
638
|
/** A whole-collection search; `q: '*'` needs no field to query by. */
|
|
517
639
|
async function search(client, collection, parameters) {
|
|
@@ -25,6 +25,10 @@ export interface InPlaceRebuildOptions extends RebuildOptions {
|
|
|
25
25
|
* document several datasets share, and the document itself only with the last
|
|
26
26
|
* of them:
|
|
27
27
|
*
|
|
28
|
+
* - a document Typesense refuses or that lacks a required field is left out
|
|
29
|
+
* of the write; the dataset’s **flush** reports those through
|
|
30
|
+
* {@link RunContext.itemsRejected} and fails the dataset, unswept, when they
|
|
31
|
+
* exceed `maxRejectedShare`;
|
|
28
32
|
* - a **successful dataset flush** drops what the dataset had and this run did
|
|
29
33
|
* not rewrite. A failed dataset is not swept – its output is incomplete, and
|
|
30
34
|
* the next successful run reconciles;
|
|
@@ -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;AASvB,OAAO,
|
|
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,EAGL,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;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;CAqFlE"}
|
package/dist/in-place-rebuild.js
CHANGED
|
@@ -2,7 +2,7 @@ import { facetableFields, physicalFields } from '@lde/search/adapter';
|
|
|
2
2
|
import { buildCollectionDefinition } from './collection-definition.js';
|
|
3
3
|
import { ensureCollectionExists, openLockedRun, releaseLock } from './lock.js';
|
|
4
4
|
import { assertBookkeepingPresent, assertWritableType, openDocuments, withBookkeeping, } from './documents.js';
|
|
5
|
-
import { resolveRebuildOptions, } from './rebuild-support.js';
|
|
5
|
+
import { resolveRebuildOptions, settleDataset, } from './rebuild-support.js';
|
|
6
6
|
/**
|
|
7
7
|
* In-place Rebuild (update the live index directly – upsert changed sources,
|
|
8
8
|
* sweep the rest – rather than swap in a fresh one) as a transactional
|
|
@@ -13,6 +13,10 @@ import { resolveRebuildOptions, } from './rebuild-support.js';
|
|
|
13
13
|
* document several datasets share, and the document itself only with the last
|
|
14
14
|
* of them:
|
|
15
15
|
*
|
|
16
|
+
* - a document Typesense refuses or that lacks a required field is left out
|
|
17
|
+
* of the write; the dataset’s **flush** reports those through
|
|
18
|
+
* {@link RunContext.itemsRejected} and fails the dataset, unswept, when they
|
|
19
|
+
* exceed `maxRejectedShare`;
|
|
16
20
|
* - a **successful dataset flush** drops what the dataset had and this run did
|
|
17
21
|
* not rewrite. A failed dataset is not swept – its output is incomplete, and
|
|
18
22
|
* the next successful run reconciles;
|
|
@@ -70,7 +74,7 @@ export class InPlaceRebuild {
|
|
|
70
74
|
this.resolved.definitionOptions.collectionNameFor(searchType);
|
|
71
75
|
}
|
|
72
76
|
async openRun(context) {
|
|
73
|
-
const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
|
|
77
|
+
const { batchSize, lockTtlMs, maxRejectedShare, definitionOptions } = this.resolved;
|
|
74
78
|
const name = this.collectionName;
|
|
75
79
|
return openLockedRun(this.client, name, lockTtlMs, async () => {
|
|
76
80
|
// Create the collection on demand: the SearchType’s schema plus whatever
|
|
@@ -92,24 +96,31 @@ export class InPlaceRebuild {
|
|
|
92
96
|
runId: context.runId,
|
|
93
97
|
startedAt: context.startedAt,
|
|
94
98
|
batchSize,
|
|
99
|
+
schema: definitionOptions.schema,
|
|
95
100
|
maxSweepableSources: this.maxSweepableSources,
|
|
96
101
|
});
|
|
97
102
|
return {
|
|
98
103
|
write: async (dataset, items) => documents.add(dataset, items),
|
|
99
104
|
flush: async (dataset, outcome) => {
|
|
105
|
+
// A dataset that left too much out fails here, unswept like any
|
|
106
|
+
// other failed dataset.
|
|
107
|
+
await settleDataset(documents, dataset, outcome, context, this.searchType, maxRejectedShare);
|
|
100
108
|
if (outcome !== 'success') {
|
|
101
109
|
// A failed dataset’s output is incomplete: sweeping against it
|
|
102
110
|
// would delete documents the run never got to rewrite. Leave the
|
|
103
|
-
// stale ones for the next successful run to reconcile.
|
|
104
|
-
// documents stay buffered; the next flush lands them.
|
|
111
|
+
// stale ones for the next successful run to reconcile.
|
|
105
112
|
return;
|
|
106
113
|
}
|
|
107
114
|
await documents.dropUnwritten(dataset);
|
|
108
115
|
},
|
|
109
116
|
// Discard only this run’s partial writes for the dataset (the failed
|
|
110
117
|
// endpoint attempt) so the dump re-run rebuilds it cleanly; the
|
|
111
|
-
// dataset’s prior-run documents stay for the success sweep.
|
|
112
|
-
|
|
118
|
+
// dataset’s prior-run documents stay for the success sweep. What the
|
|
119
|
+
// attempt left out is no longer the dataset’s to answer for.
|
|
120
|
+
reset: async (dataset) => {
|
|
121
|
+
await documents.settle(dataset);
|
|
122
|
+
await documents.dropWrittenThisRun(dataset);
|
|
123
|
+
},
|
|
113
124
|
commit: async () => {
|
|
114
125
|
await documents.flush();
|
|
115
126
|
await documents.dropUnselected(context.selectedSources());
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { Dataset } from '@lde/dataset';
|
|
2
|
+
import type { DatasetOutcome, RunContext } from '@lde/pipeline';
|
|
1
3
|
import type { SearchType } from '@lde/search';
|
|
2
4
|
import { type CollectionDefinitionOptions } from './collection-definition.js';
|
|
5
|
+
import type { RunDocuments } from './documents.js';
|
|
3
6
|
/**
|
|
4
7
|
* The tuning knobs both rebuild writers share, on top of the collection-definition
|
|
5
8
|
* options. Each writer may add its own (In-place adds `maxSweepableSources`).
|
|
@@ -9,12 +12,22 @@ export interface RebuildOptions extends CollectionDefinitionOptions {
|
|
|
9
12
|
readonly batchSize?: number;
|
|
10
13
|
/** A held lock older than this (ms) is reclaimed (default 10 minutes). */
|
|
11
14
|
readonly lockTtlMs?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The largest share of a dataset’s documents, from 0 to 1, this collection
|
|
17
|
+
* may leave out before the dataset fails (default 0.5). A document is left
|
|
18
|
+
* out when Typesense refuses it or it lacks a required field, and reported
|
|
19
|
+
* through {@link RunContext.itemsRejected} either way. A dataset whose every
|
|
20
|
+
* document was left out always fails, so it cannot go live empty; `0` fails
|
|
21
|
+
* a dataset on its first rejection.
|
|
22
|
+
*/
|
|
23
|
+
readonly maxRejectedShare?: number;
|
|
12
24
|
}
|
|
13
25
|
/** The shared options resolved to concrete values plus the residual
|
|
14
26
|
* collection-definition options a writer passes to {@link buildCollectionDefinition}. */
|
|
15
27
|
export interface ResolvedRebuildOptions {
|
|
16
28
|
readonly batchSize: number;
|
|
17
29
|
readonly lockTtlMs: number;
|
|
30
|
+
readonly maxRejectedShare: number;
|
|
18
31
|
/** The collection-definition options with `collectionNameFor` resolved to a
|
|
19
32
|
* total function – the one place the naming convention lives, so the
|
|
20
33
|
* collection a writer talks to, the definition it creates and the peer
|
|
@@ -38,4 +51,19 @@ export interface ResolvedRebuildOptions {
|
|
|
38
51
|
* been paid for.
|
|
39
52
|
*/
|
|
40
53
|
export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
|
|
54
|
+
/**
|
|
55
|
+
* Finish a dataset’s writes to one collection: land them, report what was
|
|
56
|
+
* left out ({@link RunContext.itemsRejected}), and fail an otherwise
|
|
57
|
+
* successful dataset when more was left out than `maxRejectedShare` allows –
|
|
58
|
+
* or all of it. A dataset that already failed is only reported: its output is
|
|
59
|
+
* incomplete, so its share says nothing, and failing it twice would blame the
|
|
60
|
+
* data for what a stage did.
|
|
61
|
+
*
|
|
62
|
+
* Both writers settle at the dataset’s flush rather than leave its last
|
|
63
|
+
* partial batch for the next dataset’s writes to land, so a rejection is
|
|
64
|
+
* always counted, reported and judged against the dataset it belongs to.
|
|
65
|
+
*/
|
|
66
|
+
export declare function settleDataset(documents: RunDocuments<{
|
|
67
|
+
id: string;
|
|
68
|
+
}>, dataset: Dataset, outcome: DatasetOutcome, context: RunContext, searchType: SearchType, maxRejectedShare: number): Promise<void>;
|
|
41
69
|
//# sourceMappingURL=rebuild-support.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAUnD;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;uEAGmE;IACnE,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,iBAAiB,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;KAChE,CAAC;CACH;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAyBxB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,SAAS,EAAE,YAAY,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,EACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,UAAU,EACnB,UAAU,EAAE,UAAU,EACtB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAsBf"}
|
package/dist/rebuild-support.js
CHANGED
|
@@ -4,6 +4,9 @@ import { deriveCollectionName } from './collection-name.js';
|
|
|
4
4
|
import { DEFAULT_LOCK_TTL_MS } from './lock.js';
|
|
5
5
|
/** Documents imported per Typesense request unless a writer is told otherwise. */
|
|
6
6
|
const DEFAULT_BATCH_SIZE = 1000;
|
|
7
|
+
/** The share of a dataset’s documents a collection may reject before the
|
|
8
|
+
* dataset fails, unless a writer is told otherwise. */
|
|
9
|
+
const DEFAULT_MAX_REJECTED_SHARE = 0.5;
|
|
7
10
|
/**
|
|
8
11
|
* Apply the shared defaults, once, so neither writer restates them – including
|
|
9
12
|
* the collection naming, derived from each type ({@link deriveCollectionName})
|
|
@@ -19,10 +22,14 @@ const DEFAULT_BATCH_SIZE = 1000;
|
|
|
19
22
|
* been paid for.
|
|
20
23
|
*/
|
|
21
24
|
export function resolveRebuildOptions(searchType, options) {
|
|
22
|
-
const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...definitionOptions } = options;
|
|
25
|
+
const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, maxRejectedShare = DEFAULT_MAX_REJECTED_SHARE, ...definitionOptions } = options;
|
|
26
|
+
if (!(maxRejectedShare >= 0 && maxRejectedShare <= 1)) {
|
|
27
|
+
throw new Error(`maxRejectedShare must be a share from 0 to 1, got ${maxRejectedShare}.`);
|
|
28
|
+
}
|
|
23
29
|
const resolved = {
|
|
24
30
|
batchSize,
|
|
25
31
|
lockTtlMs,
|
|
32
|
+
maxRejectedShare,
|
|
26
33
|
definitionOptions: {
|
|
27
34
|
...definitionOptions,
|
|
28
35
|
collectionNameFor: definitionOptions.collectionNameFor ?? deriveCollectionName,
|
|
@@ -32,6 +39,38 @@ export function resolveRebuildOptions(searchType, options) {
|
|
|
32
39
|
buildCollectionDefinition(searchType, resolved.definitionOptions);
|
|
33
40
|
return resolved;
|
|
34
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Finish a dataset’s writes to one collection: land them, report what was
|
|
44
|
+
* left out ({@link RunContext.itemsRejected}), and fail an otherwise
|
|
45
|
+
* successful dataset when more was left out than `maxRejectedShare` allows –
|
|
46
|
+
* or all of it. A dataset that already failed is only reported: its output is
|
|
47
|
+
* incomplete, so its share says nothing, and failing it twice would blame the
|
|
48
|
+
* data for what a stage did.
|
|
49
|
+
*
|
|
50
|
+
* Both writers settle at the dataset’s flush rather than leave its last
|
|
51
|
+
* partial batch for the next dataset’s writes to land, so a rejection is
|
|
52
|
+
* always counted, reported and judged against the dataset it belongs to.
|
|
53
|
+
*/
|
|
54
|
+
export async function settleDataset(documents, dataset, outcome, context, searchType, maxRejectedShare) {
|
|
55
|
+
const writes = await documents.settle(dataset);
|
|
56
|
+
if (writes.rejected === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
context.itemsRejected?.(dataset, { target: searchType.name, ...writes });
|
|
60
|
+
if (outcome !== 'success') {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const limit = writes.rejected === writes.written
|
|
64
|
+
? 'every one of them'
|
|
65
|
+
: writes.rejected > writes.written * maxRejectedShare
|
|
66
|
+
? `more than the ${Math.round(maxRejectedShare * 1000) / 10}% maxRejectedShare allows`
|
|
67
|
+
: undefined;
|
|
68
|
+
if (limit !== undefined) {
|
|
69
|
+
throw new Error(`“${searchType.name}” left out ${writes.rejected} of ${writes.written} documents of ${dataset.iri.toString()}, ${limit}: ${writes.reasons
|
|
70
|
+
.map(({ reason, count }) => `${count} × ${reason}`)
|
|
71
|
+
.join('; ')}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
35
74
|
/**
|
|
36
75
|
* Reject a `collectionNameFor` that gives a join target the same name as the
|
|
37
76
|
* type doing the joining.
|
package/dist/search.d.ts
CHANGED
|
@@ -69,8 +69,8 @@ export interface LabelSource {
|
|
|
69
69
|
* reference field from its own label source’s collection, all sources bundled
|
|
70
70
|
* into one lookup – and reconstructs the engine-neutral
|
|
71
71
|
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
72
|
-
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants)
|
|
73
|
-
*
|
|
72
|
+
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) in
|
|
73
|
+
* one `multi_search` round-trip with one shared label lookup; a failed
|
|
74
74
|
* entry is reported in place as a per-query outcome, so it never discards
|
|
75
75
|
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
76
76
|
* logical documents.
|
package/dist/search.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAIL,KAAK,cAAc,EAMnB,KAAK,UAAU,EAGf,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AA0BrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,aAAa,CAAC;AAErB;;;;sDAIsD;AACtD,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAIhC,SAAQ,IAAI,CAAC,wBAAwB,EAAE,eAAe,CAAC;IACvD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACnE;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CACpC,KAAK,SAAS,SAAS,UAAU,EAAE,GAAG,SAAS,UAAU,EAAE,CAC3D,SAAQ,YAAY,CAAC,KAAK,CAAC;IAC3B;;;;;;;OAOG;IACH,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC;CAC1D;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,GAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAM,GAChE,qBAAqB,CAAC,KAAK,CAAC,CAic9B;AAyCD,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAmXD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAwDtC;AAgCD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B;;2EAEmE;QACnE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;QACJ;;qFAE6E;QAC7E,QAAQ,CAAC,KAAK,CAAC,EACX;YAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;SAAE,GAC9C;YAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC;YAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAC;KACpD,EAAE,CAAC;CACL;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,EAC3C,MAAM,EAAE,YAAY,EACpB,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,GACjD,YAAY,CAuDd"}
|
package/dist/search.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NESTED_DOCUMENT_TYPE, } from '@lde/search';
|
|
2
|
-
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isBinnedRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNamesOf, labelTargetNamesOf, localLookupTargetsOf, nestedReferenceType, outputFields, physicalFields, referencedTargetsOf, referenceFields, storedTargetOf, } from '@lde/search/adapter';
|
|
2
|
+
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, facetSearches, fieldNamed, filterOn, inheritedFacetPolicies, isInlineReference, isBinnedRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNamesOf, labelTargetNamesOf, localLookupTargetsOf, nestedReferenceType, outputFields, physicalFields, referencedTargetsOf, referenceFields, storedTargetOf, } from '@lde/search/adapter';
|
|
3
3
|
import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
|
|
4
4
|
import { deriveCollectionName } from './collection-name.js';
|
|
5
5
|
import { resolveProjection, } from './lookup.js';
|
|
@@ -13,8 +13,8 @@ import { resolveProjection, } from './lookup.js';
|
|
|
13
13
|
* reference field from its own label source’s collection, all sources bundled
|
|
14
14
|
* into one lookup – and reconstructs the engine-neutral
|
|
15
15
|
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
16
|
-
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants)
|
|
17
|
-
*
|
|
16
|
+
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) in
|
|
17
|
+
* one `multi_search` round-trip with one shared label lookup; a failed
|
|
18
18
|
* entry is reported in place as a per-query outcome, so it never discards
|
|
19
19
|
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
20
20
|
* logical documents.
|
|
@@ -220,6 +220,63 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
220
220
|
return new Map();
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Answer the searches each caller’s query needs and fold them back into one
|
|
225
|
+
* response per caller: the hits and total of its first search, the facets
|
|
226
|
+
* of all. The searches travel together in one `multi_search` round-trip
|
|
227
|
+
* (split into requests of {@link MULTI_SEARCH_LIMIT}) – a POST, so a long
|
|
228
|
+
* `filter_by` (a batch `id` lookup, any membership over many IRIs) is
|
|
229
|
+
* bounded by the caller’s request, not by Typesense’s 4000-char
|
|
230
|
+
* query-string limit. A search asking for no document is answered empty
|
|
231
|
+
* without being sent: its only filter would compile away and hand back the
|
|
232
|
+
* whole collection ({@link isUnsatisfiable}). Each facet then reports the
|
|
233
|
+
* values its search selected ({@link withSelectedValues}).
|
|
234
|
+
*
|
|
235
|
+
* A failed search fails exactly its caller, which gets an `Error` in its
|
|
236
|
+
* place; only the transport failing rejects.
|
|
237
|
+
*/
|
|
238
|
+
async function answer(searchType, callers) {
|
|
239
|
+
const collection = collections.get(searchType.class);
|
|
240
|
+
const planned = callers.flat();
|
|
241
|
+
const dispatched = planned.filter(({ query }) => !isUnsatisfiable(query));
|
|
242
|
+
const results = await performInChunks(client, dispatched.map(({ query }) => ({
|
|
243
|
+
collection,
|
|
244
|
+
...buildSearchParams(query, searchType, searchParamsOptions),
|
|
245
|
+
})));
|
|
246
|
+
const transportFailure = results.find((result) => result instanceof Error);
|
|
247
|
+
if (transportFailure !== undefined) {
|
|
248
|
+
throw transportFailure;
|
|
249
|
+
}
|
|
250
|
+
const declaredNames = declaredFacetNames.get(searchType.class);
|
|
251
|
+
let entry = 0;
|
|
252
|
+
const responses = planned.map((search) => {
|
|
253
|
+
if (isUnsatisfiable(search.query)) {
|
|
254
|
+
return EMPTY_RESPONSE;
|
|
255
|
+
}
|
|
256
|
+
const result = results[entry++];
|
|
257
|
+
return result === undefined || 'error' in result
|
|
258
|
+
? searchError(collection, search, result)
|
|
259
|
+
: withDeclaredFacetNames(result, declaredNames);
|
|
260
|
+
});
|
|
261
|
+
const completed = await withSelectedValues(client, collection, searchType, planned.map((search, index) => ({
|
|
262
|
+
...search,
|
|
263
|
+
response: responses[index],
|
|
264
|
+
})), searchParamsOptions);
|
|
265
|
+
let next = 0;
|
|
266
|
+
return callers.map((searches) => {
|
|
267
|
+
const own = completed.slice(next, (next += searches.length));
|
|
268
|
+
const failed = own.find((response) => response instanceof Error);
|
|
269
|
+
if (failed !== undefined) {
|
|
270
|
+
return failed;
|
|
271
|
+
}
|
|
272
|
+
const answered = own;
|
|
273
|
+
const [first = EMPTY_RESPONSE] = answered;
|
|
274
|
+
return {
|
|
275
|
+
...first,
|
|
276
|
+
facet_counts: answered.flatMap((response) => response.facet_counts ?? []),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
223
280
|
const engine = {
|
|
224
281
|
schema,
|
|
225
282
|
collectionNameFor(searchType) {
|
|
@@ -232,21 +289,27 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
232
289
|
// rejected up front, for EVERY caller.
|
|
233
290
|
assertTypeInSchema(schema, searchType);
|
|
234
291
|
assertValidQuery(query, searchType, schema, joins);
|
|
235
|
-
// Asks for no document (an empty `id` membership): answer it without a
|
|
236
|
-
// round-trip rather than dispatching a query whose only filter compiles
|
|
237
|
-
// away, which would hand back the whole collection.
|
|
238
|
-
if (isUnsatisfiable(query)) {
|
|
239
|
-
return { total: 0, hits: [], facets: {} };
|
|
240
|
-
}
|
|
241
|
-
const params = buildSearchParams(query, searchType, searchParamsOptions);
|
|
242
292
|
const cachedLabelsPromise = startCachedLabels(searchType);
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// `
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
293
|
+
// The hits come from the query as asked, carrying the facets that share
|
|
294
|
+
// its `where`; each own-filtered facet is counted by a search of its own
|
|
295
|
+
// (`facetSearches`), in the same round-trip.
|
|
296
|
+
const searches = facetSearches(query);
|
|
297
|
+
const shared = searches.find((search) => search.own === undefined);
|
|
298
|
+
const [response] = await answer(searchType, [
|
|
299
|
+
[
|
|
300
|
+
{
|
|
301
|
+
query: { ...query, facets: shared?.query.facets ?? [] },
|
|
302
|
+
selected: {},
|
|
303
|
+
facetOnly: false,
|
|
304
|
+
},
|
|
305
|
+
...searches
|
|
306
|
+
.filter((search) => search !== shared)
|
|
307
|
+
.map(facetOnlySearch),
|
|
308
|
+
],
|
|
309
|
+
]);
|
|
310
|
+
if (response instanceof Error) {
|
|
311
|
+
throw response;
|
|
312
|
+
}
|
|
250
313
|
const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
|
|
251
314
|
// What the caller asked to resolve, level by level. Independent of the
|
|
252
315
|
// label lookup above: that one serves facet buckets and unprojected
|
|
@@ -262,56 +325,22 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
262
325
|
if (queries.length === 0) {
|
|
263
326
|
return [];
|
|
264
327
|
}
|
|
265
|
-
const collection = collections.get(searchType.class);
|
|
266
328
|
const cachedLabelsPromise = startCachedLabels(searchType);
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// facets over the whole collection. Such a query is answered with empty
|
|
270
|
-
// facets and kept OUT of the batch, so `dispatched` maps each sent search
|
|
271
|
-
// back to its caller position.
|
|
272
|
-
const dispatched = queries
|
|
273
|
-
.map((query, index) => ({ query, index }))
|
|
274
|
-
.filter(({ query }) => !isUnsatisfiable(query));
|
|
275
|
-
if (dispatched.length === 0) {
|
|
276
|
-
return queries.map(() => ({ facets: {} }));
|
|
277
|
-
}
|
|
278
|
-
// The whole batch travels as ONE multi_search round-trip. Each query
|
|
279
|
-
// compiles as facet-only regardless of what it carries: no hits
|
|
329
|
+
// Every search of every query travels in one multi_search round-trip,
|
|
330
|
+
// each facet-only regardless of what its query carries: no hits
|
|
280
331
|
// (per_page 0) and no ordering – nothing is transferred or sorted that
|
|
281
332
|
// this method cannot return.
|
|
282
|
-
const
|
|
283
|
-
searches: dispatched.map(({ query }) => ({
|
|
284
|
-
collection,
|
|
285
|
-
...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, searchParamsOptions),
|
|
286
|
-
})),
|
|
287
|
-
}));
|
|
288
|
-
const declaredNames = declaredFacetNames.get(searchType.class);
|
|
289
|
-
const results = rawResults.map((result) => 'error' in result
|
|
290
|
-
? result
|
|
291
|
-
: withDeclaredFacetNames(result, declaredNames));
|
|
333
|
+
const responses = await answer(searchType, queries.map((query) => facetSearches(query).map(facetOnlySearch)));
|
|
292
334
|
// One label lookup serves every successful facet result in the batch.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
dispatched.forEach(({ index }, entry) => {
|
|
303
|
-
const result = results[entry];
|
|
304
|
-
outcomes[index] =
|
|
305
|
-
result === undefined || 'error' in result
|
|
306
|
-
? {
|
|
307
|
-
error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result?.code !== undefined ? ` (${result.code})` : ''}: ${result?.error ?? 'no result entry'}`),
|
|
308
|
-
}
|
|
309
|
-
: {
|
|
310
|
-
facets: parseSearchResponse(result, searchType, labels, schema)
|
|
311
|
-
.facets,
|
|
312
|
-
};
|
|
313
|
-
});
|
|
314
|
-
return outcomes;
|
|
335
|
+
const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses.filter((response) => !(response instanceof Error)), labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
|
|
336
|
+
// A failed query is reported in place, so it never discards its
|
|
337
|
+
// siblings’ facets.
|
|
338
|
+
return responses.map((response) => response instanceof Error
|
|
339
|
+
? { error: response }
|
|
340
|
+
: {
|
|
341
|
+
facets: parseSearchResponse(response, searchType, labels, schema)
|
|
342
|
+
.facets,
|
|
343
|
+
});
|
|
315
344
|
},
|
|
316
345
|
};
|
|
317
346
|
// The runtime object is string-keyed; the literal-schema typing narrows it.
|
|
@@ -449,24 +478,179 @@ function withDeclaredFacetNames(response, declaredNames) {
|
|
|
449
478
|
})),
|
|
450
479
|
};
|
|
451
480
|
}
|
|
481
|
+
/** A planned facet search as sent: no hits, and with no hits no ordering. */
|
|
482
|
+
function facetOnlySearch(search) {
|
|
483
|
+
return {
|
|
484
|
+
query: { ...search.query, orderBy: [], limit: 0, offset: 0 },
|
|
485
|
+
selected: search.selected,
|
|
486
|
+
facetOnly: true,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** The error a failed or missing `multi_search` entry stands for, naming a
|
|
490
|
+
* facet search by its facets – the batch position is the adapter’s own. */
|
|
491
|
+
function searchError(collection, search, result) {
|
|
492
|
+
return entryError(search.facetOnly
|
|
493
|
+
? `facet search for “${search.query.facets.join('”, “')}”`
|
|
494
|
+
: `search of “${collection}”`, result);
|
|
495
|
+
}
|
|
496
|
+
/** The error a failed or missing `multi_search` entry stands for. */
|
|
497
|
+
function entryError(subject, result) {
|
|
498
|
+
return new Error(result === undefined
|
|
499
|
+
? `Typesense ${subject} returned no result entry.`
|
|
500
|
+
: `Typesense ${subject} failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
|
|
501
|
+
}
|
|
452
502
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* contract must not weaken just because the transport moved off GET.
|
|
503
|
+
* Send searches as `multi_search` requests of at most {@link
|
|
504
|
+
* MULTI_SEARCH_LIMIT} each, concurrently, and return their entries in order.
|
|
505
|
+
* A request that rejects rejects the whole call: the transport failed.
|
|
457
506
|
*/
|
|
458
|
-
async function
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const [result] = results;
|
|
463
|
-
if (result === undefined) {
|
|
464
|
-
throw new Error(`Typesense search of “${collection}” returned no result entry.`);
|
|
465
|
-
}
|
|
466
|
-
if ('error' in result) {
|
|
467
|
-
throw new Error(`Typesense search of “${collection}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
|
|
507
|
+
async function performInChunks(client, searches) {
|
|
508
|
+
const chunks = [];
|
|
509
|
+
for (let start = 0; start < searches.length; start += MULTI_SEARCH_LIMIT) {
|
|
510
|
+
chunks.push(searches.slice(start, start + MULTI_SEARCH_LIMIT));
|
|
468
511
|
}
|
|
469
|
-
|
|
512
|
+
const answered = await Promise.all(chunks.map(async (chunk) => {
|
|
513
|
+
try {
|
|
514
|
+
return (await client.multiSearch.perform({ searches: [...chunk] })).results;
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
// The request as a whole failed: so did every search it carried, and
|
|
518
|
+
// nothing else. The Typesense client rejects with an Error.
|
|
519
|
+
return chunk.map(() => error);
|
|
520
|
+
}
|
|
521
|
+
}));
|
|
522
|
+
return answered.flat();
|
|
523
|
+
}
|
|
524
|
+
/** A response with nothing in it: the answer to a search that asks for no
|
|
525
|
+
* document, which is never dispatched. */
|
|
526
|
+
const EMPTY_RESPONSE = { found: 0, hits: [] };
|
|
527
|
+
/** The bucket cap Typesense applies when `max_facet_values` is unset. */
|
|
528
|
+
const TYPESENSE_DEFAULT_MAX_FACET_VALUES = 10;
|
|
529
|
+
/** The searches Typesense accepts in one `multi_search` request by default
|
|
530
|
+
* (`limit_multi_searches`). */
|
|
531
|
+
const MULTI_SEARCH_LIMIT = 50;
|
|
532
|
+
/**
|
|
533
|
+
* Each response with the values its search selected among the facet buckets,
|
|
534
|
+
* merged with the engine’s own in descending count order. A response that is
|
|
535
|
+
* already an `Error` passes through unchanged.
|
|
536
|
+
*
|
|
537
|
+
* A value missing from a facet the engine reported in full matches no
|
|
538
|
+
* document, so it gets count 0 without another search. A value missing from a
|
|
539
|
+
* facet cut off at the bucket cap, or narrowed by a facet policy, may still
|
|
540
|
+
* match documents, so it is counted by a search of its own: the search’s
|
|
541
|
+
* `where` plus that value. One search per value, because a facet search
|
|
542
|
+
* filtered on several values of a multi-valued field could cut off the very
|
|
543
|
+
* values it was asked to count. These searches travel together, in as few
|
|
544
|
+
* `multi_search` requests as Typesense accepts. The common cases (no
|
|
545
|
+
* selection, a selection with hits, a facet under the cap) cost nothing.
|
|
546
|
+
*
|
|
547
|
+
* A failed count fails exactly the response it was for, which becomes an
|
|
548
|
+
* `Error` in its place.
|
|
549
|
+
*/
|
|
550
|
+
async function withSelectedValues(client, collection, searchType, entries, options) {
|
|
551
|
+
const cap = options.maxFacetValues ?? TYPESENSE_DEFAULT_MAX_FACET_VALUES;
|
|
552
|
+
const missing = entries.map(({ query, selected, response }) => response instanceof Error
|
|
553
|
+
? []
|
|
554
|
+
: Object.entries(selected).flatMap(([field, values]) => {
|
|
555
|
+
const counts = response.facet_counts?.find((facet) => facet.field_name === field)
|
|
556
|
+
?.counts ?? [];
|
|
557
|
+
const present = new Set(counts.map((bucket) => bucket.value));
|
|
558
|
+
// A facet narrowed by a facet policy never reports a value the
|
|
559
|
+
// policy excludes, though a filter on the field still matches it:
|
|
560
|
+
// absent is no proof of 0 there either, so count it. A search that
|
|
561
|
+
// asks for no document has nothing to count.
|
|
562
|
+
const uncertain = !isUnsatisfiable(query) &&
|
|
563
|
+
(counts.length >= cap ||
|
|
564
|
+
narrowedByPolicy(searchType, field, options));
|
|
565
|
+
return values
|
|
566
|
+
.filter((value) => !present.has(value))
|
|
567
|
+
.map((value) => ({ field, value, uncertain }));
|
|
568
|
+
}));
|
|
569
|
+
const toCount = missing.flatMap((values, entry) => values
|
|
570
|
+
.filter(({ uncertain }) => uncertain)
|
|
571
|
+
.map(({ field, value }) => ({ entry, field, value })));
|
|
572
|
+
const found = new Map();
|
|
573
|
+
const key = (entry, field, value) => JSON.stringify([entry, field, value]);
|
|
574
|
+
const results = await performInChunks(client, toCount.map(({ entry, field, value }) => {
|
|
575
|
+
const { query } = entries[entry];
|
|
576
|
+
return {
|
|
577
|
+
collection,
|
|
578
|
+
...buildSearchParams({
|
|
579
|
+
...query,
|
|
580
|
+
where: [...query.where, selecting(searchType, field, value)],
|
|
581
|
+
facets: [],
|
|
582
|
+
orderBy: [],
|
|
583
|
+
limit: 0,
|
|
584
|
+
offset: 0,
|
|
585
|
+
}, searchType, options),
|
|
586
|
+
};
|
|
587
|
+
}));
|
|
588
|
+
toCount.forEach(({ entry, field, value }, index) => {
|
|
589
|
+
const result = results[index];
|
|
590
|
+
found.set(key(entry, field, value), result instanceof Error
|
|
591
|
+
? result
|
|
592
|
+
: result === undefined || 'error' in result
|
|
593
|
+
? entryError(`count of facet value “${value}” on “${field}”`, result)
|
|
594
|
+
: result.found);
|
|
595
|
+
});
|
|
596
|
+
return entries.map(({ response }, entry) => {
|
|
597
|
+
const values = missing[entry];
|
|
598
|
+
if (response instanceof Error || values.length === 0) {
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
601
|
+
const added = new Map();
|
|
602
|
+
for (const { field, value, uncertain } of values) {
|
|
603
|
+
// Every uncertain value was counted above.
|
|
604
|
+
const count = uncertain
|
|
605
|
+
? found.get(key(entry, field, value))
|
|
606
|
+
: 0;
|
|
607
|
+
if (count instanceof Error) {
|
|
608
|
+
return count;
|
|
609
|
+
}
|
|
610
|
+
added.set(field, [...(added.get(field) ?? []), { value, count }]);
|
|
611
|
+
}
|
|
612
|
+
const facetCounts = response.facet_counts ?? [];
|
|
613
|
+
return {
|
|
614
|
+
...response,
|
|
615
|
+
facet_counts: [
|
|
616
|
+
...facetCounts.map((facet) => ({
|
|
617
|
+
...facet,
|
|
618
|
+
// Merged by count: a value a policy hid can outnumber every bucket.
|
|
619
|
+
counts: added.has(facet.field_name)
|
|
620
|
+
? [
|
|
621
|
+
...facet.counts,
|
|
622
|
+
...added.get(facet.field_name),
|
|
623
|
+
].sort(byCount)
|
|
624
|
+
: facet.counts,
|
|
625
|
+
})),
|
|
626
|
+
// A facet the response does not carry at all: an unanswered search’s.
|
|
627
|
+
...[...added]
|
|
628
|
+
.filter(([field]) => !facetCounts.some((facet) => facet.field_name === field))
|
|
629
|
+
.map(([field, counts]) => ({
|
|
630
|
+
field_name: field,
|
|
631
|
+
counts: counts.sort(byCount),
|
|
632
|
+
})),
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/** The clause that selects one facet value: `is` on a boolean, `in` on
|
|
638
|
+
* anything else. */
|
|
639
|
+
function selecting(searchType, field, value) {
|
|
640
|
+
return filterOn(fieldNamed(searchType, field)?.kind === 'boolean'
|
|
641
|
+
? { field, is: value === 'true' }
|
|
642
|
+
: { field, in: [value] });
|
|
643
|
+
}
|
|
644
|
+
/** Orders buckets by descending count, as the engine orders its own. */
|
|
645
|
+
function byCount(first, second) {
|
|
646
|
+
return second.count - first.count;
|
|
647
|
+
}
|
|
648
|
+
/** Whether a facet policy narrows the facet on `name` ({@link
|
|
649
|
+
* inheritedFacetPolicies}), so its buckets leave out values its field holds. */
|
|
650
|
+
function narrowedByPolicy(searchType, name, options) {
|
|
651
|
+
const field = fieldNamed(searchType, name);
|
|
652
|
+
return (field !== undefined &&
|
|
653
|
+
inheritedFacetPolicies(field, options.schema).size > 0);
|
|
470
654
|
}
|
|
471
655
|
/**
|
|
472
656
|
* Resolve labels from each group’s label-source collection. Labels are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-typesense",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
28
|
+
"@lde/search": "^0.32.0",
|
|
29
29
|
"@lde/text-normalization": "^0.2.0",
|
|
30
30
|
"tslib": "^2.3.0",
|
|
31
31
|
"typesense": "^3.1.1"
|
|
@@ -36,6 +36,6 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@lde/dataset": "^0.8.0",
|
|
39
|
-
"@lde/pipeline": "^0.
|
|
39
|
+
"@lde/pipeline": "^0.39.0"
|
|
40
40
|
}
|
|
41
41
|
}
|