@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.
@@ -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
- /** The delay before try `attempt` (0 for the first) of a backoff. */
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
- return (attempt) => {
387
- const full = Math.min(max, initial * factor ** attempt);
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
- token = change["_id"];
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) token = resumeToken;
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") return;
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
- token = result.change["_id"];
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
- token = result.change["_id"];
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
+ }