@procedures/mongo 0.36.0 → 0.37.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 +42 -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 +46 -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 +582 -50
- 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 +101 -6
- package/src/storage/index.ts +2 -2
- package/src/storage/materializer.ts +669 -46
- 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,17 +46,20 @@
|
|
|
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
|
|
|
53
56
|
import { createHash } from "node:crypto";
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
+
import { isDeepStrictEqual } from "node:util";
|
|
58
|
+
import { BSON, ObjectId, type AnyBulkWriteOperation, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
|
|
59
|
+
import { collectionChangeTopic, ms, optionalMs, realClock, type Cancel, type Clock, type CollectionChangeEvent, type Duration } from "@procedures/client";
|
|
60
|
+
import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget, type StorageWriteTarget, type StoredForm } from "./document-storage.js";
|
|
57
61
|
import { scopePipeline } from "./pipeline-scope.js";
|
|
62
|
+
import type { StorageHintPublisher } from "./storage-hints.js";
|
|
58
63
|
|
|
59
64
|
/** The fields of a storage that a row never keeps: its sequence number and its tombstone flag (roadmap CLI-77). */
|
|
60
65
|
const ROW_DROPPED_FIELDS = ["_seq", TOMBSTONE_FIELD] as const;
|
|
@@ -155,6 +160,82 @@ export interface ViewMaterializerOptions {
|
|
|
155
160
|
* When the form or the function gives `undefined`, the change reaches the views of each partition.
|
|
156
161
|
*/
|
|
157
162
|
storageSources?: Readonly<Record<string, ViewPartitionOf | ViewStorageSource>> | undefined;
|
|
163
|
+
/**
|
|
164
|
+
* The targets in the stored form of a `MongoDocumentStorage` (roadmap CLI-81), by the name of the
|
|
165
|
+
* target collection: the storage of the target, its stored form, or a `ViewStorageTarget`. The
|
|
166
|
+
* rows of such a target are documents of the storage, so a reader reads them through a
|
|
167
|
+
* `MongoDocumentStorage` with the same form, and `changes(since)` and `watchStorageChanges()`
|
|
168
|
+
* have each write of a run. See `ViewStorageTarget` for the rules. Give the same targets to each
|
|
169
|
+
* materializer of the views.
|
|
170
|
+
*
|
|
171
|
+
* A function gives the entry of a target collection at each run, or undefined for a target that
|
|
172
|
+
* is not in a stored form: an app can name its targets at run time. The materializer keeps the
|
|
173
|
+
* storage that it makes for a collection while the entry gives the same form, counters,
|
|
174
|
+
* `writeLease` and `maxTombstones`. An error of the function is an error of the view.
|
|
175
|
+
*/
|
|
176
|
+
storageTargets?: Readonly<Record<string, ViewStorageTargetEntry>> | ((collection: string) => ViewStorageTargetEntry | undefined) | undefined;
|
|
177
|
+
/**
|
|
178
|
+
* The id of a row of a target in a stored form, from the `_id` of the row in the result. It must
|
|
179
|
+
* give a text, and give the same text for the same `_id` at each run.
|
|
180
|
+
* @default `defaultStorageRowId`: a text as it is, another value as its relaxed Extended JSON text
|
|
181
|
+
*/
|
|
182
|
+
storageRowIdOf?: ((id: unknown) => string) | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* The publisher of the hints of the targets that are not in `storageTargets`, for example the bus
|
|
185
|
+
* of the procedures. After each run that changes rows, the materializer publishes one hint with
|
|
186
|
+
* the ids of the changed rows and of the removed rows. A change of generation, a row id that is
|
|
187
|
+
* not a text and more than 10000 ids give a reset hint (`reset: true`): the subscribers read the
|
|
188
|
+
* view in full. The materializer publishes no hint of a target in a stored form: a watcher of
|
|
189
|
+
* the storage (`watchStorageChanges()`) does.
|
|
190
|
+
*/
|
|
191
|
+
publish?: StorageHintPublisher | undefined;
|
|
192
|
+
/** The topic of the hints of a view, or undefined for no hint (default `collectionChangeTopic(view._id)`). */
|
|
193
|
+
hintTopic?: ((view: MaterializedView, partition: string) => string | undefined) | undefined;
|
|
194
|
+
/**
|
|
195
|
+
* The `_id` of a row in the result, from its stored `_id`, for the ids of a hint. It reverses
|
|
196
|
+
* `targetId`. The default reads the field `id` of the form of `partitionTargetId`.
|
|
197
|
+
*/
|
|
198
|
+
rowIdOf?: ((storedId: unknown) => unknown) | undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A storage with items of any type. The type of the items does not change the stored form, and a
|
|
203
|
+
* `MongoDocumentStorage<T>` of a given `T` is not a `MongoDocumentStorage<unknown>`.
|
|
204
|
+
*/
|
|
205
|
+
type AnyDocumentStorage = MongoDocumentStorage<any>;
|
|
206
|
+
|
|
207
|
+
/** An entry of `storageTargets`: the storage of a target, its stored form, or a `ViewStorageTarget`. */
|
|
208
|
+
export type ViewStorageTargetEntry = AnyDocumentStorage | StoredForm | ViewStorageTarget;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A target of `storageTargets` (roadmap CLI-81).
|
|
212
|
+
*
|
|
213
|
+
* - The rows are documents of the storage: each row gets `_key`, `_seq`, `_ord`, the partition
|
|
214
|
+
* fields of the form, and `_kid`, `_kpf` or `_json` as a write of the storage makes them. The
|
|
215
|
+
* materializer encodes each row from its own content: the fields of a storage in a row (for
|
|
216
|
+
* example of a source document that the pipeline passes through) do not go into the target.
|
|
217
|
+
* - The id of a row is `storageRowIdOf(_id)`: by default the `_id` of the row when it is a text,
|
|
218
|
+
* else its relaxed Extended JSON text (`null` gives `"null"`, `5` gives `"5"`, an ObjectId gives
|
|
219
|
+
* `{"$oid":"…"}`). The item has that id as its `_id`. Two rows of a result must not have the
|
|
220
|
+
* same id.
|
|
221
|
+
* - A row also gets the partition field of the materializer with the partition of the view, so a
|
|
222
|
+
* view that reads the target finds it. When that field is a partition field of the form, the
|
|
223
|
+
* form must give it the partition of the view.
|
|
224
|
+
* - A run takes the numbers of its writes from the sequence of the storage, and a late run moves
|
|
225
|
+
* to new numbers, as a write of the storage does. A removed row becomes a tombstone, and the
|
|
226
|
+
* prune of the tombstones raises the floor of the change log.
|
|
227
|
+
* - A partition of the storage holds only the rows of one view. The run makes a tombstone of each
|
|
228
|
+
* other live document in that partition. The materializer refuses a second view of the same
|
|
229
|
+
* target collection and partition of the storage: that view has an error.
|
|
230
|
+
* - The materializer gives the target no images before the change. Turn on
|
|
231
|
+
* `changeStreamPreAndPostImages` on the target when raw hard deletes can occur.
|
|
232
|
+
* - The rows have no generation, so `viewFilter()` and a `MongoViewStorage` do not read them.
|
|
233
|
+
*/
|
|
234
|
+
export interface ViewStorageTarget {
|
|
235
|
+
/** The storage of the target, or its stored form. The materializer writes with its own database, and takes the counters, `writeLease` and `maxTombstones` of a storage. */
|
|
236
|
+
target: AnyDocumentStorage | StoredForm;
|
|
237
|
+
/** The partition of the storage from the partition of a view (default the same partition). Undefined is the storage without a partition. */
|
|
238
|
+
storagePartitionOf?: ((viewPartition: string) => string | undefined) | undefined;
|
|
158
239
|
}
|
|
159
240
|
|
|
160
241
|
/** The function that gives the stored `_id` of a row of a view. */
|
|
@@ -212,6 +293,72 @@ function groups<T>(items: readonly T[]): T[][] {
|
|
|
212
293
|
return result;
|
|
213
294
|
}
|
|
214
295
|
|
|
296
|
+
/** 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`. */
|
|
297
|
+
const CONFLICT_RETRIES = 3;
|
|
298
|
+
|
|
299
|
+
/** The most ids of a hint of a target not in a stored form. More ids give a reset hint. */
|
|
300
|
+
const MAX_HINT_IDS = 10_000;
|
|
301
|
+
|
|
302
|
+
/** The `_id` of a row in the result, from the stored `_id` of `partitionTargetId`. */
|
|
303
|
+
export function defaultRowId(storedId: unknown): unknown {
|
|
304
|
+
return typeof storedId === "object" && storedId !== null && "id" in storedId ? (storedId as { id: unknown }).id : storedId;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The id of a row of a target in a stored form, from the `_id` of the row in the result: a text as
|
|
309
|
+
* it is, another value as its relaxed Extended JSON text. The text of a value is the same at each
|
|
310
|
+
* run (the order of the fields of an object is the order of the result). A row without `_id` has
|
|
311
|
+
* no id.
|
|
312
|
+
*/
|
|
313
|
+
export function defaultStorageRowId(id: unknown): string {
|
|
314
|
+
if (typeof id === "string") return id;
|
|
315
|
+
if (id === undefined) throw new TypeError("the row has no _id");
|
|
316
|
+
return BSON.EJSON.stringify(id as Document, { relaxed: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The hash of a stored document without its numbers (`_seq` and `_ord`): two documents with the same content give the same hash. */
|
|
320
|
+
function contentHash(doc: Document): string {
|
|
321
|
+
const { _seq: _number, _ord: _place, ...rest } = doc;
|
|
322
|
+
return createHash("sha1")
|
|
323
|
+
.update(canonical(BSON.EJSON.serialize(rest, { relaxed: false })))
|
|
324
|
+
.digest("hex");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The values of a target in a stored form that make its storage. */
|
|
328
|
+
interface StorageTargetValues {
|
|
329
|
+
readonly form: StoredForm;
|
|
330
|
+
readonly counters: string | undefined;
|
|
331
|
+
readonly writeLeaseMs: number | undefined;
|
|
332
|
+
readonly maxTombstones: number | undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A target of `storageTargets`, ready to write: a storage on the database of the materializer. */
|
|
336
|
+
interface StorageTargetEntry {
|
|
337
|
+
readonly storage: MongoDocumentStorage<unknown>;
|
|
338
|
+
readonly storagePartitionOf: (viewPartition: string) => string | undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The storage of the partition of a view in its target (roadmap CLI-81). */
|
|
342
|
+
interface TargetPartition {
|
|
343
|
+
readonly writer: StorageWriteTarget;
|
|
344
|
+
/** The filter of the rows of the view: the partition of the storage and the partition of the view. */
|
|
345
|
+
readonly filter: Document;
|
|
346
|
+
/** True when the partition field of the materializer goes in the content of an item (not a field of the storage or of the form). */
|
|
347
|
+
readonly partitionInContent: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A row of a target in a stored form that a run writes, with the place of its stored document (`_ord`) when it has one. */
|
|
351
|
+
interface StorageWrite {
|
|
352
|
+
readonly row: StoredRow;
|
|
353
|
+
readonly ord: number | undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A row of a target in a stored form that a run removes: its stored `_id`, and its id when the stored document tells one that the result does not have. */
|
|
357
|
+
interface StorageRemoval {
|
|
358
|
+
readonly storedId: unknown;
|
|
359
|
+
readonly id: string | undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
215
362
|
/** The function that gives the partition of the views from the partition of a storage, or `undefined` when it does not know. */
|
|
216
363
|
export type ViewPartitionOf = (storagePartition: string) => string | undefined;
|
|
217
364
|
|
|
@@ -226,6 +373,23 @@ export interface ViewStorageSource {
|
|
|
226
373
|
viewPartitionOf: ViewPartitionOf;
|
|
227
374
|
}
|
|
228
375
|
|
|
376
|
+
/**
|
|
377
|
+
* This function waits until a change stream is open on the server: its first resume token. From
|
|
378
|
+
* then on, the stream gets each change. The driver sends the `aggregate` of a stream only after
|
|
379
|
+
* `watch()` returns, so a change before that point does not reach the stream. An error, an end or a
|
|
380
|
+
* close of the stream also ends the wait: its rewatch catches up (roadmap O63).
|
|
381
|
+
*/
|
|
382
|
+
function openOnServer(stream: ChangeStream): Promise<void> {
|
|
383
|
+
return new Promise((resolve) => {
|
|
384
|
+
const events = ["resumeTokenChanged", "error", "end", "close"] as const;
|
|
385
|
+
const done = (): void => {
|
|
386
|
+
for (const event of events) stream.off(event, done);
|
|
387
|
+
resolve();
|
|
388
|
+
};
|
|
389
|
+
for (const event of events) stream.on(event, done);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
229
393
|
/** A view that the materializer knows. */
|
|
230
394
|
interface RunningView {
|
|
231
395
|
view: MaterializedView;
|
|
@@ -293,11 +457,19 @@ export class ViewMaterializer {
|
|
|
293
457
|
private readonly graceMs: number;
|
|
294
458
|
private readonly leaseMs: number;
|
|
295
459
|
private readonly storageSources: ReadonlyMap<string, ViewStorageSource>;
|
|
460
|
+
/** The storages that the materializer made for the targets in a stored form, by collection, with the values that made them. */
|
|
461
|
+
private readonly storageTargetCache = new Map<string, StorageTargetValues & { storage: MongoDocumentStorage<unknown> }>();
|
|
462
|
+
/** 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. */
|
|
463
|
+
private readonly storageClaims = new Map<string, { claim: string; view: string; partition: string }>();
|
|
464
|
+
private readonly storageRowIdOf: (id: unknown) => string;
|
|
465
|
+
private readonly rowIdOf: (storedId: unknown) => unknown;
|
|
296
466
|
/** False after `stopIntake()`: a change starts no pass. */
|
|
297
467
|
private intake = false;
|
|
298
468
|
/** The work of each partition, one at a time: the passes and the rebalances. */
|
|
299
469
|
private readonly chains = new Map<string, Promise<void>>();
|
|
300
470
|
private readonly pending = new Set<Promise<unknown>>();
|
|
471
|
+
/** 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. */
|
|
472
|
+
private viewChain: Promise<unknown> = Promise.resolve();
|
|
301
473
|
private stopped = true;
|
|
302
474
|
|
|
303
475
|
constructor(options: ViewMaterializerOptions) {
|
|
@@ -323,6 +495,63 @@ export class ViewMaterializer {
|
|
|
323
495
|
return [source, normal];
|
|
324
496
|
})
|
|
325
497
|
);
|
|
498
|
+
// A record of targets is examined at once: a wrong entry is an error of the options
|
|
499
|
+
const targets = options.storageTargets;
|
|
500
|
+
if (targets !== undefined && typeof targets !== "function") {
|
|
501
|
+
for (const [collection, entry] of Object.entries(targets)) this.storageTargetOf(collection, entry);
|
|
502
|
+
}
|
|
503
|
+
this.storageRowIdOf = options.storageRowIdOf ?? defaultStorageRowId;
|
|
504
|
+
this.rowIdOf = options.rowIdOf ?? defaultRowId;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A target of `storageTargets` as a storage on the database of the materializer, so its writes
|
|
509
|
+
* and the transaction of a run use one client. A given storage gives its form, its counters,
|
|
510
|
+
* `writeLease` and `maxTombstones`.
|
|
511
|
+
*/
|
|
512
|
+
private storageTargetOf(collection: string, entry: ViewStorageTargetEntry): StorageTargetEntry {
|
|
513
|
+
const wrapped = typeof entry === "object" && entry !== null && "target" in entry ? entry : { target: entry };
|
|
514
|
+
const storagePartitionOf = wrapped.storagePartitionOf ?? ((partition: string): string | undefined => partition);
|
|
515
|
+
if (typeof storagePartitionOf !== "function") throw new TypeError(`The storage target ${collection} needs a function storagePartitionOf`);
|
|
516
|
+
const target = wrapped.target;
|
|
517
|
+
let values: StorageTargetValues;
|
|
518
|
+
if (target instanceof MongoDocumentStorage) {
|
|
519
|
+
const parts = storageWriteTarget(target);
|
|
520
|
+
if (parts.collection !== collection) throw new TypeError(`The storage target ${collection} has a storage of the collection ${parts.collection}`);
|
|
521
|
+
values = { form: parts.form, counters: parts.counters, writeLeaseMs: parts.writeLeaseMs, maxTombstones: parts.maxTombstones };
|
|
522
|
+
} else if (typeof target === "object" && target !== null && typeof (target as Partial<StoredForm>).storedId === "function") {
|
|
523
|
+
values = { form: target as StoredForm, counters: undefined, writeLeaseMs: undefined, maxTombstones: undefined };
|
|
524
|
+
} else {
|
|
525
|
+
throw new TypeError(`The storage target ${collection} needs a MongoDocumentStorage, a stored form or a ViewStorageTarget`);
|
|
526
|
+
}
|
|
527
|
+
// The same values give the storage of the last run: its partitions keep their state (the floor document of O66)
|
|
528
|
+
const cached = this.storageTargetCache.get(collection);
|
|
529
|
+
if (cached && cached.form === values.form && cached.counters === values.counters && cached.writeLeaseMs === values.writeLeaseMs && cached.maxTombstones === values.maxTombstones) {
|
|
530
|
+
return { storage: cached.storage, storagePartitionOf };
|
|
531
|
+
}
|
|
532
|
+
const db = (): Db => {
|
|
533
|
+
if (!this.db) throw new Error("The materializer has not started");
|
|
534
|
+
return this.db;
|
|
535
|
+
};
|
|
536
|
+
const storage = new MongoDocumentStorage<unknown>({
|
|
537
|
+
collection,
|
|
538
|
+
db,
|
|
539
|
+
form: values.form,
|
|
540
|
+
transactions: "never",
|
|
541
|
+
...(values.counters !== undefined ? { counters: values.counters } : {}),
|
|
542
|
+
...(values.writeLeaseMs !== undefined ? { writeLease: ms(values.writeLeaseMs) } : {}),
|
|
543
|
+
...(values.maxTombstones !== undefined ? { maxTombstones: values.maxTombstones } : {}),
|
|
544
|
+
});
|
|
545
|
+
this.storageTargetCache.set(collection, { ...values, storage });
|
|
546
|
+
return { storage, storagePartitionOf };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The target in a stored form of a collection at this time, or undefined. The function of `storageTargets` runs at each call. */
|
|
550
|
+
private storageTargetFor(collection: string): StorageTargetEntry | undefined {
|
|
551
|
+
const targets = this.options.storageTargets;
|
|
552
|
+
if (targets === undefined) return undefined;
|
|
553
|
+
const entry = typeof targets === "function" ? targets(collection) : Object.hasOwn(targets, collection) ? targets[collection] : undefined;
|
|
554
|
+
return entry === undefined || entry === null ? undefined : this.storageTargetOf(collection, entry);
|
|
326
555
|
}
|
|
327
556
|
|
|
328
557
|
private log(level: "info" | "warn" | "error", message: string): void {
|
|
@@ -368,8 +597,9 @@ export class ViewMaterializer {
|
|
|
368
597
|
this.stopped = false;
|
|
369
598
|
this.intake = true;
|
|
370
599
|
this.db = await this.options.db();
|
|
600
|
+
// The stream of the views opens before the read of the views: a change between the two reaches both (roadmap O63)
|
|
601
|
+
await this.watchViews();
|
|
371
602
|
const count = await this.resync();
|
|
372
|
-
this.watchViews();
|
|
373
603
|
this.log("info", `started: ${count} view(s)`);
|
|
374
604
|
}
|
|
375
605
|
|
|
@@ -452,23 +682,40 @@ export class ViewMaterializer {
|
|
|
452
682
|
return typeof partition === "string" && partition !== "" ? partition : undefined;
|
|
453
683
|
}
|
|
454
684
|
|
|
455
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* This method runs a change of the set of views after the changes before it. A change of a view
|
|
687
|
+
* document that comes during a resync thus applies after the read of the resync, not before it.
|
|
688
|
+
*/
|
|
689
|
+
private inViewOrder<R>(work: () => R | Promise<R>): Promise<R> {
|
|
690
|
+
const run = this.viewChain.then(work);
|
|
691
|
+
this.viewChain = run.catch(() => undefined);
|
|
692
|
+
return run;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* This method closes each view, and opens each view of the views collection again: a full
|
|
697
|
+
* catch-up. Open the stream of the views first: then a change after the read reaches the stream.
|
|
698
|
+
*/
|
|
456
699
|
private async resync(): Promise<number> {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
700
|
+
const { count, registered } = await this.inViewOrder(async () => {
|
|
701
|
+
const docs = (await this.db!.collection<MaterializedView>(this.viewsName).find({}).toArray()) as MaterializedView[];
|
|
702
|
+
for (const key of [...this.running.keys()]) this.tearDown(key);
|
|
703
|
+
return { count: docs.length, registered: docs.filter((view) => this.register(view)) };
|
|
704
|
+
});
|
|
705
|
+
for (const view of registered) await this.activate(view);
|
|
706
|
+
return count;
|
|
461
707
|
}
|
|
462
708
|
|
|
463
|
-
|
|
464
|
-
|
|
709
|
+
/** This method opens the stream of the views. The promise resolves when the stream is open on the server. */
|
|
710
|
+
private watchViews(): Promise<void> {
|
|
711
|
+
if (this.stopped || !this.db) return Promise.resolve();
|
|
465
712
|
let stream: ChangeStream;
|
|
466
713
|
try {
|
|
467
714
|
stream = this.db.collection(this.viewsName).watch([], { fullDocument: "updateLookup" });
|
|
468
715
|
} catch (error) {
|
|
469
716
|
this.log("error", `cannot watch ${this.viewsName}: ${String((error as Error)?.message ?? error)}`);
|
|
470
|
-
this.later(() => this.watchViews());
|
|
471
|
-
return;
|
|
717
|
+
this.later(() => void this.watchViews());
|
|
718
|
+
return Promise.resolve();
|
|
472
719
|
}
|
|
473
720
|
const previous = this.viewsStream;
|
|
474
721
|
if (previous && previous !== stream) previous.close().catch(() => undefined);
|
|
@@ -481,8 +728,8 @@ export class ViewMaterializer {
|
|
|
481
728
|
this.log("warn", `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
482
729
|
stream.close().catch(() => undefined);
|
|
483
730
|
this.later(() => {
|
|
484
|
-
|
|
485
|
-
void this.track(this.resync()).catch((error: unknown) => this.log("error", `the resync failed: ${String((error as Error)?.message ?? error)}`));
|
|
731
|
+
// The changes while the stream was closed are lost: a resync catches up, after the new stream is open
|
|
732
|
+
void this.track(this.watchViews().then(() => this.resync())).catch((error: unknown) => this.log("error", `the resync failed: ${String((error as Error)?.message ?? error)}`));
|
|
486
733
|
});
|
|
487
734
|
};
|
|
488
735
|
|
|
@@ -499,6 +746,7 @@ export class ViewMaterializer {
|
|
|
499
746
|
});
|
|
500
747
|
stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
|
|
501
748
|
stream.on("close", () => rewatch("closed"));
|
|
749
|
+
return openOnServer(stream);
|
|
502
750
|
}
|
|
503
751
|
|
|
504
752
|
private async onViewChange(change: Document): Promise<void> {
|
|
@@ -506,39 +754,65 @@ export class ViewMaterializer {
|
|
|
506
754
|
if (type === "insert" || type === "replace" || type === "update") {
|
|
507
755
|
const view = change["fullDocument"] as MaterializedView | null;
|
|
508
756
|
if (!view) return;
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
757
|
+
const registered = await this.inViewOrder(() => {
|
|
758
|
+
const partition = this.partitionOf(view);
|
|
759
|
+
if (partition !== undefined) this.tearDown(this.keyOf(partition, view._id));
|
|
760
|
+
return this.register(view);
|
|
761
|
+
});
|
|
762
|
+
if (registered) await this.activate(view);
|
|
512
763
|
} else if (type === "delete") {
|
|
513
764
|
const id = String((change["documentKey"] as Document)["_id"]);
|
|
514
|
-
const partitions =
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
765
|
+
const partitions = await this.inViewOrder(() => {
|
|
766
|
+
const found = new Set<string>();
|
|
767
|
+
for (const [key, view] of [...this.running]) {
|
|
768
|
+
if (view.view._id === id) {
|
|
769
|
+
found.add(view.partition);
|
|
770
|
+
this.tearDown(key);
|
|
771
|
+
}
|
|
519
772
|
}
|
|
520
|
-
|
|
773
|
+
return found;
|
|
774
|
+
});
|
|
521
775
|
// A removed view can break a cycle of the other views
|
|
522
776
|
for (const partition of partitions) await this.rebalance(partition);
|
|
523
777
|
}
|
|
524
778
|
}
|
|
525
779
|
|
|
526
|
-
|
|
780
|
+
/** This method adds a view to the views that the materializer knows. It gives false for a view that cannot run. */
|
|
781
|
+
private register(view: MaterializedView): boolean {
|
|
527
782
|
const partition = this.partitionOf(view);
|
|
528
783
|
if (partition === undefined) {
|
|
529
784
|
this.log("warn", `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
|
|
530
|
-
return;
|
|
785
|
+
return false;
|
|
531
786
|
}
|
|
532
787
|
if (!Array.isArray(view.collections) || view.collections.length === 0 || typeof view.targetCollection !== "string" || !Array.isArray(view.pipeline)) {
|
|
533
788
|
this.log("warn", `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
|
|
534
|
-
return;
|
|
789
|
+
return false;
|
|
535
790
|
}
|
|
536
791
|
this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** This method makes the index of the target of a registered view, and rebalances its partition: a new view runs. */
|
|
796
|
+
private async activate(view: MaterializedView): Promise<void> {
|
|
797
|
+
const partition = this.partitionOf(view)!;
|
|
537
798
|
// The readers, the counts and the sweeps read the rows of a partition in a generation
|
|
538
799
|
await this.db!
|
|
539
800
|
.collection(view.targetCollection)
|
|
540
801
|
.createIndex({ [this.partitionField]: 1, [VIEW_GENERATION_FIELD]: 1 })
|
|
541
802
|
.catch((error: unknown) => this.log("error", `the index of ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
803
|
+
// A target in a stored form also has the indexes of the storage: the change reads, the order and the tombstones
|
|
804
|
+
let storageTarget: StorageTargetEntry | undefined;
|
|
805
|
+
try {
|
|
806
|
+
storageTarget = this.storageTargetFor(view.targetCollection);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
// The run of the view gives the error to its status
|
|
809
|
+
this.log("error", `the storage target ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
|
|
810
|
+
}
|
|
811
|
+
if (storageTarget) {
|
|
812
|
+
await storageWriteTarget(storageTarget.storage)
|
|
813
|
+
.ready()
|
|
814
|
+
.catch((error: unknown) => this.log("error", `the indexes of the storage ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
815
|
+
}
|
|
542
816
|
await this.rebalance(partition);
|
|
543
817
|
}
|
|
544
818
|
|
|
@@ -547,6 +821,7 @@ export class ViewMaterializer {
|
|
|
547
821
|
if (!view) return;
|
|
548
822
|
for (const stream of view.streams) stream.close().catch(() => undefined);
|
|
549
823
|
this.running.delete(key);
|
|
824
|
+
this.storageClaims.delete(key);
|
|
550
825
|
}
|
|
551
826
|
|
|
552
827
|
/**
|
|
@@ -630,8 +905,9 @@ export class ViewMaterializer {
|
|
|
630
905
|
return this.running.get(this.keyOf(running.partition, running.view._id)) === running;
|
|
631
906
|
}
|
|
632
907
|
|
|
633
|
-
|
|
634
|
-
|
|
908
|
+
/** This method opens the stream of a source for a view. The promise resolves when the stream is open on the server. */
|
|
909
|
+
private watchSource(running: RunningView, source: string): Promise<void> {
|
|
910
|
+
if (this.stopped || !this.db) return Promise.resolve();
|
|
635
911
|
let stream: ChangeStream;
|
|
636
912
|
try {
|
|
637
913
|
stream = this.db
|
|
@@ -640,9 +916,9 @@ export class ViewMaterializer {
|
|
|
640
916
|
} catch (error) {
|
|
641
917
|
this.log("error", `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
|
|
642
918
|
this.later(() => {
|
|
643
|
-
if (this.isCurrent(running) && running.state === "active") this.watchSource(running, source);
|
|
919
|
+
if (this.isCurrent(running) && running.state === "active") void this.watchSource(running, source);
|
|
644
920
|
});
|
|
645
|
-
return;
|
|
921
|
+
return Promise.resolve();
|
|
646
922
|
}
|
|
647
923
|
running.streams.push(stream);
|
|
648
924
|
let again = false;
|
|
@@ -657,9 +933,8 @@ export class ViewMaterializer {
|
|
|
657
933
|
this.log("warn", `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
658
934
|
this.later(() => {
|
|
659
935
|
if (!this.isCurrent(running) || running.state !== "active") return;
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
this.schedule(running.partition, source);
|
|
936
|
+
// The changes while the stream was closed are lost: a pass catches up, after the new stream is open
|
|
937
|
+
void this.watchSource(running, source).then(() => this.schedule(running.partition, source));
|
|
663
938
|
});
|
|
664
939
|
};
|
|
665
940
|
stream.on("change", (change: Document) => {
|
|
@@ -670,6 +945,7 @@ export class ViewMaterializer {
|
|
|
670
945
|
});
|
|
671
946
|
stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
|
|
672
947
|
stream.on("close", () => rewatch("closed"));
|
|
948
|
+
return openOnServer(stream);
|
|
673
949
|
}
|
|
674
950
|
|
|
675
951
|
/** This method runs a function after `rewatch`, unless the materializer stops. The timer does not keep the process alive. */
|
|
@@ -744,7 +1020,10 @@ export class ViewMaterializer {
|
|
|
744
1020
|
}
|
|
745
1021
|
} else if (running.state === "cyclic" || running.streams.length === 0) {
|
|
746
1022
|
running.state = "active";
|
|
747
|
-
|
|
1023
|
+
// The streams open before the run reads the sources: a change between the two reaches both (roadmap O63)
|
|
1024
|
+
await Promise.all(view.collections.map((source) => this.watchSource(running, source)));
|
|
1025
|
+
// A change of the view document during the wait replaced this view: the rebalance of the new one runs it
|
|
1026
|
+
if (!this.isCurrent(running)) continue;
|
|
748
1027
|
await this.runAndWriteStatus(view, partition);
|
|
749
1028
|
}
|
|
750
1029
|
}
|
|
@@ -792,25 +1071,96 @@ export class ViewMaterializer {
|
|
|
792
1071
|
*/
|
|
793
1072
|
private async run(view: MaterializedView, partition: string): Promise<boolean> {
|
|
794
1073
|
const target = this.db!.collection(view.targetCollection);
|
|
1074
|
+
const storage = this.targetPartition(view, partition);
|
|
795
1075
|
const { status, now } = await this.statusOf(view, partition);
|
|
796
1076
|
if (status?.lease && status.lease.until instanceof Date && now < status.lease.until) throw new PassSuperseded("a pass of another process holds the view");
|
|
797
|
-
|
|
1077
|
+
// The rows of a target in a stored form have no generation: a removal is a tombstone, never a delete of an old generation
|
|
1078
|
+
if (!storage) await this.dropOldGenerations(target, partition, status, now);
|
|
798
1079
|
const current = status?.generation;
|
|
799
1080
|
const generations = await this.currentGenerations(partition);
|
|
800
|
-
|
|
1081
|
+
// The floor document of the partition of the storage, before the first write (roadmap O66)
|
|
1082
|
+
if (storage) await storage.writer.noteFloor(this.db!);
|
|
1083
|
+
const size = storage
|
|
1084
|
+
? await target.countDocuments({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } })
|
|
1085
|
+
: current
|
|
1086
|
+
? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current })
|
|
1087
|
+
: 0;
|
|
801
1088
|
if (size <= this.maxTransactionRows) {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1089
|
+
for (let attempt = 1; ; attempt++) {
|
|
1090
|
+
try {
|
|
1091
|
+
return storage
|
|
1092
|
+
? await this.runStorageInTransaction(view, partition, target, storage, current, generations)
|
|
1093
|
+
: await this.runInTransaction(view, partition, target, current, generations);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
if (error instanceof ViewRunError || error instanceof PassSuperseded) throw error;
|
|
1096
|
+
if (isWriteConflict(error)) {
|
|
1097
|
+
// A write conflict tries again a few times after a short wait, so a steady stream of writes does not starve the view
|
|
1098
|
+
if (attempt < CONFLICT_RETRIES) {
|
|
1099
|
+
await this.pause(25 * 2 ** attempt * (1 + Math.random()));
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
throw new PassSuperseded(`another process wrote the view at the same time (${String((error as Error)?.message ?? error)})`);
|
|
1103
|
+
}
|
|
1104
|
+
this.log("warn", `${view._id}: the transaction failed (${String((error as Error)?.message ?? error)}): the pass writes ${storage ? "in groups" : "a new generation in groups"}`);
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
808
1107
|
}
|
|
809
1108
|
}
|
|
1109
|
+
if (storage) return this.runStorageInGroups(view, partition, target, storage, current, generations);
|
|
810
1110
|
await this.runInGroups(view, partition, target, current, generations);
|
|
811
1111
|
return true;
|
|
812
1112
|
}
|
|
813
1113
|
|
|
1114
|
+
/** This method waits on the clock of the materializer. The timer does not keep the process alive. */
|
|
1115
|
+
private pause(delay: number): Promise<void> {
|
|
1116
|
+
return new Promise((resolve) => {
|
|
1117
|
+
const cancel = this.clock.after(Math.round(delay), resolve);
|
|
1118
|
+
cancel.unref?.();
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* The storage of the partition of a view in its target, or undefined for a target that is not in
|
|
1124
|
+
* `storageTargets`. A partition that the storage refuses, and a partition field of the form with
|
|
1125
|
+
* another value than the partition of the view, are an error of the view.
|
|
1126
|
+
*/
|
|
1127
|
+
private targetPartition(view: MaterializedView, partition: string): TargetPartition | undefined {
|
|
1128
|
+
let entry: StorageTargetEntry | undefined;
|
|
1129
|
+
try {
|
|
1130
|
+
entry = this.storageTargetFor(view.targetCollection);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
throw new ViewRunError(`the storage target ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
|
|
1133
|
+
}
|
|
1134
|
+
if (!entry) return undefined;
|
|
1135
|
+
let writer: StorageWriteTarget;
|
|
1136
|
+
let storagePartition: string | undefined;
|
|
1137
|
+
try {
|
|
1138
|
+
storagePartition = entry.storagePartitionOf(partition);
|
|
1139
|
+
writer = storageWriteTarget(storagePartition === undefined ? entry.storage : entry.storage.forPartition(storagePartition));
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
throw new ViewRunError(`the partition of the storage ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
|
|
1142
|
+
}
|
|
1143
|
+
// One view writes a partition of a storage target: its run makes a tombstone of each other document of it
|
|
1144
|
+
const claim = JSON.stringify([view.targetCollection, storagePartition ?? null]);
|
|
1145
|
+
const key = this.keyOf(partition, view._id);
|
|
1146
|
+
for (const [other, held] of this.storageClaims) {
|
|
1147
|
+
if (other !== key && held.claim === claim && this.running.has(other)) {
|
|
1148
|
+
const place = storagePartition === undefined ? "the storage" : `the partition ${storagePartition} of the storage`;
|
|
1149
|
+
throw new ViewRunError(`the view ${held.view} (partition ${held.partition}) writes ${place} ${view.targetCollection} already: one view writes a partition of a storage target`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
this.storageClaims.set(key, { claim, view: view._id, partition });
|
|
1153
|
+
const field = this.partitionField;
|
|
1154
|
+
if (writer.partitionFields.has(field) && !isDeepStrictEqual(writer.partitionFields.get(field), partition)) {
|
|
1155
|
+
throw new ViewRunError(`the stored form of ${view.targetCollection} gives the field ${field} another value than the partition ${partition}`);
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
writer,
|
|
1159
|
+
filter: { ...writer.partitionFilter(), [field]: partition },
|
|
1160
|
+
partitionInContent: !writer.partitionFields.has(field) && !DOCUMENT_STORAGE_FIELDS.includes(field),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
814
1164
|
/**
|
|
815
1165
|
* The status of a view in the database (the current generation, and the previous one), and the
|
|
816
1166
|
* time of the server: the times of the generations are times of the server.
|
|
@@ -852,7 +1202,7 @@ export class ViewMaterializer {
|
|
|
852
1202
|
}
|
|
853
1203
|
|
|
854
1204
|
/** 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`. */
|
|
855
|
-
private async *resultGroups(view: MaterializedView, partition: string, generations: readonly ObjectId[], generation: ObjectId): AsyncGenerator<StoredRow[]> {
|
|
1205
|
+
private async *resultGroups(view: MaterializedView, partition: string, generations: readonly ObjectId[], generation: ObjectId, storage?: TargetPartition): AsyncGenerator<StoredRow[]> {
|
|
856
1206
|
let pipeline: Document[];
|
|
857
1207
|
try {
|
|
858
1208
|
pipeline = this.scoped(view.pipeline, partition, view, generations);
|
|
@@ -874,6 +1224,14 @@ export class ViewMaterializer {
|
|
|
874
1224
|
}
|
|
875
1225
|
if (row === null) break;
|
|
876
1226
|
if (++count > this.maxRows) throw new ViewRunError(`the result has more than ${this.maxRows} rows`);
|
|
1227
|
+
if (storage) {
|
|
1228
|
+
group.push(this.storageRowOf(row, partition, storage));
|
|
1229
|
+
if (group.length === WRITE_GROUP) {
|
|
1230
|
+
yield group;
|
|
1231
|
+
group = [];
|
|
1232
|
+
}
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
877
1235
|
// The fields of the materializer, for a row that comes from a target: not in the content
|
|
878
1236
|
const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
|
|
879
1237
|
if (_id === undefined || _id === null) throw new ViewRunError("each row of the result needs an _id");
|
|
@@ -900,6 +1258,43 @@ export class ViewMaterializer {
|
|
|
900
1258
|
}
|
|
901
1259
|
}
|
|
902
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* A row of the result as a document of a target in a stored form (roadmap CLI-81). The content is
|
|
1263
|
+
* the row without the fields of the materializer and of a storage: a pass-through row of a source
|
|
1264
|
+
* in a stored form loses its `_key`, `_kid`, `_kpf`, `_ord` and `_json`, and the encoding of the
|
|
1265
|
+
* storage makes them again from the row (`_json` from the content of the row). A field of the row
|
|
1266
|
+
* with the name of a partition field of the form takes the value of the partition of the target.
|
|
1267
|
+
* The numbers (`_seq`, `_ord`) come at the write.
|
|
1268
|
+
*/
|
|
1269
|
+
private storageRowOf(row: Document, partition: string, storage: TargetPartition): StoredRow {
|
|
1270
|
+
const { _id: rowId, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
|
|
1271
|
+
let _id: string;
|
|
1272
|
+
try {
|
|
1273
|
+
_id = this.storageRowIdOf(rowId);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
throw new ViewRunError(`the id of a row of a target in a stored form: ${String((error as Error)?.message ?? error)}`);
|
|
1276
|
+
}
|
|
1277
|
+
if (typeof _id !== "string") throw new ViewRunError(`storageRowIdOf must give a text, not ${typeof _id}`);
|
|
1278
|
+
const field = this.partitionField;
|
|
1279
|
+
delete content[field];
|
|
1280
|
+
for (const name of DOCUMENT_STORAGE_FIELDS) delete content[name];
|
|
1281
|
+
for (const [name, value] of storage.writer.partitionFields) {
|
|
1282
|
+
if (!(name in content)) continue;
|
|
1283
|
+
if (value === undefined) delete content[name];
|
|
1284
|
+
else content[name] = value;
|
|
1285
|
+
}
|
|
1286
|
+
if (storage.partitionInContent) content[field] = partition;
|
|
1287
|
+
let doc: Document;
|
|
1288
|
+
try {
|
|
1289
|
+
doc = storage.writer.encode(_id, { _id, ...content }, 0, 0);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
throw new ViewRunError(`the row ${JSON.stringify(_id)}: ${String((error as Error)?.message ?? error)}`);
|
|
1292
|
+
}
|
|
1293
|
+
// A view that reads the target finds the row by the partition field, also in an item stored as JSON text
|
|
1294
|
+
if (doc[field] === undefined) doc[field] = partition;
|
|
1295
|
+
return { id: _id, key: idKey(doc["_id"]), hash: contentHash(doc), doc };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
903
1298
|
private replaceOf(row: StoredRow, partition: string): Document {
|
|
904
1299
|
return { replaceOne: { filter: { _id: row.doc["_id"], [this.partitionField]: partition }, replacement: row.doc, upsert: true } };
|
|
905
1300
|
}
|
|
@@ -918,6 +1313,7 @@ export class ViewMaterializer {
|
|
|
918
1313
|
const generation = current ?? new ObjectId();
|
|
919
1314
|
const session = this.db!.client.startSession();
|
|
920
1315
|
let changed = current === undefined;
|
|
1316
|
+
const hint = { changed: [] as unknown[], deleted: [] as unknown[] };
|
|
921
1317
|
try {
|
|
922
1318
|
session.startTransaction();
|
|
923
1319
|
// In the transaction: a change of generation by another process since the start supersedes this pass
|
|
@@ -930,8 +1326,10 @@ export class ViewMaterializer {
|
|
|
930
1326
|
.find({ _id: { $in: group.map((row) => row.doc["_id"]) }, [this.partitionField]: partition } as Document, { projection: { [VIEW_HASH_FIELD]: 1 }, session })
|
|
931
1327
|
.toArray();
|
|
932
1328
|
const hashes = new Map(stored.map((doc) => [idKey(doc["_id"]), doc[VIEW_HASH_FIELD]]));
|
|
933
|
-
const
|
|
1329
|
+
const rows = group.filter((row) => hashes.get(row.key) !== row.hash);
|
|
1330
|
+
const writes = rows.map((row) => this.replaceOf(row, partition));
|
|
934
1331
|
for (const row of group) seen.add(row.key);
|
|
1332
|
+
for (const row of rows) hint.changed.push(row.id);
|
|
935
1333
|
if (writes.length > 0) {
|
|
936
1334
|
await target.bulkWrite(writes as never, { ordered: false, session });
|
|
937
1335
|
changed = true;
|
|
@@ -947,6 +1345,7 @@ export class ViewMaterializer {
|
|
|
947
1345
|
}
|
|
948
1346
|
for (const group of groups(stale)) await target.deleteMany({ [this.partitionField]: partition, _id: { $in: group } } as Document, { session });
|
|
949
1347
|
if (stale.length > 0) changed = true;
|
|
1348
|
+
for (const storedId of stale) hint.deleted.push(this.rowIdOf(storedId));
|
|
950
1349
|
if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
|
|
951
1350
|
throw new PassSuperseded("another process named the first generation of the view");
|
|
952
1351
|
}
|
|
@@ -957,9 +1356,35 @@ export class ViewMaterializer {
|
|
|
957
1356
|
} finally {
|
|
958
1357
|
await session.endSession().catch(() => undefined);
|
|
959
1358
|
}
|
|
1359
|
+
if (hint.changed.length > 0 || hint.deleted.length > 0) this.publishHint(view, partition, hint);
|
|
960
1360
|
return changed;
|
|
961
1361
|
}
|
|
962
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* This method publishes the hint of a run of a target that is not in a stored form: one hint
|
|
1365
|
+
* for the run, with the ids of the changed rows and of the removed rows. Without ids (a change
|
|
1366
|
+
* of generation), with an id that is not a text, or with more than `MAX_HINT_IDS` ids, it is a
|
|
1367
|
+
* reset hint. A failure of the publisher goes to the log: the run does not fail.
|
|
1368
|
+
*/
|
|
1369
|
+
private publishHint(view: MaterializedView, partition: string, ids: { changed: readonly unknown[]; deleted: readonly unknown[] } | undefined): void {
|
|
1370
|
+
const publish = this.options.publish;
|
|
1371
|
+
if (!publish) return;
|
|
1372
|
+
const fail = (error: unknown): void => this.log("error", `the hint of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
|
|
1373
|
+
try {
|
|
1374
|
+
const topic = this.options.hintTopic ? this.options.hintTopic(view, partition) : collectionChangeTopic(view._id);
|
|
1375
|
+
if (topic === undefined) return;
|
|
1376
|
+
const texts = (values: readonly unknown[]): values is readonly string[] => values.every((value) => typeof value === "string");
|
|
1377
|
+
const event: CollectionChangeEvent =
|
|
1378
|
+
ids && ids.changed.length + ids.deleted.length <= MAX_HINT_IDS && texts(ids.changed) && texts(ids.deleted)
|
|
1379
|
+
? { changed: [...new Set(ids.changed)], deleted: [...new Set(ids.deleted)] }
|
|
1380
|
+
: { changed: [], deleted: [], reset: true };
|
|
1381
|
+
const result = typeof publish === "function" ? publish(topic, event) : publish.emit(topic, event);
|
|
1382
|
+
if (result instanceof Promise) result.catch(fail);
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
fail(error);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
963
1388
|
/**
|
|
964
1389
|
* A run in groups, outside a transaction: a whole new generation, then the change of the status to
|
|
965
1390
|
* it. The readers of `viewFilter` move to it at once. The rows of no generation go at once: no
|
|
@@ -995,6 +1420,8 @@ export class ViewMaterializer {
|
|
|
995
1420
|
await this.releaseLease(view, partition, owner).catch(() => undefined);
|
|
996
1421
|
throw error;
|
|
997
1422
|
}
|
|
1423
|
+
// The readers of the view moved to the new generation at once: a gap, so the subscribers read the view in full
|
|
1424
|
+
this.publishHint(view, partition, undefined);
|
|
998
1425
|
await target.deleteMany({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: { $exists: false } });
|
|
999
1426
|
// 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
|
|
1000
1427
|
const key = this.keyOf(partition, view._id);
|
|
@@ -1010,6 +1437,202 @@ export class ViewMaterializer {
|
|
|
1010
1437
|
this.retries.add(cancel);
|
|
1011
1438
|
}
|
|
1012
1439
|
|
|
1440
|
+
/**
|
|
1441
|
+
* The rows of a group that a run writes in a target in a stored form: each row whose stored
|
|
1442
|
+
* document is absent, is a tombstone, or has other content (the numbers do not count). A row
|
|
1443
|
+
* keeps the place (`_ord`) of its live stored document. Two rows with the same `_id` in a result
|
|
1444
|
+
* are an error of the view.
|
|
1445
|
+
*/
|
|
1446
|
+
private async storageWrites(target: Collection, storage: TargetPartition, group: readonly StoredRow[], seen: Set<string>, session: ClientSession | undefined): Promise<StorageWrite[]> {
|
|
1447
|
+
for (const row of group) {
|
|
1448
|
+
if (seen.has(row.key)) throw new ViewRunError(`the result has two rows with the _id ${JSON.stringify(row.id)}`);
|
|
1449
|
+
seen.add(row.key);
|
|
1450
|
+
}
|
|
1451
|
+
const stored = await target
|
|
1452
|
+
.find({ _id: { $in: group.map((row) => row.doc["_id"]) }, ...storage.writer.partitionFilter() } as Document, session ? { session } : {})
|
|
1453
|
+
.toArray();
|
|
1454
|
+
const byKey = new Map(stored.map((doc) => [idKey(doc["_id"]), doc]));
|
|
1455
|
+
const writes: StorageWrite[] = [];
|
|
1456
|
+
for (const row of group) {
|
|
1457
|
+
const doc = byKey.get(row.key);
|
|
1458
|
+
const live = doc !== undefined && doc[TOMBSTONE_FIELD] !== true;
|
|
1459
|
+
if (live && contentHash(doc) === row.hash) continue;
|
|
1460
|
+
writes.push({ row, ord: live && typeof doc["_ord"] === "number" ? doc["_ord"] : undefined });
|
|
1461
|
+
}
|
|
1462
|
+
return writes;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* The live documents of the partition of a view in a target in a stored form that the result
|
|
1467
|
+
* does not have. A document whose id the result does not have becomes the tombstone of that id. A
|
|
1468
|
+
* document that tells no id, or the id of a row of the result (under another stored `_id`),
|
|
1469
|
+
* becomes a tombstone without `_key`: no reader reads a delete of an item from it. More than
|
|
1470
|
+
* `limit` documents is an error, so a transaction goes in groups.
|
|
1471
|
+
*/
|
|
1472
|
+
private async *storageRemovals(
|
|
1473
|
+
target: Collection,
|
|
1474
|
+
storage: TargetPartition,
|
|
1475
|
+
seen: ReadonlySet<string>,
|
|
1476
|
+
session: ClientSession | undefined,
|
|
1477
|
+
limit = Number.POSITIVE_INFINITY
|
|
1478
|
+
): AsyncGenerator<StorageRemoval[]> {
|
|
1479
|
+
let group: StorageRemoval[] = [];
|
|
1480
|
+
let count = 0;
|
|
1481
|
+
const cursor = target.find({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } } as Document, { projection: { _id: 1, _key: 1 }, ...(session ? { session } : {}) });
|
|
1482
|
+
try {
|
|
1483
|
+
for await (const doc of cursor) {
|
|
1484
|
+
if (seen.has(idKey(doc["_id"]))) continue;
|
|
1485
|
+
if (++count > limit) throw new Error(`more than ${limit} rows to remove`);
|
|
1486
|
+
let id = storage.writer.idOfDoc(doc);
|
|
1487
|
+
try {
|
|
1488
|
+
if (id !== undefined && seen.has(idKey(storage.writer.storedId(id)))) id = undefined;
|
|
1489
|
+
} catch {
|
|
1490
|
+
id = undefined;
|
|
1491
|
+
}
|
|
1492
|
+
group.push({ storedId: doc["_id"], id });
|
|
1493
|
+
if (group.length === WRITE_GROUP) {
|
|
1494
|
+
yield group;
|
|
1495
|
+
group = [];
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if (group.length > 0) yield group;
|
|
1499
|
+
} finally {
|
|
1500
|
+
await cursor.close().catch(() => undefined);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* The writes of a target in a stored form with their numbers from `first`: the tombstones first,
|
|
1506
|
+
* then the rows. A new row takes its own number as its place. The filter of a write holds the
|
|
1507
|
+
* partition: a row of another partition of the view fails with a duplicate key.
|
|
1508
|
+
*/
|
|
1509
|
+
private storageOperations(storage: TargetPartition, partition: string, writes: readonly StorageWrite[], removals: readonly StorageRemoval[], first: number): AnyBulkWriteOperation<Document>[] {
|
|
1510
|
+
const field = this.partitionField;
|
|
1511
|
+
const operations: AnyBulkWriteOperation<Document>[] = [];
|
|
1512
|
+
let seq = first;
|
|
1513
|
+
for (const removal of removals) {
|
|
1514
|
+
const number = seq++;
|
|
1515
|
+
const tomb: Document = removal.id === undefined ? { _id: removal.storedId, _seq: number, [TOMBSTONE_FIELD]: true } : storage.writer.tombstone(removal.id, number);
|
|
1516
|
+
const replacement: Document = { ...tomb, _id: removal.storedId };
|
|
1517
|
+
for (const [name, value] of storage.writer.partitionFields) if (value !== undefined) replacement[name] = value;
|
|
1518
|
+
replacement[field] = partition;
|
|
1519
|
+
operations.push({ replaceOne: { filter: { _id: removal.storedId, ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } } as Document, replacement } });
|
|
1520
|
+
}
|
|
1521
|
+
for (const { row, ord } of writes) {
|
|
1522
|
+
const number = seq++;
|
|
1523
|
+
const filter = { _id: row.doc["_id"], ...storage.writer.partitionFilter(), $or: [{ [field]: partition }, { [TOMBSTONE_FIELD]: true }, { [field]: { $exists: false } }] };
|
|
1524
|
+
operations.push({ replaceOne: { filter, replacement: { ...row.doc, _seq: number, _ord: ord ?? number }, upsert: true } });
|
|
1525
|
+
}
|
|
1526
|
+
return operations;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* A run of a target in a stored form in one transaction (roadmap CLI-81): the rows that changed,
|
|
1531
|
+
* and a tombstone for each row that the result does not have. The numbers are pending numbers of
|
|
1532
|
+
* the storage: the run takes them after its reads, writes with them in the transaction, and ends
|
|
1533
|
+
* them after the commit. A late run moves its documents to new numbers. The transaction does not
|
|
1534
|
+
* write the counters, so the writes of the storage do not conflict with it.
|
|
1535
|
+
*/
|
|
1536
|
+
private async runStorageInTransaction(
|
|
1537
|
+
view: MaterializedView,
|
|
1538
|
+
partition: string,
|
|
1539
|
+
target: Collection,
|
|
1540
|
+
storage: TargetPartition,
|
|
1541
|
+
current: ObjectId | undefined,
|
|
1542
|
+
generations: readonly ObjectId[]
|
|
1543
|
+
): Promise<boolean> {
|
|
1544
|
+
const generation = current ?? new ObjectId();
|
|
1545
|
+
const db = this.db!;
|
|
1546
|
+
const session = db.client.startSession();
|
|
1547
|
+
let count = 0;
|
|
1548
|
+
let removed = 0;
|
|
1549
|
+
try {
|
|
1550
|
+
session.startTransaction();
|
|
1551
|
+
const fresh = await db.collection(this.viewsName).findOne({ _id: view._id, [this.partitionField]: partition } as Document, { projection: { [this.statusField]: 1 }, session });
|
|
1552
|
+
const now = (fresh?.[this.statusField] as Partial<MaterializedViewStatus> | undefined)?.generation;
|
|
1553
|
+
if (!(now === undefined ? current === undefined : current !== undefined && now.equals(current))) throw new PassSuperseded("another process changed the generation of the view");
|
|
1554
|
+
const seen = new Set<string>();
|
|
1555
|
+
const writes: StorageWrite[] = [];
|
|
1556
|
+
for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
|
|
1557
|
+
writes.push(...(await this.storageWrites(target, storage, group, seen, session)));
|
|
1558
|
+
if (writes.length > this.maxTransactionRows) throw new Error(`more than ${this.maxTransactionRows} rows to write`);
|
|
1559
|
+
}
|
|
1560
|
+
const removals: StorageRemoval[] = [];
|
|
1561
|
+
for await (const group of this.storageRemovals(target, storage, seen, session, this.maxTransactionRows - writes.length)) removals.push(...group);
|
|
1562
|
+
count = writes.length + removals.length;
|
|
1563
|
+
removed = removals.length;
|
|
1564
|
+
const commit = async (first: number): Promise<void> => {
|
|
1565
|
+
for (const operations of groups(this.storageOperations(storage, partition, writes, removals, first))) await target.bulkWrite(operations, { ordered: true, session });
|
|
1566
|
+
if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
|
|
1567
|
+
throw new PassSuperseded("another process named the first generation of the view");
|
|
1568
|
+
}
|
|
1569
|
+
await session.commitTransaction();
|
|
1570
|
+
};
|
|
1571
|
+
if (count === 0) await commit(0);
|
|
1572
|
+
else await storage.writer.withPendingNumbers(db, count, commit);
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
if (session.inTransaction()) await session.abortTransaction().catch(() => undefined);
|
|
1575
|
+
throw error;
|
|
1576
|
+
} finally {
|
|
1577
|
+
await session.endSession().catch(() => undefined);
|
|
1578
|
+
}
|
|
1579
|
+
if (removed > 0) await storage.writer.prune(db);
|
|
1580
|
+
return count > 0 || current === undefined;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* A run of a target in a stored form in groups, outside a transaction (roadmap CLI-81): the rows
|
|
1585
|
+
* of each group that changed, then the tombstones of the rows that the result does not have, with
|
|
1586
|
+
* the pending numbers of the storage. The run writes in place, so a reader can see a part of it.
|
|
1587
|
+
* It holds the lease of the view, and renews it after each group. The first run names a
|
|
1588
|
+
* generation in the status of the view: the mark of a view that ran.
|
|
1589
|
+
*/
|
|
1590
|
+
private async runStorageInGroups(
|
|
1591
|
+
view: MaterializedView,
|
|
1592
|
+
partition: string,
|
|
1593
|
+
target: Collection,
|
|
1594
|
+
storage: TargetPartition,
|
|
1595
|
+
current: ObjectId | undefined,
|
|
1596
|
+
generations: readonly ObjectId[]
|
|
1597
|
+
): Promise<boolean> {
|
|
1598
|
+
const generation = current ?? new ObjectId();
|
|
1599
|
+
const owner = new ObjectId().toHexString();
|
|
1600
|
+
const db = this.db!;
|
|
1601
|
+
if (!(await this.takeLease(view, partition, owner, generation))) throw new PassSuperseded("a pass of another process holds the view");
|
|
1602
|
+
let changed = current === undefined;
|
|
1603
|
+
let removed = false;
|
|
1604
|
+
try {
|
|
1605
|
+
const seen = new Set<string>();
|
|
1606
|
+
for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
|
|
1607
|
+
const writes = await this.storageWrites(target, storage, group, seen, undefined);
|
|
1608
|
+
if (writes.length > 0) {
|
|
1609
|
+
changed = true;
|
|
1610
|
+
await storage.writer.withPendingNumbers(db, writes.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, writes, [], first), { ordered: true }));
|
|
1611
|
+
}
|
|
1612
|
+
if (!(await this.renewLease(view, partition, owner))) throw new PassSuperseded("the pass lost its lease");
|
|
1613
|
+
}
|
|
1614
|
+
for await (const removals of this.storageRemovals(target, storage, seen, undefined)) {
|
|
1615
|
+
changed = true;
|
|
1616
|
+
removed = true;
|
|
1617
|
+
await storage.writer.withPendingNumbers(db, removals.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, [], removals, first), { ordered: true }));
|
|
1618
|
+
if (!(await this.renewLease(view, partition, owner))) throw new PassSuperseded("the pass lost its lease");
|
|
1619
|
+
}
|
|
1620
|
+
if (current === undefined) {
|
|
1621
|
+
if (!(await this.setGeneration(view, partition, generation, { expected: undefined, owner }))) {
|
|
1622
|
+
throw new PassSuperseded("the pass lost its lease, or another process named the first generation of the view");
|
|
1623
|
+
}
|
|
1624
|
+
} else {
|
|
1625
|
+
await this.releaseLease(view, partition, owner);
|
|
1626
|
+
}
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
await this.releaseLease(view, partition, owner).catch(() => undefined);
|
|
1629
|
+
throw error;
|
|
1630
|
+
} finally {
|
|
1631
|
+
if (removed) await storage.writer.prune(db).catch((error: unknown) => this.log("error", `${view._id}: the prune of the tombstones failed: ${String((error as Error)?.message ?? error)}`));
|
|
1632
|
+
}
|
|
1633
|
+
return changed;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1013
1636
|
/** True while the view is the one that the materializer runs for this key. */
|
|
1014
1637
|
private isCurrentView(key: string, view: MaterializedView): boolean {
|
|
1015
1638
|
return this.running.get(key)?.view === view;
|