@procedures/mongo 0.52.2 → 0.53.1
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 +70 -0
- package/README.md +1 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/database-changes.d.ts +6 -1
- package/dist/storage/database-changes.d.ts.map +1 -1
- package/dist/storage/database-changes.js +15 -3
- package/dist/storage/database-changes.js.map +1 -1
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +25 -11
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/materializer.d.ts +6 -0
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +12 -5
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/storage-hints.d.ts +9 -71
- package/dist/storage/storage-hints.d.ts.map +1 -1
- package/dist/storage/storage-hints.js +33 -11
- package/dist/storage/storage-hints.js.map +1 -1
- package/dist/storage/token-age.d.ts +30 -0
- package/dist/storage/token-age.d.ts.map +1 -0
- package/dist/storage/token-age.js +55 -0
- package/dist/storage/token-age.js.map +1 -0
- package/package.json +10 -8
- package/src/storage/database-changes.ts +22 -3
- package/src/storage/document-storage.ts +27 -12
- package/src/storage/materializer.ts +13 -5
- package/src/storage/storage-hints.ts +38 -10
- package/src/storage/token-age.ts +63 -0
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
* - Change streams need a replica set or a sharded cluster. The watcher asks the server (`hello`)
|
|
69
69
|
* before the first open: on a single server, `ready` rejects (code 40573).
|
|
70
70
|
*/
|
|
71
|
+
import { backoffDelay, type Backoff } from "@mark1russell7/service/backoff";
|
|
71
72
|
import { MongoAPIError, MongoParseError, BSON, type ChangeStream, type Db, type Document } from "mongodb";
|
|
72
73
|
import {
|
|
73
74
|
collectionChangeTopic,
|
|
@@ -94,6 +95,7 @@ import {
|
|
|
94
95
|
} from "./document-storage.js";
|
|
95
96
|
import { SAME_TOKEN_LIMIT, SPLIT_LARGE_EVENT_STAGE, UNKNOWN_STAGE_CODE, eventJoiner, sameTokenFaults } from "./split-events.js";
|
|
96
97
|
import { trackOwnNumbers, type OwnNumbers } from "./own-writes.js";
|
|
98
|
+
import { tokenAge } from "./token-age.js";
|
|
97
99
|
|
|
98
100
|
/** The change of one item of a change event of a `MongoDocumentStorage` collection, from `storageChangeOf()`. */
|
|
99
101
|
export interface StorageChange {
|
|
@@ -230,6 +232,11 @@ export interface StorageChangesWatch {
|
|
|
230
232
|
* opens the stream again after the other errors.
|
|
231
233
|
*/
|
|
232
234
|
readonly ready: Promise<void>;
|
|
235
|
+
/**
|
|
236
|
+
* The milliseconds of the clock since the resume token of the stream last moved to a later cluster time, or since the start
|
|
237
|
+
* before the first move. The same cluster time again does not move it. A large value on an open watcher tells a stalled feed (roadmap CLI-110).
|
|
238
|
+
*/
|
|
239
|
+
readonly tokenAgeMs: number;
|
|
233
240
|
/** This method stops the watcher: it closes the stream and cancels the timer of a backoff. */
|
|
234
241
|
stop(): Promise<void>;
|
|
235
242
|
}
|
|
@@ -374,7 +381,10 @@ export function storageChangeOf(change: Document, form: StoredForm = DEFAULT_STO
|
|
|
374
381
|
return { id, partition, deleted: false, raw: false, version, seq: written };
|
|
375
382
|
}
|
|
376
383
|
|
|
377
|
-
/**
|
|
384
|
+
/**
|
|
385
|
+
* The delay before try `attempt` (0 for the first) of a backoff. The `backoffDelay` function of
|
|
386
|
+
* `@mark1russell7/service` gives it: its first try is 1.
|
|
387
|
+
*/
|
|
378
388
|
export function backoffOf(options: ResubscribeBackoff | undefined): (attempt: number) => number {
|
|
379
389
|
const initial = optionalMs(options?.initialDelay, "backoff.initialDelay") ?? 250;
|
|
380
390
|
const max = optionalMs(options?.maxDelay, "backoff.maxDelay") ?? 30_000;
|
|
@@ -383,10 +393,8 @@ export function backoffOf(options: ResubscribeBackoff | undefined): (attempt: nu
|
|
|
383
393
|
if (!(initial >= 0) || !(max >= initial)) throw new RangeError("backoff: initialDelay must be 0 or more, and maxDelay must be initialDelay or more");
|
|
384
394
|
if (!(factor >= 1)) throw new RangeError("backoff: backoffMultiplier must be 1 or more");
|
|
385
395
|
if (!(jitter >= 0 && jitter <= 1)) throw new RangeError("backoff: jitter must be from 0 to 1");
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return full - full * jitter * Math.random();
|
|
389
|
-
};
|
|
396
|
+
const policy: Backoff = { base: initial, factor, cap: max, jitter: { kind: "subtract", fraction: jitter } };
|
|
397
|
+
return (attempt) => backoffDelay(policy, attempt + 1);
|
|
390
398
|
}
|
|
391
399
|
|
|
392
400
|
/** The message of an error. */
|
|
@@ -491,6 +499,12 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
491
499
|
let stopped = false;
|
|
492
500
|
let stream: ChangeStream | undefined;
|
|
493
501
|
let token: unknown;
|
|
502
|
+
const age = tokenAge(clock);
|
|
503
|
+
/** This function sets the token of the watcher, and notes it for `tokenAgeMs`. */
|
|
504
|
+
const setToken = (next: unknown): void => {
|
|
505
|
+
token = next;
|
|
506
|
+
age.note(next);
|
|
507
|
+
};
|
|
494
508
|
/** True after a stream was open: a new stream without a token can then miss changes. */
|
|
495
509
|
let opened = false;
|
|
496
510
|
let attempt = 0;
|
|
@@ -836,7 +850,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
836
850
|
};
|
|
837
851
|
|
|
838
852
|
const onChange = (change: Document): void => {
|
|
839
|
-
|
|
853
|
+
setToken(change["_id"]);
|
|
840
854
|
attempt = 0;
|
|
841
855
|
faults.reset();
|
|
842
856
|
const type = change["operationType"];
|
|
@@ -940,6 +954,8 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
940
954
|
}
|
|
941
955
|
stream = next;
|
|
942
956
|
const joiner = eventJoiner();
|
|
957
|
+
/** The token before the document of the last token: the token before the event at fragment 1. */
|
|
958
|
+
let tokenBefore: unknown = token;
|
|
943
959
|
/** The token at the open: a reopen at the same token is no success, so the backoff does not start again. */
|
|
944
960
|
const openToken = JSON.stringify(token ?? null);
|
|
945
961
|
let ended = false;
|
|
@@ -975,7 +991,11 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
975
991
|
next.on("resumeTokenChanged", (resumeToken: unknown) => {
|
|
976
992
|
if (resumeToken === undefined || resumeToken === null) return;
|
|
977
993
|
// In the middle of a split event, the token stays before the event: a stream that opens again gets all its fragments
|
|
978
|
-
if (!joiner.partial)
|
|
994
|
+
if (!joiner.partial) {
|
|
995
|
+
// The driver gives the token of a document before its change: fragment 1 puts the token back (audit 2026-11-08)
|
|
996
|
+
tokenBefore = token;
|
|
997
|
+
setToken(resumeToken);
|
|
998
|
+
}
|
|
979
999
|
opened = true;
|
|
980
1000
|
if (JSON.stringify(resumeToken) !== openToken) attempt = 0;
|
|
981
1001
|
markReady();
|
|
@@ -988,15 +1008,20 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
988
1008
|
live = true;
|
|
989
1009
|
afterQueue();
|
|
990
1010
|
const result = joiner.push(change);
|
|
991
|
-
if (result.kind === "part")
|
|
1011
|
+
if (result.kind === "part") {
|
|
1012
|
+
// The first fragment of a split event: the token goes back to the token before the event
|
|
1013
|
+
// The token goes back with no note for `tokenAgeMs`: the age keeps the latest token that moved it
|
|
1014
|
+
if ((change["splitEvent"] as { fragment?: unknown } | undefined)?.fragment === 1) token = tokenBefore;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
992
1017
|
if (result.kind === "missing") {
|
|
993
1018
|
// The stream started after a fragment: the event cannot be read, so the subscribers read the collection in full
|
|
994
|
-
|
|
1019
|
+
setToken(result.change["_id"]);
|
|
995
1020
|
return gap(`a split event of ${source.collection} came without its first ${result.missing} fragments`, { missing: result.missing, resumeToken: true });
|
|
996
1021
|
}
|
|
997
1022
|
if (result.change["operationType"] === "invalidate") {
|
|
998
1023
|
// The collection was dropped or renamed: the stream starts after the invalidate event
|
|
999
|
-
|
|
1024
|
+
setToken(result.change["_id"]);
|
|
1000
1025
|
return end("ended (invalidate)");
|
|
1001
1026
|
}
|
|
1002
1027
|
onChange(result.change);
|
|
@@ -1010,6 +1035,9 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
1010
1035
|
|
|
1011
1036
|
return {
|
|
1012
1037
|
ready,
|
|
1038
|
+
get tokenAgeMs(): number {
|
|
1039
|
+
return age.ageMs();
|
|
1040
|
+
},
|
|
1013
1041
|
async stop(): Promise<void> {
|
|
1014
1042
|
if (stopped) return;
|
|
1015
1043
|
stopped = true;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The age of the resume token of a change watcher (roadmap CLI-110). A token moves when its cluster time is later
|
|
3
|
+
* than the cluster time of the token before it. The same token again, or a new token at the same cluster time,
|
|
4
|
+
* does not move it. A user reads a stalled feed from the age, without a sampler of its own.
|
|
5
|
+
*
|
|
6
|
+
* The package does not export this module. `watchDatabaseChanges()` and `watchStorageChanges()` give the age as
|
|
7
|
+
* `tokenAgeMs` on their handles.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Clock } from "@procedures/client";
|
|
11
|
+
|
|
12
|
+
/** The type byte of a timestamp at the start of the key of a resume token of the server. */
|
|
13
|
+
const TIMESTAMP_TYPE = "82";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* This function gives the cluster time of a resume token as 16 hex digits, or undefined for a token of another form.
|
|
17
|
+
* The key of a token of the server starts with the type byte `82` and the 8 bytes of the timestamp, high byte first.
|
|
18
|
+
* So the order of two of these texts is the order of their cluster times.
|
|
19
|
+
*/
|
|
20
|
+
export function clusterTimeOfToken(token: unknown): string | undefined {
|
|
21
|
+
const data = typeof token === "object" && token !== null ? (token as { _data?: unknown })._data : undefined;
|
|
22
|
+
if (typeof data !== "string" || !data.startsWith(TIMESTAMP_TYPE) || !/^[0-9a-f]{16}$/i.test(data.slice(2, 18))) return undefined;
|
|
23
|
+
return data.slice(2, 18).toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** This function tells if `next` is later than `before`. A token without a cluster time moves when it is a different token. */
|
|
27
|
+
function moves(next: unknown, before: unknown): boolean {
|
|
28
|
+
if (next === undefined || next === null) return false;
|
|
29
|
+
if (before === undefined || before === null) return true;
|
|
30
|
+
const nextTime = clusterTimeOfToken(next);
|
|
31
|
+
const beforeTime = clusterTimeOfToken(before);
|
|
32
|
+
if (nextTime !== undefined && beforeTime !== undefined) return nextTime > beforeTime;
|
|
33
|
+
return JSON.stringify(next) !== JSON.stringify(before);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The age of the token of one watcher. */
|
|
37
|
+
export interface TokenAge {
|
|
38
|
+
/** This method notes a token of the watcher. It moves the age to 0 when the token is later than the last token that moved it. */
|
|
39
|
+
note(token: unknown): void;
|
|
40
|
+
/** This method gives the milliseconds of the clock since the token last moved, or since the start before the first move. */
|
|
41
|
+
ageMs(): number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function starts the age of the token of one watcher at the time of the clock.
|
|
46
|
+
*
|
|
47
|
+
* @param clock - The clock of the watcher
|
|
48
|
+
* @param start - The start token of the watcher, or undefined. A later token moves the age.
|
|
49
|
+
*/
|
|
50
|
+
export function tokenAge(clock: Clock, start?: unknown): TokenAge {
|
|
51
|
+
let last: unknown = start;
|
|
52
|
+
let movedAt = clock.now();
|
|
53
|
+
return {
|
|
54
|
+
note(token: unknown): void {
|
|
55
|
+
if (!moves(token, last)) return;
|
|
56
|
+
last = token;
|
|
57
|
+
movedAt = clock.now();
|
|
58
|
+
},
|
|
59
|
+
ageMs(): number {
|
|
60
|
+
return clock.now() - movedAt;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|