@procedures/mongo 0.34.0 → 0.35.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 +69 -0
- package/README.md +61 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +2 -2
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +4 -5
- package/dist/module.js.map +1 -1
- package/dist/procedures/input-schemas.d.ts.map +1 -1
- package/dist/procedures/input-schemas.js +29 -2
- package/dist/procedures/input-schemas.js.map +1 -1
- package/dist/storage/document-storage.d.ts +338 -10
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +704 -75
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/index.d.ts +5 -3
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +4 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/materializer.d.ts +27 -12
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +44 -15
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/own-writes.d.ts +51 -0
- package/dist/storage/own-writes.d.ts.map +1 -0
- package/dist/storage/own-writes.js +130 -0
- package/dist/storage/own-writes.js.map +1 -0
- package/dist/storage/storage-hints.d.ts +230 -0
- package/dist/storage/storage-hints.d.ts.map +1 -0
- package/dist/storage/storage-hints.js +876 -0
- package/dist/storage/storage-hints.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +15 -3
- package/src/module.ts +5 -5
- package/src/procedures/input-schemas.ts +29 -2
- package/src/storage/document-storage.ts +832 -75
- package/src/storage/index.ts +15 -3
- package/src/storage/materializer.ts +61 -22
- package/src/storage/own-writes.ts +157 -0
- package/src/storage/storage-hints.ts +971 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change hints of a `MongoDocumentStorage` collection (roadmap CLI-77). `watchStorageChanges()`
|
|
3
|
+
* watches the change stream of the collection, and publishes each change as a
|
|
4
|
+
* `CollectionChangeEvent` on the topic of the collection procedures. Thus a subscriber also gets
|
|
5
|
+
* the writes that did not go through the procedures: a raw write, a script, another process, a
|
|
6
|
+
* view.
|
|
7
|
+
*
|
|
8
|
+
* - The topic is `collectionChangeTopic(name, partition)`, as the procedures publish it: the
|
|
9
|
+
* partition of a document comes from `storage.form.partitionOf()`.
|
|
10
|
+
* - The event is a hint, without `since` and without `version` (the version of the change log): the
|
|
11
|
+
* subscriber reads the changes with `changes`, as for each event without `since`. The event has
|
|
12
|
+
* the id in `changed` or in `deleted`, and the version of the item in `versions` (a field of
|
|
13
|
+
* `CollectionChangeEvent`): the version that `storage.versionOf(id)` gives after the write.
|
|
14
|
+
* - A write through the storage gives the document a new `_seq`. The watcher reads that from the
|
|
15
|
+
* event, never from the `fullDocument` of the lookup: an update with `_seq` in
|
|
16
|
+
* `updateDescription.updatedFields`, an insert or a replace with `_seq`. With the image before
|
|
17
|
+
* the change, a replace whose `_seq` is the `_seq` before is a raw write (a copy). Without the
|
|
18
|
+
* image, a replace with `_seq` counts as a write of the storage.
|
|
19
|
+
* - A raw write (D39), with `rawWrites: "adopt"` (the default): the watcher publishes nothing for
|
|
20
|
+
* its event. The storage adopts it: one conditional write through the path of each write gives
|
|
21
|
+
* the document a new `_seq` (and the storage fields and partition fields that a raw replace took
|
|
22
|
+
* away), so `changes(since)` has it. The event of the adoption is a write of the storage: its
|
|
23
|
+
* hint goes out, with the new version. When two watchers adopt the same write, or a write of the
|
|
24
|
+
* storage comes first, one write wins and the others do nothing. A raw write never gets the old
|
|
25
|
+
* version. With `rawWrites: "ignore"` (for example for a user that can only read), the hint of a
|
|
26
|
+
* raw write goes out without a version, and `changes(since)` does not have the write.
|
|
27
|
+
* - The adoptions wait in one queue for each watcher: one adoption at a time, and the raw writes
|
|
28
|
+
* that wait together take one block of numbers for each partition. An adoption that fails with
|
|
29
|
+
* an error that can go away tries again after the backoff. When the queue is full, the hint of
|
|
30
|
+
* the next raw write goes out without a version. The watcher keeps the ids of the raw writes that
|
|
31
|
+
* did not fit or that failed (at most 10,000), and adopts them again when the queue is empty,
|
|
32
|
+
* after a sweep. An adoption that fails for good (after its last try, or a second time), or an id
|
|
33
|
+
* over that limit, gives its partition a reset hint (`reset: true`): its subscribers read it in full.
|
|
34
|
+
* - The adoption is exact only with the images before the change (`changeStreamPreAndPostImages`).
|
|
35
|
+
* An insert and a replace show their document, but an update shows only its fields: without the
|
|
36
|
+
* image, the `_seq` that the update kept comes from the lookup. When a write of the storage comes
|
|
37
|
+
* after the raw update, the adoption can then give that write a newer version, and a client that
|
|
38
|
+
* holds the version of that write gets a conflict of its precondition.
|
|
39
|
+
* - The sweep: after each gap (`sweep`, on by default with `rawWrites: "adopt"`), after a full
|
|
40
|
+
* queue, and when the stream opens (`sweepAtOpen`, off by default), the watcher sends each
|
|
41
|
+
* document without `_seq` to the same queue, in pages. Thus a document without `_seq` gets its
|
|
42
|
+
* number. A raw update of a document with `_seq` keeps that `_seq`: no sweep finds it, so a gap
|
|
43
|
+
* publishes a reset hint. The scan walks the index of `_id` and reads each document of the
|
|
44
|
+
* collection: a partial index cannot express `$exists: false`. A document that the sweep cannot
|
|
45
|
+
* place (no id, or another partition) goes to the log once, and the next sweeps leave it.
|
|
46
|
+
* - A raw hard delete of an item: with the image before the change, the storage adopts it as a
|
|
47
|
+
* tombstone. Without the image (no `changeStreamPreAndPostImages` on the collection), the hint
|
|
48
|
+
* goes out without a version, and the delete stays out of `changes(since)`.
|
|
49
|
+
* - A delete of a tombstone (the storage prunes the oldest ones) deletes no item: no hint, no
|
|
50
|
+
* adoption. The image before the change tells it. Without the image, the watcher knows the
|
|
51
|
+
* prunes of this process only.
|
|
52
|
+
* - The writes of this process through the storage: with `ownWrites: "skip"` (the default), the
|
|
53
|
+
* watcher skips their events, because the collection procedures publish them already, with
|
|
54
|
+
* `since`. Thus a subscriber gets one event for each write. With `ownWrites: "publish"`, the
|
|
55
|
+
* watcher publishes each change. Use it when the writes of this process do not go through the
|
|
56
|
+
* procedures, or when the procedures publish no events (`changeEvents: false`). An adoption is
|
|
57
|
+
* never an own write.
|
|
58
|
+
* - After an error of the stream, the watcher opens the stream again after a backoff, from the
|
|
59
|
+
* resume token of the last event: it loses no change. When the server does not have the token
|
|
60
|
+
* any more, or the collection was dropped or renamed, the watcher cannot know what it missed: it
|
|
61
|
+
* publishes a reset hint (a gap: no ids, `reset: true`) on the plain topic and on the topic of
|
|
62
|
+
* each partition that it knows: the partitions of the counters, of `forPartition()`, of the
|
|
63
|
+
* hints, of the subscribed topics of the bus, and of `gapPartitions`. The counters have a floor
|
|
64
|
+
* document for each partition with a write through a storage. The subscribers read the
|
|
65
|
+
* collection in full.
|
|
66
|
+
* - The pipeline of the stream is `[]`: the storage of a partition (`forPartition()`) watches the
|
|
67
|
+
* whole collection and drops the events of other partitions.
|
|
68
|
+
* - Change streams need a replica set or a sharded cluster. The watcher asks the server (`hello`)
|
|
69
|
+
* before the first open: on a single server, `ready` rejects (code 40573).
|
|
70
|
+
*/
|
|
71
|
+
import { MongoAPIError, MongoParseError, BSON, type ChangeStream, type Db, type Document } from "mongodb";
|
|
72
|
+
import {
|
|
73
|
+
collectionChangeTopic,
|
|
74
|
+
collectionTopicPartition,
|
|
75
|
+
optionalMs,
|
|
76
|
+
realClock,
|
|
77
|
+
type Cancel,
|
|
78
|
+
type Clock,
|
|
79
|
+
type CollectionChangeEvent,
|
|
80
|
+
type ResubscribeBackoff,
|
|
81
|
+
} from "@procedures/client";
|
|
82
|
+
import {
|
|
83
|
+
DEFAULT_STORED_FORM,
|
|
84
|
+
TOMBSTONE_FIELD,
|
|
85
|
+
storageChangeSource,
|
|
86
|
+
versionOfStored,
|
|
87
|
+
type MongoDocumentStorage,
|
|
88
|
+
type RawAdoptionResult,
|
|
89
|
+
type RawStorageWrite,
|
|
90
|
+
type StoredForm,
|
|
91
|
+
} from "./document-storage.js";
|
|
92
|
+
import { trackOwnNumbers, type OwnNumbers } from "./own-writes.js";
|
|
93
|
+
|
|
94
|
+
/** The change of one item of a change event of a `MongoDocumentStorage` collection, from `storageChangeOf()`. */
|
|
95
|
+
export interface StorageChange {
|
|
96
|
+
/** The id of the item. */
|
|
97
|
+
readonly id: string;
|
|
98
|
+
/** The partition of the document. */
|
|
99
|
+
readonly partition: string | undefined;
|
|
100
|
+
/** True for a delete: a tombstone, or a delete of the document. */
|
|
101
|
+
readonly deleted: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* True for a raw write: a write that did not give the document a new `_seq` (see the module
|
|
104
|
+
* comment), and each delete of a document that is not a tombstone.
|
|
105
|
+
*/
|
|
106
|
+
readonly raw: boolean;
|
|
107
|
+
/** The version of the item after a write of the storage (`versionOfStored()`). A delete and a raw write have no version. */
|
|
108
|
+
readonly version?: string | undefined;
|
|
109
|
+
/** The new `_seq` of a write of the storage, or the `_seq` that a raw write kept (none for a document without `_seq`). */
|
|
110
|
+
readonly seq?: number | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The change event of `watchStorageChanges()`: a `CollectionChangeEvent` without `since` (a hint),
|
|
115
|
+
* with the version of each changed item in `versions`. A subscriber of the collection procedures
|
|
116
|
+
* reads it as each other event. It is an alias of `CollectionChangeEvent`.
|
|
117
|
+
*/
|
|
118
|
+
export type StorageChangeHint = CollectionChangeEvent;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The function or the bus that gets the events of `watchStorageChanges()`, for example the bus of
|
|
122
|
+
* the procedures. A bus with `channels()` (the `EventBus` of `@procedures/client`) also tells the
|
|
123
|
+
* subscribed topics: the hint of a gap goes to their partitions.
|
|
124
|
+
*/
|
|
125
|
+
export type StorageHintPublisher =
|
|
126
|
+
| ((topic: string, event: StorageChangeHint) => void | Promise<void>)
|
|
127
|
+
| { emit(topic: string, event: StorageChangeHint): void; channels?(): readonly string[] };
|
|
128
|
+
|
|
129
|
+
/** The options of `watchStorageChanges()`. */
|
|
130
|
+
export interface WatchStorageChangesOptions {
|
|
131
|
+
/**
|
|
132
|
+
* The name of the collection in the topic: the name of `createCollectionProcedures()`.
|
|
133
|
+
* @default the MongoDB collection of the storage
|
|
134
|
+
*/
|
|
135
|
+
collection?: string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* The topic of the events of a partition, or undefined for no event. Give the same rule as the
|
|
138
|
+
* option `changeEvents.topic` of the procedures.
|
|
139
|
+
* @default `collectionChangeTopic(collection, partition)`
|
|
140
|
+
*/
|
|
141
|
+
topic?: ((collection: string, partition: string | undefined) => string | undefined) | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* `skip`: the watcher skips the events of the writes of this process through the storage, because
|
|
144
|
+
* the collection procedures publish them. `publish`: the watcher publishes each change.
|
|
145
|
+
* @default "skip"
|
|
146
|
+
*/
|
|
147
|
+
ownWrites?: "skip" | "publish" | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* `adopt`: the storage gives each raw write a new `_seq`, so `changes(since)` has it, and the hint
|
|
150
|
+
* goes out with the new version (D39). The watcher needs write access. `ignore`: the hint of a raw
|
|
151
|
+
* write goes out without a version, and the storage writes nothing (for a user that can only read).
|
|
152
|
+
* @default "adopt"
|
|
153
|
+
*/
|
|
154
|
+
rawWrites?: "adopt" | "ignore" | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* More partitions for the hint of a gap. The host gives the partitions that its subscribers
|
|
157
|
+
* subscribed to, when the publisher cannot tell them (a function, or a bus without
|
|
158
|
+
* `channels()`). The watcher adds the plain topic, the partitions of the counters and of
|
|
159
|
+
* `forPartition()`, the partitions of the hints, and the subscribed topics of the bus. Each
|
|
160
|
+
* partition with a write through a storage (in any process) has a floor document in the
|
|
161
|
+
* counters (roadmap O66). So give `gapPartitions` only for the partitions that only raw writes
|
|
162
|
+
* wrote.
|
|
163
|
+
*/
|
|
164
|
+
gapPartitions?: (() => Iterable<string | undefined>) | undefined;
|
|
165
|
+
/**
|
|
166
|
+
* True: after each gap, the watcher adopts each document without `_seq` (a raw write during the
|
|
167
|
+
* gap), in pages, through the queue of the adoptions. It needs `rawWrites: "adopt"`. With
|
|
168
|
+
* `false`, a sweep still runs when the watcher had more raw writes to adopt again than it can
|
|
169
|
+
* keep.
|
|
170
|
+
* @default true with `rawWrites: "adopt"`, else false
|
|
171
|
+
*/
|
|
172
|
+
sweep?: boolean | undefined;
|
|
173
|
+
/**
|
|
174
|
+
* True: a sweep also runs when the stream opens first, for the raw writes before the watcher
|
|
175
|
+
* started. It needs `rawWrites: "adopt"`. The first watcher on a legacy collection adopts every
|
|
176
|
+
* legacy document, and the next delta of every subscriber is the whole collection.
|
|
177
|
+
* @default false
|
|
178
|
+
*/
|
|
179
|
+
sweepAtOpen?: boolean | undefined;
|
|
180
|
+
/**
|
|
181
|
+
* The most raw writes that wait for an adoption. Internal: for the tests.
|
|
182
|
+
* @internal
|
|
183
|
+
* @default 10_000
|
|
184
|
+
*/
|
|
185
|
+
adoptQueueLimit?: number | undefined;
|
|
186
|
+
/**
|
|
187
|
+
* The most raw writes that the watcher keeps to adopt again. Internal: for the tests.
|
|
188
|
+
* @internal
|
|
189
|
+
* @default 10_000
|
|
190
|
+
*/
|
|
191
|
+
adoptRedoLimit?: number | undefined;
|
|
192
|
+
/**
|
|
193
|
+
* The backoff before the stream opens again after an error.
|
|
194
|
+
* @default { initialDelay: ms(250), maxDelay: seconds(30), backoffMultiplier: 2, jitter: 1 }
|
|
195
|
+
*/
|
|
196
|
+
backoff?: ResubscribeBackoff | undefined;
|
|
197
|
+
/** The clock of the timers and of the time limit of the own writes (default the clock of the platform). The timers do not keep a process alive. */
|
|
198
|
+
clock?: Clock | undefined;
|
|
199
|
+
/** The log (default `console`, with the prefix `[storage-hints]`). */
|
|
200
|
+
log?: ((level: "info" | "warn" | "error", message: string) => void) | undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The handle of `watchStorageChanges()`. */
|
|
204
|
+
export interface StorageChangesWatch {
|
|
205
|
+
/**
|
|
206
|
+
* It resolves when the first stream is open (each later change reaches the publisher), or when
|
|
207
|
+
* `stop()` runs. It rejects when the first open fails with an error that does not go away (a
|
|
208
|
+
* single server, a refused authentication or permission): the watcher then stops. The watcher
|
|
209
|
+
* opens the stream again after the other errors.
|
|
210
|
+
*/
|
|
211
|
+
readonly ready: Promise<void>;
|
|
212
|
+
/** This method stops the watcher: it closes the stream and cancels the timer of a backoff. */
|
|
213
|
+
stop(): Promise<void>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A raw write that waits for its adoption: of a change event, of the sweep, or adopted again. `done` ends a write of the sweep or of the redo. */
|
|
217
|
+
interface AdoptJob {
|
|
218
|
+
readonly item: StorageChange;
|
|
219
|
+
readonly raw: RawStorageWrite;
|
|
220
|
+
readonly tries: number;
|
|
221
|
+
readonly kind?: "sweep" | "redo" | undefined;
|
|
222
|
+
readonly done?: (() => void) | undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The fields of the storage that the change of an item reads. */
|
|
226
|
+
const SEQ = "_seq";
|
|
227
|
+
const KEY = "_key";
|
|
228
|
+
|
|
229
|
+
/** The codes of the errors after which the server does not know the resume token: the watcher opens a new stream and publishes a gap. */
|
|
230
|
+
const LOST_TOKEN_CODES: ReadonlySet<number> = new Set([260, 280, 286]);
|
|
231
|
+
|
|
232
|
+
/** The codes of a refused access: Unauthorized, AuthenticationFailed, and the access error of Atlas. */
|
|
233
|
+
const REFUSED_CODES: ReadonlySet<number> = new Set([13, 18, 8000]);
|
|
234
|
+
|
|
235
|
+
/** The codes of the errors of a first open that do not go away: a refused access, no change streams on a single server (40573), a command that the server does not know (115). */
|
|
236
|
+
const PERMANENT_CODES: ReadonlySet<number> = new Set([...REFUSED_CODES, 40573, 115]);
|
|
237
|
+
|
|
238
|
+
/** The most partitions whose topics get the hint of a gap. */
|
|
239
|
+
const MAX_GAP_PARTITIONS = 10_000;
|
|
240
|
+
|
|
241
|
+
/** The most raw writes of one adoption: they take one block of numbers for each partition. */
|
|
242
|
+
const ADOPT_BATCH = 100;
|
|
243
|
+
|
|
244
|
+
/** The most raw writes that wait for an adoption. The hint of a raw write over the limit goes out without a version. */
|
|
245
|
+
const MAX_ADOPT_QUEUE = 10_000;
|
|
246
|
+
|
|
247
|
+
/** The tries of an adoption that fails with an error that can go away. */
|
|
248
|
+
const ADOPT_TRIES = 5;
|
|
249
|
+
|
|
250
|
+
/** The documents of one page of the sweep. */
|
|
251
|
+
const SWEEP_PAGE = 500;
|
|
252
|
+
|
|
253
|
+
/** The most raw writes that the watcher keeps to adopt again. Over the limit, the partition gets a reset hint. */
|
|
254
|
+
const MAX_ADOPT_REDO = 10_000;
|
|
255
|
+
|
|
256
|
+
/** The most documents that the sweep cannot place and that the next sweeps leave. */
|
|
257
|
+
const MAX_UNPLACED = 10_000;
|
|
258
|
+
|
|
259
|
+
/** The hint of a gap: no ids, and the subscribers read the collection in full. */
|
|
260
|
+
function resetHint(): StorageChangeHint {
|
|
261
|
+
return { changed: [], deleted: [], reset: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The code of a command that the server does not know (an old server without `hello`). */
|
|
265
|
+
const COMMAND_NOT_FOUND = 59;
|
|
266
|
+
|
|
267
|
+
function asDocument(value: unknown): Document | undefined {
|
|
268
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Document) : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function seqOf(doc: Document | undefined): number | undefined {
|
|
272
|
+
const seq = doc?.[SEQ];
|
|
273
|
+
return typeof seq === "number" ? seq : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The new `_seq` of a write of the storage, read from the event, or undefined for a raw write. */
|
|
277
|
+
function writtenSeq(type: string, change: Document, after: Document, before: Document | undefined): number | undefined {
|
|
278
|
+
if (type === "update") return seqOf(asDocument(asDocument(change["updateDescription"])?.["updatedFields"]));
|
|
279
|
+
const seq = seqOf(after);
|
|
280
|
+
// A replace that copies the `_seq` of the document before it is a raw write. It needs the image before the change.
|
|
281
|
+
if (seq !== undefined && type === "replace" && before !== undefined && seqOf(before) === seq) return undefined;
|
|
282
|
+
return seq;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The `_seq` of the document after a raw write: the state that the event saw. */
|
|
286
|
+
function keptSeq(type: string, change: Document, after: Document, before: Document | undefined): number | undefined {
|
|
287
|
+
if (type !== "update") return seqOf(after);
|
|
288
|
+
const removed = asDocument(change["updateDescription"])?.["removedFields"];
|
|
289
|
+
if (Array.isArray(removed) && removed.includes(SEQ)) return undefined;
|
|
290
|
+
// An update that did not touch `_seq` keeps the `_seq` before it. Without the image, the lookup tells it.
|
|
291
|
+
return seqOf(before ?? after);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* This function reads the change of an item from a change event of a `MongoDocumentStorage`
|
|
296
|
+
* collection (roadmap CLI-77), for an app that watches the collection itself. It gives `undefined`
|
|
297
|
+
* for an event of no item: another operation (for example `drop`), an update whose document is
|
|
298
|
+
* gone (its delete event follows), the delete of a tombstone, or a document whose id neither
|
|
299
|
+
* `idOf` nor `_key` tells.
|
|
300
|
+
*
|
|
301
|
+
* - The partition is `form.partitionOf()` of `fullDocumentBeforeChange`, else of `fullDocument`,
|
|
302
|
+
* else of `documentKey`: the first whose partition gives the id with `form.idOf()` of the stored
|
|
303
|
+
* `_id`. Else the id is `_key`, in the partition of the first document.
|
|
304
|
+
* - A tombstone and a delete are deletes.
|
|
305
|
+
* - `raw` tells a raw write, from the event only (see the module comment). A write of the storage
|
|
306
|
+
* has the version of its new `_seq`. A raw write has no version.
|
|
307
|
+
*
|
|
308
|
+
* @param change - A change event of the collection, with `fullDocument: "updateLookup"` for the updates
|
|
309
|
+
* @param form - The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`)
|
|
310
|
+
*/
|
|
311
|
+
export function storageChangeOf(change: Document, form: StoredForm = DEFAULT_STORED_FORM): StorageChange | undefined {
|
|
312
|
+
const type = change["operationType"];
|
|
313
|
+
if (type !== "insert" && type !== "replace" && type !== "update" && type !== "delete") return undefined;
|
|
314
|
+
const after = type === "delete" ? undefined : asDocument(change["fullDocument"]);
|
|
315
|
+
const before = asDocument(change["fullDocumentBeforeChange"]);
|
|
316
|
+
const key = asDocument(change["documentKey"]);
|
|
317
|
+
// The partition: of the image before the change, else of the document after it, else of the stored _id (the order of the adoption)
|
|
318
|
+
const places = [before, after, key].filter((doc): doc is Document => doc !== undefined);
|
|
319
|
+
if (places.length === 0) return undefined;
|
|
320
|
+
const storedId = (key ?? places[0]!)["_id"];
|
|
321
|
+
let partition: string | undefined;
|
|
322
|
+
let id: string | undefined;
|
|
323
|
+
for (const doc of places) {
|
|
324
|
+
const place = form.partitionOf(doc);
|
|
325
|
+
const fromId = form.idOf(storedId, place);
|
|
326
|
+
if (typeof fromId === "string") {
|
|
327
|
+
partition = place;
|
|
328
|
+
id = fromId;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (id === undefined) {
|
|
333
|
+
// No partition tells the id through the form: the id is `_key`, in the first partition
|
|
334
|
+
const keyField = (after ?? before)?.[KEY];
|
|
335
|
+
if (typeof keyField !== "string") return undefined;
|
|
336
|
+
partition = form.partitionOf(places[0]!);
|
|
337
|
+
id = keyField;
|
|
338
|
+
}
|
|
339
|
+
if (!after) {
|
|
340
|
+
// An update whose document is gone: the delete event follows. A delete of a tombstone deletes no item.
|
|
341
|
+
if (type !== "delete" || before?.[TOMBSTONE_FIELD] === true) return undefined;
|
|
342
|
+
// The storage deletes an item with a tombstone: a delete of an item is a raw write
|
|
343
|
+
return { id, partition, deleted: true, raw: true };
|
|
344
|
+
}
|
|
345
|
+
const deleted = after[TOMBSTONE_FIELD] === true;
|
|
346
|
+
const written = writtenSeq(type, change, after, before);
|
|
347
|
+
if (written === undefined) {
|
|
348
|
+
const seq = keptSeq(type, change, after, before);
|
|
349
|
+
return { id, partition, deleted, raw: true, ...(seq !== undefined && { seq }) };
|
|
350
|
+
}
|
|
351
|
+
if (deleted) return { id, partition, deleted: true, raw: false, seq: written };
|
|
352
|
+
const version = versionOfStored({ ...after, [SEQ]: written }, form, partition) ?? String(written);
|
|
353
|
+
return { id, partition, deleted: false, raw: false, version, seq: written };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The delay before try `attempt` (0 for the first) of a backoff. */
|
|
357
|
+
function backoffOf(options: ResubscribeBackoff | undefined): (attempt: number) => number {
|
|
358
|
+
const initial = optionalMs(options?.initialDelay, "backoff.initialDelay") ?? 250;
|
|
359
|
+
const max = optionalMs(options?.maxDelay, "backoff.maxDelay") ?? 30_000;
|
|
360
|
+
const factor = options?.backoffMultiplier ?? 2;
|
|
361
|
+
const jitter = options?.jitter ?? 1;
|
|
362
|
+
if (!(initial >= 0) || !(max >= initial)) throw new RangeError("backoff: initialDelay must be 0 or more, and maxDelay must be initialDelay or more");
|
|
363
|
+
if (!(factor >= 1)) throw new RangeError("backoff: backoffMultiplier must be 1 or more");
|
|
364
|
+
if (!(jitter >= 0 && jitter <= 1)) throw new RangeError("backoff: jitter must be from 0 to 1");
|
|
365
|
+
return (attempt) => {
|
|
366
|
+
const full = Math.min(max, initial * factor ** attempt);
|
|
367
|
+
return full - full * jitter * Math.random();
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The message of an error. */
|
|
372
|
+
function messageOf(error: unknown): string {
|
|
373
|
+
return String((error as Error | undefined)?.message ?? error);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function codeOf(error: unknown): number | undefined {
|
|
377
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
378
|
+
return typeof code === "number" ? code : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** True for a refused access: the user may not write (or read) the collection. */
|
|
382
|
+
function isRefused(error: unknown): boolean {
|
|
383
|
+
const code = codeOf(error);
|
|
384
|
+
return code !== undefined && REFUSED_CODES.has(code);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** True for an error of a first open that does not go away: a single server, a refused access, a wrong option. */
|
|
388
|
+
function isPermanent(error: unknown): boolean {
|
|
389
|
+
const code = codeOf(error);
|
|
390
|
+
if (code !== undefined && PERMANENT_CODES.has(code)) return true;
|
|
391
|
+
return error instanceof MongoAPIError || error instanceof MongoParseError;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A stored `_id` for the log. */
|
|
395
|
+
function describeId(storedId: unknown): string {
|
|
396
|
+
try {
|
|
397
|
+
return BSON.EJSON.stringify({ _id: storedId as never }, { relaxed: false });
|
|
398
|
+
} catch {
|
|
399
|
+
return String(storedId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The key of a stored `_id` in the sets of the watcher, or undefined without an id. */
|
|
404
|
+
function idKey(storedId: unknown): string | undefined {
|
|
405
|
+
return storedId === undefined || storedId === null ? undefined : describeId(storedId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* This function watches the change stream of the collection of a `MongoDocumentStorage`, and
|
|
410
|
+
* publishes a hint of each change of an item on the topic of the collection procedures (roadmap
|
|
411
|
+
* CLI-77). The writes that do not go through the procedures (a raw write, another process, a
|
|
412
|
+
* script) then reach the subscribers too, and the storage adopts each raw write (D39). See the
|
|
413
|
+
* module comment for the rules.
|
|
414
|
+
*
|
|
415
|
+
* - The storage of a partition (`forPartition()`) publishes only the documents of its partition.
|
|
416
|
+
* - The watcher reads only the collection of the items. The documents of the counters
|
|
417
|
+
* (`__storage_counters`) are in another collection, and a change there is never a hint.
|
|
418
|
+
* - An error of the publisher goes to the log. It never stops the watcher.
|
|
419
|
+
* - The adoption of raw writes needs write access. A refused adoption goes to the log once, and
|
|
420
|
+
* the hint of that write goes out without a version: set `rawWrites: "ignore"` for a user that
|
|
421
|
+
* can only read.
|
|
422
|
+
* - The adoption is exact only with `changeStreamPreAndPostImages` on the collection. Without the
|
|
423
|
+
* image, a raw update and a later write of the storage can give that write an extra version.
|
|
424
|
+
*
|
|
425
|
+
* @param storage - The storage, for its collection, its database and its stored form
|
|
426
|
+
* @param publish - The function or the bus of the events, for example the bus of the procedures
|
|
427
|
+
* @param options - The topic, the own writes, the raw writes, the gap, the backoff, the clock and the log
|
|
428
|
+
* @returns The handle: `ready` and `stop()`
|
|
429
|
+
*
|
|
430
|
+
* @example
|
|
431
|
+
* ```typescript
|
|
432
|
+
* const notes = new MongoDocumentStorage<Note>({ collection: "notes" });
|
|
433
|
+
* const watch = watchStorageChanges(notes, bus); // the bus of the procedures
|
|
434
|
+
* await watch.ready;
|
|
435
|
+
* // ... a raw write of a note reaches the subscribers of collections.notes.<partition>
|
|
436
|
+
* await watch.stop();
|
|
437
|
+
* ```
|
|
438
|
+
*/
|
|
439
|
+
export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish: StorageHintPublisher, options: WatchStorageChangesOptions = {}): StorageChangesWatch {
|
|
440
|
+
const source = storageChangeSource(storage);
|
|
441
|
+
const name = options.collection ?? source.collection;
|
|
442
|
+
const topicOf = options.topic ?? ((collection: string, partition: string | undefined): string | undefined => collectionChangeTopic(collection, partition));
|
|
443
|
+
const clock = options.clock ?? realClock;
|
|
444
|
+
const log = options.log ?? ((level: "info" | "warn" | "error", message: string): void => console[level === "info" ? "log" : level](`[storage-hints] ${message}`));
|
|
445
|
+
const emit: (topic: string, event: StorageChangeHint) => void | Promise<void> = typeof publish === "function" ? publish : publish.emit.bind(publish);
|
|
446
|
+
// The subscribed topics of the bus name the partitions of a gap. A custom topic rule cannot be read back.
|
|
447
|
+
const channels = typeof publish === "object" && typeof publish.channels === "function" && options.topic === undefined ? publish.channels.bind(publish) : undefined;
|
|
448
|
+
const delayOf = backoffOf(options.backoff);
|
|
449
|
+
if (options.ownWrites !== undefined && options.ownWrites !== "skip" && options.ownWrites !== "publish") throw new TypeError('ownWrites must be "skip" or "publish"');
|
|
450
|
+
if (options.rawWrites !== undefined && options.rawWrites !== "adopt" && options.rawWrites !== "ignore") throw new TypeError('rawWrites must be "adopt" or "ignore"');
|
|
451
|
+
const skipOwn = (options.ownWrites ?? "skip") === "skip";
|
|
452
|
+
const adoptRaw = (options.rawWrites ?? "adopt") === "adopt";
|
|
453
|
+
if (options.sweep !== undefined && typeof options.sweep !== "boolean") throw new TypeError("sweep must be a boolean");
|
|
454
|
+
if (options.sweep === true && !adoptRaw) throw new TypeError('sweep needs rawWrites: "adopt"');
|
|
455
|
+
if (options.sweepAtOpen !== undefined && typeof options.sweepAtOpen !== "boolean") throw new TypeError("sweepAtOpen must be a boolean");
|
|
456
|
+
if (options.sweepAtOpen === true && !adoptRaw) throw new TypeError('sweepAtOpen needs rawWrites: "adopt"');
|
|
457
|
+
const sweepOn = options.sweep ?? adoptRaw;
|
|
458
|
+
const queueLimit = options.adoptQueueLimit ?? MAX_ADOPT_QUEUE;
|
|
459
|
+
if (!Number.isInteger(queueLimit) || queueLimit < 1) throw new RangeError("adoptQueueLimit must be a whole number of 1 or more");
|
|
460
|
+
const redoLimit = options.adoptRedoLimit ?? MAX_ADOPT_REDO;
|
|
461
|
+
if (!Number.isInteger(redoLimit) || redoLimit < 0) throw new RangeError("adoptRedoLimit must be a whole number of 0 or more");
|
|
462
|
+
const sweepPage = Math.min(SWEEP_PAGE, queueLimit);
|
|
463
|
+
const redoChunk = Math.min(ADOPT_BATCH, queueLimit);
|
|
464
|
+
|
|
465
|
+
let stopped = false;
|
|
466
|
+
let stream: ChangeStream | undefined;
|
|
467
|
+
let token: unknown;
|
|
468
|
+
/** True after a stream was open: a new stream without a token can then miss changes. */
|
|
469
|
+
let opened = false;
|
|
470
|
+
let attempt = 0;
|
|
471
|
+
let timer: Cancel | undefined;
|
|
472
|
+
let own: OwnNumbers | undefined;
|
|
473
|
+
/** True after the first refused adoption: the log tells it once. */
|
|
474
|
+
let refused = false;
|
|
475
|
+
/** True after the server said that it has change streams. */
|
|
476
|
+
let checked = false;
|
|
477
|
+
/** The raw writes that wait for an adoption, and true while one adoption runs. */
|
|
478
|
+
const queue: AdoptJob[] = [];
|
|
479
|
+
let adopting = false;
|
|
480
|
+
/** True after the queue was full, until it is empty: the log tells it once, and the sweep waits. */
|
|
481
|
+
let full = false;
|
|
482
|
+
/** The timers of the adoptions that try again. */
|
|
483
|
+
const retries = new Map<Cancel, AdoptJob>();
|
|
484
|
+
/** True when a sweep must run when the stream is open and the queue is empty, and true while one runs. */
|
|
485
|
+
let sweepWanted = options.sweepAtOpen ?? false;
|
|
486
|
+
let sweeping = false;
|
|
487
|
+
/** True while a stream is open: it saw its first resume token or change. */
|
|
488
|
+
let live = false;
|
|
489
|
+
/** The raw writes to adopt again when the queue is empty, by the key of the stored id, and true while that runs. */
|
|
490
|
+
const redo = new Map<string, AdoptJob>();
|
|
491
|
+
let redoing = false;
|
|
492
|
+
/** True after the set of the redo was full, until it is empty: the log tells it once. */
|
|
493
|
+
let redoFull = false;
|
|
494
|
+
/** The keys of the documents that the sweep cannot place: the next sweeps leave them. */
|
|
495
|
+
const unplaced = new Set<string>();
|
|
496
|
+
/** The partitions that got a hint: the topics of a gap. */
|
|
497
|
+
const partitions = new Set<string | undefined>();
|
|
498
|
+
let markReady: () => void = () => undefined;
|
|
499
|
+
let failReady: (error: unknown) => void = () => undefined;
|
|
500
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
501
|
+
markReady = resolve;
|
|
502
|
+
failReady = reject;
|
|
503
|
+
});
|
|
504
|
+
// A caller that does not wait for `ready` gets no unhandled rejection: the log tells the error
|
|
505
|
+
ready.catch(() => undefined);
|
|
506
|
+
|
|
507
|
+
const send = (partition: string | undefined, event: StorageChangeHint): void => {
|
|
508
|
+
let topic: string | undefined;
|
|
509
|
+
try {
|
|
510
|
+
topic = topicOf(name, partition);
|
|
511
|
+
if (topic === undefined) return;
|
|
512
|
+
const result: unknown = emit(topic, event);
|
|
513
|
+
if (result instanceof Promise) result.catch((error: unknown) => log("error", `the publish on ${topic} failed: ${messageOf(error)}`));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
log("error", `the publish on ${topic ?? name} failed: ${messageOf(error)}`);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** The hint of a change of an item. A raw write that the storage does not adopt has no version. */
|
|
520
|
+
const publishItem = (item: StorageChange): void => {
|
|
521
|
+
if (stopped) return;
|
|
522
|
+
if (partitions.size < MAX_GAP_PARTITIONS) partitions.add(item.partition);
|
|
523
|
+
if (item.deleted) return send(item.partition, { changed: [], deleted: [item.id] });
|
|
524
|
+
send(item.partition, item.version === undefined ? { changed: [item.id], deleted: [] } : { changed: [item.id], deleted: [], versions: [{ id: item.id, version: item.version }] });
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** The partitions of the subscribed topics of the bus and of `gapPartitions`. */
|
|
528
|
+
const extraPartitions = (into: Set<string | undefined>): void => {
|
|
529
|
+
try {
|
|
530
|
+
for (const topic of channels?.() ?? []) {
|
|
531
|
+
const parsed = collectionTopicPartition(topic);
|
|
532
|
+
if (parsed?.collection === name) into.add(parsed.partition);
|
|
533
|
+
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
log("warn", `the bus does not tell its channels: ${messageOf(error)}`);
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
for (const partition of options.gapPartitions?.() ?? []) into.add(partition);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
log("warn", `gapPartitions failed: ${messageOf(error)}`);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/** The reset hint of a gap: the subscribers read the collection in full. */
|
|
545
|
+
const gap = (reason: string): void => {
|
|
546
|
+
log("warn", `${reason}: the subscribers read the collection in full`);
|
|
547
|
+
// The raw writes of the gap have no event: the sweep adopts the documents without _seq
|
|
548
|
+
if (sweepOn) {
|
|
549
|
+
sweepWanted = true;
|
|
550
|
+
afterQueue();
|
|
551
|
+
}
|
|
552
|
+
if (source.partition !== undefined) return send(source.partition, resetHint());
|
|
553
|
+
const topics = new Set<string | undefined>([undefined, ...partitions]);
|
|
554
|
+
void source
|
|
555
|
+
.partitions()
|
|
556
|
+
.then(
|
|
557
|
+
(known) => {
|
|
558
|
+
for (const partition of known) topics.add(partition);
|
|
559
|
+
},
|
|
560
|
+
(error: unknown) => log("warn", `the counters of ${source.collection} cannot be read (${messageOf(error)}): the gap goes to the partitions of the hints and of the subscribers only`)
|
|
561
|
+
)
|
|
562
|
+
.then(() => {
|
|
563
|
+
if (stopped) return;
|
|
564
|
+
extraPartitions(topics);
|
|
565
|
+
let count = 0;
|
|
566
|
+
for (const partition of topics) {
|
|
567
|
+
if (count++ >= MAX_GAP_PARTITIONS) break;
|
|
568
|
+
send(partition, resetHint());
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/** The reset hint of each partition of the raw writes: their subscribers read the collection in full. */
|
|
574
|
+
const resetPartitions = (jobs: Iterable<AdoptJob>): void => {
|
|
575
|
+
if (stopped) return;
|
|
576
|
+
const done = new Set<string | undefined>();
|
|
577
|
+
for (const job of jobs) {
|
|
578
|
+
if (done.has(job.item.partition)) continue;
|
|
579
|
+
done.add(job.item.partition);
|
|
580
|
+
send(job.item.partition, resetHint());
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* It keeps a raw write to adopt again when the queue is empty, with the newest write of each
|
|
586
|
+
* document. False when the watcher cannot keep it: no id, or the set is full.
|
|
587
|
+
*/
|
|
588
|
+
const remember = (job: AdoptJob): boolean => {
|
|
589
|
+
const key = idKey(job.raw.storedId);
|
|
590
|
+
if (key === undefined) return false;
|
|
591
|
+
if (redo.has(key) || redo.size < redoLimit) {
|
|
592
|
+
redo.set(key, { item: job.item, raw: job.raw, tries: 0 });
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
if (!redoFull) log("warn", `more than ${redoLimit} raw writes of ${source.collection} wait to be adopted again: the partitions of the next ones get a reset hint, and a sweep runs`);
|
|
596
|
+
redoFull = true;
|
|
597
|
+
return false;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/** The count and the first of the documents that the current sweep cannot place. */
|
|
601
|
+
let leftCount = 0;
|
|
602
|
+
let leftFirst = "";
|
|
603
|
+
/** A document that the sweep cannot place: the next sweeps leave it. */
|
|
604
|
+
const leave = (storedId: unknown, why: string): void => {
|
|
605
|
+
const key = idKey(storedId);
|
|
606
|
+
if (key !== undefined && unplaced.size < MAX_UNPLACED) unplaced.add(key);
|
|
607
|
+
if (leftCount++ === 0) leftFirst = `${describeId(storedId)} (${why})`;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** The end of one raw write of an adoption. The change event of an adoption is its hint. */
|
|
611
|
+
const settle = (job: AdoptJob, result: RawAdoptionResult, failed: Array<{ readonly job: AdoptJob; readonly error: unknown }>): void => {
|
|
612
|
+
if (typeof result === "string") {
|
|
613
|
+
// `adopted`: the event of the adoption is the hint. `lost`: a newer write won, and its event is the hint.
|
|
614
|
+
if (result === "unknown") {
|
|
615
|
+
if (job.kind === "sweep") leave(job.raw.storedId, "neither its id nor its partition");
|
|
616
|
+
else {
|
|
617
|
+
log("warn", `the raw write of ${describeId(job.raw.storedId)} in ${source.collection} tells neither its id nor its partition: the storage leaves it`);
|
|
618
|
+
publishItem(job.item);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return job.done?.();
|
|
622
|
+
}
|
|
623
|
+
const error = result.error;
|
|
624
|
+
if (isRefused(error)) {
|
|
625
|
+
if (!refused) log("error", `the adoption of a raw write of ${source.collection} was refused (${messageOf(error)}): the watcher needs write access, or set rawWrites: "ignore". The hints of raw writes go out without a version`);
|
|
626
|
+
refused = true;
|
|
627
|
+
publishItem(job.item);
|
|
628
|
+
return job.done?.();
|
|
629
|
+
}
|
|
630
|
+
if (isPermanent(error) || job.tries + 1 >= ADOPT_TRIES) {
|
|
631
|
+
failed.push({ job, error });
|
|
632
|
+
return job.done?.();
|
|
633
|
+
}
|
|
634
|
+
// An error that can go away: the adoption tries again after the backoff
|
|
635
|
+
const retry = clock.after(delayOf(job.tries), () => {
|
|
636
|
+
retries.delete(retry);
|
|
637
|
+
enqueue({ ...job, tries: job.tries + 1 });
|
|
638
|
+
});
|
|
639
|
+
retry.unref?.();
|
|
640
|
+
retries.set(retry, job);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/** One adoption at a time: the raw writes that wait together take one block of numbers for each storage. */
|
|
644
|
+
const work = async (): Promise<void> => {
|
|
645
|
+
adopting = true;
|
|
646
|
+
try {
|
|
647
|
+
while (!stopped && queue.length > 0) {
|
|
648
|
+
const batch = queue.splice(0, ADOPT_BATCH);
|
|
649
|
+
let results: RawAdoptionResult[];
|
|
650
|
+
try {
|
|
651
|
+
results = await source.adoptAll(batch.map((job) => job.raw));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
results = batch.map(() => ({ error }));
|
|
654
|
+
}
|
|
655
|
+
if (stopped) {
|
|
656
|
+
for (const job of batch) job.done?.();
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
const failed: Array<{ readonly job: AdoptJob; readonly error: unknown }> = [];
|
|
660
|
+
batch.forEach((job, index) => settle(job, results[index] ?? "unknown", failed));
|
|
661
|
+
const first = failed[0];
|
|
662
|
+
if (first) {
|
|
663
|
+
// The last try failed: a reset hint for each partition. The redo tries once more; a second failure is for good.
|
|
664
|
+
const names = [...new Set(failed.map(({ job }) => job.item.partition))].map((partition) => (partition === undefined ? "(none)" : JSON.stringify(partition)));
|
|
665
|
+
log("warn", `the adoption of ${failed.length} raw writes of ${source.collection} failed (${messageOf(first.error)}): the partitions ${names.join(", ")} get a reset hint`);
|
|
666
|
+
for (const { job } of failed) if (job.kind !== "redo" && !remember(job)) sweepWanted = true;
|
|
667
|
+
resetPartitions(failed.map(({ job }) => job));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (queue.length === 0) full = false;
|
|
671
|
+
} finally {
|
|
672
|
+
adopting = false;
|
|
673
|
+
}
|
|
674
|
+
if (stopped) for (const job of queue.splice(0)) job.done?.();
|
|
675
|
+
// The queue is empty: the sweep, then the redo, can start
|
|
676
|
+
else afterQueue();
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const enqueue = (job: AdoptJob): void => {
|
|
680
|
+
if (stopped) return job.done?.();
|
|
681
|
+
if (queue.length >= queueLimit) {
|
|
682
|
+
if (!full) log("warn", `more than ${queueLimit} raw writes of ${source.collection} wait for an adoption: the next ones are adopted again when the queue is empty`);
|
|
683
|
+
full = true;
|
|
684
|
+
if (remember(job)) {
|
|
685
|
+
// The hint of a raw write of a change event goes out now, without a version. Its adoption gives the version.
|
|
686
|
+
if (job.kind === undefined) publishItem(job.item);
|
|
687
|
+
} else {
|
|
688
|
+
// The sweep finds the documents without _seq. A write of the sweep had no hint.
|
|
689
|
+
sweepWanted = true;
|
|
690
|
+
if (job.kind !== "sweep") resetPartitions([job]);
|
|
691
|
+
}
|
|
692
|
+
return job.done?.();
|
|
693
|
+
}
|
|
694
|
+
queue.push(job);
|
|
695
|
+
if (!adopting) void work();
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** The adoption of a raw write (D39), through the queue. */
|
|
699
|
+
const adopt = (item: StorageChange, change: Document, before: Document | undefined): void => {
|
|
700
|
+
const after = change["operationType"] === "delete" ? undefined : asDocument(change["fullDocument"]);
|
|
701
|
+
// A hard delete without the image before the change: the storage cannot write its tombstone
|
|
702
|
+
if (!after && !before) return publishItem(item);
|
|
703
|
+
const storedId = asDocument(change["documentKey"])?.["_id"];
|
|
704
|
+
enqueue({ item, raw: { storedId, seq: item.seq, after, before }, tries: 0 });
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/** The sweep: each document without `_seq` goes to the queue of the adoptions, one page at a time. */
|
|
708
|
+
const sweep = async (): Promise<void> => {
|
|
709
|
+
let last: { readonly id: unknown } | undefined;
|
|
710
|
+
let count = 0;
|
|
711
|
+
leftCount = 0;
|
|
712
|
+
try {
|
|
713
|
+
for (;;) {
|
|
714
|
+
if (stopped) return;
|
|
715
|
+
const page = await source.rawDocuments(last, sweepPage);
|
|
716
|
+
const end = page[page.length - 1];
|
|
717
|
+
if (!end) break;
|
|
718
|
+
last = { id: end["_id"] };
|
|
719
|
+
const waits: Array<Promise<void>> = [];
|
|
720
|
+
for (const doc of page) {
|
|
721
|
+
const key = idKey(doc["_id"]);
|
|
722
|
+
if (key !== undefined && unplaced.has(key)) continue;
|
|
723
|
+
let item: StorageChange | undefined;
|
|
724
|
+
try {
|
|
725
|
+
item = storageChangeOf({ operationType: "insert", fullDocument: doc, documentKey: { _id: doc["_id"] } }, source.form);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
leave(doc["_id"], messageOf(error));
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (!item) {
|
|
731
|
+
leave(doc["_id"], "no id");
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (source.partition !== undefined && item.partition !== source.partition) {
|
|
735
|
+
leave(doc["_id"], "another partition");
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!item.raw) continue;
|
|
739
|
+
count++;
|
|
740
|
+
const job: AdoptJob = { item, raw: { storedId: doc["_id"], seq: undefined, after: doc }, tries: 0, kind: "sweep" };
|
|
741
|
+
waits.push(new Promise<void>((resolve) => enqueue({ ...job, done: resolve })));
|
|
742
|
+
}
|
|
743
|
+
await Promise.all(waits);
|
|
744
|
+
if (page.length < sweepPage) break;
|
|
745
|
+
}
|
|
746
|
+
if (stopped) return;
|
|
747
|
+
if (count > 0) log("info", `the sweep of ${source.collection} sent ${count} documents without _seq to the adoption`);
|
|
748
|
+
if (leftCount > 0) log("warn", `the sweep of ${source.collection} cannot place ${leftCount} documents without _seq, for example ${leftFirst}: the next sweeps leave them`);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (!stopped) log("warn", `the sweep of the documents without _seq of ${source.collection} failed: ${messageOf(error)}`);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
/** The redo: each raw write that did not fit or that failed goes to the queue again, with its document now. */
|
|
755
|
+
const adoptAgain = async (): Promise<void> => {
|
|
756
|
+
const jobs = [...redo.values()];
|
|
757
|
+
redo.clear();
|
|
758
|
+
redoFull = false;
|
|
759
|
+
for (let start = 0; start < jobs.length; start += redoChunk) {
|
|
760
|
+
if (stopped) return;
|
|
761
|
+
const part = jobs.slice(start, start + redoChunk);
|
|
762
|
+
let docs: Document[];
|
|
763
|
+
try {
|
|
764
|
+
docs = await source.storedDocuments(part.map((job) => job.raw.storedId));
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (stopped) return;
|
|
767
|
+
log("warn", `the documents of ${part.length} raw writes of ${source.collection} to adopt again cannot be read (${messageOf(error)}): their partitions get a reset hint`);
|
|
768
|
+
resetPartitions(part);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const current = new Map<string | undefined, Document>(docs.map((doc) => [idKey(doc["_id"]), doc]));
|
|
772
|
+
const waits: Array<Promise<void>> = [];
|
|
773
|
+
for (const job of part) {
|
|
774
|
+
const doc = current.get(idKey(job.raw.storedId));
|
|
775
|
+
// A newer write (a write of the storage, or a delete) has its own event: no redo
|
|
776
|
+
let raw: RawStorageWrite | undefined;
|
|
777
|
+
if (!doc) raw = job.raw.after === undefined ? job.raw : undefined;
|
|
778
|
+
else raw = seqOf(doc) === job.raw.seq ? { ...job.raw, after: doc } : undefined;
|
|
779
|
+
if (!raw) continue;
|
|
780
|
+
const again: AdoptJob = { item: job.item, raw, tries: 0, kind: "redo" };
|
|
781
|
+
waits.push(new Promise<void>((resolve) => enqueue({ ...again, done: resolve })));
|
|
782
|
+
}
|
|
783
|
+
await Promise.all(waits);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
/** The work after an empty queue: the sweep when a stream is open, then the redo. */
|
|
788
|
+
const afterQueue = (): void => {
|
|
789
|
+
if (stopped || full || adopting || queue.length > 0 || sweeping || redoing) return;
|
|
790
|
+
if (sweepWanted && live) {
|
|
791
|
+
sweepWanted = false;
|
|
792
|
+
sweeping = true;
|
|
793
|
+
void sweep().finally(() => {
|
|
794
|
+
sweeping = false;
|
|
795
|
+
afterQueue();
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (redo.size === 0) return;
|
|
800
|
+
redoing = true;
|
|
801
|
+
void adoptAgain()
|
|
802
|
+
.catch((error: unknown) => log("warn", `the redo of the raw writes of ${source.collection} failed: ${messageOf(error)}`))
|
|
803
|
+
.finally(() => {
|
|
804
|
+
redoing = false;
|
|
805
|
+
afterQueue();
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const onChange = (change: Document): void => {
|
|
810
|
+
token = change["_id"];
|
|
811
|
+
attempt = 0;
|
|
812
|
+
const type = change["operationType"];
|
|
813
|
+
if (type === "drop" || type === "rename" || type === "dropDatabase") return gap(`the collection ${source.collection} had a ${String(type)}`);
|
|
814
|
+
const ns = asDocument(change["ns"]);
|
|
815
|
+
if (ns?.["coll"] === source.counters) return;
|
|
816
|
+
let item: StorageChange | undefined;
|
|
817
|
+
try {
|
|
818
|
+
item = storageChangeOf(change, source.form);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
log("warn", `a change of ${source.collection} has no item: ${messageOf(error)}`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!item) return;
|
|
824
|
+
if (source.partition !== undefined && item.partition !== source.partition) return;
|
|
825
|
+
if (!item.raw) {
|
|
826
|
+
// The event of a write of this process: the collection procedures publish it
|
|
827
|
+
if (skipOwn && own && item.seq !== undefined && own.take(item.seq)) return;
|
|
828
|
+
return publishItem(item);
|
|
829
|
+
}
|
|
830
|
+
const before = asDocument(change["fullDocumentBeforeChange"]);
|
|
831
|
+
// The delete of a tombstone that this process pruned: no item. With the image, storageChangeOf() tells it.
|
|
832
|
+
if (type === "delete" && !before && own?.takePruned(asDocument(change["documentKey"])?.["_id"])) return;
|
|
833
|
+
if (!adoptRaw) return publishItem(item);
|
|
834
|
+
adopt(item, change, before);
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const schedule = (reason: string): void => {
|
|
838
|
+
if (stopped) return;
|
|
839
|
+
const delay = delayOf(attempt++);
|
|
840
|
+
log("warn", `${reason}: the stream of ${source.collection} opens again in ${Math.round(delay)} ms`);
|
|
841
|
+
timer = clock.after(delay, () => {
|
|
842
|
+
timer = undefined;
|
|
843
|
+
void open();
|
|
844
|
+
});
|
|
845
|
+
timer.unref?.();
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
/** The end of the adoptions that wait: a stopped watcher adopts nothing. */
|
|
849
|
+
const endAdoptions = (): void => {
|
|
850
|
+
for (const [retry, job] of retries) {
|
|
851
|
+
retry();
|
|
852
|
+
job.done?.();
|
|
853
|
+
}
|
|
854
|
+
retries.clear();
|
|
855
|
+
for (const job of queue.splice(0)) job.done?.();
|
|
856
|
+
redo.clear();
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/** The end of a watcher whose first open failed with an error that does not go away. */
|
|
860
|
+
const fail = (error: unknown): void => {
|
|
861
|
+
log("error", `the stream of ${source.collection} cannot open (${messageOf(error)}): the watcher stops`);
|
|
862
|
+
stopped = true;
|
|
863
|
+
endAdoptions();
|
|
864
|
+
own?.close();
|
|
865
|
+
own = undefined;
|
|
866
|
+
failReady(error);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const open = async (): Promise<void> => {
|
|
870
|
+
if (stopped) return;
|
|
871
|
+
let db: Db;
|
|
872
|
+
try {
|
|
873
|
+
db = await source.db();
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (!opened && isPermanent(error)) return fail(error);
|
|
876
|
+
return schedule(`the database is not available (${messageOf(error)})`);
|
|
877
|
+
}
|
|
878
|
+
if (stopped) return;
|
|
879
|
+
if (!checked) {
|
|
880
|
+
// On a single server, the driver can wait without an error: the watcher asks the server first
|
|
881
|
+
let hello: Document | undefined;
|
|
882
|
+
try {
|
|
883
|
+
hello = await db.command({ hello: 1 });
|
|
884
|
+
} catch (error) {
|
|
885
|
+
// An old server without hello: the stream tells the error
|
|
886
|
+
if (codeOf(error) !== COMMAND_NOT_FOUND) {
|
|
887
|
+
if (!opened && isPermanent(error)) return fail(error);
|
|
888
|
+
return schedule(`the server does not answer hello (${messageOf(error)})`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (stopped) return;
|
|
892
|
+
if (hello && typeof hello["setName"] !== "string" && hello["msg"] !== "isdbgrid") {
|
|
893
|
+
return fail(Object.assign(new Error("change streams need a replica set or a sharded cluster, and the server is a single server"), { code: 40573 }));
|
|
894
|
+
}
|
|
895
|
+
checked = true;
|
|
896
|
+
}
|
|
897
|
+
own ??= trackOwnNumbers(db, source.collection, { numbers: skipOwn, clock });
|
|
898
|
+
if (token === undefined && opened) gap(`the stream of ${source.collection} opens again without a resume token`);
|
|
899
|
+
let next: ChangeStream;
|
|
900
|
+
try {
|
|
901
|
+
next = db.collection(source.collection).watch([], {
|
|
902
|
+
fullDocument: "updateLookup",
|
|
903
|
+
fullDocumentBeforeChange: "whenAvailable",
|
|
904
|
+
...(token !== undefined && { startAfter: token }),
|
|
905
|
+
});
|
|
906
|
+
} catch (error) {
|
|
907
|
+
if (!opened && isPermanent(error)) return fail(error);
|
|
908
|
+
return schedule(`the stream cannot open (${messageOf(error)})`);
|
|
909
|
+
}
|
|
910
|
+
stream = next;
|
|
911
|
+
let ended = false;
|
|
912
|
+
const end = (reason: string, error?: unknown): void => {
|
|
913
|
+
if (ended) return;
|
|
914
|
+
ended = true;
|
|
915
|
+
next.close().catch(() => undefined);
|
|
916
|
+
if (stream !== next || stopped) return;
|
|
917
|
+
stream = undefined;
|
|
918
|
+
live = false;
|
|
919
|
+
if (!opened && error !== undefined && isPermanent(error)) return fail(error);
|
|
920
|
+
const code = codeOf(error);
|
|
921
|
+
if (code !== undefined && LOST_TOKEN_CODES.has(code) && token !== undefined) {
|
|
922
|
+
// The next stream starts now, without the token: it publishes a gap
|
|
923
|
+
log("warn", `the server does not know the resume token of ${source.collection} (code ${code})`);
|
|
924
|
+
token = undefined;
|
|
925
|
+
}
|
|
926
|
+
schedule(`the stream ${reason}`);
|
|
927
|
+
};
|
|
928
|
+
next.on("resumeTokenChanged", (resumeToken: unknown) => {
|
|
929
|
+
if (resumeToken === undefined || resumeToken === null) return;
|
|
930
|
+
token = resumeToken;
|
|
931
|
+
opened = true;
|
|
932
|
+
attempt = 0;
|
|
933
|
+
markReady();
|
|
934
|
+
live = true;
|
|
935
|
+
afterQueue();
|
|
936
|
+
});
|
|
937
|
+
next.on("change", (change: Document) => {
|
|
938
|
+
opened = true;
|
|
939
|
+
markReady();
|
|
940
|
+
live = true;
|
|
941
|
+
afterQueue();
|
|
942
|
+
if (change["operationType"] === "invalidate") {
|
|
943
|
+
// The collection was dropped or renamed: the stream starts after the invalidate event
|
|
944
|
+
token = change["_id"];
|
|
945
|
+
return end("ended (invalidate)");
|
|
946
|
+
}
|
|
947
|
+
onChange(change);
|
|
948
|
+
});
|
|
949
|
+
next.on("error", (error: Error) => end(`failed: ${error.message}`, error));
|
|
950
|
+
next.on("close", () => end("closed"));
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
void open();
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
ready,
|
|
957
|
+
async stop(): Promise<void> {
|
|
958
|
+
if (stopped) return;
|
|
959
|
+
stopped = true;
|
|
960
|
+
timer?.();
|
|
961
|
+
timer = undefined;
|
|
962
|
+
endAdoptions();
|
|
963
|
+
own?.close();
|
|
964
|
+
own = undefined;
|
|
965
|
+
markReady();
|
|
966
|
+
const current = stream;
|
|
967
|
+
stream = undefined;
|
|
968
|
+
await current?.close().catch(() => undefined);
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|