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