@procedures/mongo 0.36.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.
@@ -33,6 +33,8 @@
33
33
  * 1000 rows. It keeps nothing between passes.
34
34
  * - A change stream that ends (for example after `dropDatabase`) opens again after `rewatch`,
35
35
  * and a pass catches up on the changes that the stream missed.
36
+ * - Each stream is open on the server before the materializer reads what it watches: the views
37
+ * before the read of the views, the sources before a run. A change between the two reaches both.
36
38
  * - The status of each view (`ok`, `errored`, `cycle`) goes into the field `statusField` of its
37
39
  * document. The materializer ignores an update of only that field.
38
40
  *
@@ -44,15 +46,17 @@
44
46
  * that moves a document to another partition also reaches the views of its old partition.
45
47
  *
46
48
  * Change streams and transactions need a replica set (MongoDB 6 or later). The target of a view is
47
- * a plain collection that only the materializer writes.
49
+ * a plain collection that only the materializer writes. A target of `storageTargets` is in the
50
+ * stored form of a `MongoDocumentStorage` instead (roadmap CLI-81): see `ViewStorageTarget`.
48
51
  *
49
52
  * The stop has the parts of a transport of a service: `stopIntake()`, `drain()`, `inFlight()` and
50
53
  * `close()`. A host composes them with no adapter. The timers come from the injected clock.
51
54
  */
52
55
  import { createHash } from "node:crypto";
56
+ import { isDeepStrictEqual } from "node:util";
53
57
  import { BSON, ObjectId } from "mongodb";
54
- import { optionalMs, realClock } from "@procedures/client";
55
- import { DEFAULT_STORED_FORM, TOMBSTONE_FIELD } from "./document-storage.js";
58
+ import { collectionChangeTopic, ms, optionalMs, realClock } from "@procedures/client";
59
+ import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget } from "./document-storage.js";
56
60
  import { scopePipeline } from "./pipeline-scope.js";
57
61
  /** The fields of a storage that a row never keeps: its sequence number and its tombstone flag (roadmap CLI-77). */
58
62
  const ROW_DROPPED_FIELDS = ["_seq", TOMBSTONE_FIELD];
@@ -94,14 +98,60 @@ function groups(items) {
94
98
  result.push(items.slice(start, start + WRITE_GROUP));
95
99
  return result;
96
100
  }
101
+ /** The most tries of a run in a transaction after a write conflict (code 112). After them, the run gives way, and comes back after `rewatch`. */
102
+ const CONFLICT_RETRIES = 3;
103
+ /** The most ids of a hint of a target not in a stored form. More ids give a reset hint. */
104
+ const MAX_HINT_IDS = 10_000;
105
+ /** The `_id` of a row in the result, from the stored `_id` of `partitionTargetId`. */
106
+ export function defaultRowId(storedId) {
107
+ return typeof storedId === "object" && storedId !== null && "id" in storedId ? storedId.id : storedId;
108
+ }
109
+ /**
110
+ * The id of a row of a target in a stored form, from the `_id` of the row in the result: a text as
111
+ * it is, another value as its relaxed Extended JSON text. The text of a value is the same at each
112
+ * run (the order of the fields of an object is the order of the result). A row without `_id` has
113
+ * no id.
114
+ */
115
+ export function defaultStorageRowId(id) {
116
+ if (typeof id === "string")
117
+ return id;
118
+ if (id === undefined)
119
+ throw new TypeError("the row has no _id");
120
+ return BSON.EJSON.stringify(id, { relaxed: true });
121
+ }
122
+ /** The hash of a stored document without its numbers (`_seq` and `_ord`): two documents with the same content give the same hash. */
123
+ function contentHash(doc) {
124
+ const { _seq: _number, _ord: _place, ...rest } = doc;
125
+ return createHash("sha1")
126
+ .update(canonical(BSON.EJSON.serialize(rest, { relaxed: false })))
127
+ .digest("hex");
128
+ }
97
129
  /** The partition of the views is the partition of the storage. */
98
130
  export const identityPartition = (storagePartition) => storagePartition;
131
+ /**
132
+ * This function waits until a change stream is open on the server: its first resume token. From
133
+ * then on, the stream gets each change. The driver sends the `aggregate` of a stream only after
134
+ * `watch()` returns, so a change before that point does not reach the stream. An error, an end or a
135
+ * close of the stream also ends the wait: its rewatch catches up (roadmap O63).
136
+ */
137
+ function openOnServer(stream) {
138
+ return new Promise((resolve) => {
139
+ const events = ["resumeTokenChanged", "error", "end", "close"];
140
+ const done = () => {
141
+ for (const event of events)
142
+ stream.off(event, done);
143
+ resolve();
144
+ };
145
+ for (const event of events)
146
+ stream.on(event, done);
147
+ });
148
+ }
99
149
  /** A path without its positions in arrays (the segments of digits only): `lines.0.price` gives `lines.price`. */
100
150
  function withoutPositions(path) {
101
- return path
102
- .split(".")
103
- .filter((segment) => !/^\d+$/.test(segment))
104
- .join(".");
151
+ // path-check: a field path of a MongoDB document, not a procedure path.
152
+ const segments = path.split(".");
153
+ // path-check: a field path of a MongoDB document, not a procedure path.
154
+ return segments.filter((segment) => !/^\d+$/.test(segment)).join(".");
105
155
  }
106
156
  /** The key of a row id for the signatures: type-aware, so an ObjectId and its text are different keys. */
107
157
  function idKey(id) {
@@ -153,11 +203,19 @@ export class ViewMaterializer {
153
203
  graceMs;
154
204
  leaseMs;
155
205
  storageSources;
206
+ /** The storages that the materializer made for the targets in a stored form, by collection, with the values that made them. */
207
+ storageTargetCache = new Map();
208
+ /** The partition of a storage target that each running view writes (by the key of the view), so a second view of it is refused. */
209
+ storageClaims = new Map();
210
+ storageRowIdOf;
211
+ rowIdOf;
156
212
  /** False after `stopIntake()`: a change starts no pass. */
157
213
  intake = false;
158
214
  /** The work of each partition, one at a time: the passes and the rebalances. */
159
215
  chains = new Map();
160
216
  pending = new Set();
217
+ /** The changes of the set of views (a resync, a change of a view document), one at a time, in the order of the views stream. */
218
+ viewChain = Promise.resolve();
161
219
  stopped = true;
162
220
  constructor(options) {
163
221
  this.options = options;
@@ -180,6 +238,68 @@ export class ViewMaterializer {
180
238
  }
181
239
  return [source, normal];
182
240
  }));
241
+ // A record of targets is examined at once: a wrong entry is an error of the options
242
+ const targets = options.storageTargets;
243
+ if (targets !== undefined && typeof targets !== "function") {
244
+ for (const [collection, entry] of Object.entries(targets))
245
+ this.storageTargetOf(collection, entry);
246
+ }
247
+ this.storageRowIdOf = options.storageRowIdOf ?? defaultStorageRowId;
248
+ this.rowIdOf = options.rowIdOf ?? defaultRowId;
249
+ }
250
+ /**
251
+ * A target of `storageTargets` as a storage on the database of the materializer, so its writes
252
+ * and the transaction of a run use one client. A given storage gives its form, its counters,
253
+ * `writeLease` and `maxTombstones`.
254
+ */
255
+ storageTargetOf(collection, entry) {
256
+ const wrapped = typeof entry === "object" && entry !== null && "target" in entry ? entry : { target: entry };
257
+ const storagePartitionOf = wrapped.storagePartitionOf ?? ((partition) => partition);
258
+ if (typeof storagePartitionOf !== "function")
259
+ throw new TypeError(`The storage target ${collection} needs a function storagePartitionOf`);
260
+ const target = wrapped.target;
261
+ let values;
262
+ if (target instanceof MongoDocumentStorage) {
263
+ const parts = storageWriteTarget(target);
264
+ if (parts.collection !== collection)
265
+ throw new TypeError(`The storage target ${collection} has a storage of the collection ${parts.collection}`);
266
+ values = { form: parts.form, counters: parts.counters, writeLeaseMs: parts.writeLeaseMs, maxTombstones: parts.maxTombstones };
267
+ }
268
+ else if (typeof target === "object" && target !== null && typeof target.storedId === "function") {
269
+ values = { form: target, counters: undefined, writeLeaseMs: undefined, maxTombstones: undefined };
270
+ }
271
+ else {
272
+ throw new TypeError(`The storage target ${collection} needs a MongoDocumentStorage, a stored form or a ViewStorageTarget`);
273
+ }
274
+ // The same values give the storage of the last run: its partitions keep their state (the floor document of O66)
275
+ const cached = this.storageTargetCache.get(collection);
276
+ if (cached && cached.form === values.form && cached.counters === values.counters && cached.writeLeaseMs === values.writeLeaseMs && cached.maxTombstones === values.maxTombstones) {
277
+ return { storage: cached.storage, storagePartitionOf };
278
+ }
279
+ const db = () => {
280
+ if (!this.db)
281
+ throw new Error("The materializer has not started");
282
+ return this.db;
283
+ };
284
+ const storage = new MongoDocumentStorage({
285
+ collection,
286
+ db,
287
+ form: values.form,
288
+ transactions: "never",
289
+ ...(values.counters !== undefined ? { counters: values.counters } : {}),
290
+ ...(values.writeLeaseMs !== undefined ? { writeLease: ms(values.writeLeaseMs) } : {}),
291
+ ...(values.maxTombstones !== undefined ? { maxTombstones: values.maxTombstones } : {}),
292
+ });
293
+ this.storageTargetCache.set(collection, { ...values, storage });
294
+ return { storage, storagePartitionOf };
295
+ }
296
+ /** The target in a stored form of a collection at this time, or undefined. The function of `storageTargets` runs at each call. */
297
+ storageTargetFor(collection) {
298
+ const targets = this.options.storageTargets;
299
+ if (targets === undefined)
300
+ return undefined;
301
+ const entry = typeof targets === "function" ? targets(collection) : Object.hasOwn(targets, collection) ? targets[collection] : undefined;
302
+ return entry === undefined || entry === null ? undefined : this.storageTargetOf(collection, entry);
183
303
  }
184
304
  log(level, message) {
185
305
  if (this.options.log)
@@ -223,8 +343,9 @@ export class ViewMaterializer {
223
343
  this.stopped = false;
224
344
  this.intake = true;
225
345
  this.db = await this.options.db();
346
+ // The stream of the views opens before the read of the views: a change between the two reaches both (roadmap O63)
347
+ await this.watchViews();
226
348
  const count = await this.resync();
227
- this.watchViews();
228
349
  this.log("info", `started: ${count} view(s)`);
229
350
  }
230
351
  /** This method stops the passes of new changes: a change starts no pass, and a waiting pass does not start. */
@@ -296,26 +417,42 @@ export class ViewMaterializer {
296
417
  const partition = view[this.partitionField];
297
418
  return typeof partition === "string" && partition !== "" ? partition : undefined;
298
419
  }
299
- /** This method closes each view, and opens each view of the views collection again: a full catch-up. */
420
+ /**
421
+ * This method runs a change of the set of views after the changes before it. A change of a view
422
+ * document that comes during a resync thus applies after the read of the resync, not before it.
423
+ */
424
+ inViewOrder(work) {
425
+ const run = this.viewChain.then(work);
426
+ this.viewChain = run.catch(() => undefined);
427
+ return run;
428
+ }
429
+ /**
430
+ * This method closes each view, and opens each view of the views collection again: a full
431
+ * catch-up. Open the stream of the views first: then a change after the read reaches the stream.
432
+ */
300
433
  async resync() {
301
- for (const key of [...this.running.keys()])
302
- this.tearDown(key);
303
- const docs = (await this.db.collection(this.viewsName).find({}).toArray());
304
- for (const view of docs)
305
- await this.spinUp(view);
306
- return docs.length;
434
+ const { count, registered } = await this.inViewOrder(async () => {
435
+ const docs = (await this.db.collection(this.viewsName).find({}).toArray());
436
+ for (const key of [...this.running.keys()])
437
+ this.tearDown(key);
438
+ return { count: docs.length, registered: docs.filter((view) => this.register(view)) };
439
+ });
440
+ for (const view of registered)
441
+ await this.activate(view);
442
+ return count;
307
443
  }
444
+ /** This method opens the stream of the views. The promise resolves when the stream is open on the server. */
308
445
  watchViews() {
309
446
  if (this.stopped || !this.db)
310
- return;
447
+ return Promise.resolve();
311
448
  let stream;
312
449
  try {
313
450
  stream = this.db.collection(this.viewsName).watch([], { fullDocument: "updateLookup" });
314
451
  }
315
452
  catch (error) {
316
453
  this.log("error", `cannot watch ${this.viewsName}: ${String(error?.message ?? error)}`);
317
- this.later(() => this.watchViews());
318
- return;
454
+ this.later(() => void this.watchViews());
455
+ return Promise.resolve();
319
456
  }
320
457
  const previous = this.viewsStream;
321
458
  if (previous && previous !== stream)
@@ -329,8 +466,8 @@ export class ViewMaterializer {
329
466
  this.log("warn", `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
330
467
  stream.close().catch(() => undefined);
331
468
  this.later(() => {
332
- this.watchViews();
333
- void this.track(this.resync()).catch((error) => this.log("error", `the resync failed: ${String(error?.message ?? error)}`));
469
+ // The changes while the stream was closed are lost: a resync catches up, after the new stream is open
470
+ void this.track(this.watchViews().then(() => this.resync())).catch((error) => this.log("error", `the resync failed: ${String(error?.message ?? error)}`));
334
471
  });
335
472
  };
336
473
  stream.on("change", (change) => {
@@ -349,6 +486,7 @@ export class ViewMaterializer {
349
486
  });
350
487
  stream.on("error", (error) => rewatch(`failed: ${error.message}`));
351
488
  stream.on("close", () => rewatch("closed"));
489
+ return openOnServer(stream);
352
490
  }
353
491
  async onViewChange(change) {
354
492
  const type = change["operationType"];
@@ -356,41 +494,68 @@ export class ViewMaterializer {
356
494
  const view = change["fullDocument"];
357
495
  if (!view)
358
496
  return;
359
- const partition = this.partitionOf(view);
360
- if (partition !== undefined)
361
- this.tearDown(this.keyOf(partition, view._id));
362
- await this.spinUp(view);
497
+ const registered = await this.inViewOrder(() => {
498
+ const partition = this.partitionOf(view);
499
+ if (partition !== undefined)
500
+ this.tearDown(this.keyOf(partition, view._id));
501
+ return this.register(view);
502
+ });
503
+ if (registered)
504
+ await this.activate(view);
363
505
  }
364
506
  else if (type === "delete") {
365
507
  const id = String(change["documentKey"]["_id"]);
366
- const partitions = new Set();
367
- for (const [key, view] of [...this.running]) {
368
- if (view.view._id === id) {
369
- partitions.add(view.partition);
370
- this.tearDown(key);
508
+ const partitions = await this.inViewOrder(() => {
509
+ const found = new Set();
510
+ for (const [key, view] of [...this.running]) {
511
+ if (view.view._id === id) {
512
+ found.add(view.partition);
513
+ this.tearDown(key);
514
+ }
371
515
  }
372
- }
516
+ return found;
517
+ });
373
518
  // A removed view can break a cycle of the other views
374
519
  for (const partition of partitions)
375
520
  await this.rebalance(partition);
376
521
  }
377
522
  }
378
- async spinUp(view) {
523
+ /** This method adds a view to the views that the materializer knows. It gives false for a view that cannot run. */
524
+ register(view) {
379
525
  const partition = this.partitionOf(view);
380
526
  if (partition === undefined) {
381
527
  this.log("warn", `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
382
- return;
528
+ return false;
383
529
  }
384
530
  if (!Array.isArray(view.collections) || view.collections.length === 0 || typeof view.targetCollection !== "string" || !Array.isArray(view.pipeline)) {
385
531
  this.log("warn", `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
386
- return;
532
+ return false;
387
533
  }
388
534
  this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
535
+ return true;
536
+ }
537
+ /** This method makes the index of the target of a registered view, and rebalances its partition: a new view runs. */
538
+ async activate(view) {
539
+ const partition = this.partitionOf(view);
389
540
  // The readers, the counts and the sweeps read the rows of a partition in a generation
390
541
  await this.db
391
542
  .collection(view.targetCollection)
392
543
  .createIndex({ [this.partitionField]: 1, [VIEW_GENERATION_FIELD]: 1 })
393
544
  .catch((error) => this.log("error", `the index of ${view.targetCollection} failed: ${String(error?.message ?? error)}`));
545
+ // A target in a stored form also has the indexes of the storage: the change reads, the order and the tombstones
546
+ let storageTarget;
547
+ try {
548
+ storageTarget = this.storageTargetFor(view.targetCollection);
549
+ }
550
+ catch (error) {
551
+ // The run of the view gives the error to its status
552
+ this.log("error", `the storage target ${view.targetCollection}: ${String(error?.message ?? error)}`);
553
+ }
554
+ if (storageTarget) {
555
+ await storageWriteTarget(storageTarget.storage)
556
+ .ready()
557
+ .catch((error) => this.log("error", `the indexes of the storage ${view.targetCollection} failed: ${String(error?.message ?? error)}`));
558
+ }
394
559
  await this.rebalance(partition);
395
560
  }
396
561
  tearDown(key) {
@@ -400,6 +565,7 @@ export class ViewMaterializer {
400
565
  for (const stream of view.streams)
401
566
  stream.close().catch(() => undefined);
402
567
  this.running.delete(key);
568
+ this.storageClaims.delete(key);
403
569
  }
404
570
  /**
405
571
  * The filter of the change stream of a source for a partition. It only removes the events that
@@ -486,9 +652,10 @@ export class ViewMaterializer {
486
652
  isCurrent(running) {
487
653
  return this.running.get(this.keyOf(running.partition, running.view._id)) === running;
488
654
  }
655
+ /** This method opens the stream of a source for a view. The promise resolves when the stream is open on the server. */
489
656
  watchSource(running, source) {
490
657
  if (this.stopped || !this.db)
491
- return;
658
+ return Promise.resolve();
492
659
  let stream;
493
660
  try {
494
661
  stream = this.db
@@ -499,9 +666,9 @@ export class ViewMaterializer {
499
666
  this.log("error", `cannot watch ${source} for the view ${running.view._id}: ${String(error?.message ?? error)}`);
500
667
  this.later(() => {
501
668
  if (this.isCurrent(running) && running.state === "active")
502
- this.watchSource(running, source);
669
+ void this.watchSource(running, source);
503
670
  });
504
- return;
671
+ return Promise.resolve();
505
672
  }
506
673
  running.streams.push(stream);
507
674
  let again = false;
@@ -519,9 +686,8 @@ export class ViewMaterializer {
519
686
  this.later(() => {
520
687
  if (!this.isCurrent(running) || running.state !== "active")
521
688
  return;
522
- this.watchSource(running, source);
523
- // The changes while the stream was closed are lost: a pass catches up
524
- this.schedule(running.partition, source);
689
+ // The changes while the stream was closed are lost: a pass catches up, after the new stream is open
690
+ void this.watchSource(running, source).then(() => this.schedule(running.partition, source));
525
691
  });
526
692
  };
527
693
  stream.on("change", (change) => {
@@ -535,6 +701,7 @@ export class ViewMaterializer {
535
701
  });
536
702
  stream.on("error", (error) => rewatch(`failed: ${error.message}`));
537
703
  stream.on("close", () => rewatch("closed"));
704
+ return openOnServer(stream);
538
705
  }
539
706
  /** This method runs a function after `rewatch`, unless the materializer stops. The timer does not keep the process alive. */
540
707
  later(work) {
@@ -611,8 +778,11 @@ export class ViewMaterializer {
611
778
  }
612
779
  else if (running.state === "cyclic" || running.streams.length === 0) {
613
780
  running.state = "active";
614
- for (const source of view.collections)
615
- this.watchSource(running, source);
781
+ // The streams open before the run reads the sources: a change between the two reaches both (roadmap O63)
782
+ await Promise.all(view.collections.map((source) => this.watchSource(running, source)));
783
+ // A change of the view document during the wait replaced this view: the rebalance of the new one runs it
784
+ if (!this.isCurrent(running))
785
+ continue;
616
786
  await this.runAndWriteStatus(view, partition);
617
787
  }
618
788
  }
@@ -661,28 +831,102 @@ export class ViewMaterializer {
661
831
  */
662
832
  async run(view, partition) {
663
833
  const target = this.db.collection(view.targetCollection);
834
+ const storage = this.targetPartition(view, partition);
664
835
  const { status, now } = await this.statusOf(view, partition);
665
836
  if (status?.lease && status.lease.until instanceof Date && now < status.lease.until)
666
837
  throw new PassSuperseded("a pass of another process holds the view");
667
- await this.dropOldGenerations(target, partition, status, now);
838
+ // The rows of a target in a stored form have no generation: a removal is a tombstone, never a delete of an old generation
839
+ if (!storage)
840
+ await this.dropOldGenerations(target, partition, status, now);
668
841
  const current = status?.generation;
669
842
  const generations = await this.currentGenerations(partition);
670
- const size = current ? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current }) : 0;
843
+ // The floor document of the partition of the storage, before the first write (roadmap O66)
844
+ if (storage)
845
+ await storage.writer.noteFloor(this.db);
846
+ const size = storage
847
+ ? await target.countDocuments({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } })
848
+ : current
849
+ ? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current })
850
+ : 0;
671
851
  if (size <= this.maxTransactionRows) {
672
- try {
673
- return await this.runInTransaction(view, partition, target, current, generations);
674
- }
675
- catch (error) {
676
- if (error instanceof ViewRunError || error instanceof PassSuperseded)
677
- throw error;
678
- if (isWriteConflict(error))
679
- throw new PassSuperseded(`another process wrote the view at the same time (${String(error?.message ?? error)})`);
680
- this.log("warn", `${view._id}: the transaction failed (${String(error?.message ?? error)}): the pass writes a new generation in groups`);
852
+ for (let attempt = 1;; attempt++) {
853
+ try {
854
+ return storage
855
+ ? await this.runStorageInTransaction(view, partition, target, storage, current, generations)
856
+ : await this.runInTransaction(view, partition, target, current, generations);
857
+ }
858
+ catch (error) {
859
+ if (error instanceof ViewRunError || error instanceof PassSuperseded)
860
+ throw error;
861
+ if (isWriteConflict(error)) {
862
+ // A write conflict tries again a few times after a short wait, so a steady stream of writes does not starve the view
863
+ if (attempt < CONFLICT_RETRIES) {
864
+ await this.pause(25 * 2 ** attempt * (1 + Math.random()));
865
+ continue;
866
+ }
867
+ throw new PassSuperseded(`another process wrote the view at the same time (${String(error?.message ?? error)})`);
868
+ }
869
+ this.log("warn", `${view._id}: the transaction failed (${String(error?.message ?? error)}): the pass writes ${storage ? "in groups" : "a new generation in groups"}`);
870
+ break;
871
+ }
681
872
  }
682
873
  }
874
+ if (storage)
875
+ return this.runStorageInGroups(view, partition, target, storage, current, generations);
683
876
  await this.runInGroups(view, partition, target, current, generations);
684
877
  return true;
685
878
  }
879
+ /** This method waits on the clock of the materializer. The timer does not keep the process alive. */
880
+ pause(delay) {
881
+ return new Promise((resolve) => {
882
+ const cancel = this.clock.after(Math.round(delay), resolve);
883
+ cancel.unref?.();
884
+ });
885
+ }
886
+ /**
887
+ * The storage of the partition of a view in its target, or undefined for a target that is not in
888
+ * `storageTargets`. A partition that the storage refuses, and a partition field of the form with
889
+ * another value than the partition of the view, are an error of the view.
890
+ */
891
+ targetPartition(view, partition) {
892
+ let entry;
893
+ try {
894
+ entry = this.storageTargetFor(view.targetCollection);
895
+ }
896
+ catch (error) {
897
+ throw new ViewRunError(`the storage target ${view.targetCollection}: ${String(error?.message ?? error)}`);
898
+ }
899
+ if (!entry)
900
+ return undefined;
901
+ let writer;
902
+ let storagePartition;
903
+ try {
904
+ storagePartition = entry.storagePartitionOf(partition);
905
+ writer = storageWriteTarget(storagePartition === undefined ? entry.storage : entry.storage.forPartition(storagePartition));
906
+ }
907
+ catch (error) {
908
+ throw new ViewRunError(`the partition of the storage ${view.targetCollection}: ${String(error?.message ?? error)}`);
909
+ }
910
+ // One view writes a partition of a storage target: its run makes a tombstone of each other document of it
911
+ const claim = JSON.stringify([view.targetCollection, storagePartition ?? null]);
912
+ const key = this.keyOf(partition, view._id);
913
+ for (const [other, held] of this.storageClaims) {
914
+ if (other !== key && held.claim === claim && this.running.has(other)) {
915
+ const place = storagePartition === undefined ? "the storage" : `the partition ${storagePartition} of the storage`;
916
+ throw new ViewRunError(`the view ${held.view} (partition ${held.partition}) writes ${place} ${view.targetCollection} already: one view writes a partition of a storage target`);
917
+ }
918
+ }
919
+ this.storageClaims.set(key, { claim, view: view._id, partition });
920
+ const field = this.partitionField;
921
+ if (writer.partitionFields.has(field) && !isDeepStrictEqual(writer.partitionFields.get(field), partition)) {
922
+ throw new ViewRunError(`the stored form of ${view.targetCollection} gives the field ${field} another value than the partition ${partition}`);
923
+ }
924
+ return {
925
+ writer,
926
+ filter: { ...writer.partitionFilter(), [field]: partition },
927
+ partitionInContent: !writer.partitionFields.has(field) && !DOCUMENT_STORAGE_FIELDS.includes(field),
928
+ };
929
+ }
686
930
  /**
687
931
  * The status of a view in the database (the current generation, and the previous one), and the
688
932
  * time of the server: the times of the generations are times of the server.
@@ -724,7 +968,7 @@ export class ViewMaterializer {
724
968
  }
725
969
  }
726
970
  /** The rows of the result, in groups of 1000. A failure of the pipeline, a row without `_id` and too many rows are a `ViewRunError`. */
727
- async *resultGroups(view, partition, generations, generation) {
971
+ async *resultGroups(view, partition, generations, generation, storage) {
728
972
  let pipeline;
729
973
  try {
730
974
  pipeline = this.scoped(view.pipeline, partition, view, generations);
@@ -750,6 +994,14 @@ export class ViewMaterializer {
750
994
  break;
751
995
  if (++count > this.maxRows)
752
996
  throw new ViewRunError(`the result has more than ${this.maxRows} rows`);
997
+ if (storage) {
998
+ group.push(this.storageRowOf(row, partition, storage));
999
+ if (group.length === WRITE_GROUP) {
1000
+ yield group;
1001
+ group = [];
1002
+ }
1003
+ continue;
1004
+ }
753
1005
  // The fields of the materializer, for a row that comes from a target: not in the content
754
1006
  const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
755
1007
  if (_id === undefined || _id === null)
@@ -779,6 +1031,51 @@ export class ViewMaterializer {
779
1031
  await cursor.close().catch(() => undefined);
780
1032
  }
781
1033
  }
1034
+ /**
1035
+ * A row of the result as a document of a target in a stored form (roadmap CLI-81). The content is
1036
+ * the row without the fields of the materializer and of a storage: a pass-through row of a source
1037
+ * in a stored form loses its `_key`, `_kid`, `_kpf`, `_ord` and `_json`, and the encoding of the
1038
+ * storage makes them again from the row (`_json` from the content of the row). A field of the row
1039
+ * with the name of a partition field of the form takes the value of the partition of the target.
1040
+ * The numbers (`_seq`, `_ord`) come at the write.
1041
+ */
1042
+ storageRowOf(row, partition, storage) {
1043
+ const { _id: rowId, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
1044
+ let _id;
1045
+ try {
1046
+ _id = this.storageRowIdOf(rowId);
1047
+ }
1048
+ catch (error) {
1049
+ throw new ViewRunError(`the id of a row of a target in a stored form: ${String(error?.message ?? error)}`);
1050
+ }
1051
+ if (typeof _id !== "string")
1052
+ throw new ViewRunError(`storageRowIdOf must give a text, not ${typeof _id}`);
1053
+ const field = this.partitionField;
1054
+ delete content[field];
1055
+ for (const name of DOCUMENT_STORAGE_FIELDS)
1056
+ delete content[name];
1057
+ for (const [name, value] of storage.writer.partitionFields) {
1058
+ if (!(name in content))
1059
+ continue;
1060
+ if (value === undefined)
1061
+ delete content[name];
1062
+ else
1063
+ content[name] = value;
1064
+ }
1065
+ if (storage.partitionInContent)
1066
+ content[field] = partition;
1067
+ let doc;
1068
+ try {
1069
+ doc = storage.writer.encode(_id, { _id, ...content }, 0, 0);
1070
+ }
1071
+ catch (error) {
1072
+ throw new ViewRunError(`the row ${JSON.stringify(_id)}: ${String(error?.message ?? error)}`);
1073
+ }
1074
+ // A view that reads the target finds the row by the partition field, also in an item stored as JSON text
1075
+ if (doc[field] === undefined)
1076
+ doc[field] = partition;
1077
+ return { id: _id, key: idKey(doc["_id"]), hash: contentHash(doc), doc };
1078
+ }
782
1079
  replaceOf(row, partition) {
783
1080
  return { replaceOne: { filter: { _id: row.doc["_id"], [this.partitionField]: partition }, replacement: row.doc, upsert: true } };
784
1081
  }
@@ -796,6 +1093,7 @@ export class ViewMaterializer {
796
1093
  const generation = current ?? new ObjectId();
797
1094
  const session = this.db.client.startSession();
798
1095
  let changed = current === undefined;
1096
+ const hint = { changed: [], deleted: [] };
799
1097
  try {
800
1098
  session.startTransaction();
801
1099
  // In the transaction: a change of generation by another process since the start supersedes this pass
@@ -809,9 +1107,12 @@ export class ViewMaterializer {
809
1107
  .find({ _id: { $in: group.map((row) => row.doc["_id"]) }, [this.partitionField]: partition }, { projection: { [VIEW_HASH_FIELD]: 1 }, session })
810
1108
  .toArray();
811
1109
  const hashes = new Map(stored.map((doc) => [idKey(doc["_id"]), doc[VIEW_HASH_FIELD]]));
812
- const writes = group.filter((row) => hashes.get(row.key) !== row.hash).map((row) => this.replaceOf(row, partition));
1110
+ const rows = group.filter((row) => hashes.get(row.key) !== row.hash);
1111
+ const writes = rows.map((row) => this.replaceOf(row, partition));
813
1112
  for (const row of group)
814
1113
  seen.add(row.key);
1114
+ for (const row of rows)
1115
+ hint.changed.push(row.id);
815
1116
  if (writes.length > 0) {
816
1117
  await target.bulkWrite(writes, { ordered: false, session });
817
1118
  changed = true;
@@ -831,6 +1132,8 @@ export class ViewMaterializer {
831
1132
  await target.deleteMany({ [this.partitionField]: partition, _id: { $in: group } }, { session });
832
1133
  if (stale.length > 0)
833
1134
  changed = true;
1135
+ for (const storedId of stale)
1136
+ hint.deleted.push(this.rowIdOf(storedId));
834
1137
  if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
835
1138
  throw new PassSuperseded("another process named the first generation of the view");
836
1139
  }
@@ -843,8 +1146,37 @@ export class ViewMaterializer {
843
1146
  finally {
844
1147
  await session.endSession().catch(() => undefined);
845
1148
  }
1149
+ if (hint.changed.length > 0 || hint.deleted.length > 0)
1150
+ this.publishHint(view, partition, hint);
846
1151
  return changed;
847
1152
  }
1153
+ /**
1154
+ * This method publishes the hint of a run of a target that is not in a stored form: one hint
1155
+ * for the run, with the ids of the changed rows and of the removed rows. Without ids (a change
1156
+ * of generation), with an id that is not a text, or with more than `MAX_HINT_IDS` ids, it is a
1157
+ * reset hint. A failure of the publisher goes to the log: the run does not fail.
1158
+ */
1159
+ publishHint(view, partition, ids) {
1160
+ const publish = this.options.publish;
1161
+ if (!publish)
1162
+ return;
1163
+ const fail = (error) => this.log("error", `the hint of ${view._id} failed: ${String(error?.message ?? error)}`);
1164
+ try {
1165
+ const topic = this.options.hintTopic ? this.options.hintTopic(view, partition) : collectionChangeTopic(view._id);
1166
+ if (topic === undefined)
1167
+ return;
1168
+ const texts = (values) => values.every((value) => typeof value === "string");
1169
+ const event = ids && ids.changed.length + ids.deleted.length <= MAX_HINT_IDS && texts(ids.changed) && texts(ids.deleted)
1170
+ ? { changed: [...new Set(ids.changed)], deleted: [...new Set(ids.deleted)] }
1171
+ : { changed: [], deleted: [], reset: true };
1172
+ const result = typeof publish === "function" ? publish(topic, event) : publish.emit(topic, event);
1173
+ if (result instanceof Promise)
1174
+ result.catch(fail);
1175
+ }
1176
+ catch (error) {
1177
+ fail(error);
1178
+ }
1179
+ }
848
1180
  /**
849
1181
  * A run in groups, outside a transaction: a whole new generation, then the change of the status to
850
1182
  * it. The readers of `viewFilter` move to it at once. The rows of no generation go at once: no
@@ -884,6 +1216,8 @@ export class ViewMaterializer {
884
1216
  await this.releaseLease(view, partition, owner).catch(() => undefined);
885
1217
  throw error;
886
1218
  }
1219
+ // The readers of the view moved to the new generation at once: a gap, so the subscribers read the view in full
1220
+ this.publishHint(view, partition, undefined);
887
1221
  await target.deleteMany({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: { $exists: false } });
888
1222
  // At the end of the grace time, the previous generation goes. The time of the server decides it, so a timer that ends early removes nothing
889
1223
  const key = this.keyOf(partition, view._id);
@@ -899,6 +1233,204 @@ export class ViewMaterializer {
899
1233
  cancel.unref?.();
900
1234
  this.retries.add(cancel);
901
1235
  }
1236
+ /**
1237
+ * The rows of a group that a run writes in a target in a stored form: each row whose stored
1238
+ * document is absent, is a tombstone, or has other content (the numbers do not count). A row
1239
+ * keeps the place (`_ord`) of its live stored document. Two rows with the same `_id` in a result
1240
+ * are an error of the view.
1241
+ */
1242
+ async storageWrites(target, storage, group, seen, session) {
1243
+ for (const row of group) {
1244
+ if (seen.has(row.key))
1245
+ throw new ViewRunError(`the result has two rows with the _id ${JSON.stringify(row.id)}`);
1246
+ seen.add(row.key);
1247
+ }
1248
+ const stored = await target
1249
+ .find({ _id: { $in: group.map((row) => row.doc["_id"]) }, ...storage.writer.partitionFilter() }, session ? { session } : {})
1250
+ .toArray();
1251
+ const byKey = new Map(stored.map((doc) => [idKey(doc["_id"]), doc]));
1252
+ const writes = [];
1253
+ for (const row of group) {
1254
+ const doc = byKey.get(row.key);
1255
+ const live = doc !== undefined && doc[TOMBSTONE_FIELD] !== true;
1256
+ if (live && contentHash(doc) === row.hash)
1257
+ continue;
1258
+ writes.push({ row, ord: live && typeof doc["_ord"] === "number" ? doc["_ord"] : undefined });
1259
+ }
1260
+ return writes;
1261
+ }
1262
+ /**
1263
+ * The live documents of the partition of a view in a target in a stored form that the result
1264
+ * does not have. A document whose id the result does not have becomes the tombstone of that id. A
1265
+ * document that tells no id, or the id of a row of the result (under another stored `_id`),
1266
+ * becomes a tombstone without `_key`: no reader reads a delete of an item from it. More than
1267
+ * `limit` documents is an error, so a transaction goes in groups.
1268
+ */
1269
+ async *storageRemovals(target, storage, seen, session, limit = Number.POSITIVE_INFINITY) {
1270
+ let group = [];
1271
+ let count = 0;
1272
+ const cursor = target.find({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } }, { projection: { _id: 1, _key: 1 }, ...(session ? { session } : {}) });
1273
+ try {
1274
+ for await (const doc of cursor) {
1275
+ if (seen.has(idKey(doc["_id"])))
1276
+ continue;
1277
+ if (++count > limit)
1278
+ throw new Error(`more than ${limit} rows to remove`);
1279
+ let id = storage.writer.idOfDoc(doc);
1280
+ try {
1281
+ if (id !== undefined && seen.has(idKey(storage.writer.storedId(id))))
1282
+ id = undefined;
1283
+ }
1284
+ catch {
1285
+ id = undefined;
1286
+ }
1287
+ group.push({ storedId: doc["_id"], id });
1288
+ if (group.length === WRITE_GROUP) {
1289
+ yield group;
1290
+ group = [];
1291
+ }
1292
+ }
1293
+ if (group.length > 0)
1294
+ yield group;
1295
+ }
1296
+ finally {
1297
+ await cursor.close().catch(() => undefined);
1298
+ }
1299
+ }
1300
+ /**
1301
+ * The writes of a target in a stored form with their numbers from `first`: the tombstones first,
1302
+ * then the rows. A new row takes its own number as its place. The filter of a write holds the
1303
+ * partition: a row of another partition of the view fails with a duplicate key.
1304
+ */
1305
+ storageOperations(storage, partition, writes, removals, first) {
1306
+ const field = this.partitionField;
1307
+ const operations = [];
1308
+ let seq = first;
1309
+ for (const removal of removals) {
1310
+ const number = seq++;
1311
+ const tomb = removal.id === undefined ? { _id: removal.storedId, _seq: number, [TOMBSTONE_FIELD]: true } : storage.writer.tombstone(removal.id, number);
1312
+ const replacement = { ...tomb, _id: removal.storedId };
1313
+ for (const [name, value] of storage.writer.partitionFields)
1314
+ if (value !== undefined)
1315
+ replacement[name] = value;
1316
+ replacement[field] = partition;
1317
+ operations.push({ replaceOne: { filter: { _id: removal.storedId, ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } }, replacement } });
1318
+ }
1319
+ for (const { row, ord } of writes) {
1320
+ const number = seq++;
1321
+ const filter = { _id: row.doc["_id"], ...storage.writer.partitionFilter(), $or: [{ [field]: partition }, { [TOMBSTONE_FIELD]: true }, { [field]: { $exists: false } }] };
1322
+ operations.push({ replaceOne: { filter, replacement: { ...row.doc, _seq: number, _ord: ord ?? number }, upsert: true } });
1323
+ }
1324
+ return operations;
1325
+ }
1326
+ /**
1327
+ * A run of a target in a stored form in one transaction (roadmap CLI-81): the rows that changed,
1328
+ * and a tombstone for each row that the result does not have. The numbers are pending numbers of
1329
+ * the storage: the run takes them after its reads, writes with them in the transaction, and ends
1330
+ * them after the commit. A late run moves its documents to new numbers. The transaction does not
1331
+ * write the counters, so the writes of the storage do not conflict with it.
1332
+ */
1333
+ async runStorageInTransaction(view, partition, target, storage, current, generations) {
1334
+ const generation = current ?? new ObjectId();
1335
+ const db = this.db;
1336
+ const session = db.client.startSession();
1337
+ let count = 0;
1338
+ let removed = 0;
1339
+ try {
1340
+ session.startTransaction();
1341
+ const fresh = await db.collection(this.viewsName).findOne({ _id: view._id, [this.partitionField]: partition }, { projection: { [this.statusField]: 1 }, session });
1342
+ const now = fresh?.[this.statusField]?.generation;
1343
+ if (!(now === undefined ? current === undefined : current !== undefined && now.equals(current)))
1344
+ throw new PassSuperseded("another process changed the generation of the view");
1345
+ const seen = new Set();
1346
+ const writes = [];
1347
+ for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
1348
+ writes.push(...(await this.storageWrites(target, storage, group, seen, session)));
1349
+ if (writes.length > this.maxTransactionRows)
1350
+ throw new Error(`more than ${this.maxTransactionRows} rows to write`);
1351
+ }
1352
+ const removals = [];
1353
+ for await (const group of this.storageRemovals(target, storage, seen, session, this.maxTransactionRows - writes.length))
1354
+ removals.push(...group);
1355
+ count = writes.length + removals.length;
1356
+ removed = removals.length;
1357
+ const commit = async (first) => {
1358
+ for (const operations of groups(this.storageOperations(storage, partition, writes, removals, first)))
1359
+ await target.bulkWrite(operations, { ordered: true, session });
1360
+ if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
1361
+ throw new PassSuperseded("another process named the first generation of the view");
1362
+ }
1363
+ await session.commitTransaction();
1364
+ };
1365
+ if (count === 0)
1366
+ await commit(0);
1367
+ else
1368
+ await storage.writer.withPendingNumbers(db, count, commit);
1369
+ }
1370
+ catch (error) {
1371
+ if (session.inTransaction())
1372
+ await session.abortTransaction().catch(() => undefined);
1373
+ throw error;
1374
+ }
1375
+ finally {
1376
+ await session.endSession().catch(() => undefined);
1377
+ }
1378
+ if (removed > 0)
1379
+ await storage.writer.prune(db);
1380
+ return count > 0 || current === undefined;
1381
+ }
1382
+ /**
1383
+ * A run of a target in a stored form in groups, outside a transaction (roadmap CLI-81): the rows
1384
+ * of each group that changed, then the tombstones of the rows that the result does not have, with
1385
+ * the pending numbers of the storage. The run writes in place, so a reader can see a part of it.
1386
+ * It holds the lease of the view, and renews it after each group. The first run names a
1387
+ * generation in the status of the view: the mark of a view that ran.
1388
+ */
1389
+ async runStorageInGroups(view, partition, target, storage, current, generations) {
1390
+ const generation = current ?? new ObjectId();
1391
+ const owner = new ObjectId().toHexString();
1392
+ const db = this.db;
1393
+ if (!(await this.takeLease(view, partition, owner, generation)))
1394
+ throw new PassSuperseded("a pass of another process holds the view");
1395
+ let changed = current === undefined;
1396
+ let removed = false;
1397
+ try {
1398
+ const seen = new Set();
1399
+ for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
1400
+ const writes = await this.storageWrites(target, storage, group, seen, undefined);
1401
+ if (writes.length > 0) {
1402
+ changed = true;
1403
+ await storage.writer.withPendingNumbers(db, writes.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, writes, [], first), { ordered: true }));
1404
+ }
1405
+ if (!(await this.renewLease(view, partition, owner)))
1406
+ throw new PassSuperseded("the pass lost its lease");
1407
+ }
1408
+ for await (const removals of this.storageRemovals(target, storage, seen, undefined)) {
1409
+ changed = true;
1410
+ removed = true;
1411
+ await storage.writer.withPendingNumbers(db, removals.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, [], removals, first), { ordered: true }));
1412
+ if (!(await this.renewLease(view, partition, owner)))
1413
+ throw new PassSuperseded("the pass lost its lease");
1414
+ }
1415
+ if (current === undefined) {
1416
+ if (!(await this.setGeneration(view, partition, generation, { expected: undefined, owner }))) {
1417
+ throw new PassSuperseded("the pass lost its lease, or another process named the first generation of the view");
1418
+ }
1419
+ }
1420
+ else {
1421
+ await this.releaseLease(view, partition, owner);
1422
+ }
1423
+ }
1424
+ catch (error) {
1425
+ await this.releaseLease(view, partition, owner).catch(() => undefined);
1426
+ throw error;
1427
+ }
1428
+ finally {
1429
+ if (removed)
1430
+ await storage.writer.prune(db).catch((error) => this.log("error", `${view._id}: the prune of the tombstones failed: ${String(error?.message ?? error)}`));
1431
+ }
1432
+ return changed;
1433
+ }
902
1434
  /** True while the view is the one that the materializer runs for this key. */
903
1435
  isCurrentView(key, view) {
904
1436
  return this.running.get(key)?.view === view;