@procedures/mongo 0.47.1 → 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.
- package/CHANGELOG.md +41 -8
- package/README.md +2 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/storage/database-changes.d.ts.map +1 -1
- package/dist/storage/database-changes.js.map +1 -1
- package/dist/storage/document-storage.d.ts +6 -1
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +22 -11
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/materializer.d.ts +44 -12
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +164 -62
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/own-writes.d.ts +2 -0
- package/dist/storage/own-writes.d.ts.map +1 -1
- package/dist/storage/own-writes.js +2 -1
- package/dist/storage/own-writes.js.map +1 -1
- package/dist/storage/pipeline-scope.d.ts +1 -1
- package/dist/storage/pipeline-scope.js +1 -1
- package/package.json +6 -4
- package/src/index.ts +2 -2
- package/src/storage/database-changes.ts +7 -2
- package/src/storage/document-storage.ts +29 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/materializer.ts +169 -57
- package/src/storage/own-writes.ts +2 -1
- package/src/storage/pipeline-scope.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Materialized views driven by change streams
|
|
2
|
+
* Materialized views driven by change streams. A view is a document
|
|
3
3
|
* of a views collection: an aggregation pipeline over source collections whose result goes into a
|
|
4
4
|
* target collection, for one partition (for example one tenant). The materializer runs each view
|
|
5
5
|
* when its sources change, and keeps the target equal to the result of the pipeline.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
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
|
-
|
|
869
|
+
this.unwatch(view);
|
|
834
870
|
this.running.delete(key);
|
|
835
871
|
this.storageClaims.delete(key);
|
|
836
872
|
}
|
|
837
873
|
|
|
838
874
|
/**
|
|
839
|
-
* The
|
|
840
|
-
*
|
|
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:
|
|
848
|
-
* reads the partition from the id (`isOtherPartition`).
|
|
849
|
-
* - The handler checks the fields of an update against `sourceFields` (`
|
|
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
|
|
886
|
+
private partitionsOfChange(change: Document, watch: SourceWatch): Iterable<string> {
|
|
852
887
|
const field = this.partitionField;
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
/**
|
|
920
|
-
|
|
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
|
|
929
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
this.
|
|
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.
|
|
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.
|
|
979
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The guard of an aggregation pipeline over shared collections
|
|
2
|
+
* The guard of an aggregation pipeline over shared collections. A
|
|
3
3
|
* pipeline of a caller reads only the documents of a filter (for example of one tenant or one
|
|
4
4
|
* partition), on each collection that it reads, and a write stamps its fields.
|
|
5
5
|
*
|