@procedures/mongo 0.47.2 → 0.48.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.
@@ -31,8 +31,11 @@
31
31
  * of another view. The materializer writes the rows.
32
32
  * - A pass keeps in memory the key of each row of the result (at most `maxRows`) and one group of
33
33
  * 1000 rows. It keeps nothing between passes.
34
+ * - Each source has one change stream, for all views of all partitions (roadmap CLI-92). A stream at
35
+ * rest holds one connection of the pool for its `getMore`, so the count of streams must not grow
36
+ * with the partitions. The handler of the stream gives a change to the views of its partition.
34
37
  * - A change stream that ends (for example after `dropDatabase`) opens again after `rewatch`,
35
- * and a pass catches up on the changes that the stream missed.
38
+ * and a pass of each partition of that source catches up on the changes that the stream missed.
36
39
  * - Each stream is open on the server before the materializer reads what it watches: the views
37
40
  * before the read of the views, the sources before a run. A change between the two reaches both.
38
41
  * - The status of each view (`ok`, `errored`, `cycle`) goes into the field `statusField` of its
@@ -56,7 +59,7 @@
56
59
  import { createHash } from "node:crypto";
57
60
  import { isDeepStrictEqual } from "node:util";
58
61
  import { BSON, ObjectId, type AnyBulkWriteOperation, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
59
- import { collectionChangeTopic, ms, optionalMs, realClock, writeLog, type Cancel, type Clock, type CollectionChangeEvent, type Duration, type LogFields, type Logger } from "@procedures/client";
62
+ import { collectionChangeTopic, ms, optionalMs, realClock, Semaphore, writeLog, type Cancel, type Clock, type CollectionChangeEvent, type Duration, type LogFields, type Logger } from "@procedures/client";
60
63
  import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget, type StorageWriteTarget, type StoredForm } from "./document-storage.js";
61
64
  import { scopePipeline } from "./pipeline-scope.js";
62
65
  import type { StorageHintPublisher } from "./storage-hints.js";
@@ -308,6 +311,19 @@ const CONFLICT_RETRIES = 3;
308
311
  /** The most ids of a hint of a target not in a stored form. More ids give a reset hint. */
309
312
  const MAX_HINT_IDS = 10_000;
310
313
 
314
+ /** The most passes that run at one time: about one third of the default pool of the driver (100). */
315
+ const MOST_PASSES = 32;
316
+
317
+ /**
318
+ * This function gives the most passes that run at one time on a pool. A pass uses one connection at
319
+ * a time, so two thirds of the pool stay free for the other operations of the process.
320
+ */
321
+ function passLimit(db: Db): number {
322
+ // A fake database of a test has no client
323
+ const pool = (db.client as Db["client"] | undefined)?.options.maxPoolSize;
324
+ return pool ? Math.max(1, Math.min(MOST_PASSES, Math.floor(pool / 3))) : MOST_PASSES;
325
+ }
326
+
311
327
  /** The `_id` of a row in the result, from the stored `_id` of `partitionTargetId`. */
312
328
  export function defaultRowId(storedId: unknown): unknown {
313
329
  return typeof storedId === "object" && storedId !== null && "id" in storedId ? (storedId as { id: unknown }).id : storedId;
@@ -403,9 +419,21 @@ function openOnServer(stream: ChangeStream): Promise<void> {
403
419
  interface RunningView {
404
420
  view: MaterializedView;
405
421
  partition: string;
406
- /** `active`: its streams are open, and it runs in the passes. `cyclic`: it is in a cycle, and it waits. */
422
+ /** `active`: its sources are watched, and it runs in the passes. `cyclic`: it is in a cycle, and it waits. */
407
423
  state: "active" | "cyclic";
408
- streams: ChangeStream[];
424
+ /** The sources that this view watches. Empty until its first rebalance, and for a view in a cycle. */
425
+ watched: string[];
426
+ }
427
+
428
+ /** The one change stream of a source, and the views that it serves. */
429
+ interface SourceWatch {
430
+ source: string;
431
+ /** The stream. Undefined from the end of a stream until the next one opens. */
432
+ stream: ChangeStream | undefined;
433
+ /** The views of the source, for each partition. */
434
+ partitions: Map<string, Set<RunningView>>;
435
+ /** The promise that resolves when the first stream is open on the server. After an end, the pass of the next stream catches up. */
436
+ opened: Promise<void>;
409
437
  }
410
438
 
411
439
  /** A path without its positions in arrays (the segments of digits only): `lines.0.price` gives `lines.price`. */
@@ -455,9 +483,15 @@ export class ViewMaterializer {
455
483
  private db: Db | undefined;
456
484
  private viewsStream: ChangeStream | undefined;
457
485
  private readonly running = new Map<string, RunningView>();
486
+ /** The one change stream of each source that an active view reads (roadmap CLI-92). */
487
+ private readonly sources = new Map<string, SourceWatch>();
458
488
  /** The collections that changed since the last pass, for each partition. */
459
489
  private readonly dirty = new Map<string, Set<string>>();
460
490
  private readonly timers = new Map<string, Cancel>();
491
+ /** The time of the last change of each partition with a timer: the end of the wait of the pass. */
492
+ private readonly lastChange = new Map<string, number>();
493
+ /** The passes that run at one time. A burst that reaches each partition does not fill the pool. */
494
+ private passes = new Semaphore(MOST_PASSES);
461
495
  /** The timers of the streams that open again, and of the ends of the grace times. */
462
496
  private readonly retries = new Set<Cancel>();
463
497
  private readonly clock: Clock;
@@ -586,10 +620,10 @@ export class ViewMaterializer {
586
620
  return scopePipeline(pipeline, { match, collections: view.collections });
587
621
  }
588
622
 
589
- /** The change streams that are open: the views stream and the source streams of the active views. */
623
+ /** The change streams that are open: the views stream and one stream for each source of the active views. */
590
624
  openStreamCount(): number {
591
625
  let count = this.viewsStream ? 1 : 0;
592
- for (const view of this.running.values()) count += view.streams.length;
626
+ for (const watch of this.sources.values()) if (watch.stream) count++;
593
627
  return count;
594
628
  }
595
629
 
@@ -608,6 +642,7 @@ export class ViewMaterializer {
608
642
  this.stopped = false;
609
643
  this.intake = true;
610
644
  this.db = await this.options.db();
645
+ this.passes = new Semaphore(passLimit(this.db));
611
646
  // The stream of the views opens before the read of the views: a change between the two reaches both (roadmap O63)
612
647
  await this.watchViews();
613
648
  const count = await this.resync();
@@ -619,6 +654,7 @@ export class ViewMaterializer {
619
654
  this.intake = false;
620
655
  for (const cancel of this.timers.values()) cancel();
621
656
  this.timers.clear();
657
+ this.lastChange.clear();
622
658
  this.dirty.clear();
623
659
  }
624
660
 
@@ -799,7 +835,7 @@ export class ViewMaterializer {
799
835
  this.log("warn", "The view needs collections, a pipeline and a targetCollection: it does not run", { view: view._id, partition }, `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
800
836
  return false;
801
837
  }
802
- this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
838
+ this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", watched: [] });
803
839
  return true;
804
840
  }
805
841
 
@@ -830,37 +866,36 @@ export class ViewMaterializer {
830
866
  private tearDown(key: string): void {
831
867
  const view = this.running.get(key);
832
868
  if (!view) return;
833
- for (const stream of view.streams) stream.close().catch(() => undefined);
869
+ this.unwatch(view);
834
870
  this.running.delete(key);
835
871
  this.storageClaims.delete(key);
836
872
  }
837
873
 
838
874
  /**
839
- * The filter of the change stream of a source for a partition. It only removes the events that
840
- * cannot change a view of the partition: an event whose partition is unknown passes, and the
841
- * pass of the partition reads its own documents only.
875
+ * The partitions that a change of a source can reach (roadmap CLI-92). This list only removes the
876
+ * partitions that the change cannot touch: the pass of a partition reads its own documents only.
842
877
  *
843
878
  * - An insert, a replace or an update has the document after the change (`fullDocument`).
844
879
  * - With a pre-image (a collection with `changeStreamPreAndPostImages`), each change also has the
845
880
  * document before it: a delete, and an update that moves a document out of the partition.
846
881
  * - Without a pre-image, a delete, a replace and an update of the partition field (a top-level
847
- * field) can leave any partition: they pass. In a source of `storageSources`, the handler then
848
- * reads the partition from the id (`isOtherPartition`).
849
- * - The handler checks the fields of an update against `sourceFields` (`touchesFields`).
882
+ * field) can leave any partition: the change reaches each partition of the source. In a source
883
+ * of `storageSources`, the handler then reads the partition from the id (`isOtherPartition`).
884
+ * - The handler checks the fields of an update against `sourceFields` (`touchesNoField`).
850
885
  */
851
- private sourceMatch(partition: string): Document {
886
+ private partitionsOfChange(change: Document, watch: SourceWatch): Iterable<string> {
852
887
  const field = this.partitionField;
853
- const noPreImage = { fullDocumentBeforeChange: null };
854
- return {
855
- $match: {
856
- $or: [
857
- { [`fullDocument.${field}`]: partition },
858
- { [`fullDocumentBeforeChange.${field}`]: partition },
859
- { operationType: { $in: ["delete", "replace"] }, ...noPreImage },
860
- { operationType: "update", ...noPreImage, $or: [{ [`updateDescription.updatedFields.${field}`]: { $exists: true } }, { "updateDescription.removedFields": field }] },
861
- ],
862
- },
863
- };
888
+ const type = change["operationType"];
889
+ const before = change["fullDocumentBeforeChange"] as Document | null | undefined;
890
+ if (before === null || before === undefined) {
891
+ const description = (change["updateDescription"] ?? {}) as { updatedFields?: Document; removedFields?: string[] };
892
+ const movable = type === "delete" || type === "replace" || (type === "update" && (Object.hasOwn(description.updatedFields ?? {}, field) || (description.removedFields ?? []).includes(field)));
893
+ if (movable) return watch.partitions.keys();
894
+ }
895
+ const after = change["fullDocument"] as Document | null | undefined;
896
+ const named = new Set<string>();
897
+ for (const value of [after?.[field], before?.[field]].flat()) if (typeof value === "string") named.add(value);
898
+ return named;
864
899
  }
865
900
 
866
901
  /**
@@ -916,49 +951,112 @@ export class ViewMaterializer {
916
951
  return this.running.get(this.keyOf(running.partition, running.view._id)) === running;
917
952
  }
918
953
 
919
- /** This method opens the stream of a source for a view. The promise resolves when the stream is open on the server. */
920
- private watchSource(running: RunningView, source: string): Promise<void> {
954
+ /**
955
+ * This method adds a view to the stream of each of its sources, and opens a stream that does not
956
+ * exist. The promise resolves when each stream is open on the server, so a change after that
957
+ * reaches the view.
958
+ */
959
+ private watchSources(running: RunningView): Promise<void> {
960
+ const opened: Promise<void>[] = [];
961
+ for (const source of new Set(running.view.collections)) {
962
+ let watch = this.sources.get(source);
963
+ if (!watch) {
964
+ watch = { source, stream: undefined, partitions: new Map(), opened: Promise.resolve() };
965
+ this.sources.set(source, watch);
966
+ watch.opened = this.openSource(watch);
967
+ }
968
+ let views = watch.partitions.get(running.partition);
969
+ if (!views) watch.partitions.set(running.partition, (views = new Set()));
970
+ views.add(running);
971
+ running.watched.push(source);
972
+ opened.push(watch.opened);
973
+ }
974
+ return Promise.all(opened).then(() => undefined);
975
+ }
976
+
977
+ /** This method removes a view from its streams. The stream of a source with no view left closes. */
978
+ private unwatch(running: RunningView): void {
979
+ for (const source of running.watched) {
980
+ const watch = this.sources.get(source);
981
+ const views = watch?.partitions.get(running.partition);
982
+ if (!watch || !views) continue;
983
+ views.delete(running);
984
+ if (views.size === 0) watch.partitions.delete(running.partition);
985
+ if (watch.partitions.size === 0) this.dropWatch(watch);
986
+ }
987
+ running.watched = [];
988
+ }
989
+
990
+ /** This method closes the stream of a source and forgets it. */
991
+ private dropWatch(watch: SourceWatch): void {
992
+ if (this.sources.get(watch.source) === watch) this.sources.delete(watch.source);
993
+ const stream = watch.stream;
994
+ watch.stream = undefined;
995
+ stream?.close().catch(() => undefined);
996
+ }
997
+
998
+ /** This method opens the stream of a source. The promise resolves when the stream is open on the server. */
999
+ private openSource(watch: SourceWatch): Promise<void> {
921
1000
  if (this.stopped || !this.db) return Promise.resolve();
1001
+ const { source } = watch;
922
1002
  let stream: ChangeStream;
923
1003
  try {
924
- stream = this.db
925
- .collection(source)
926
- .watch([this.sourceMatch(running.partition)], { fullDocument: "updateLookup", fullDocumentBeforeChange: "whenAvailable" });
1004
+ stream = this.db.collection(source).watch([], { fullDocument: "updateLookup", fullDocumentBeforeChange: "whenAvailable" });
927
1005
  } catch (error) {
928
- this.log("error", "Cannot watch a source of the view", { view: running.view._id, partition: running.partition, collection: source, error }, `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
929
- this.later(() => {
930
- if (this.isCurrent(running) && running.state === "active") void this.watchSource(running, source);
931
- });
1006
+ this.log("error", "Cannot watch a source", { collection: source, error }, `cannot watch ${source}: ${String((error as Error)?.message ?? error)}`);
1007
+ this.afterStreamEnd(watch);
932
1008
  return Promise.resolve();
933
1009
  }
934
- running.streams.push(stream);
1010
+ watch.stream = stream;
935
1011
  let again = false;
936
1012
  const rewatch = (reason: string): void => {
937
1013
  if (again) return;
938
1014
  again = true;
939
1015
  stream.close().catch(() => undefined);
940
- const index = running.streams.indexOf(stream);
941
- // A close of a teardown also ends the stream: open again only a stream of a current active view
942
- if (index === -1 || !this.isCurrent(running) || running.state !== "active") return;
943
- running.streams.splice(index, 1);
944
- this.log("warn", "The stream of a source ended: it opens again", { view: running.view._id, partition: running.partition, collection: source, reason, delayMs: this.rewatchMs }, `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
945
- this.later(() => {
946
- if (!this.isCurrent(running) || running.state !== "active") return;
947
- // The changes while the stream was closed are lost: a pass catches up, after the new stream is open
948
- void this.watchSource(running, source).then(() => this.schedule(running.partition, source));
949
- });
1016
+ // A close of a teardown also ends the stream: open again only the stream of a source with views
1017
+ if (watch.stream !== stream || this.sources.get(source) !== watch) return;
1018
+ watch.stream = undefined;
1019
+ this.log("warn", "The stream of a source ended: it opens again", { collection: source, reason, delayMs: this.rewatchMs }, `the stream of ${source} ${reason}: it opens again in ${this.rewatchMs} ms`);
1020
+ this.afterStreamEnd(watch);
950
1021
  };
951
1022
  stream.on("change", (change: Document) => {
952
1023
  if (change["operationType"] === "invalidate") return rewatch("ended (invalidate)");
953
- if (this.isOtherPartition(change, source, running.partition)) return;
954
- if (this.touchesNoField(change, running.view.sourceFields?.[source])) return;
955
- this.schedule(running.partition, source);
1024
+ if (watch.stream === stream) this.route(change, watch);
956
1025
  });
957
1026
  stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
958
1027
  stream.on("close", () => rewatch("closed"));
959
1028
  return openOnServer(stream);
960
1029
  }
961
1030
 
1031
+ /**
1032
+ * This method opens the stream of a source again after `rewatch`. The changes while no stream was
1033
+ * open are lost, so a pass of each partition of the source catches up, after the new stream is
1034
+ * open. A view that joins before then is in that pass.
1035
+ */
1036
+ private afterStreamEnd(watch: SourceWatch): void {
1037
+ this.later(() => {
1038
+ if (this.sources.get(watch.source) !== watch) return;
1039
+ void this.openSource(watch).then(() => {
1040
+ if (this.sources.get(watch.source) !== watch) return;
1041
+ for (const partition of watch.partitions.keys()) this.schedule(partition, watch.source);
1042
+ });
1043
+ });
1044
+ }
1045
+
1046
+ /** This method gives a change of a source to the views of its partitions. */
1047
+ private route(change: Document, watch: SourceWatch): void {
1048
+ const { source } = watch;
1049
+ for (const partition of this.partitionsOfChange(change, watch)) {
1050
+ const views = watch.partitions.get(partition);
1051
+ if (!views || this.isOtherPartition(change, source, partition)) continue;
1052
+ for (const running of views) {
1053
+ if (this.touchesNoField(change, running.view.sourceFields?.[source])) continue;
1054
+ this.schedule(partition, source);
1055
+ break;
1056
+ }
1057
+ }
1058
+ }
1059
+
962
1060
  /** This method runs a function after `rewatch`, unless the materializer stops. The timer does not keep the process alive. */
963
1061
  private later(work: () => void): void {
964
1062
  const cancel = this.clock.after(this.rewatchMs, () => {
@@ -975,11 +1073,26 @@ export class ViewMaterializer {
975
1073
  let dirty = this.dirty.get(partition);
976
1074
  if (!dirty) this.dirty.set(partition, (dirty = new Set()));
977
1075
  dirty.add(collection);
978
- this.timers.get(partition)?.();
979
- const cancel = this.clock.after(this.debounceMs, () => {
1076
+ this.lastChange.set(partition, this.clock.now());
1077
+ if (!this.timers.has(partition)) this.waitForPass(partition, this.debounceMs);
1078
+ }
1079
+
1080
+ /**
1081
+ * This method starts the timer of the pass of a partition. A change during the wait moves the end
1082
+ * of the wait and makes no new timer: 1,000 deletes with no pre-image at 1,000 partitions are a
1083
+ * million changes. A pass waits for its place in `passes` after the work of its partition before it.
1084
+ */
1085
+ private waitForPass(partition: string, delay: number): void {
1086
+ const cancel = this.clock.after(delay, () => {
1087
+ const left = this.lastChange.get(partition)! + this.debounceMs - this.clock.now();
1088
+ if (left > 0) {
1089
+ this.waitForPass(partition, left);
1090
+ return;
1091
+ }
980
1092
  this.timers.delete(partition);
1093
+ this.lastChange.delete(partition);
981
1094
  if (!this.intake) return;
982
- void this.serial(partition, () => this.pass(partition)).catch((error: unknown) =>
1095
+ void this.serial(partition, () => this.passes.withPermit(() => this.pass(partition))).catch((error: unknown) =>
983
1096
  this.log("error", "The pass failed", { partition, error }, `the pass of ${partition} failed: ${String((error as Error)?.message ?? error)}`)
984
1097
  );
985
1098
  });
@@ -1024,15 +1137,14 @@ export class ViewMaterializer {
1024
1137
  if (!running) continue;
1025
1138
  if (cyclic.has(view._id)) {
1026
1139
  if (running.state === "active") {
1027
- for (const stream of running.streams) stream.close().catch(() => undefined);
1028
- running.streams = [];
1140
+ this.unwatch(running);
1029
1141
  running.state = "cyclic";
1030
1142
  await this.writeStatus(view, partition, { state: "cycle", at: Date.now() });
1031
1143
  }
1032
- } else if (running.state === "cyclic" || running.streams.length === 0) {
1144
+ } else if (running.state === "cyclic" || running.watched.length === 0) {
1033
1145
  running.state = "active";
1034
1146
  // The streams open before the run reads the sources: a change between the two reaches both (roadmap O63)
1035
- await Promise.all(view.collections.map((source) => this.watchSource(running, source)));
1147
+ await this.watchSources(running);
1036
1148
  // A change of the view document during the wait replaced this view: the rebalance of the new one runs it
1037
1149
  if (!this.isCurrent(running)) continue;
1038
1150
  await this.runAndWriteStatus(view, partition);
@@ -60,7 +60,8 @@ interface Tracker {
60
60
  /** The trackers of each collection: by the client (else the `Db` object), then by the names of the database and the collection. */
61
61
  const TRACKERS = new WeakMap<object, Map<string, Set<Tracker>>>();
62
62
 
63
- function ownerOf(db: Db): object {
63
+ /** This function gives the client of a database, else the `Db` object: a key that stays the same when `client.db(name)` makes a new `Db` at each call. */
64
+ export function ownerOf(db: Db): object {
64
65
  const client = (db as { client?: unknown }).client;
65
66
  return typeof client === "object" && client !== null ? client : db;
66
67
  }