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