@procedures/mongo 0.9.0 → 0.9.2

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.
@@ -0,0 +1,1044 @@
1
+ /**
2
+ * Materialized views driven by change streams (Graph's split plan, part P4). A view is a document
3
+ * of a views collection: an aggregation pipeline over source collections whose result goes into a
4
+ * target collection, for one partition (for example one tenant). The materializer runs each view
5
+ * when its sources change, and keeps the target equal to the result of the pipeline.
6
+ *
7
+ * - The views of a partition run in the order of their dependencies: a view whose target is the
8
+ * source of another view runs first, in the same pass. A view in a cycle does not run, and its
9
+ * status is `cycle`.
10
+ * - A pass runs after the last change of a burst (`debounce`), and only the views whose sources
11
+ * changed. A run streams the result in groups, and compares each row with the hash that its
12
+ * stored row keeps. A result that did not change writes nothing.
13
+ * - The rows of a view are a generation (the field `_viewGen`), and the status of the view names
14
+ * the current one. A reader filters with `viewFilter(view)`, and thus always reads one whole
15
+ * generation.
16
+ * - A run writes in one transaction: the rows that changed, in the current generation, and the
17
+ * removal of the rows that the result does not have. A view with more rows than
18
+ * `maxTransactionRows`, or whose transaction fails (for example over the limits of MongoDB),
19
+ * writes a whole new generation in groups instead. Then the status names it, and the previous
20
+ * generation stays for `generationGrace` (on the time of the server), so a reader of it can finish.
21
+ * - A pass in groups holds a lease on its view (`passLease`, on the time of the server), and renews
22
+ * it after each group. Another process leaves the view and the rows of that generation alone while
23
+ * the lease holds. The change to the new generation is a compare-and-set: it needs the lease, and
24
+ * the generation that the pass started from. A pass that loses either one gives way: its rows go
25
+ * in a later pass, and a new pass of the view follows.
26
+ * - The stored `_id` of a row carries the partition and the generation (`targetId`): partitions
27
+ * with the same keys share a target. The rows of an older materializer (no generation) go in the
28
+ * first pass.
29
+ * - Each pipeline goes through the guard of the scope (`scopePipeline` by default): it reads only
30
+ * the documents of its partition, in its sources, and only the current generation of a target
31
+ * of another view. The materializer writes the rows.
32
+ * - A pass keeps in memory the key of each row of the result (at most `maxRows`) and one group of
33
+ * 1000 rows. It keeps nothing between passes.
34
+ * - 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.
36
+ * - The status of each view (`ok`, `errored`, `cycle`) goes into the field `statusField` of its
37
+ * document. The materializer ignores an update of only that field.
38
+ *
39
+ * A change reaches only the views of its partition when its partition is known. A delete has no
40
+ * document: its partition comes from the pre-image (a collection with
41
+ * `changeStreamPreAndPostImages`), or from the id in a source of `storageSources`, through its
42
+ * mapping. Else it reaches the views of each partition of its source. With pre-images, an update
43
+ * that moves a document to another partition also reaches the views of its old partition.
44
+ *
45
+ * Change streams and transactions need a replica set (MongoDB 6 or later). The target of a view is
46
+ * a plain collection that only the materializer writes.
47
+ *
48
+ * The stop has the parts of a transport of a service: `stopIntake()`, `drain()`, `inFlight()` and
49
+ * `close()`. A host composes them with no adapter. The timers come from the injected clock.
50
+ */
51
+
52
+ import { createHash } from "node:crypto";
53
+ import { BSON, ObjectId, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
54
+ import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client";
55
+ import { SEPARATOR } from "./document-storage.js";
56
+ import { scopePipeline } from "./pipeline-scope.js";
57
+
58
+ /** The definition of a view, as a document of the views collection. */
59
+ export interface MaterializedView {
60
+ _id: string;
61
+ /** The source collections. The pipeline runs on the first one, and reads the others with `$lookup` or `$unionWith`. */
62
+ collections: string[];
63
+ /** The aggregation pipeline, without `$merge`. */
64
+ pipeline: Document[];
65
+ /** The collection that gets the result. */
66
+ targetCollection: string;
67
+ /**
68
+ * The fields that the pipeline reads, for each source (paths with dots). An update of a source
69
+ * that changes none of them (a set, a removal, a truncated array, at the field, under it or above
70
+ * it), and does not change the partition field, does not start a pass. Without it, each update of
71
+ * the source starts a pass.
72
+ */
73
+ sourceFields?: Record<string, string[]>;
74
+ [field: string]: unknown;
75
+ }
76
+
77
+ /** The status of a view, in the field `statusField` of its document. */
78
+ export interface MaterializedViewStatus {
79
+ state: "ok" | "errored" | "cycle";
80
+ lastError?: string;
81
+ /** The time of the status, in milliseconds since the epoch. */
82
+ at: number;
83
+ /** The current generation of the rows of the view. */
84
+ generation?: ObjectId;
85
+ /** The generation before the last change of generation, and the time of that change on the server (`$$NOW`). */
86
+ previous?: { generation: ObjectId | null; flippedAt: Date };
87
+ /** The lease of a pass in groups: its owner, the generation that it writes, and its end on the server. */
88
+ lease?: { owner: string; generation: ObjectId; until: Date };
89
+ }
90
+
91
+ /**
92
+ * The function that guards the pipeline of a view for its partition. `match` is the filter of the
93
+ * documents that the pipeline can read: the partition, and the current generations of the targets.
94
+ */
95
+ export type ViewPipelineScope = (pipeline: Document[], partition: string, view: MaterializedView, match: Document) => Document[];
96
+
97
+ /** The options of a `ViewMaterializer`. */
98
+ export interface ViewMaterializerOptions {
99
+ /** The database of the views, the sources and the targets. */
100
+ db: () => Db | Promise<Db>;
101
+ /** The collection of the view documents (default `_aggregations`). */
102
+ views?: string | undefined;
103
+ /** The field of a document that names its partition (default `_part`). The views and the source documents have it. */
104
+ partitionField?: string | undefined;
105
+ /** The field of a view document that gets its status (default `_materializerStatus`). */
106
+ statusField?: string | undefined;
107
+ /**
108
+ * The guard of a pipeline. The default is `scopePipeline` with `match` as the filter and the
109
+ * sources as the collections. Another guard applies `match` to each collection too.
110
+ */
111
+ scope?: ViewPipelineScope | undefined;
112
+ /** The time after the last change before a pass (default 100 ms). */
113
+ debounce?: Duration | undefined;
114
+ /** The time before a change stream that ended opens again (default 1 s). */
115
+ rewatch?: Duration | undefined;
116
+ /** The longest time of a pipeline (default 30 s). */
117
+ maxTime?: Duration | undefined;
118
+ /** The most rows of the result of a view (default 100000). A larger result is an error of the view. */
119
+ maxRows?: number | undefined;
120
+ /** The log (default `console`, with the prefix `[materializer]`). */
121
+ log?: ((level: "info" | "warn" | "error", message: string) => void) | undefined;
122
+ /** The clock of the timers (default the clock of the platform). A test gives a fake clock. The timers do not keep a process alive. */
123
+ clock?: Clock | undefined;
124
+ /**
125
+ * The stored `_id` of a row of a view, from the `_id` of the row, the partition and the
126
+ * generation (default `partitionTargetId`: `{ partition, generation, id }`). Two partitions, or two
127
+ * generations, must not give the same stored `_id`. The write of a row checks the partition, and
128
+ * fails on the row of another partition. A pass in groups fails when the generation does not
129
+ * change the `_id`.
130
+ */
131
+ targetId?: ViewTargetId | undefined;
132
+ /** A view with more rows than this writes a new generation in groups, not in one transaction (default 10000). */
133
+ maxTransactionRows?: number | undefined;
134
+ /**
135
+ * The time that the previous generation stays after a change of generation (default 60 s). The
136
+ * time of the server (`$$NOW`) measures it, so the processes and the restarts agree.
137
+ */
138
+ generationGrace?: Duration | undefined;
139
+ /**
140
+ * The lease of a pass in groups on its view (default 60 s), on the time of the server. The pass
141
+ * renews it after each group, so a group must take less time.
142
+ */
143
+ passLease?: Duration | undefined;
144
+ /**
145
+ * The sources that are collections of a `MongoDocumentStorage`, each with the function that
146
+ * gives the partition of the views from the partition of the storage. The stored id of a document
147
+ * starts with its storage partition and U+001F. A delete without a pre-image then reaches only the
148
+ * views of its partition. Give `identityPartition` when the two partitions are the same. Without a
149
+ * function, or when it gives `undefined`, the delete reaches the views of each partition.
150
+ */
151
+ storageSources?: Readonly<Record<string, ViewPartitionOf>> | undefined;
152
+ }
153
+
154
+ /** The function that gives the stored `_id` of a row of a view. */
155
+ export type ViewTargetId = (id: unknown, partition: string, generation: ObjectId) => unknown;
156
+
157
+ /** The stored `_id` of a row: `{ partition, generation, id }`. A reader reads the key of the row in `_id.id`. */
158
+ export const partitionTargetId: ViewTargetId = (id, partition, generation) => ({ partition, generation, id });
159
+
160
+ /** The field of a row of a view that names its generation. */
161
+ export const VIEW_GENERATION_FIELD = "_viewGen";
162
+ /** The field of a row of a view that keeps the hash of its content. */
163
+ export const VIEW_HASH_FIELD = "_viewHash";
164
+
165
+ /**
166
+ * The filter of the rows of a view for a reader: its partition and its current generation. A
167
+ * reader of a target applies it, so it reads one whole generation. Before the first run, it
168
+ * matches no row.
169
+ */
170
+ export function viewFilter(view: MaterializedView, options: { partitionField?: string | undefined; statusField?: string | undefined } = {}): Document {
171
+ const partitionField = options.partitionField ?? "_part";
172
+ const status = view[options.statusField ?? "_materializerStatus"] as Partial<MaterializedViewStatus> | undefined;
173
+ return { [partitionField]: view[partitionField], [VIEW_GENERATION_FIELD]: status?.generation ?? { $in: [] } };
174
+ }
175
+
176
+ /** An error of the result of a view (the pipeline, the size, a row): a pass in groups gives it too. */
177
+ class ViewRunError extends Error {}
178
+
179
+ /** A pass that another process supersedes (its lease, or a newer generation): it gives way, with no error of the view. */
180
+ class PassSuperseded extends Error {}
181
+
182
+ /**
183
+ * True for a write conflict with another process (code 112). The label `TransientTransactionError`
184
+ * is not enough: a transaction over its lifetime limit has it too, and that view goes in groups.
185
+ */
186
+ function isWriteConflict(error: unknown): boolean {
187
+ const value = error as { code?: unknown; codeName?: unknown } | null;
188
+ return value?.code === 112 || value?.codeName === "WriteConflict";
189
+ }
190
+
191
+ /** A row of the result, ready to store. */
192
+ interface StoredRow {
193
+ /** The `_id` of the row in the result. */
194
+ id: unknown;
195
+ key: string;
196
+ hash: string;
197
+ doc: Document;
198
+ }
199
+
200
+ /** The writes of a run go to the server in groups of this size. */
201
+ const WRITE_GROUP = 1000;
202
+
203
+ function groups<T>(items: readonly T[]): T[][] {
204
+ const result: T[][] = [];
205
+ for (let start = 0; start < items.length; start += WRITE_GROUP) result.push(items.slice(start, start + WRITE_GROUP));
206
+ return result;
207
+ }
208
+
209
+ /** The function that gives the partition of the views from the partition of a storage, or `undefined` when it does not know. */
210
+ export type ViewPartitionOf = (storagePartition: string) => string | undefined;
211
+
212
+ /** The partition of the views is the partition of the storage. */
213
+ export const identityPartition: ViewPartitionOf = (storagePartition) => storagePartition;
214
+
215
+ /** A view that the materializer knows. */
216
+ interface RunningView {
217
+ view: MaterializedView;
218
+ partition: string;
219
+ /** `active`: its streams are open, and it runs in the passes. `cyclic`: it is in a cycle, and it waits. */
220
+ state: "active" | "cyclic";
221
+ streams: ChangeStream[];
222
+ }
223
+
224
+ /** A path without its positions in arrays (the segments of digits only): `lines.0.price` gives `lines.price`. */
225
+ function withoutPositions(path: string): string {
226
+ return path
227
+ .split(".")
228
+ .filter((segment) => !/^\d+$/.test(segment))
229
+ .join(".");
230
+ }
231
+
232
+ /** The key of a row id for the signatures: type-aware, so an ObjectId and its text are different keys. */
233
+ function idKey(id: unknown): string {
234
+ return BSON.EJSON.stringify({ id }, { relaxed: false });
235
+ }
236
+
237
+ /** A text of a value with sorted keys: two objects with the same fields give the same text. */
238
+ function canonical(value: unknown): string {
239
+ if (value === null || value === undefined || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
240
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
241
+ const record = value as Record<string, unknown>;
242
+ return `{${Object.keys(record)
243
+ .sort()
244
+ .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
245
+ .join(",")}}`;
246
+ }
247
+
248
+ /**
249
+ * A `ViewMaterializer` keeps the target of each view equal to the result of its pipeline.
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * const views = new ViewMaterializer({ db: () => client.db("app"), partitionField: "tenant" });
254
+ * await views.start();
255
+ * // ... later
256
+ * await views.stop();
257
+ * ```
258
+ */
259
+ export class ViewMaterializer {
260
+ private readonly options: ViewMaterializerOptions;
261
+ private readonly viewsName: string;
262
+ private readonly partitionField: string;
263
+ private readonly statusField: string;
264
+ private readonly debounceMs: number;
265
+ private readonly rewatchMs: number;
266
+ private readonly maxTimeMS: number;
267
+ private readonly maxRows: number;
268
+ private db: Db | undefined;
269
+ private viewsStream: ChangeStream | undefined;
270
+ private readonly running = new Map<string, RunningView>();
271
+ /** The collections that changed since the last pass, for each partition. */
272
+ private readonly dirty = new Map<string, Set<string>>();
273
+ private readonly timers = new Map<string, Cancel>();
274
+ /** The timers of the streams that open again, and of the ends of the grace times. */
275
+ private readonly retries = new Set<Cancel>();
276
+ private readonly clock: Clock;
277
+ private readonly targetId: ViewTargetId;
278
+ private readonly maxTransactionRows: number;
279
+ private readonly graceMs: number;
280
+ private readonly leaseMs: number;
281
+ private readonly storageSources: ReadonlyMap<string, ViewPartitionOf>;
282
+ /** False after `stopIntake()`: a change starts no pass. */
283
+ private intake = false;
284
+ /** The work of each partition, one at a time: the passes and the rebalances. */
285
+ private readonly chains = new Map<string, Promise<void>>();
286
+ private readonly pending = new Set<Promise<unknown>>();
287
+ private stopped = true;
288
+
289
+ constructor(options: ViewMaterializerOptions) {
290
+ this.options = options;
291
+ this.viewsName = options.views ?? "_aggregations";
292
+ this.partitionField = options.partitionField ?? "_part";
293
+ this.statusField = options.statusField ?? "_materializerStatus";
294
+ this.debounceMs = optionalMs(options.debounce, "debounce") ?? 100;
295
+ this.rewatchMs = optionalMs(options.rewatch, "rewatch") ?? 1000;
296
+ this.maxTimeMS = optionalMs(options.maxTime, "maxTime") ?? 30_000;
297
+ this.maxRows = options.maxRows ?? 100_000;
298
+ this.clock = options.clock ?? realClock;
299
+ this.targetId = options.targetId ?? partitionTargetId;
300
+ this.maxTransactionRows = options.maxTransactionRows ?? 10_000;
301
+ this.graceMs = optionalMs(options.generationGrace, "generationGrace") ?? 60_000;
302
+ this.leaseMs = Math.round(optionalMs(options.passLease, "passLease") ?? 60_000);
303
+ this.storageSources = new Map(Object.entries(options.storageSources ?? {}));
304
+ }
305
+
306
+ private log(level: "info" | "warn" | "error", message: string): void {
307
+ if (this.options.log) this.options.log(level, message);
308
+ else console[level === "info" ? "log" : level](`[materializer] ${message}`);
309
+ }
310
+
311
+ /**
312
+ * The guard of a pipeline of a view. A target of another view counts only in its current
313
+ * generation. A source document has no generation.
314
+ */
315
+ private scoped(pipeline: Document[], partition: string, view: MaterializedView, generations: readonly ObjectId[]): Document[] {
316
+ const match = {
317
+ [this.partitionField]: partition,
318
+ $or: [{ [VIEW_GENERATION_FIELD]: { $exists: false } }, { [VIEW_GENERATION_FIELD]: { $in: [...generations] } }],
319
+ };
320
+ if (this.options.scope) return this.options.scope(pipeline, partition, view, match);
321
+ // No stamp: the materializer writes the rows, so the guard refuses a $merge of the pipeline
322
+ return scopePipeline(pipeline, { match, collections: view.collections });
323
+ }
324
+
325
+ /** The change streams that are open: the views stream and the source streams of the active views. */
326
+ openStreamCount(): number {
327
+ let count = this.viewsStream ? 1 : 0;
328
+ for (const view of this.running.values()) count += view.streams.length;
329
+ return count;
330
+ }
331
+
332
+ /** The passes, rebalances and status writes in flight. */
333
+ inFlight(): number {
334
+ return this.pending.size;
335
+ }
336
+
337
+ /** The status of the views that the materializer knows, by key `<partition>::<view id>`. */
338
+ views(): Map<string, { state: "active" | "cyclic"; sources: string[]; target: string }> {
339
+ return new Map([...this.running].map(([key, view]) => [key, { state: view.state, sources: view.view.collections, target: view.view.targetCollection }]));
340
+ }
341
+
342
+ /** This method starts the materializer: it reads each view, runs it, and watches the views and their sources. */
343
+ async start(): Promise<void> {
344
+ this.stopped = false;
345
+ this.intake = true;
346
+ this.db = await this.options.db();
347
+ const count = await this.resync();
348
+ this.watchViews();
349
+ this.log("info", `started: ${count} view(s)`);
350
+ }
351
+
352
+ /** This method stops the passes of new changes: a change starts no pass, and a waiting pass does not start. */
353
+ stopIntake(): void {
354
+ this.intake = false;
355
+ for (const cancel of this.timers.values()) cancel();
356
+ this.timers.clear();
357
+ this.dirty.clear();
358
+ }
359
+
360
+ /**
361
+ * This method waits until no work is in flight, until the `deadline` (milliseconds on the clock
362
+ * of the materializer), or until the signal aborts.
363
+ */
364
+ async drain(deadline: number, signal?: AbortSignal): Promise<void> {
365
+ while (this.pending.size > 0 && !signal?.aborted && this.clock.now() < deadline) {
366
+ let cancel: Cancel | undefined;
367
+ const tick = new Promise<void>((resolve) => {
368
+ cancel = this.clock.after(Math.max(0, Math.min(50, deadline - this.clock.now())), resolve);
369
+ cancel.unref?.();
370
+ signal?.addEventListener("abort", () => resolve(), { once: true });
371
+ });
372
+ await Promise.race([Promise.allSettled([...this.pending]), tick]);
373
+ cancel?.();
374
+ }
375
+ }
376
+
377
+ /** This method closes the streams and cancels each timer. The work in flight ends on its own. */
378
+ async close(): Promise<void> {
379
+ this.stopIntake();
380
+ this.stopped = true;
381
+ for (const cancel of this.retries) cancel();
382
+ this.retries.clear();
383
+ for (const key of [...this.running.keys()]) this.tearDown(key);
384
+ const stream = this.viewsStream;
385
+ this.viewsStream = undefined;
386
+ await stream?.close().catch(() => undefined);
387
+ }
388
+
389
+ /** This method stops the materializer: `stopIntake()`, `drain()` with no deadline, and `close()`. */
390
+ async stop(): Promise<void> {
391
+ this.stopIntake();
392
+ await this.drain(Number.POSITIVE_INFINITY);
393
+ await this.close();
394
+ await Promise.allSettled([...this.pending]);
395
+ }
396
+
397
+ /** This method keeps a promise of work until it ends, so `stop()` can wait for it. */
398
+ private track<R>(work: Promise<R>): Promise<R> {
399
+ this.pending.add(work);
400
+ void work.then(
401
+ () => this.pending.delete(work),
402
+ () => this.pending.delete(work)
403
+ );
404
+ return work;
405
+ }
406
+
407
+ /** This method runs work of a partition after the work of that partition before it. */
408
+ private serial(partition: string, work: () => Promise<void>): Promise<void> {
409
+ const before = this.chains.get(partition) ?? Promise.resolve();
410
+ const run = before.then(work, work);
411
+ const settled = run.then(
412
+ () => undefined,
413
+ () => undefined
414
+ );
415
+ this.chains.set(partition, settled);
416
+ void settled.then(() => {
417
+ if (this.chains.get(partition) === settled) this.chains.delete(partition);
418
+ });
419
+ return this.track(run);
420
+ }
421
+
422
+ private keyOf(partition: string, id: string): string {
423
+ return `${partition}::${id}`;
424
+ }
425
+
426
+ private partitionOf(view: MaterializedView): string | undefined {
427
+ const partition = view[this.partitionField];
428
+ return typeof partition === "string" && partition !== "" ? partition : undefined;
429
+ }
430
+
431
+ /** This method closes each view, and opens each view of the views collection again: a full catch-up. */
432
+ private async resync(): Promise<number> {
433
+ for (const key of [...this.running.keys()]) this.tearDown(key);
434
+ const docs = (await this.db!.collection<MaterializedView>(this.viewsName).find({}).toArray()) as MaterializedView[];
435
+ for (const view of docs) await this.spinUp(view);
436
+ return docs.length;
437
+ }
438
+
439
+ private watchViews(): void {
440
+ if (this.stopped || !this.db) return;
441
+ let stream: ChangeStream;
442
+ try {
443
+ stream = this.db.collection(this.viewsName).watch([], { fullDocument: "updateLookup" });
444
+ } catch (error) {
445
+ this.log("error", `cannot watch ${this.viewsName}: ${String((error as Error)?.message ?? error)}`);
446
+ this.later(() => this.watchViews());
447
+ return;
448
+ }
449
+ const previous = this.viewsStream;
450
+ if (previous && previous !== stream) previous.close().catch(() => undefined);
451
+ this.viewsStream = stream;
452
+
453
+ let again = false;
454
+ const rewatch = (reason: string): void => {
455
+ if (again || this.stopped || this.viewsStream !== stream) return;
456
+ again = true;
457
+ this.log("warn", `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
458
+ stream.close().catch(() => undefined);
459
+ this.later(() => {
460
+ this.watchViews();
461
+ void this.track(this.resync()).catch((error: unknown) => this.log("error", `the resync failed: ${String((error as Error)?.message ?? error)}`));
462
+ });
463
+ };
464
+
465
+ stream.on("change", (change: Document) => {
466
+ if (this.viewsStream !== stream) return;
467
+ if (change["operationType"] === "invalidate") return rewatch("ended (invalidate)");
468
+ if (change["operationType"] === "update") {
469
+ const description = (change["updateDescription"] ?? {}) as { updatedFields?: Document; removedFields?: string[] };
470
+ const touched = [...Object.keys(description.updatedFields ?? {}), ...(description.removedFields ?? [])];
471
+ // A write of the status only is ours: it does not open the view again
472
+ if (touched.length > 0 && touched.every((path) => path === this.statusField || path.startsWith(`${this.statusField}.`))) return;
473
+ }
474
+ void this.track(this.onViewChange(change)).catch((error: unknown) => this.log("error", `a change of a view failed: ${String((error as Error)?.message ?? error)}`));
475
+ });
476
+ stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
477
+ stream.on("close", () => rewatch("closed"));
478
+ }
479
+
480
+ private async onViewChange(change: Document): Promise<void> {
481
+ const type = change["operationType"];
482
+ if (type === "insert" || type === "replace" || type === "update") {
483
+ const view = change["fullDocument"] as MaterializedView | null;
484
+ if (!view) return;
485
+ const partition = this.partitionOf(view);
486
+ if (partition !== undefined) this.tearDown(this.keyOf(partition, view._id));
487
+ await this.spinUp(view);
488
+ } else if (type === "delete") {
489
+ const id = String((change["documentKey"] as Document)["_id"]);
490
+ const partitions = new Set<string>();
491
+ for (const [key, view] of [...this.running]) {
492
+ if (view.view._id === id) {
493
+ partitions.add(view.partition);
494
+ this.tearDown(key);
495
+ }
496
+ }
497
+ // A removed view can break a cycle of the other views
498
+ for (const partition of partitions) await this.rebalance(partition);
499
+ }
500
+ }
501
+
502
+ private async spinUp(view: MaterializedView): Promise<void> {
503
+ const partition = this.partitionOf(view);
504
+ if (partition === undefined) {
505
+ this.log("warn", `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
506
+ return;
507
+ }
508
+ if (!Array.isArray(view.collections) || view.collections.length === 0 || typeof view.targetCollection !== "string" || !Array.isArray(view.pipeline)) {
509
+ this.log("warn", `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
510
+ return;
511
+ }
512
+ this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
513
+ // The readers, the counts and the sweeps read the rows of a partition in a generation
514
+ await this.db!
515
+ .collection(view.targetCollection)
516
+ .createIndex({ [this.partitionField]: 1, [VIEW_GENERATION_FIELD]: 1 })
517
+ .catch((error: unknown) => this.log("error", `the index of ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
518
+ await this.rebalance(partition);
519
+ }
520
+
521
+ private tearDown(key: string): void {
522
+ const view = this.running.get(key);
523
+ if (!view) return;
524
+ for (const stream of view.streams) stream.close().catch(() => undefined);
525
+ this.running.delete(key);
526
+ }
527
+
528
+ /**
529
+ * The filter of the change stream of a source for a partition. It only removes the events that
530
+ * cannot change a view of the partition: an event whose partition is unknown passes, and the
531
+ * pass of the partition reads its own documents only.
532
+ *
533
+ * - An insert, a replace or an update has the document after the change (`fullDocument`).
534
+ * - With a pre-image (a collection with `changeStreamPreAndPostImages`), each change also has the
535
+ * document before it: a delete, and an update that moves a document out of the partition.
536
+ * - Without a pre-image, a delete, a replace and an update of the partition field (a top-level
537
+ * field) can leave any partition: they pass. In a source of `storageSources`, the handler then
538
+ * reads the partition from the id (`isOtherPartition`).
539
+ * - The handler checks the fields of an update against `sourceFields` (`touchesFields`).
540
+ */
541
+ private sourceMatch(partition: string): Document {
542
+ const field = this.partitionField;
543
+ const noPreImage = { fullDocumentBeforeChange: null };
544
+ return {
545
+ $match: {
546
+ $or: [
547
+ { [`fullDocument.${field}`]: partition },
548
+ { [`fullDocumentBeforeChange.${field}`]: partition },
549
+ { operationType: { $in: ["delete", "replace"] }, ...noPreImage },
550
+ { operationType: "update", ...noPreImage, $or: [{ [`updateDescription.updatedFields.${field}`]: { $exists: true } }, { "updateDescription.removedFields": field }] },
551
+ ],
552
+ },
553
+ };
554
+ }
555
+
556
+ /**
557
+ * True for an update that changes no field of `fields` and not the partition field. The keys of
558
+ * an update are paths with dots (`tags.3` after a `$push`, `a.b` after a `$set` of `a.b`), so a key
559
+ * counts when it is a field, a field under it, or a field above it. The removed fields and the
560
+ * truncated arrays count too. The positions in an array do not count (`lines.0.price` is
561
+ * `lines.price`, and `lines.3` is `lines`): that only widens the match.
562
+ */
563
+ private touchesNoField(change: Document, fields: readonly string[] | undefined): boolean {
564
+ if (change["operationType"] !== "update" || !fields || fields.length === 0) return false;
565
+ const description = (change["updateDescription"] ?? {}) as { updatedFields?: Document; removedFields?: string[]; truncatedArrays?: Array<{ field: string }> };
566
+ const keys = [...Object.keys(description.updatedFields ?? {}), ...(description.removedFields ?? []), ...(description.truncatedArrays ?? []).map((truncated) => truncated.field)];
567
+ const watched = [...fields, this.partitionField].map(withoutPositions);
568
+ return !keys.map(withoutPositions).some((key) => watched.some((name) => key === name || key.startsWith(`${name}.`) || name.startsWith(`${key}.`)));
569
+ }
570
+
571
+ /**
572
+ * True for a change without a pre-image (a delete, a replace, an update), in a source of
573
+ * `storageSources`, whose id names a storage partition that maps to another partition of the
574
+ * views. A document that is in the partition after the change, an id without the separator, a
575
+ * mapping that gives `undefined` and a mapping that fails do not tell another partition: false.
576
+ */
577
+ private isOtherPartition(change: Document, source: string, partition: string): boolean {
578
+ const viewPartitionOf = this.storageSources.get(source);
579
+ if (!viewPartitionOf || change["fullDocumentBeforeChange"]) return false;
580
+ if (!["delete", "replace", "update"].includes(change["operationType"] as string)) return false;
581
+ if ((change["fullDocument"] as Document | null | undefined)?.[this.partitionField] === partition) return false;
582
+ const id = (change["documentKey"] as Document | undefined)?.["_id"];
583
+ const at = typeof id === "string" ? id.indexOf(SEPARATOR) : -1;
584
+ if (at <= 0) return false;
585
+ let viewPartition: string | undefined;
586
+ try {
587
+ viewPartition = viewPartitionOf((id as string).slice(0, at));
588
+ } catch {
589
+ return false;
590
+ }
591
+ return typeof viewPartition === "string" && viewPartition !== partition;
592
+ }
593
+
594
+ private isCurrent(running: RunningView): boolean {
595
+ return this.running.get(this.keyOf(running.partition, running.view._id)) === running;
596
+ }
597
+
598
+ private watchSource(running: RunningView, source: string): void {
599
+ if (this.stopped || !this.db) return;
600
+ let stream: ChangeStream;
601
+ try {
602
+ stream = this.db
603
+ .collection(source)
604
+ .watch([this.sourceMatch(running.partition)], { fullDocument: "updateLookup", fullDocumentBeforeChange: "whenAvailable" });
605
+ } catch (error) {
606
+ this.log("error", `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
607
+ this.later(() => {
608
+ if (this.isCurrent(running) && running.state === "active") this.watchSource(running, source);
609
+ });
610
+ return;
611
+ }
612
+ running.streams.push(stream);
613
+ let again = false;
614
+ const rewatch = (reason: string): void => {
615
+ if (again) return;
616
+ again = true;
617
+ stream.close().catch(() => undefined);
618
+ const index = running.streams.indexOf(stream);
619
+ // A close of a teardown also ends the stream: open again only a stream of a current active view
620
+ if (index === -1 || !this.isCurrent(running) || running.state !== "active") return;
621
+ running.streams.splice(index, 1);
622
+ this.log("warn", `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
623
+ this.later(() => {
624
+ if (!this.isCurrent(running) || running.state !== "active") return;
625
+ this.watchSource(running, source);
626
+ // The changes while the stream was closed are lost: a pass catches up
627
+ this.schedule(running.partition, source);
628
+ });
629
+ };
630
+ stream.on("change", (change: Document) => {
631
+ if (change["operationType"] === "invalidate") return rewatch("ended (invalidate)");
632
+ if (this.isOtherPartition(change, source, running.partition)) return;
633
+ if (this.touchesNoField(change, running.view.sourceFields?.[source])) return;
634
+ this.schedule(running.partition, source);
635
+ });
636
+ stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
637
+ stream.on("close", () => rewatch("closed"));
638
+ }
639
+
640
+ /** This method runs a function after `rewatch`, unless the materializer stops. The timer does not keep the process alive. */
641
+ private later(work: () => void): void {
642
+ const cancel = this.clock.after(this.rewatchMs, () => {
643
+ this.retries.delete(cancel);
644
+ if (!this.stopped) work();
645
+ });
646
+ cancel.unref?.();
647
+ this.retries.add(cancel);
648
+ }
649
+
650
+ /** This method marks a collection as changed, and starts the pass of the partition after the burst. */
651
+ private schedule(partition: string, collection: string): void {
652
+ if (this.stopped || !this.intake) return;
653
+ let dirty = this.dirty.get(partition);
654
+ if (!dirty) this.dirty.set(partition, (dirty = new Set()));
655
+ dirty.add(collection);
656
+ this.timers.get(partition)?.();
657
+ const cancel = this.clock.after(this.debounceMs, () => {
658
+ this.timers.delete(partition);
659
+ if (!this.intake) return;
660
+ void this.serial(partition, () => this.pass(partition)).catch((error: unknown) =>
661
+ this.log("error", `the pass of ${partition} failed: ${String((error as Error)?.message ?? error)}`)
662
+ );
663
+ });
664
+ cancel.unref?.();
665
+ this.timers.set(partition, cancel);
666
+ }
667
+
668
+ /** The views of a partition, in the order of their dependencies, and the views in a cycle. */
669
+ private order(partition: string): { sorted: MaterializedView[]; cyclic: Set<string> } {
670
+ const views = [...this.running.values()].filter((running) => running.partition === partition).map((running) => running.view);
671
+ const downstream = new Map<string, Set<string>>(views.map((view) => [view._id, new Set()]));
672
+ const inDegree = new Map<string, number>(views.map((view) => [view._id, 0]));
673
+ for (const a of views) {
674
+ for (const b of views) {
675
+ if (a._id !== b._id && b.collections.includes(a.targetCollection) && !downstream.get(a._id)!.has(b._id)) {
676
+ downstream.get(a._id)!.add(b._id);
677
+ inDegree.set(b._id, inDegree.get(b._id)! + 1);
678
+ }
679
+ }
680
+ }
681
+ const byId = new Map(views.map((view) => [view._id, view]));
682
+ const queue = [...inDegree].filter(([, degree]) => degree === 0).map(([id]) => id);
683
+ const sorted: MaterializedView[] = [];
684
+ while (queue.length > 0) {
685
+ const id = queue.shift()!;
686
+ sorted.push(byId.get(id)!);
687
+ for (const next of downstream.get(id)!) {
688
+ inDegree.set(next, inDegree.get(next)! - 1);
689
+ if (inDegree.get(next) === 0) queue.push(next);
690
+ }
691
+ }
692
+ const done = new Set(sorted.map((view) => view._id));
693
+ return { sorted, cyclic: new Set(views.filter((view) => !done.has(view._id)).map((view) => view._id)) };
694
+ }
695
+
696
+ /** This method moves the views of a partition between active and cyclic, and runs each view that becomes active. */
697
+ private rebalance(partition: string): Promise<void> {
698
+ return this.serial(partition, async () => {
699
+ const { sorted, cyclic } = this.order(partition);
700
+ for (const view of [...sorted, ...[...cyclic].map((id) => this.running.get(this.keyOf(partition, id))!.view)]) {
701
+ const running = this.running.get(this.keyOf(partition, view._id));
702
+ if (!running) continue;
703
+ if (cyclic.has(view._id)) {
704
+ if (running.state === "active") {
705
+ for (const stream of running.streams) stream.close().catch(() => undefined);
706
+ running.streams = [];
707
+ running.state = "cyclic";
708
+ await this.writeStatus(view, partition, { state: "cycle", at: Date.now() });
709
+ }
710
+ } else if (running.state === "cyclic" || running.streams.length === 0) {
711
+ running.state = "active";
712
+ for (const source of view.collections) this.watchSource(running, source);
713
+ await this.runAndWriteStatus(view, partition);
714
+ }
715
+ }
716
+ });
717
+ }
718
+
719
+ /** A pass of a partition: each active view whose sources changed, in the order of the dependencies. */
720
+ private async pass(partition: string): Promise<void> {
721
+ const dirty = this.dirty.get(partition);
722
+ if (!dirty || dirty.size === 0) return;
723
+ // The pass takes the set: a change during the pass goes into a new set and a new pass
724
+ this.dirty.delete(partition);
725
+ for (const view of this.order(partition).sorted) {
726
+ const running = this.running.get(this.keyOf(partition, view._id));
727
+ if (!running || running.state !== "active" || !view.collections.some((collection) => dirty.has(collection))) continue;
728
+ const { changed } = await this.runAndWriteStatus(view, partition);
729
+ // A view whose result changed makes its target dirty: the views after it run in this pass
730
+ if (changed) dirty.add(view.targetCollection);
731
+ }
732
+ }
733
+
734
+ /** This method runs a view, and writes its status. A failed view does not make its target dirty. */
735
+ private async runAndWriteStatus(view: MaterializedView, partition: string): Promise<{ changed: boolean }> {
736
+ try {
737
+ const changed = await this.run(view, partition);
738
+ await this.writeStatus(view, partition, { state: "ok", at: Date.now() });
739
+ return { changed };
740
+ } catch (error) {
741
+ if (error instanceof PassSuperseded) {
742
+ // Not an error of the view: another process builds it now. A pass comes back after `rewatch`
743
+ this.log("info", `${view._id}: ${error.message}: the view runs again later`);
744
+ this.later(() => this.schedule(partition, view.collections[0]!));
745
+ return { changed: false };
746
+ }
747
+ const message = String((error as Error)?.message ?? error);
748
+ this.log("error", `${view._id}: ${message}`);
749
+ await this.writeStatus(view, partition, { state: "errored", lastError: message, at: Date.now() });
750
+ return { changed: false };
751
+ }
752
+ }
753
+
754
+ /**
755
+ * One run of a view. It gives true when the rows of the view changed: then the views that read
756
+ * its target run after it, in the same pass.
757
+ */
758
+ private async run(view: MaterializedView, partition: string): Promise<boolean> {
759
+ const target = this.db!.collection(view.targetCollection);
760
+ const { status, now } = await this.statusOf(view, partition);
761
+ if (status?.lease && status.lease.until instanceof Date && now < status.lease.until) throw new PassSuperseded("a pass of another process holds the view");
762
+ await this.dropOldGenerations(target, partition, status, now);
763
+ const current = status?.generation;
764
+ const generations = await this.currentGenerations(partition);
765
+ const size = current ? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current }) : 0;
766
+ if (size <= this.maxTransactionRows) {
767
+ try {
768
+ return await this.runInTransaction(view, partition, target, current, generations);
769
+ } catch (error) {
770
+ if (error instanceof ViewRunError || error instanceof PassSuperseded) throw error;
771
+ if (isWriteConflict(error)) throw new PassSuperseded(`another process wrote the view at the same time (${String((error as Error)?.message ?? error)})`);
772
+ this.log("warn", `${view._id}: the transaction failed (${String((error as Error)?.message ?? error)}): the pass writes a new generation in groups`);
773
+ }
774
+ }
775
+ await this.runInGroups(view, partition, target, current, generations);
776
+ return true;
777
+ }
778
+
779
+ /**
780
+ * The status of a view in the database (the current generation, and the previous one), and the
781
+ * time of the server: the times of the generations are times of the server.
782
+ */
783
+ private async statusOf(view: MaterializedView, partition: string): Promise<{ status: Partial<MaterializedViewStatus> | undefined; now: Date }> {
784
+ const [doc] = await this.db!
785
+ .collection(this.viewsName)
786
+ .aggregate([{ $match: { _id: view._id, [this.partitionField]: partition } }, { $project: { status: `$${this.statusField}`, now: "$$NOW" } }])
787
+ .toArray();
788
+ if (doc) return { status: doc["status"] as Partial<MaterializedViewStatus> | undefined, now: doc["now"] as Date };
789
+ // The view document went away: no generation to keep or to remove
790
+ return { status: undefined, now: new Date() };
791
+ }
792
+
793
+ /** The current generations of the views of a partition: the rows of a target that a pipeline can read. */
794
+ private async currentGenerations(partition: string): Promise<ObjectId[]> {
795
+ const docs = await this.db!.collection(this.viewsName).find({ [this.partitionField]: partition }, { projection: { [`${this.statusField}.generation`]: 1 } }).toArray();
796
+ return docs.map((doc) => (doc[this.statusField] as Partial<MaterializedViewStatus> | undefined)?.generation).filter((generation): generation is ObjectId => generation instanceof ObjectId);
797
+ }
798
+
799
+ /**
800
+ * This method removes the rows of the old generations of a view: not the current one, not the
801
+ * previous one during its grace time, and not the generation of a live lease (a pass in groups,
802
+ * maybe of another process, writes it). The rows of a pass that stopped go when its lease ends.
803
+ */
804
+ private async dropOldGenerations(target: Collection, partition: string, status: Partial<MaterializedViewStatus> | undefined, now: Date): Promise<void> {
805
+ const current = status?.generation;
806
+ if (!current) return;
807
+ const previous = status.previous;
808
+ const inGrace = previous?.flippedAt instanceof Date && now.getTime() < previous.flippedAt.getTime() + this.graceMs;
809
+ const lease = status.lease;
810
+ const leased = lease?.until instanceof Date && now < lease.until ? [lease.generation] : [];
811
+ const keep = [current, ...(inGrace ? [previous.generation] : []), ...leased].filter((generation): generation is ObjectId => generation instanceof ObjectId);
812
+ try {
813
+ await target.deleteMany({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: { $exists: true, $nin: keep } });
814
+ } catch (error) {
815
+ throw new Error(`the removal of the old generations: ${String((error as Error)?.message ?? error)}`);
816
+ }
817
+ }
818
+
819
+ /** 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`. */
820
+ private async *resultGroups(view: MaterializedView, partition: string, generations: readonly ObjectId[], generation: ObjectId): AsyncGenerator<StoredRow[]> {
821
+ let pipeline: Document[];
822
+ try {
823
+ pipeline = this.scoped(view.pipeline, partition, view, generations);
824
+ } catch (error) {
825
+ throw new ViewRunError(`the guard: ${String((error as Error)?.message ?? error)}`);
826
+ }
827
+ const cursor = this.db!
828
+ .collection(view.collections[0]!)
829
+ .aggregate([...pipeline, { $limit: this.maxRows + 1 }], { maxTimeMS: this.maxTimeMS, batchSize: WRITE_GROUP });
830
+ let count = 0;
831
+ let group: StoredRow[] = [];
832
+ try {
833
+ for (;;) {
834
+ let row: Document | null;
835
+ try {
836
+ row = await cursor.next();
837
+ } catch (error) {
838
+ throw new ViewRunError(`the pipeline: ${String((error as Error)?.message ?? error)}`);
839
+ }
840
+ if (row === null) break;
841
+ if (++count > this.maxRows) throw new ViewRunError(`the result has more than ${this.maxRows} rows`);
842
+ // The fields of the materializer, for a row that comes from a target: not in the content
843
+ const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
844
+ if (_id === undefined || _id === null) throw new ViewRunError("each row of the result needs an _id");
845
+ const stored = this.targetId(_id, partition, generation);
846
+ const hash = createHash("sha1").update(canonical(content)).digest("hex");
847
+ group.push({
848
+ id: _id,
849
+ key: idKey(stored),
850
+ hash,
851
+ doc: { _id: stored, ...content, [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: generation, [VIEW_HASH_FIELD]: hash },
852
+ });
853
+ if (group.length === WRITE_GROUP) {
854
+ yield group;
855
+ group = [];
856
+ }
857
+ }
858
+ if (group.length > 0) yield group;
859
+ } finally {
860
+ await cursor.close().catch(() => undefined);
861
+ }
862
+ }
863
+
864
+ private replaceOf(row: StoredRow, partition: string): Document {
865
+ return { replaceOne: { filter: { _id: row.doc["_id"], [this.partitionField]: partition }, replacement: row.doc, upsert: true } };
866
+ }
867
+
868
+ /**
869
+ * A run in one transaction, in the current generation (a new one at the first run): the rows that
870
+ * changed, and the removal of the rows that the result does not have. At the first run, the
871
+ * removal also takes the rows of no generation (an older materializer).
872
+ *
873
+ * The rule of convergence: a pass in groups of another process can start after this transaction
874
+ * reads its generation, and flip after it commits. Then the flip overtakes this commit, and the
875
+ * new generation can miss a change that only this pass saw. The change also made the partition
876
+ * dirty in the other process, so its next pass computes the view again, and the view converges.
877
+ */
878
+ private async runInTransaction(view: MaterializedView, partition: string, target: Collection, current: ObjectId | undefined, generations: readonly ObjectId[]): Promise<boolean> {
879
+ const generation = current ?? new ObjectId();
880
+ const session = this.db!.client.startSession();
881
+ let changed = current === undefined;
882
+ try {
883
+ session.startTransaction();
884
+ // In the transaction: a change of generation by another process since the start supersedes this pass
885
+ const fresh = await this.db!.collection(this.viewsName).findOne({ _id: view._id, [this.partitionField]: partition } as Document, { projection: { [this.statusField]: 1 }, session });
886
+ const now = (fresh?.[this.statusField] as Partial<MaterializedViewStatus> | undefined)?.generation;
887
+ if (!(now === undefined ? current === undefined : current !== undefined && now.equals(current))) throw new PassSuperseded("another process changed the generation of the view");
888
+ const seen = new Set<string>();
889
+ for await (const group of this.resultGroups(view, partition, generations, generation)) {
890
+ const stored = await target
891
+ .find({ _id: { $in: group.map((row) => row.doc["_id"]) }, [this.partitionField]: partition } as Document, { projection: { [VIEW_HASH_FIELD]: 1 }, session })
892
+ .toArray();
893
+ const hashes = new Map(stored.map((doc) => [idKey(doc["_id"]), doc[VIEW_HASH_FIELD]]));
894
+ const writes = group.filter((row) => hashes.get(row.key) !== row.hash).map((row) => this.replaceOf(row, partition));
895
+ for (const row of group) seen.add(row.key);
896
+ if (writes.length > 0) {
897
+ await target.bulkWrite(writes as never, { ordered: false, session });
898
+ changed = true;
899
+ }
900
+ }
901
+ // The rows of the generation that the result does not have: a group at most, else the pass goes in groups
902
+ const sweep = { [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current ? generation : { $in: [generation, null] } };
903
+ const stale: unknown[] = [];
904
+ for await (const doc of target.find(sweep, { projection: { _id: 1 }, session })) {
905
+ if (seen.has(idKey(doc["_id"]))) continue;
906
+ stale.push(doc["_id"]);
907
+ if (stale.length > this.maxTransactionRows) throw new Error(`more than ${this.maxTransactionRows} rows to remove`);
908
+ }
909
+ for (const group of groups(stale)) await target.deleteMany({ [this.partitionField]: partition, _id: { $in: group } } as Document, { session });
910
+ if (stale.length > 0) changed = true;
911
+ if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
912
+ throw new PassSuperseded("another process named the first generation of the view");
913
+ }
914
+ await session.commitTransaction();
915
+ } catch (error) {
916
+ await session.abortTransaction().catch(() => undefined);
917
+ throw error;
918
+ } finally {
919
+ await session.endSession().catch(() => undefined);
920
+ }
921
+ return changed;
922
+ }
923
+
924
+ /**
925
+ * A run in groups, outside a transaction: a whole new generation, then the change of the status to
926
+ * it. The readers of `viewFilter` move to it at once. The rows of no generation go at once: no
927
+ * reader of a generation reads them.
928
+ */
929
+ private async runInGroups(view: MaterializedView, partition: string, target: Collection, current: ObjectId | undefined, generations: readonly ObjectId[]): Promise<void> {
930
+ const generation = new ObjectId();
931
+ const owner = generation.toHexString();
932
+ if (!(await this.takeLease(view, partition, owner, generation))) throw new PassSuperseded("a pass of another process holds the view");
933
+ try {
934
+ let checked = false;
935
+ for await (const group of this.resultGroups(view, partition, generations, generation)) {
936
+ if (!checked) {
937
+ checked = true;
938
+ const first = group[0]!;
939
+ if (idKey(this.targetId(first.id, partition, new ObjectId())) === first.key) {
940
+ throw new ViewRunError("targetId gives the same _id in each generation: the view is too large for one transaction");
941
+ }
942
+ }
943
+ try {
944
+ await target.bulkWrite(group.map((row) => this.replaceOf(row, partition)) as never, { ordered: false });
945
+ } catch (error) {
946
+ throw new Error(`the write of a group of rows: ${String((error as Error)?.message ?? error)}`);
947
+ }
948
+ if (!(await this.renewLease(view, partition, owner))) throw new PassSuperseded("the pass lost its lease");
949
+ }
950
+ // The flip: still the owner of a live lease, and from the generation of the start
951
+ if (!(await this.setGeneration(view, partition, generation, { expected: current, owner, previous: { generation: current ?? null } }))) {
952
+ throw new PassSuperseded("the pass lost its lease, or a newer generation took the view");
953
+ }
954
+ } catch (error) {
955
+ // Its rows stay for a later pass: the end of the lease lets the cleanup remove them
956
+ await this.releaseLease(view, partition, owner).catch(() => undefined);
957
+ throw error;
958
+ }
959
+ await target.deleteMany({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: { $exists: false } });
960
+ // 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
961
+ const key = this.keyOf(partition, view._id);
962
+ const cancel = this.clock.after(this.graceMs, () => {
963
+ this.retries.delete(cancel);
964
+ if (this.stopped || !this.isCurrentView(key, view)) return;
965
+ void this.serial(partition, async () => {
966
+ const { status, now } = await this.statusOf(view, partition);
967
+ await this.dropOldGenerations(target, partition, status, now);
968
+ }).catch((error: unknown) => this.log("error", `${view._id}: ${String((error as Error)?.message ?? error)}`));
969
+ });
970
+ cancel.unref?.();
971
+ this.retries.add(cancel);
972
+ }
973
+
974
+ /** True while the view is the one that the materializer runs for this key. */
975
+ private isCurrentView(key: string, view: MaterializedView): boolean {
976
+ return this.running.get(key)?.view === view;
977
+ }
978
+
979
+ /**
980
+ * This method names the current generation of a view in its status, as a compare-and-set: only
981
+ * from the generation `expected`, and with `owner`, only while that pass holds a live lease. Then
982
+ * it ends the lease, and names the previous generation with the time of the change on the server
983
+ * (`$$NOW`). It gives false when another process changed the view.
984
+ */
985
+ private async setGeneration(
986
+ view: MaterializedView,
987
+ partition: string,
988
+ generation: ObjectId,
989
+ options: { expected: ObjectId | undefined; owner?: string; previous?: { generation: ObjectId | null }; session?: ClientSession }
990
+ ): Promise<boolean> {
991
+ const field = this.statusField;
992
+ const filter: Document = { _id: view._id, [this.partitionField]: partition, [`${field}.generation`]: options.expected ?? { $exists: false } };
993
+ if (options.owner !== undefined) {
994
+ filter[`${field}.lease.owner`] = options.owner;
995
+ filter["$expr"] = { $gt: [`$${field}.lease.until`, "$$NOW"] };
996
+ }
997
+ const set: Document = { [`${field}.generation`]: { $literal: generation } };
998
+ if (options.previous) set[`${field}.previous`] = { generation: { $literal: options.previous.generation }, flippedAt: "$$NOW" };
999
+ const update: Document[] = [{ $set: set }];
1000
+ if (options.owner !== undefined) update.push({ $unset: `${field}.lease` });
1001
+ const result = await this.db!.collection(this.viewsName).updateOne(filter, update, options.session ? { session: options.session } : {});
1002
+ return result.matchedCount === 1;
1003
+ }
1004
+
1005
+ /** This method takes the lease of a view for a pass in groups, when no live lease holds it. It gives false when one does. */
1006
+ private async takeLease(view: MaterializedView, partition: string, owner: string, generation: ObjectId): Promise<boolean> {
1007
+ const field = this.statusField;
1008
+ const result = await this.db!.collection(this.viewsName).updateOne(
1009
+ { _id: view._id, [this.partitionField]: partition, $expr: { $not: [{ $gt: [`$${field}.lease.until`, "$$NOW"] }] } } as Document,
1010
+ [{ $set: { [`${field}.lease`]: { owner: { $literal: owner }, generation: { $literal: generation }, until: { $add: ["$$NOW", this.leaseMs] } } } }]
1011
+ );
1012
+ return result.matchedCount === 1;
1013
+ }
1014
+
1015
+ /** This method renews the lease of a pass, while it is live and still its own. It gives false when the pass lost it. */
1016
+ private async renewLease(view: MaterializedView, partition: string, owner: string): Promise<boolean> {
1017
+ const field = this.statusField;
1018
+ const renewed = await this.db!.collection(this.viewsName).findOneAndUpdate(
1019
+ { _id: view._id, [this.partitionField]: partition, [`${field}.lease.owner`]: owner, $expr: { $gt: [`$${field}.lease.until`, "$$NOW"] } } as Document,
1020
+ [{ $set: { [`${field}.lease.until`]: { $add: ["$$NOW", this.leaseMs] } } }],
1021
+ { projection: { _id: 1 } }
1022
+ );
1023
+ return renewed !== null;
1024
+ }
1025
+
1026
+ /** This method ends the lease of a pass, when it is still its own. */
1027
+ private async releaseLease(view: MaterializedView, partition: string, owner: string): Promise<void> {
1028
+ await this.db!.collection(this.viewsName).updateOne({ _id: view._id, [this.partitionField]: partition, [`${this.statusField}.lease.owner`]: owner } as Document, { $unset: { [`${this.statusField}.lease`]: "" } });
1029
+ }
1030
+
1031
+ /** This method writes the state of a view. It keeps the generations of the status. */
1032
+ private async writeStatus(view: MaterializedView, partition: string, status: Pick<MaterializedViewStatus, "state" | "lastError" | "at">): Promise<void> {
1033
+ if (this.stopped) return;
1034
+ const field = this.statusField;
1035
+ const update: Document = { $set: { [`${field}.state`]: status.state, [`${field}.at`]: status.at } };
1036
+ if (status.lastError !== undefined) update["$set"][`${field}.lastError`] = status.lastError;
1037
+ else update["$unset"] = { [`${field}.lastError`]: "" };
1038
+ try {
1039
+ await this.db!.collection(this.viewsName).updateOne({ _id: view._id, [this.partitionField]: partition } as Document, update);
1040
+ } catch (error) {
1041
+ this.log("error", `the status of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
1042
+ }
1043
+ }
1044
+ }