@mark1russell7/harness 0.6.0 → 0.8.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.
@@ -0,0 +1,11 @@
1
+ # The harness's MongoDB client: the driver mongodb 7.7.0 on Node 24.21.0. It runs as a container
2
+ # in the client's network namespace, as the harness's own TCP client does, and prints the same JSON
3
+ # lines, with the driver's own events beside them. The same image sends a planned step-down.
4
+ # lab.ts stages the build context: this folder, under harness/image/mongo. The lockfile is
5
+ # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
6
+ FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
7
+ WORKDIR /opt/harness/mongo
8
+ COPY harness/image/mongo/package.json ./
9
+ COPY harness/image/mongo/npm-lock.json ./package-lock.json
10
+ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
11
+ COPY harness/image/mongo/client-mongo.mjs harness/image/mongo/stepdown-mongo.mjs ./
@@ -0,0 +1,273 @@
1
+ // client-mongo HOST PORT --mode idle|inflight|stream [--hosts H:P,H:P] [--set NAME] [--op write|read-long]
2
+ // [--interval-ms N] [--max-s N] [--go PATH] [--w majority|1] [--before] [--options JSON]
3
+ //
4
+ // The client of a MongoDB cell: the driver mongodb 7.7.0 on Node 24.21.0, against a replica set.
5
+ // Each event is a JSON line with `ev` and `t`, the time on CLOCK_MONOTONIC in nanoseconds
6
+ // (process.hrtime, which libuv reads from it). The prober stamps the records of the kernel on the
7
+ // same clock, and the injector stamps each fault on it.
8
+ //
9
+ // The lines:
10
+ // - start: the version of the driver and of Node, the mode and the options.
11
+ // - clocks: the wall clock in ms beside the monotonic clock, each second. The log of mongod is on
12
+ // the wall clock, and these lines put it on the monotonic clock.
13
+ // - socket, socket-end, socket-error: each socket of the driver by its role (monitor, rtt, pool),
14
+ // with its local address. The role comes from the stack of the call that makes the socket.
15
+ // - connected: the first socket of the pool. The TCP client of the harness names its own socket so.
16
+ // - sdam: each heartbeat, each change of the description of a server, and each change of the topology.
17
+ // - cmap: each event of a connection pool, and each clear with its interruption of connections in use.
18
+ // - command: each try of an operation, with its server. A retry is a second try with the same number.
19
+ // - connecting: the start of the selection of the first server.
20
+ // - ready: the reply to the first write once the monitor streams (idle, inflight), or the
21
+ // connection of the client (stream).
22
+ // - sent, ack, fail: each operation by its sequence number, with its server or its error and labels.
23
+ // - data, error: the end of the operation in flight, as the TCP client of the harness tells it.
24
+ // In idle mode, an error of the socket of the first pool connection is an error line too.
25
+ // - done, deadline: the end of the client.
26
+ //
27
+ // The modes: idle sends one write, then holds the connections until --max-s. inflight sends one
28
+ // write, waits for the file --go, sends one more operation into the fault, and holds the
29
+ // connections after its end. stream sends a write each --interval-ms after the reply before, until
30
+ // --max-s or SIGTERM. --before starts the client into a fault that holds already: its first
31
+ // selection meets it.
32
+
33
+ import { randomUUID } from "node:crypto";
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import net from "node:net";
36
+
37
+ const now = () => process.hrtime.bigint();
38
+ const emit = (ev, fields = {}, at = now()) => process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
39
+
40
+ const argv = process.argv.slice(2);
41
+ const flag = (name) => {
42
+ const at = argv.indexOf(name);
43
+ return at === -1 ? undefined : argv[at + 1];
44
+ };
45
+ const [host, port] = argv;
46
+ const mode = flag("--mode");
47
+ if (host === undefined || port === undefined || !["idle", "inflight", "stream"].includes(mode)) {
48
+ emit("fail", { why: "client-mongo HOST PORT --mode idle|inflight|stream" });
49
+ process.exit(1);
50
+ }
51
+ const first = `${host}:${port}`;
52
+ const hosts = [first, ...(flag("--hosts") ?? "").split(",").filter((each) => each !== "" && each !== first)];
53
+ const set = flag("--set") ?? "rs0";
54
+ const op = flag("--op") ?? "write";
55
+ const interval = Number(flag("--interval-ms") ?? 50);
56
+ const max = Number(flag("--max-s") ?? 120) * 1000;
57
+ const go = flag("--go") ?? "/tmp/go";
58
+ const w = flag("--w") === "1" ? 1 : "majority";
59
+ const before = argv.includes("--before");
60
+ const options = JSON.parse(flag("--options") ?? "{}");
61
+
62
+ // ---- the two clocks: the wall clock of a log line of mongod goes onto the monotonic clock by them
63
+ const clocks = () => {
64
+ const t = now();
65
+ emit("clocks", { real: Date.now() }, t);
66
+ };
67
+ clocks();
68
+ setInterval(clocks, 1000).unref();
69
+
70
+ // ---- each socket of the driver, by its role: the stack of the call that makes it names the caller
71
+ const roleOf = (stack) => {
72
+ if (/measureRoundTripTime/.test(stack)) return "rtt";
73
+ if (/sdam[\\/]monitor\.js/.test(stack)) return "monitor";
74
+ if (/cmap[\\/]connection_pool\.js/.test(stack)) return "pool";
75
+ return "other";
76
+ };
77
+ let connected = null;
78
+ const createConnection = net.createConnection;
79
+ Error.stackTraceLimit = 50;
80
+ net.createConnection = function (...args) {
81
+ const role = roleOf(new Error().stack ?? "");
82
+ const socket = createConnection.apply(this, args);
83
+ const server = `${args[0]?.host}:${args[0]?.port}`;
84
+ socket.once("connect", () => {
85
+ const local = `${socket.localAddress}:${socket.localPort}`;
86
+ emit("socket", { role, server, local });
87
+ if (role === "pool" && connected === null) {
88
+ connected = local;
89
+ emit("connected", { local });
90
+ }
91
+ socket.once("close", (hadError) => emit("socket-end", { role, server, local, error: hadError }));
92
+ });
93
+ socket.once("error", (error) => {
94
+ const local = socket.localPort === undefined ? null : `${socket.localAddress}:${socket.localPort}`;
95
+ const errno = error.code ?? error.errno ?? null;
96
+ emit("socket-error", { role, server, local, errno, message: error.message });
97
+ // the socket of the client in idle mode: its error is the detection, as for the TCP client
98
+ if (mode === "idle" && local !== null && local === connected) emit("error", { errno });
99
+ });
100
+ return socket;
101
+ };
102
+
103
+ // the driver loads after the patch, thus each of its sockets goes through it
104
+ const { MongoClient } = await import("mongodb");
105
+ const version = JSON.parse(readFileSync(new URL("./node_modules/mongodb/package.json", import.meta.url), "utf8")).version;
106
+
107
+ const bootId = () => {
108
+ try {
109
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
110
+ } catch {
111
+ return null;
112
+ }
113
+ };
114
+ emit("start", { boot_id: bootId(), client: "mongodb", driver: version, node: process.versions.node, mode, op, w, hosts, options });
115
+
116
+ const uri = `mongodb://${hosts.join(",")}/?replicaSet=${set}`;
117
+ const client = new MongoClient(uri, { ...options, writeConcern: { w }, monitorCommands: true });
118
+
119
+ // ---- the driver's own events, each stamped as the driver emits it
120
+ const failure = (error) =>
121
+ error === undefined || error === null
122
+ ? null
123
+ : { name: error.name, code: error.code ?? null, labels: [...(error.errorLabelSet ?? [])], message: error.message };
124
+ const describe = (d) =>
125
+ d === undefined ? null : { type: d.type, setName: d.setName ?? null, electionId: d.electionId?.toHexString?.() ?? null, error: failure(d.error) };
126
+ /** This promise resolves at the first awaited hello of a monitor: the stream of the monitor has begun. */
127
+ let streamed = () => undefined;
128
+ const streaming = new Promise((resolve) => {
129
+ streamed = resolve;
130
+ });
131
+ client.on("serverHeartbeatStarted", (e) => {
132
+ emit("sdam", { event: "heartbeat-started", address: e.connectionId, awaited: e.awaited });
133
+ if (e.awaited) streamed();
134
+ });
135
+ client.on("serverHeartbeatSucceeded", (e) =>
136
+ emit("sdam", { event: "heartbeat-succeeded", address: e.connectionId, awaited: e.awaited, duration: e.duration, primary: e.reply?.isWritablePrimary ?? null }),
137
+ );
138
+ client.on("serverHeartbeatFailed", (e) =>
139
+ emit("sdam", { event: "heartbeat-failed", address: e.connectionId, awaited: e.awaited, duration: e.duration, failure: failure(e.failure) }),
140
+ );
141
+ client.on("serverDescriptionChanged", (e) =>
142
+ emit("sdam", { event: "server-changed", address: e.address, previous: describe(e.previousDescription), next: describe(e.newDescription) }),
143
+ );
144
+ client.on("topologyDescriptionChanged", (e) => {
145
+ const primary = [...e.newDescription.servers.values()].find((d) => d.type === "RSPrimary")?.address ?? null;
146
+ emit("sdam", { event: "topology-changed", type: e.newDescription.type, primary });
147
+ });
148
+ client.on("serverOpening", (e) => emit("sdam", { event: "server-opening", address: e.address }));
149
+ client.on("serverClosed", (e) => emit("sdam", { event: "server-closed", address: e.address }));
150
+ for (const [event, name] of [
151
+ ["connectionPoolCreated", "pool-created"],
152
+ ["connectionPoolReady", "pool-ready"],
153
+ ["connectionPoolClosed", "pool-closed"],
154
+ ["connectionCreated", "created"],
155
+ ["connectionReady", "connection-ready"],
156
+ ["connectionCheckOutStarted", "checkout-started"],
157
+ ["connectionCheckedOut", "checked-out"],
158
+ ["connectionCheckedIn", "checked-in"],
159
+ ]) {
160
+ client.on(event, (e) => emit("cmap", { event: name, address: e.address, connectionId: e.connectionId ?? null }));
161
+ }
162
+ client.on("connectionPoolCleared", (e) => emit("cmap", { event: "pool-cleared", address: e.address, interruptInUseConnections: e.interruptInUseConnections ?? false }));
163
+ client.on("connectionClosed", (e) => emit("cmap", { event: "closed", address: e.address, connectionId: e.connectionId, reason: e.reason, failure: failure(e.error) }));
164
+ client.on("connectionCheckOutFailed", (e) => emit("cmap", { event: "checkout-failed", address: e.address, reason: e.reason, failure: failure(e.error) }));
165
+
166
+ /** This function gives the sequence number of an operation from its comment. */
167
+ const numberOf = (command) => {
168
+ const match = /^[wr]:(\d+)$/.exec(String(command?.comment ?? ""));
169
+ return match === null ? null : Number(match[1]);
170
+ };
171
+ const started = new Map();
172
+ /** This map holds the server of the last try of each operation that succeeded, by its sequence number. */
173
+ const lastServer = new Map();
174
+ client.on("commandStarted", (e) => {
175
+ const n = numberOf(e.command);
176
+ if (n === null) return;
177
+ started.set(e.requestId, n);
178
+ emit("command", { event: "started", name: e.commandName, n, address: e.address, connectionId: e.connectionId, requestId: e.requestId });
179
+ });
180
+ client.on("commandSucceeded", (e) => {
181
+ if (!started.has(e.requestId)) return;
182
+ const n = started.get(e.requestId);
183
+ started.delete(e.requestId);
184
+ lastServer.set(n, e.address);
185
+ emit("command", { event: "succeeded", name: e.commandName, n, address: e.address, requestId: e.requestId, duration: e.duration });
186
+ });
187
+ client.on("commandFailed", (e) => {
188
+ if (!started.has(e.requestId)) return;
189
+ const n = started.get(e.requestId);
190
+ started.delete(e.requestId);
191
+ emit("command", { event: "failed", name: e.commandName, n, address: e.address, requestId: e.requestId, duration: e.duration, failure: failure(e.failure) });
192
+ });
193
+
194
+ // ---- the end of the client
195
+ let stopped = false;
196
+ const stop = (code = 0) => {
197
+ if (stopped) return;
198
+ stopped = true;
199
+ emit("done");
200
+ client.close().finally(() => setTimeout(() => process.exit(code), 50));
201
+ };
202
+ process.on("SIGTERM", () => stop(0));
203
+ setTimeout(() => {
204
+ emit("deadline");
205
+ stop(0);
206
+ }, max);
207
+
208
+ const collection = client.db("cells").collection("w");
209
+ // the writes of one client: each run starts its numbers at 1, and the field `run` keeps them apart
210
+ const run = randomUUID();
211
+
212
+ /**
213
+ * This function sends operation n: a write of its sequence number, or the read of the long
214
+ * document. Its stamp is before the call. It gives null for a reply, or the error.
215
+ */
216
+ const operate = async (n) => {
217
+ const at = now();
218
+ const kind = op === "read-long" && mode === "inflight" && n > 1 ? "read" : "write";
219
+ const reply = kind === "read" ? collection.findOne({ _id: "long" }, { comment: `r:${n}` }) : collection.insertOne({ n, run }, { comment: `w:${n}` });
220
+ emit("sent", { n, kind }, at);
221
+ try {
222
+ const answer = await reply;
223
+ emit("ack", { n, kind, at: lastServer.get(n) ?? null, bytes: kind === "read" ? JSON.stringify(answer ?? {}).length : 0 });
224
+ return null;
225
+ } catch (error) {
226
+ emit("fail", { n, kind, ...failure(error) });
227
+ return error;
228
+ }
229
+ };
230
+
231
+ emit("connecting", { hosts });
232
+ try {
233
+ await client.connect();
234
+ } catch (error) {
235
+ emit("fail", { why: "connect", ...failure(error) });
236
+ emit("error", { errno: error.name, message: error.message });
237
+ stop(before ? 0 : 1);
238
+ }
239
+ if (stopped) await new Promise(() => undefined);
240
+
241
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
242
+
243
+ if (mode === "stream") {
244
+ emit("ready", { at: first });
245
+ for (let n = 1; !stopped; n += 1) {
246
+ await operate(n);
247
+ await sleep(interval);
248
+ }
249
+ } else {
250
+ // the first write makes the connection of the pool. Its reply is the last data before the fault
251
+ const failed = await operate(1);
252
+ if (failed !== null) {
253
+ emit("error", { errno: failed.name, message: failed.message });
254
+ stop(1);
255
+ } else {
256
+ // the monitor sends its first awaited hello minHeartbeatFrequencyMS after its first check. A
257
+ // fault before that hello meets the start of the stream, not the stream that a cell measures
258
+ const awaited = await Promise.race([streaming.then(() => true), sleep(5000).then(() => false)]);
259
+ emit("ready", { reply: "ack", streaming: awaited });
260
+ if (mode === "inflight") {
261
+ await new Promise((resolve) => {
262
+ const wait = setInterval(() => {
263
+ if (!existsSync(go)) return;
264
+ clearInterval(wait);
265
+ resolve();
266
+ }, 2);
267
+ });
268
+ const error = await operate(2);
269
+ if (error === null) emit("data", { n: 1 });
270
+ else emit("error", { errno: error.name, message: error.message });
271
+ }
272
+ }
273
+ }
@@ -0,0 +1,163 @@
1
+ {
2
+ "name": "loom-harness-mongo-client",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "loom-harness-mongo-client",
8
+ "dependencies": {
9
+ "mongodb": "7.7.0"
10
+ }
11
+ },
12
+ "node_modules/@mongodb-js/saslprep": {
13
+ "version": "1.6.1",
14
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz",
15
+ "integrity": "sha512-D4CQtk9IwjCLAvCf1xvHEod3960q8XMUzfmdRU9qjUNlHnesGuTOvJcOTaPm9koZ1caUKA2aTbszZiAkcmSgmg==",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "sparse-bitfield": "^3.0.3"
19
+ }
20
+ },
21
+ "node_modules/@types/webidl-conversions": {
22
+ "version": "7.0.3",
23
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
24
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
25
+ "license": "MIT"
26
+ },
27
+ "node_modules/@types/whatwg-url": {
28
+ "version": "13.0.0",
29
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
30
+ "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@types/webidl-conversions": "*"
34
+ }
35
+ },
36
+ "node_modules/bson": {
37
+ "version": "7.3.3",
38
+ "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz",
39
+ "integrity": "sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==",
40
+ "license": "Apache-2.0",
41
+ "engines": {
42
+ "node": ">=20.19.0"
43
+ }
44
+ },
45
+ "node_modules/memory-pager": {
46
+ "version": "1.5.0",
47
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
48
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
49
+ "license": "MIT"
50
+ },
51
+ "node_modules/mongodb": {
52
+ "version": "7.7.0",
53
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.7.0.tgz",
54
+ "integrity": "sha512-XG1xXEqyouAV8GCb3PuRaujofeXKpAJAPvXYWZgHl6Dz4QHSJ1nRLl0+GpqaLTwKvC0RFE9truOur34RNFY1nw==",
55
+ "license": "Apache-2.0",
56
+ "dependencies": {
57
+ "@mongodb-js/saslprep": "^1.5.0",
58
+ "bson": "^7.3.2",
59
+ "mongodb-connection-string-url": "^7.0.2"
60
+ },
61
+ "engines": {
62
+ "node": ">=20.19.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@aws-sdk/credential-providers": "^3.806.0",
66
+ "@mongodb-js/zstd": "^7.0.0",
67
+ "gcp-metadata": "^7.0.1",
68
+ "kerberos": "^7.0.0",
69
+ "mongodb-client-encryption": "^7.2.0",
70
+ "snappy": "^7.3.2",
71
+ "socks": "^2.8.6"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@aws-sdk/credential-providers": {
75
+ "optional": true
76
+ },
77
+ "@mongodb-js/zstd": {
78
+ "optional": true
79
+ },
80
+ "gcp-metadata": {
81
+ "optional": true
82
+ },
83
+ "kerberos": {
84
+ "optional": true
85
+ },
86
+ "mongodb-client-encryption": {
87
+ "optional": true
88
+ },
89
+ "snappy": {
90
+ "optional": true
91
+ },
92
+ "socks": {
93
+ "optional": true
94
+ }
95
+ }
96
+ },
97
+ "node_modules/mongodb-connection-string-url": {
98
+ "version": "7.0.2",
99
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz",
100
+ "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==",
101
+ "license": "Apache-2.0",
102
+ "dependencies": {
103
+ "@types/whatwg-url": "^13.0.0",
104
+ "whatwg-url": "^14.1.0"
105
+ },
106
+ "engines": {
107
+ "node": ">=20.19.0"
108
+ }
109
+ },
110
+ "node_modules/punycode": {
111
+ "version": "2.3.1",
112
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
113
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
114
+ "license": "MIT",
115
+ "engines": {
116
+ "node": ">=6"
117
+ }
118
+ },
119
+ "node_modules/sparse-bitfield": {
120
+ "version": "3.0.3",
121
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
122
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
123
+ "license": "MIT",
124
+ "dependencies": {
125
+ "memory-pager": "^1.0.2"
126
+ }
127
+ },
128
+ "node_modules/tr46": {
129
+ "version": "5.1.1",
130
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
131
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
132
+ "license": "MIT",
133
+ "dependencies": {
134
+ "punycode": "^2.3.1"
135
+ },
136
+ "engines": {
137
+ "node": ">=18"
138
+ }
139
+ },
140
+ "node_modules/webidl-conversions": {
141
+ "version": "7.0.0",
142
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
143
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
144
+ "license": "BSD-2-Clause",
145
+ "engines": {
146
+ "node": ">=12"
147
+ }
148
+ },
149
+ "node_modules/whatwg-url": {
150
+ "version": "14.2.0",
151
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
152
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
153
+ "license": "MIT",
154
+ "dependencies": {
155
+ "tr46": "^5.1.0",
156
+ "webidl-conversions": "^7.0.0"
157
+ },
158
+ "engines": {
159
+ "node": ">=18"
160
+ }
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "loom-harness-mongo-client",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "mongodb": "7.7.0"
7
+ }
8
+ }
@@ -0,0 +1,37 @@
1
+ // stepdown-mongo HOST:PORT STEP_DOWN_SECS CATCH_UP_SECS
2
+ //
3
+ // A planned step-down of a replica set: replSetStepDown to its primary, as a client sends it. The
4
+ // script connects to the member and pings it first. Then it stamps t0 on CLOCK_MONOTONIC and sends
5
+ // the command. It stamps t1 on the next turn of the event loop after the driver starts the command,
6
+ // when the driver wrote it. Its last line is the stamps, as the injector prints them.
7
+
8
+ import { MongoClient } from "mongodb";
9
+
10
+ const now = () => Number(process.hrtime.bigint());
11
+ const [address, stepDownSecs = "60", catchUpSecs = "10"] = process.argv.slice(2);
12
+ const client = new MongoClient(`mongodb://${address}/?directConnection=true`, { serverSelectionTimeoutMS: 10000, monitorCommands: true });
13
+ let written = null;
14
+ const sent = new Promise((resolve) => {
15
+ client.on("commandStarted", (event) => {
16
+ if (event.commandName === "replSetStepDown") setImmediate(() => resolve(now()));
17
+ });
18
+ });
19
+ try {
20
+ await client.connect();
21
+ const admin = client.db("admin");
22
+ await admin.command({ ping: 1 });
23
+ const t0 = now();
24
+ const reply = admin.command({ replSetStepDown: Number(stepDownSecs), secondaryCatchUpPeriodSecs: Number(catchUpSecs) });
25
+ written = await sent;
26
+ let ok = false;
27
+ let error = null;
28
+ try {
29
+ ok = (await reply).ok === 1;
30
+ } catch (failure) {
31
+ // a primary that steps down closes its connections: the reply can be a network error
32
+ error = failure.message;
33
+ }
34
+ process.stdout.write(`${JSON.stringify({ action: "step-down", t0, t1: written, replied: now(), ok, error })}\n`);
35
+ } finally {
36
+ await client.close().catch(() => undefined);
37
+ }
@@ -0,0 +1,13 @@
1
+ # The procedures image of the harness: @procedures/client 0.3.0 and ws 8.22.0 on Node 24.21.0. Its WebSocket
2
+ # client runs as a container in the client's network namespace, as the harness's own TCP client
3
+ # does, and prints the same JSON lines (client-ws.mjs). A target runs the same image with the
4
+ # script of the procedures server (server-ws.mjs), thus the client and the server share one
5
+ # release of the package. The digest of the image and an npm lockfile pin them.
6
+ # lab.ts stages the build context: this folder, under harness/image/procedures. The lockfile is
7
+ # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
8
+ FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
9
+ WORKDIR /opt/harness/procedures
10
+ COPY harness/image/procedures/package.json ./
11
+ COPY harness/image/procedures/npm-lock.json ./package-lock.json
12
+ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
13
+ COPY harness/image/procedures/lines.mjs harness/image/procedures/client-ws.mjs harness/image/procedures/server-ws.mjs ./