@lde/search-typesense 0.37.0 → 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.
@@ -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,EAEL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;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;IAsGjE,4EAA4E;YAC9D,WAAW;CAW1B"}
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 is rolled back so the swap never ships it.
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) => rollback(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
@@ -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
- /** Write a dataset’s documents, batched. */
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
  *
@@ -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;AAmCtE;;;;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;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,4CAA4C;IAC5C,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,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;;;;;;;;;;;;;;;;;;;;;;;;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"}
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 > 0) {
212
- await importDocuments(client, collection, batch, 'upsert');
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]: dataset.iri.toString(),
248
+ [field]: iri,
235
249
  [LAST_SEEN_FIELD]: runId,
236
250
  };
237
251
  for await (const document of documents) {
238
- buffer.push({ ...document, ...stamp });
239
- if (buffer.full()) {
240
- await land();
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
- await importDocuments(client, collection, [...merged.values()].map(({ document, datasets }) => ({
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
- })), 'upsert');
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 importDocuments(client, collection, keeping, 'update');
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
- buffer.push({ document, dataset: iri });
364
- if (buffer.full()) {
365
- await land();
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
- * 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.
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 importDocuments(client, collection, batch, action) {
502
- const results = (await client
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,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"}
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"}
@@ -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. The buffered
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
- reset: async (dataset) => documents.dropWrittenThisRun(dataset),
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;AAOpC;;;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;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;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,CAkBxB"}
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"}
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.37.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.31.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.38.1"
39
+ "@lde/pipeline": "^0.39.0"
40
40
  }
41
41
  }