@procedures/mongo 0.43.0 → 0.44.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 +16 -0
- package/README.md +47 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/storage/database-changes.d.ts +120 -0
- package/dist/storage/database-changes.d.ts.map +1 -0
- package/dist/storage/database-changes.js +273 -0
- package/dist/storage/database-changes.js.map +1 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/materializer.d.ts +12 -2
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +27 -25
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/storage-hints.d.ts +23 -2
- package/dist/storage/storage-hints.d.ts.map +1 -1
- package/dist/storage/storage-hints.js +45 -37
- package/dist/storage/storage-hints.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +3 -0
- package/src/storage/database-changes.ts +357 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/materializer.ts +36 -25
- package/src/storage/storage-hints.ts +56 -37
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One change watcher for a whole database (roadmap CLI-88). `watchDatabaseChanges()` opens the
|
|
3
|
+
* change stream of the database, and gives each raw change event to `onChange`. After an error, it
|
|
4
|
+
* opens the stream again from the resume token of the last change that `onChange` took.
|
|
5
|
+
*
|
|
6
|
+
* - The event goes to `onChange` as the driver gives it: `fullDocument`, `fullDocumentBeforeChange`
|
|
7
|
+
* (the image before the change), `ns`, `clusterTime`, `wallTime` and each other field. Set
|
|
8
|
+
* `fullDocumentBeforeChange` to get the image. A collection gives it only with
|
|
9
|
+
* `changeStreamPreAndPostImages` (MongoDB 6 or later).
|
|
10
|
+
* - The events go one at a time, in the order of the stream. The watcher reads the next event
|
|
11
|
+
* after `onChange` resolves.
|
|
12
|
+
* - The resume token is the `_id` of the last event, after its `onChange` resolved. A batch without
|
|
13
|
+
* events gives the token of the batch (the post-batch resume token). `resumeToken` of the handle
|
|
14
|
+
* gives the token at this time.
|
|
15
|
+
* - After an error of the stream, the watcher waits for the backoff. Then it opens the stream again
|
|
16
|
+
* with `startAfter` and the token. Thus it loses no change and gives no change twice.
|
|
17
|
+
* - An error of `onChange` has the same backoff. The token stays at the change before, so the same
|
|
18
|
+
* change comes again.
|
|
19
|
+
* - The backoff starts at `initialDelay` and grows by `backoffMultiplier` to at most `maxDelay`,
|
|
20
|
+
* with `jitter`. A change or a batch without events sets it back to `initialDelay`.
|
|
21
|
+
* - Some errors do not go away. The watcher then stops, and the error goes to `onError` and to the
|
|
22
|
+
* logger:
|
|
23
|
+
* - An `invalidate` event (for example after `dropDatabase`). `resumeToken` is then the token of
|
|
24
|
+
* that event: a new watcher with `startAfter` starts after it.
|
|
25
|
+
* - A token that the server does not know any more (codes 260, 280 and 286, for example
|
|
26
|
+
* `ChangeStreamHistoryLost`).
|
|
27
|
+
* - Before the first open: a refused access, a single server (code 40573), a wrong option.
|
|
28
|
+
* - `stop()` or an abort of `signal` stops the watcher. It closes the stream and cancels the timer
|
|
29
|
+
* of the backoff. An `onChange` that runs finishes, and no change comes after it.
|
|
30
|
+
* - Change streams need a replica set or a sharded cluster. The watcher asks the server (`hello`)
|
|
31
|
+
* before the first open: on a single server, it stops with the code 40573.
|
|
32
|
+
*/
|
|
33
|
+
import type { ChangeStream, ChangeStreamDocument, ChangeStreamOptions, Db, Document, ResumeToken, Timestamp } from "mongodb";
|
|
34
|
+
import { realClock, writeLog, type Cancel, type Clock, type LogFields, type Logger, type ResubscribeBackoff } from "@procedures/client";
|
|
35
|
+
import { COMMAND_NOT_FOUND, LOST_TOKEN_CODES, backoffOf, codeOf, isPermanent, messageOf } from "./storage-hints.js";
|
|
36
|
+
|
|
37
|
+
/** The options of `watchDatabaseChanges()`. */
|
|
38
|
+
export interface WatchDatabaseChangesOptions<TChange extends Document = ChangeStreamDocument> {
|
|
39
|
+
/**
|
|
40
|
+
* The function that gets each raw change event, one at a time. The watcher keeps the token of
|
|
41
|
+
* the event after the function resolves. When it fails, the same event comes again after the backoff.
|
|
42
|
+
*/
|
|
43
|
+
onChange: (change: TChange) => void | Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* The function that gets the error that stops the watcher. The error is an `invalidate` event, a
|
|
46
|
+
* lost resume token, or an error of the first open that does not go away. An `invalidate` event
|
|
47
|
+
* gives an `Error` with the event in its field `change`.
|
|
48
|
+
*/
|
|
49
|
+
onError?: ((error: unknown) => void) | undefined;
|
|
50
|
+
/** The resume token after which the first stream starts. Give only one of `resumeAfter`, `startAfter` and `startAtOperationTime`. */
|
|
51
|
+
resumeAfter?: ResumeToken | undefined;
|
|
52
|
+
/** The resume token after which the first stream starts, also the token of an `invalidate` event. */
|
|
53
|
+
startAfter?: ResumeToken | undefined;
|
|
54
|
+
/** The time of the cluster at which the first stream starts. */
|
|
55
|
+
startAtOperationTime?: Timestamp | undefined;
|
|
56
|
+
/** The `fullDocument` option of the stream: `updateLookup`, `whenAvailable` or `required`. */
|
|
57
|
+
fullDocument?: "updateLookup" | "whenAvailable" | "required" | undefined;
|
|
58
|
+
/** The `fullDocumentBeforeChange` option of the stream: `whenAvailable`, `required` or `off`. */
|
|
59
|
+
fullDocumentBeforeChange?: "whenAvailable" | "required" | "off" | undefined;
|
|
60
|
+
/** The pipeline of the stream, for example a `$match` of the collections. @default [] */
|
|
61
|
+
pipeline?: Document[] | undefined;
|
|
62
|
+
/** More options of the stream, for example `maxAwaitTimeMS`, `batchSize` or `showExpandedEvents`. */
|
|
63
|
+
streamOptions?: Omit<ChangeStreamOptions, "resumeAfter" | "startAfter" | "startAtOperationTime" | "fullDocument" | "fullDocumentBeforeChange"> | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* The backoff before the stream opens again after an error.
|
|
66
|
+
* @default { initialDelay: ms(250), maxDelay: seconds(30), backoffMultiplier: 2, jitter: 1 }
|
|
67
|
+
*/
|
|
68
|
+
backoff?: ResubscribeBackoff | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* The logger of the watcher. The fields of each line hold its context: `database`, `collection`,
|
|
71
|
+
* `operationType`, `resumeToken` (true when the watcher has a token), `error`, `attempt` and `delayMs`.
|
|
72
|
+
* @default the console, with the prefix `[database-changes]`
|
|
73
|
+
*/
|
|
74
|
+
logger?: Logger | undefined;
|
|
75
|
+
/** The signal that stops the watcher. */
|
|
76
|
+
signal?: AbortSignal | undefined;
|
|
77
|
+
/** The clock of the timers (default the clock of the platform). The timers do not keep a process alive. */
|
|
78
|
+
clock?: Clock | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The handle of `watchDatabaseChanges()`. */
|
|
82
|
+
export interface DatabaseChangesWatch {
|
|
83
|
+
/**
|
|
84
|
+
* It resolves when the first stream is open (each later change reaches `onChange`), or when the
|
|
85
|
+
* watcher stops. It rejects when the watcher stops on an error before the first open.
|
|
86
|
+
*/
|
|
87
|
+
readonly ready: Promise<void>;
|
|
88
|
+
/** The resume token of the last change that `onChange` took, or of the last batch without events. It is undefined before the first open without a start token. */
|
|
89
|
+
readonly resumeToken: ResumeToken | undefined;
|
|
90
|
+
/** True after `stop()`, an abort of `signal`, or an error that stops the watcher. */
|
|
91
|
+
readonly stopped: boolean;
|
|
92
|
+
/** This method stops the watcher: it closes the stream and cancels the timer of a backoff. */
|
|
93
|
+
stop(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An error of `onChange`: the watcher waits, and gives the same change again. */
|
|
97
|
+
class HandlerError extends Error {
|
|
98
|
+
constructor(
|
|
99
|
+
readonly error: unknown,
|
|
100
|
+
readonly change: Document
|
|
101
|
+
) {
|
|
102
|
+
super(`onChange failed: ${messageOf(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The collection of a change event, from its `ns`. */
|
|
107
|
+
function collectionOf(change: Document): unknown {
|
|
108
|
+
const ns: unknown = change["ns"];
|
|
109
|
+
return typeof ns === "object" && ns !== null ? (ns as Document)["coll"] : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This function watches the change stream of a whole database, and gives each raw change event to
|
|
114
|
+
* `onChange` (roadmap CLI-88). After an error, it opens the stream again from the resume token,
|
|
115
|
+
* after a bounded backoff. Refer to the module comment for the rules.
|
|
116
|
+
*
|
|
117
|
+
* @param db - The database, or a function that gives it
|
|
118
|
+
* @param options - The handlers, the start of the stream, its options, the backoff, the logger and the signal
|
|
119
|
+
* @returns The handle: `ready`, `resumeToken`, `stopped` and `stop()`
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const watch = watchDatabaseChanges(db, {
|
|
124
|
+
* fullDocument: "updateLookup",
|
|
125
|
+
* fullDocumentBeforeChange: "whenAvailable",
|
|
126
|
+
* resumeAfter: savedToken,
|
|
127
|
+
* onChange: async (change) => {
|
|
128
|
+
* await apply(change);
|
|
129
|
+
* savedToken = change._id;
|
|
130
|
+
* },
|
|
131
|
+
* onError: (error) => console.error("the watcher stopped", error),
|
|
132
|
+
* logger,
|
|
133
|
+
* });
|
|
134
|
+
* await watch.ready;
|
|
135
|
+
* // ...
|
|
136
|
+
* await watch.stop();
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function watchDatabaseChanges<TChange extends Document = ChangeStreamDocument>(
|
|
140
|
+
db: Db | (() => Db | Promise<Db>),
|
|
141
|
+
options: WatchDatabaseChangesOptions<TChange>
|
|
142
|
+
): DatabaseChangesWatch {
|
|
143
|
+
if (typeof options?.onChange !== "function") throw new TypeError("onChange must be a function");
|
|
144
|
+
const starts = [options.resumeAfter, options.startAfter, options.startAtOperationTime].filter((value) => value !== undefined);
|
|
145
|
+
if (starts.length > 1) throw new TypeError("give only one of resumeAfter, startAfter and startAtOperationTime");
|
|
146
|
+
const delayOf = backoffOf(options.backoff);
|
|
147
|
+
const clock = options.clock ?? realClock;
|
|
148
|
+
const logger = options.logger;
|
|
149
|
+
const dbOf = typeof db === "function" ? db : (): Db => db;
|
|
150
|
+
|
|
151
|
+
let stopped = false;
|
|
152
|
+
/** The token of the last change that `onChange` took, or of the last batch without events. */
|
|
153
|
+
let token: ResumeToken | undefined = options.startAfter ?? options.resumeAfter;
|
|
154
|
+
/** True after a stream was open. */
|
|
155
|
+
let opened = false;
|
|
156
|
+
/** True after the server said that it has change streams. */
|
|
157
|
+
let checked = false;
|
|
158
|
+
/** The tries since the last success. The backoff gives the delay of the next try from it. */
|
|
159
|
+
let attempt = 0;
|
|
160
|
+
let database: string | undefined;
|
|
161
|
+
let stream: ChangeStream<Document, TChange> | undefined;
|
|
162
|
+
/** The timer of a backoff, and the end of its wait. */
|
|
163
|
+
let timer: Cancel | undefined;
|
|
164
|
+
let wake: (() => void) | undefined;
|
|
165
|
+
let markReady: () => void = () => undefined;
|
|
166
|
+
let failReady: (error: unknown) => void = () => undefined;
|
|
167
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
168
|
+
markReady = resolve;
|
|
169
|
+
failReady = reject;
|
|
170
|
+
});
|
|
171
|
+
// A caller that does not wait for `ready` gets no unhandled rejection: the logger tells the error
|
|
172
|
+
ready.catch(() => undefined);
|
|
173
|
+
|
|
174
|
+
const log = (level: "info" | "warn" | "error", message: string, fields: LogFields): void => {
|
|
175
|
+
const all = { component: "database-changes", database, resumeToken: token !== undefined, ...fields };
|
|
176
|
+
writeLog(logger, level, message, all, `[database-changes] ${message}`, all);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The end of the watcher after an error that does not go away. */
|
|
180
|
+
const fail = (error: unknown, fields: LogFields): void => {
|
|
181
|
+
if (stopped) return;
|
|
182
|
+
log("error", "The change stream stopped: the error does not go away", { error, ...fields });
|
|
183
|
+
halt();
|
|
184
|
+
failReady(error);
|
|
185
|
+
try {
|
|
186
|
+
options.onError?.(error);
|
|
187
|
+
} catch (thrown) {
|
|
188
|
+
log("error", "onError failed", { error: thrown });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** The wait of the backoff. `stop()` ends it. */
|
|
193
|
+
const backoff = (message: string, fields: LogFields): Promise<void> => {
|
|
194
|
+
const delay = delayOf(attempt);
|
|
195
|
+
attempt++;
|
|
196
|
+
log("warn", message, { ...fields, attempt, delayMs: Math.round(delay) });
|
|
197
|
+
return new Promise<void>((resolve) => {
|
|
198
|
+
wake = resolve;
|
|
199
|
+
timer = clock.after(delay, () => {
|
|
200
|
+
timer = undefined;
|
|
201
|
+
wake = undefined;
|
|
202
|
+
resolve();
|
|
203
|
+
});
|
|
204
|
+
timer.unref?.();
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** The start of the next stream: after the token, else the start of the options. */
|
|
209
|
+
const start = (): Pick<ChangeStreamOptions, "resumeAfter" | "startAfter" | "startAtOperationTime"> => {
|
|
210
|
+
if (!opened && options.resumeAfter !== undefined) return { resumeAfter: options.resumeAfter };
|
|
211
|
+
if (token !== undefined) return { startAfter: token };
|
|
212
|
+
return options.startAtOperationTime !== undefined ? { startAtOperationTime: options.startAtOperationTime } : {};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** The stream is open: the next change reaches `onChange`. */
|
|
216
|
+
const markOpen = (): void => {
|
|
217
|
+
if (opened || stopped) return;
|
|
218
|
+
opened = true;
|
|
219
|
+
markReady();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** A success: a batch without events, or a change that `onChange` took. The backoff starts again. */
|
|
223
|
+
const succeeded = (): void => {
|
|
224
|
+
if (attempt > 0) log("info", "The change stream opened again", { attempts: attempt });
|
|
225
|
+
attempt = 0;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** One stream: it reads the events until an error, an `invalidate` event or the stop. */
|
|
229
|
+
const read = async (next: ChangeStream<Document, TChange>): Promise<void> => {
|
|
230
|
+
for (;;) {
|
|
231
|
+
const change = await next.tryNext();
|
|
232
|
+
if (stopped) return;
|
|
233
|
+
markOpen();
|
|
234
|
+
if (change === null) {
|
|
235
|
+
// A batch without events: its token is after each event that `onChange` took
|
|
236
|
+
const batchToken = next.resumeToken;
|
|
237
|
+
if (batchToken !== undefined && batchToken !== null) token = batchToken;
|
|
238
|
+
succeeded();
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (change["operationType"] === "invalidate") {
|
|
242
|
+
token = change["_id"];
|
|
243
|
+
return fail(Object.assign(new Error(`the change stream of the database ${database ?? ""} was invalidated`), { change }), { operationType: "invalidate" });
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
await options.onChange(change);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw new HandlerError(error, change);
|
|
249
|
+
}
|
|
250
|
+
token = change["_id"];
|
|
251
|
+
succeeded();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const run = async (): Promise<void> => {
|
|
256
|
+
while (!stopped) {
|
|
257
|
+
let handle: Db;
|
|
258
|
+
try {
|
|
259
|
+
handle = await dbOf();
|
|
260
|
+
database = handle.databaseName;
|
|
261
|
+
if (!checked) {
|
|
262
|
+
// On a single server, the driver can wait without an error: the watcher asks the server first
|
|
263
|
+
let hello: Document | undefined;
|
|
264
|
+
try {
|
|
265
|
+
hello = await handle.command({ hello: 1 });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// An old server without hello: the stream tells the error
|
|
268
|
+
if (codeOf(error) !== COMMAND_NOT_FOUND) throw error;
|
|
269
|
+
}
|
|
270
|
+
if (hello && typeof hello["setName"] !== "string" && hello["msg"] !== "isdbgrid") {
|
|
271
|
+
const single = Object.assign(new Error("change streams need a replica set or a sharded cluster, and the server is a single server"), { code: 40573 });
|
|
272
|
+
return fail(single, { code: 40573 });
|
|
273
|
+
}
|
|
274
|
+
checked = true;
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (stopped) return;
|
|
278
|
+
if (!opened && isPermanent(error)) return fail(error, { code: codeOf(error) });
|
|
279
|
+
await backoff("The database is not available: the change stream opens again after a backoff", { error, code: codeOf(error) });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (stopped) return;
|
|
283
|
+
if (token === undefined && opened) log("warn", "The change stream opens again without a resume token: it can miss changes", {});
|
|
284
|
+
try {
|
|
285
|
+
const next = handle.watch<Document, TChange>(options.pipeline ?? [], {
|
|
286
|
+
...options.streamOptions,
|
|
287
|
+
...(options.fullDocument !== undefined && { fullDocument: options.fullDocument }),
|
|
288
|
+
...(options.fullDocumentBeforeChange !== undefined && { fullDocumentBeforeChange: options.fullDocumentBeforeChange }),
|
|
289
|
+
...start(),
|
|
290
|
+
});
|
|
291
|
+
stream = next;
|
|
292
|
+
// The first token of the stream comes with the answer of the server: the stream is open, before the first getMore
|
|
293
|
+
next.on("resumeTokenChanged", markOpen);
|
|
294
|
+
try {
|
|
295
|
+
await read(next);
|
|
296
|
+
} finally {
|
|
297
|
+
if (stream === next) stream = undefined;
|
|
298
|
+
await next.close().catch(() => undefined);
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (stopped) return;
|
|
302
|
+
if (error instanceof HandlerError) {
|
|
303
|
+
await backoff("onChange failed: the same change comes again after a backoff", {
|
|
304
|
+
error: error.error,
|
|
305
|
+
collection: collectionOf(error.change),
|
|
306
|
+
operationType: error.change["operationType"],
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const code = codeOf(error);
|
|
311
|
+
if (code !== undefined && LOST_TOKEN_CODES.has(code)) return fail(error, { code });
|
|
312
|
+
if (!opened && isPermanent(error)) return fail(error, { code });
|
|
313
|
+
await backoff("The change stream failed: it opens again after a backoff", { error, code });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** The end of the watcher: no new stream, no timer. */
|
|
319
|
+
const halt = (): void => {
|
|
320
|
+
stopped = true;
|
|
321
|
+
timer?.();
|
|
322
|
+
timer = undefined;
|
|
323
|
+
wake?.();
|
|
324
|
+
wake = undefined;
|
|
325
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const stop = async (): Promise<void> => {
|
|
329
|
+
if (stopped) return;
|
|
330
|
+
halt();
|
|
331
|
+
markReady();
|
|
332
|
+
const current = stream;
|
|
333
|
+
stream = undefined;
|
|
334
|
+
await current?.close().catch(() => undefined);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const onAbort = (): void => void stop();
|
|
338
|
+
|
|
339
|
+
if (options.signal?.aborted) {
|
|
340
|
+
stopped = true;
|
|
341
|
+
markReady();
|
|
342
|
+
} else {
|
|
343
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
344
|
+
void run().catch((error: unknown) => fail(error, {}));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
ready,
|
|
349
|
+
get resumeToken(): ResumeToken | undefined {
|
|
350
|
+
return token;
|
|
351
|
+
},
|
|
352
|
+
get stopped(): boolean {
|
|
353
|
+
return stopped;
|
|
354
|
+
},
|
|
355
|
+
stop,
|
|
356
|
+
};
|
|
357
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export type { MongoDocumentStorageOptions, SelectorPrefilterOptions, StoredForm,
|
|
|
18
18
|
// The change hints of a storage collection from its change stream (roadmap CLI-77)
|
|
19
19
|
export { watchStorageChanges, storageChangeOf } from "./storage-hints.js";
|
|
20
20
|
export type { StorageChange, StorageChangeHint, StorageChangesWatch, StorageHintPublisher, WatchStorageChangesOptions } from "./storage-hints.js";
|
|
21
|
+
// One change watcher for a whole database, with resume and backoff (roadmap CLI-88)
|
|
22
|
+
export { watchDatabaseChanges } from "./database-changes.js";
|
|
23
|
+
export type { DatabaseChangesWatch, WatchDatabaseChangesOptions } from "./database-changes.js";
|
|
21
24
|
export {
|
|
22
25
|
BLOCKED_PIPELINE_OPERATORS,
|
|
23
26
|
ALLOWED_PIPELINE_STAGES,
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
import { createHash } from "node:crypto";
|
|
57
57
|
import { isDeepStrictEqual } from "node:util";
|
|
58
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";
|
|
59
|
+
import { collectionChangeTopic, ms, optionalMs, realClock, writeLog, type Cancel, type Clock, type CollectionChangeEvent, type Duration, type LogFields, type Logger } from "@procedures/client";
|
|
60
60
|
import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget, type StorageWriteTarget, type StoredForm } from "./document-storage.js";
|
|
61
61
|
import { scopePipeline } from "./pipeline-scope.js";
|
|
62
62
|
import type { StorageHintPublisher } from "./storage-hints.js";
|
|
@@ -126,7 +126,16 @@ export interface ViewMaterializerOptions {
|
|
|
126
126
|
maxTime?: Duration | undefined;
|
|
127
127
|
/** The most rows of the result of a view (default 100000). A larger result is an error of the view. */
|
|
128
128
|
maxRows?: number | undefined;
|
|
129
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The logger of the materializer (CLI-88). The fields of each line hold its context: the view, the
|
|
131
|
+
* partition, the collection, the error and the delay. When both are set, `logger` wins over `log`.
|
|
132
|
+
* @default the console, with the prefix `[materializer]`
|
|
133
|
+
*/
|
|
134
|
+
logger?: Logger | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* The log function of each line, with the context in the text.
|
|
137
|
+
* @deprecated Use `logger`. Without a `logger`, the materializer still uses `log`.
|
|
138
|
+
*/
|
|
130
139
|
log?: ((level: "info" | "warn" | "error", message: string) => void) | undefined;
|
|
131
140
|
/** The clock of the timers (default the clock of the platform). A test gives a fake clock. The timers do not keep a process alive. */
|
|
132
141
|
clock?: Clock | undefined;
|
|
@@ -554,9 +563,11 @@ export class ViewMaterializer {
|
|
|
554
563
|
return entry === undefined || entry === null ? undefined : this.storageTargetOf(collection, entry);
|
|
555
564
|
}
|
|
556
565
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
566
|
+
/** This method writes one line: to `logger` with the fields, else to the old `log` with the text, else to the console with the text. */
|
|
567
|
+
private log(level: "info" | "warn" | "error", message: string, fields: LogFields, text: string): void {
|
|
568
|
+
const { logger, log } = this.options;
|
|
569
|
+
if (!logger && log) log(level, text);
|
|
570
|
+
else writeLog(logger, level, message, { component: "materializer", ...fields }, `[materializer] ${text}`);
|
|
560
571
|
}
|
|
561
572
|
|
|
562
573
|
/**
|
|
@@ -600,7 +611,7 @@ export class ViewMaterializer {
|
|
|
600
611
|
// The stream of the views opens before the read of the views: a change between the two reaches both (roadmap O63)
|
|
601
612
|
await this.watchViews();
|
|
602
613
|
const count = await this.resync();
|
|
603
|
-
this.log("info", `started: ${count} view(s)`);
|
|
614
|
+
this.log("info", "Started", { views: count }, `started: ${count} view(s)`);
|
|
604
615
|
}
|
|
605
616
|
|
|
606
617
|
/** This method stops the passes of new changes: a change starts no pass, and a waiting pass does not start. */
|
|
@@ -713,7 +724,7 @@ export class ViewMaterializer {
|
|
|
713
724
|
try {
|
|
714
725
|
stream = this.db.collection(this.viewsName).watch([], { fullDocument: "updateLookup" });
|
|
715
726
|
} catch (error) {
|
|
716
|
-
this.log("error", `cannot watch ${this.viewsName}: ${String((error as Error)?.message ?? error)}`);
|
|
727
|
+
this.log("error", "Cannot watch the views", { collection: this.viewsName, error }, `cannot watch ${this.viewsName}: ${String((error as Error)?.message ?? error)}`);
|
|
717
728
|
this.later(() => void this.watchViews());
|
|
718
729
|
return Promise.resolve();
|
|
719
730
|
}
|
|
@@ -725,11 +736,11 @@ export class ViewMaterializer {
|
|
|
725
736
|
const rewatch = (reason: string): void => {
|
|
726
737
|
if (again || this.stopped || this.viewsStream !== stream) return;
|
|
727
738
|
again = true;
|
|
728
|
-
this.log("warn", `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
739
|
+
this.log("warn", "The stream of the views ended: it opens again", { collection: this.viewsName, reason, delayMs: this.rewatchMs }, `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
729
740
|
stream.close().catch(() => undefined);
|
|
730
741
|
this.later(() => {
|
|
731
742
|
// 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)}`));
|
|
743
|
+
void this.track(this.watchViews().then(() => this.resync())).catch((error: unknown) => this.log("error", "The resync failed", { collection: this.viewsName, error }, `the resync failed: ${String((error as Error)?.message ?? error)}`));
|
|
733
744
|
});
|
|
734
745
|
};
|
|
735
746
|
|
|
@@ -742,7 +753,7 @@ export class ViewMaterializer {
|
|
|
742
753
|
// A write of the status only is ours: it does not open the view again
|
|
743
754
|
if (touched.length > 0 && touched.every((path) => path === this.statusField || path.startsWith(`${this.statusField}.`))) return;
|
|
744
755
|
}
|
|
745
|
-
void this.track(this.onViewChange(change)).catch((error: unknown) => this.log("error", `a change of a view failed: ${String((error as Error)?.message ?? error)}`));
|
|
756
|
+
void this.track(this.onViewChange(change)).catch((error: unknown) => this.log("error", "A change of a view failed", { collection: this.viewsName, operationType: change["operationType"], error }, `a change of a view failed: ${String((error as Error)?.message ?? error)}`));
|
|
746
757
|
});
|
|
747
758
|
stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
|
|
748
759
|
stream.on("close", () => rewatch("closed"));
|
|
@@ -781,11 +792,11 @@ export class ViewMaterializer {
|
|
|
781
792
|
private register(view: MaterializedView): boolean {
|
|
782
793
|
const partition = this.partitionOf(view);
|
|
783
794
|
if (partition === undefined) {
|
|
784
|
-
this.log("warn", `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
|
|
795
|
+
this.log("warn", "The view has no partition: it does not run", { view: view._id, partitionField: this.partitionField }, `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
|
|
785
796
|
return false;
|
|
786
797
|
}
|
|
787
798
|
if (!Array.isArray(view.collections) || view.collections.length === 0 || typeof view.targetCollection !== "string" || !Array.isArray(view.pipeline)) {
|
|
788
|
-
this.log("warn", `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
|
|
799
|
+
this.log("warn", "The view needs collections, a pipeline and a targetCollection: it does not run", { view: view._id, partition }, `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
|
|
789
800
|
return false;
|
|
790
801
|
}
|
|
791
802
|
this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
|
|
@@ -799,19 +810,19 @@ export class ViewMaterializer {
|
|
|
799
810
|
await this.db!
|
|
800
811
|
.collection(view.targetCollection)
|
|
801
812
|
.createIndex({ [this.partitionField]: 1, [VIEW_GENERATION_FIELD]: 1 })
|
|
802
|
-
.catch((error: unknown) => this.log("error", `the index of ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
813
|
+
.catch((error: unknown) => this.log("error", "The index of the target failed", { view: view._id, collection: view.targetCollection, error }, `the index of ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
803
814
|
// A target in a stored form also has the indexes of the storage: the change reads, the order and the tombstones
|
|
804
815
|
let storageTarget: StorageTargetEntry | undefined;
|
|
805
816
|
try {
|
|
806
817
|
storageTarget = this.storageTargetFor(view.targetCollection);
|
|
807
818
|
} catch (error) {
|
|
808
819
|
// 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)}`);
|
|
820
|
+
this.log("error", "The storage target is not valid", { view: view._id, collection: view.targetCollection, error }, `the storage target ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
|
|
810
821
|
}
|
|
811
822
|
if (storageTarget) {
|
|
812
823
|
await storageWriteTarget(storageTarget.storage)
|
|
813
824
|
.ready()
|
|
814
|
-
.catch((error: unknown) => this.log("error", `the indexes of the storage ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
825
|
+
.catch((error: unknown) => this.log("error", "The indexes of the storage target failed", { view: view._id, collection: view.targetCollection, error }, `the indexes of the storage ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
|
|
815
826
|
}
|
|
816
827
|
await this.rebalance(partition);
|
|
817
828
|
}
|
|
@@ -914,7 +925,7 @@ export class ViewMaterializer {
|
|
|
914
925
|
.collection(source)
|
|
915
926
|
.watch([this.sourceMatch(running.partition)], { fullDocument: "updateLookup", fullDocumentBeforeChange: "whenAvailable" });
|
|
916
927
|
} catch (error) {
|
|
917
|
-
this.log("error", `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
|
|
928
|
+
this.log("error", "Cannot watch a source of the view", { view: running.view._id, partition: running.partition, collection: source, error }, `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
|
|
918
929
|
this.later(() => {
|
|
919
930
|
if (this.isCurrent(running) && running.state === "active") void this.watchSource(running, source);
|
|
920
931
|
});
|
|
@@ -930,7 +941,7 @@ export class ViewMaterializer {
|
|
|
930
941
|
// A close of a teardown also ends the stream: open again only a stream of a current active view
|
|
931
942
|
if (index === -1 || !this.isCurrent(running) || running.state !== "active") return;
|
|
932
943
|
running.streams.splice(index, 1);
|
|
933
|
-
this.log("warn", `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
944
|
+
this.log("warn", "The stream of a source ended: it opens again", { view: running.view._id, partition: running.partition, collection: source, reason, delayMs: this.rewatchMs }, `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
|
|
934
945
|
this.later(() => {
|
|
935
946
|
if (!this.isCurrent(running) || running.state !== "active") return;
|
|
936
947
|
// The changes while the stream was closed are lost: a pass catches up, after the new stream is open
|
|
@@ -969,7 +980,7 @@ export class ViewMaterializer {
|
|
|
969
980
|
this.timers.delete(partition);
|
|
970
981
|
if (!this.intake) return;
|
|
971
982
|
void this.serial(partition, () => this.pass(partition)).catch((error: unknown) =>
|
|
972
|
-
this.log("error", `the pass of ${partition} failed: ${String((error as Error)?.message ?? error)}`)
|
|
983
|
+
this.log("error", "The pass failed", { partition, error }, `the pass of ${partition} failed: ${String((error as Error)?.message ?? error)}`)
|
|
973
984
|
);
|
|
974
985
|
});
|
|
975
986
|
cancel.unref?.();
|
|
@@ -1054,12 +1065,12 @@ export class ViewMaterializer {
|
|
|
1054
1065
|
} catch (error) {
|
|
1055
1066
|
if (error instanceof PassSuperseded) {
|
|
1056
1067
|
// Not an error of the view: another process builds it now. A pass comes back after `rewatch`
|
|
1057
|
-
this.log("info", `${view._id}: ${error.message}: the view runs again later`);
|
|
1068
|
+
this.log("info", "The pass gave way: the view runs again later", { view: view._id, partition, reason: error.message }, `${view._id}: ${error.message}: the view runs again later`);
|
|
1058
1069
|
this.later(() => this.schedule(partition, view.collections[0]!));
|
|
1059
1070
|
return { changed: false };
|
|
1060
1071
|
}
|
|
1061
1072
|
const message = String((error as Error)?.message ?? error);
|
|
1062
|
-
this.log("error", `${view._id}: ${message}`);
|
|
1073
|
+
this.log("error", "The view failed", { view: view._id, partition, error }, `${view._id}: ${message}`);
|
|
1063
1074
|
await this.writeStatus(view, partition, { state: "errored", lastError: message, at: Date.now() });
|
|
1064
1075
|
return { changed: false };
|
|
1065
1076
|
}
|
|
@@ -1101,7 +1112,7 @@ export class ViewMaterializer {
|
|
|
1101
1112
|
}
|
|
1102
1113
|
throw new PassSuperseded(`another process wrote the view at the same time (${String((error as Error)?.message ?? error)})`);
|
|
1103
1114
|
}
|
|
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"}`);
|
|
1115
|
+
this.log("warn", "The transaction failed: the pass writes in groups", { view: view._id, partition, error }, `${view._id}: the transaction failed (${String((error as Error)?.message ?? error)}): the pass writes ${storage ? "in groups" : "a new generation in groups"}`);
|
|
1105
1116
|
break;
|
|
1106
1117
|
}
|
|
1107
1118
|
}
|
|
@@ -1369,7 +1380,7 @@ export class ViewMaterializer {
|
|
|
1369
1380
|
private publishHint(view: MaterializedView, partition: string, ids: { changed: readonly unknown[]; deleted: readonly unknown[] } | undefined): void {
|
|
1370
1381
|
const publish = this.options.publish;
|
|
1371
1382
|
if (!publish) return;
|
|
1372
|
-
const fail = (error: unknown): void => this.log("error", `the hint of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
|
|
1383
|
+
const fail = (error: unknown): void => this.log("error", "The hint of the view failed", { view: view._id, partition, error }, `the hint of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
|
|
1373
1384
|
try {
|
|
1374
1385
|
const topic = this.options.hintTopic ? this.options.hintTopic(view, partition) : collectionChangeTopic(view._id);
|
|
1375
1386
|
if (topic === undefined) return;
|
|
@@ -1431,7 +1442,7 @@ export class ViewMaterializer {
|
|
|
1431
1442
|
void this.serial(partition, async () => {
|
|
1432
1443
|
const { status, now } = await this.statusOf(view, partition);
|
|
1433
1444
|
await this.dropOldGenerations(target, partition, status, now);
|
|
1434
|
-
}).catch((error: unknown) => this.log("error", `${view._id}: ${String((error as Error)?.message ?? error)}`));
|
|
1445
|
+
}).catch((error: unknown) => this.log("error", "The drop of the old generations failed", { view: view._id, partition, error }, `${view._id}: ${String((error as Error)?.message ?? error)}`));
|
|
1435
1446
|
});
|
|
1436
1447
|
cancel.unref?.();
|
|
1437
1448
|
this.retries.add(cancel);
|
|
@@ -1628,7 +1639,7 @@ export class ViewMaterializer {
|
|
|
1628
1639
|
await this.releaseLease(view, partition, owner).catch(() => undefined);
|
|
1629
1640
|
throw error;
|
|
1630
1641
|
} 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)}`));
|
|
1642
|
+
if (removed) await storage.writer.prune(db).catch((error: unknown) => this.log("error", "The prune of the tombstones failed", { view: view._id, partition, error }, `${view._id}: the prune of the tombstones failed: ${String((error as Error)?.message ?? error)}`));
|
|
1632
1643
|
}
|
|
1633
1644
|
return changed;
|
|
1634
1645
|
}
|
|
@@ -1700,7 +1711,7 @@ export class ViewMaterializer {
|
|
|
1700
1711
|
try {
|
|
1701
1712
|
await this.db!.collection(this.viewsName).updateOne({ _id: view._id, [this.partitionField]: partition } as Document, update);
|
|
1702
1713
|
} catch (error) {
|
|
1703
|
-
this.log("error", `the status of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
|
|
1714
|
+
this.log("error", "The status of the view failed", { view: view._id, partition, error }, `the status of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
|
|
1704
1715
|
}
|
|
1705
1716
|
}
|
|
1706
1717
|
}
|