@mark1russell7/harness 0.14.2 → 0.14.4
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/README.md +42 -5
- package/build/{cell-BccWKiXh.d.ts → cell-DZm8zXN4.d.ts} +354 -10
- package/build/{image-Lg8LL6C3.js → image-EZ8ioZ3L.js} +161 -11
- package/build/image-EZ8ioZ3L.js.map +1 -0
- package/build/index.d.ts +742 -107
- package/build/index.js +2403 -164
- package/build/index.js.map +1 -1
- package/build/schema.d.ts +1 -1
- package/build/schema.js +1 -1
- package/image/Dockerfile +1 -1
- package/image/amqp/client-cluster.mjs +68 -31
- package/image/hikari/ClientHikari.java +81 -6
- package/image/kafka/AdminKafka.java +117 -0
- package/image/kafka/ClientKafka.java +234 -0
- package/image/kafka/Dockerfile +23 -0
- package/image/kafka/jars.sha256 +5 -0
- package/image/mongo/client-mongo.mjs +5 -3
- package/image/native/src/bin/inject.rs +40 -13
- package/image/native/src/bin/ports.rs +374 -0
- package/image/native/src/bin/queue.rs +553 -0
- package/image/node/client-sentinel.mjs +28 -12
- package/image/pg/client-pg.mjs +31 -4
- package/package.json +5 -1
- package/build/image-Lg8LL6C3.js.map +0 -1
package/build/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { A as unlockedManifests, C as programOf, D as subjectProblems, E as subjectClient, F as jarVersions, I as locksOf, L as npmVersions, M as LOCK_NAMES, N as cargoVersions, O as targetImageOf, P as goVersions, R as versionsOf, S as planOf, T as shippedSource, _ as kafkaClientImage, a as SHIPPED_NAMES, b as nodeHttpImage, c as buildRecordOf, d as harnessImage, f as harnessVersion, g as inContext, h as imageProblems, i as SHIPPED_CLIENTS, j as unpinnedBases, k as targetSourceOf, l as goHttpImage, m as imageOf, n as PINNED, o as amqpClientImage, p as hikariClientImage, r as REPOSITORY, s as basesOf, t as CLIENT_NAMES, u as grpcClientImage, v as mongoClientImage, w as routerImage, x as pgClientImage, y as nodeClientImage } from "./image-EZ8ioZ3L.js";
|
|
2
2
|
import { quantity, valueIn } from "@mark1russell7/quantity";
|
|
3
3
|
import { median, medianInterval as medianInterval$1, quantileSorted } from "@mark1russell7/rigor-statistics";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { release, tmpdir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
10
|
//#region src/cell.ts
|
|
9
11
|
/** This function tells if a fault place is a hop of a topology. */
|
|
10
12
|
const isHopPlace = (place) => typeof place === "object" && "hop" in place;
|
|
@@ -286,6 +288,10 @@ const groupVerdictOf = (prediction, counted, delta = NOISE) => {
|
|
|
286
288
|
const errors = /* @__PURE__ */ new Map();
|
|
287
289
|
for (const run of counted) for (const error of run.group?.errors ?? []) errors.set(error.name, [...errors.get(error.name) ?? [], valueIn(error.first, "s")]);
|
|
288
290
|
const lags = counted.flatMap((run) => run.group?.lags ?? []);
|
|
291
|
+
const copyCounts = counted.flatMap((run) => {
|
|
292
|
+
const copies = run.group?.writes.duplicates;
|
|
293
|
+
return copies === null || copies === void 0 ? [] : [copies.length];
|
|
294
|
+
});
|
|
289
295
|
return {
|
|
290
296
|
stages,
|
|
291
297
|
medians,
|
|
@@ -308,7 +314,13 @@ const groupVerdictOf = (prediction, counted, delta = NOISE) => {
|
|
|
308
314
|
runs: firsts.length,
|
|
309
315
|
first: s(median(firsts.sort((a, b) => a - b)))
|
|
310
316
|
})),
|
|
311
|
-
...lags.length === 0 ? {} : { lags: spreadOf(lags) }
|
|
317
|
+
...lags.length === 0 ? {} : { lags: spreadOf(lags) },
|
|
318
|
+
...prediction.group?.duplicates === void 0 ? {} : { duplicates: {
|
|
319
|
+
runs: copyCounts.length,
|
|
320
|
+
total: copyCounts.reduce((sum, n) => sum + n, 0),
|
|
321
|
+
median: medianCount(copyCounts),
|
|
322
|
+
max: copyCounts.length === 0 ? null : Math.max(...copyCounts)
|
|
323
|
+
} }
|
|
312
324
|
};
|
|
313
325
|
};
|
|
314
326
|
/** This function gives the verdict of the band of a cell: its containment and the equivalence of its median. */
|
|
@@ -333,6 +345,16 @@ const verdictOfBand = (prediction, runs, delta = NOISE) => {
|
|
|
333
345
|
why: "no run counted"
|
|
334
346
|
};
|
|
335
347
|
if (prediction.detects === null) {
|
|
348
|
+
if (prediction.queue !== void 0 || prediction.ports !== void 0) {
|
|
349
|
+
const off = new Set(outside.map((each) => each.run)).size;
|
|
350
|
+
return {
|
|
351
|
+
...empty,
|
|
352
|
+
decision: off === 0 ? "holds" : "fails",
|
|
353
|
+
band: null,
|
|
354
|
+
outside,
|
|
355
|
+
why: `${counted.length - off} of ${counted.length} runs hold the law of the ${prediction.queue === void 0 ? "port range" : "queue"}; ${prediction.why}`
|
|
356
|
+
};
|
|
357
|
+
}
|
|
336
358
|
const crossed = counted.flatMap((run) => run.crossings === void 0 ? [] : [run.crossings]);
|
|
337
359
|
if (crossed.length > 0) {
|
|
338
360
|
const hop = crossed[0].hop;
|
|
@@ -361,16 +383,30 @@ const verdictOfBand = (prediction, runs, delta = NOISE) => {
|
|
|
361
383
|
}
|
|
362
384
|
const outcomes = [...kinds].map(([kind, n]) => `${n} ${kind}`).join(", ");
|
|
363
385
|
const timed = times.length === 0 ? "no time" : `median ${s(median(times)).value} s from ${prediction.from}, range [${s(times[0]).value}, ${s(times.at(-1)).value}] s`;
|
|
364
|
-
const counts = prediction
|
|
386
|
+
const { counts } = prediction;
|
|
365
387
|
if (counts !== void 0) {
|
|
388
|
+
const exact = counts.map((each) => `${each.what} ${each.count}`).join(", ");
|
|
389
|
+
return {
|
|
390
|
+
...empty,
|
|
391
|
+
decision: outside.length === 0 ? "holds" : "fails",
|
|
392
|
+
band: null,
|
|
393
|
+
outside,
|
|
394
|
+
median: times.length === 0 ? null : s(median(times)),
|
|
395
|
+
why: `${counted.length - outside.length} of ${counted.length} runs gave the exact counts (${exact}); ${outcomes}; ${timed}. ${prediction.why}`
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const lost = prediction.group?.lost;
|
|
399
|
+
const copies = prediction.group?.duplicates;
|
|
400
|
+
if (lost !== void 0 || copies !== void 0) {
|
|
366
401
|
const off = new Set(outside.map((each) => each.run)).size;
|
|
402
|
+
const bands = [...lost === void 0 ? [] : [`lost writes [${lost.low}, ${lost.high}]`], ...copies === void 0 ? [] : [`extra copies [${copies.low}, ${copies.high}]`]];
|
|
367
403
|
return {
|
|
368
404
|
...empty,
|
|
369
405
|
decision: off === 0 ? "holds" : "fails",
|
|
370
406
|
band: null,
|
|
371
407
|
outside,
|
|
372
408
|
median: times.length === 0 ? null : s(median(times)),
|
|
373
|
-
why: `${counted.length - off} of ${counted.length} runs inside the band of
|
|
409
|
+
why: `${counted.length - off} of ${counted.length} runs inside the band of ${bands.join(" and the band of ")}; ${outcomes}; ${timed}. ${prediction.why}`
|
|
374
410
|
};
|
|
375
411
|
}
|
|
376
412
|
const missed = outside.filter((each) => counted.some((run) => name(run) === each.run && run.reconnect !== void 0));
|
|
@@ -404,6 +440,7 @@ const verdictOfBand = (prediction, runs, delta = NOISE) => {
|
|
|
404
440
|
band: null,
|
|
405
441
|
outside: [],
|
|
406
442
|
median: samples.length === 0 ? null : s(median(samples)),
|
|
443
|
+
...samples.length === 0 ? {} : { slowest: s(samples.at(-1)) },
|
|
407
444
|
...reordering === void 0 ? {} : { reordering },
|
|
408
445
|
why
|
|
409
446
|
};
|
|
@@ -526,7 +563,7 @@ const DROP_HANDLING = quantity(1.5, "ms");
|
|
|
526
563
|
/** This function gives the window of the act of a drop, in seconds: `DROP_HANDLING`, or the pairing window (`delta`) when it is longer. */
|
|
527
564
|
const dropWindow = (delta) => Math.max(valueIn(DROP_HANDLING, "s"), valueIn(delta, "s"));
|
|
528
565
|
/** This function gives the interval between two instants on the one clock, in seconds to the microsecond. */
|
|
529
|
-
const between$
|
|
566
|
+
const between$4 = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
|
|
530
567
|
/** This function gives the state of a state record after the change, and before it. TCP_ESTABLISHED is 1, TCP_SYN_SENT 2. */
|
|
531
568
|
const newState$2 = (record) => record.new;
|
|
532
569
|
const oldState$2 = (record) => record.old;
|
|
@@ -568,7 +605,7 @@ const eventOf = (socket, line, matches, delta = NOISE) => {
|
|
|
568
605
|
*/
|
|
569
606
|
const leftAfter = (socket, line) => {
|
|
570
607
|
const left = socket.find((record) => record.ev === "state" && oldState$2(record) === 1 && (newState$2(record) === 4 || newState$2(record) === 7) && record.mono >= line.t);
|
|
571
|
-
return left === void 0 ? null : between$
|
|
608
|
+
return left === void 0 ? null : between$4(line.t, left.mono);
|
|
572
609
|
};
|
|
573
610
|
/**
|
|
574
611
|
* This function gives the end of the client process in a run, as the kernel stamps it. The process
|
|
@@ -660,19 +697,19 @@ const reconnectOf = (run, detected, cookie, from = detected) => {
|
|
|
660
697
|
const waits = starts.flatMap((start, at) => {
|
|
661
698
|
const next = starts[at + 1]?.t ?? Number.POSITIVE_INFINITY;
|
|
662
699
|
const dial = dials.find((each) => each.t >= start.t && each.t < next);
|
|
663
|
-
return dial === void 0 ? [] : [between$
|
|
700
|
+
return dial === void 0 ? [] : [between$4(start.t, dial.t)];
|
|
664
701
|
});
|
|
665
702
|
const fresh = run.kernel.filter((record) => record.netns === run.netns && record.cookie !== cookie && record.ev === "state" && record.mono > detected);
|
|
666
703
|
const syns = fresh.filter((record) => newState$2(record) === 2);
|
|
667
704
|
const established = fresh.find((record) => newState$2(record) === 1);
|
|
668
705
|
const connects = dials.flatMap((dial) => {
|
|
669
706
|
const syn = syns.find((record) => record.mono >= dial.t);
|
|
670
|
-
return syn === void 0 ? [] : [between$
|
|
707
|
+
return syn === void 0 ? [] : [between$4(dial.t, syn.mono)];
|
|
671
708
|
});
|
|
672
709
|
return {
|
|
673
710
|
waits,
|
|
674
|
-
syn: syns[0] === void 0 ? null : between$
|
|
675
|
-
established: established === void 0 ? null : between$
|
|
711
|
+
syn: syns[0] === void 0 ? null : between$4(detected, syns[0].mono),
|
|
712
|
+
established: established === void 0 ? null : between$4(detected, established.mono),
|
|
676
713
|
connects
|
|
677
714
|
};
|
|
678
715
|
};
|
|
@@ -699,7 +736,7 @@ const callOf = (events, request, detected) => {
|
|
|
699
736
|
};
|
|
700
737
|
return {
|
|
701
738
|
outcome: end.ev === "data" ? "a reply" : String(end["message"] ?? end["code"] ?? "an error"),
|
|
702
|
-
after: detected === void 0 ? null : between$
|
|
739
|
+
after: detected === void 0 ? null : between$4(detected, end.t)
|
|
703
740
|
};
|
|
704
741
|
};
|
|
705
742
|
//#endregion
|
|
@@ -714,12 +751,22 @@ const ERRNO$1 = {
|
|
|
714
751
|
111: "ECONNREFUSED",
|
|
715
752
|
113: "EHOSTUNREACH"
|
|
716
753
|
};
|
|
717
|
-
const between$
|
|
754
|
+
const between$3 = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
|
|
718
755
|
const round = (seconds) => Math.round(seconds * 1e6) / 1e6;
|
|
719
756
|
/** This function gives the value of a field of a line by its name. A dotted name is a field in a field. */
|
|
720
757
|
const fieldOf = (line, path) => path.split(".").reduce((value, key) => value !== null && typeof value === "object" ? value[key] : void 0, line);
|
|
721
758
|
/** This function tells if a line of the client matches a line match: its `ev`, and each field that the match names. */
|
|
722
759
|
const matches = (line, match) => line.ev === match.ev && Object.entries(match.where ?? {}).every(([path, value]) => fieldOf(line, path) === value);
|
|
760
|
+
/** This function gives the count of the lines that match. */
|
|
761
|
+
const countOf = (lines, match) => lines.filter((line) => matches(line, match)).length;
|
|
762
|
+
/** This function gives the finding of the line counts of a run, or null when each count is the exact one. */
|
|
763
|
+
const countFinding = (counts, lines) => {
|
|
764
|
+
const off = counts.flatMap((each) => {
|
|
765
|
+
const seen = countOf(lines, each.match);
|
|
766
|
+
return seen === each.count ? [] : [`${each.what}: ${seen} lines, and the law gives ${each.count}`];
|
|
767
|
+
});
|
|
768
|
+
return off.length === 0 ? null : off.join("; ");
|
|
769
|
+
};
|
|
723
770
|
/** This function says a line match in words, for a finding. */
|
|
724
771
|
const wordsOf = (match) => {
|
|
725
772
|
const where = Object.entries(match.where ?? {}).map(([path, value]) => `${path} ${String(value)}`);
|
|
@@ -870,7 +917,7 @@ const ipOf$1 = (address) => address.slice(0, address.lastIndexOf(":"));
|
|
|
870
917
|
/** This function gives the first line of the client that matches, after an instant. */
|
|
871
918
|
const lineAfter = (events, match, after) => events.find((event) => event.t > after && matches(event, match));
|
|
872
919
|
/** This function gives the name of the error of a line: the field that the match names, or `name`. */
|
|
873
|
-
const nameOf = (line, match) => String(fieldOf(line, match.name ?? "name") ?? line.ev);
|
|
920
|
+
const nameOf$1 = (line, match) => String(fieldOf(line, match.name ?? "name") ?? line.ev);
|
|
874
921
|
/**
|
|
875
922
|
* This function gives the report of the kernel on a socket after an instant: an error, or the end of
|
|
876
923
|
* the file (CLOSE_WAIT). With neither, a close of the socket in SYN_SENT is the end. The program gave
|
|
@@ -1022,7 +1069,7 @@ const measureDriver = (cell, run) => {
|
|
|
1022
1069
|
const until = prediction.detects ? Infinity : after + valueIn(prediction.window, "s") * 1e9;
|
|
1023
1070
|
for (const match of driver.quiet ?? []) {
|
|
1024
1071
|
const line = events.find((event) => event.t > after && event.t <= until && matches(event, match));
|
|
1025
|
-
if (line !== void 0) findings.push(`${wordsOf(match)} came ${between$
|
|
1072
|
+
if (line !== void 0) findings.push(`${wordsOf(match)} came ${between$3(after, line.t).value} s after the fault, where none should`);
|
|
1026
1073
|
}
|
|
1027
1074
|
const expected = prediction.detects ? void 0 : prediction.middlebox?.forgets;
|
|
1028
1075
|
if (!prediction.detects && (run.middlebox !== void 0 || expected !== void 0)) {
|
|
@@ -1032,14 +1079,14 @@ const measureDriver = (cell, run) => {
|
|
|
1032
1079
|
for (const socket of socketsOf(events)) {
|
|
1033
1080
|
const forgot = middlebox.find((line) => line.ev === "ct" && line["type"] === "DESTROY" && String(line["orig"] ?? "").startsWith(`${socket.local}>`) && line.t > socket.opened && (socket.ended === null || line.t < socket.ended) && line.t <= until);
|
|
1034
1081
|
if (expected === void 0 || socket.local !== own) {
|
|
1035
|
-
if (forgot !== void 0) findings.push(`a router forgot the flow of the ${socket.role} socket ${between$
|
|
1082
|
+
if (forgot !== void 0) findings.push(`a router forgot the flow of the ${socket.role} socket ${between$3(after, forgot.t).value} s after the fault, where it should keep it`);
|
|
1036
1083
|
continue;
|
|
1037
1084
|
}
|
|
1038
1085
|
const last = referenceOf(run, "last-received", driver.socket, false);
|
|
1039
1086
|
if (forgot === void 0) findings.push(`no router forgot the flow of the ${socket.role} socket in the window, where it should forget it`);
|
|
1040
1087
|
else if (last === void 0) findings.push(`the ${socket.role} socket got no segment before the fault`);
|
|
1041
1088
|
else {
|
|
1042
|
-
const at = between$
|
|
1089
|
+
const at = between$3(last, forgot.t).value;
|
|
1043
1090
|
const [low, high] = [round(valueIn(expected.low, "s") - noise), round(valueIn(expected.high, "s") + noise)];
|
|
1044
1091
|
if (at < low || at > high) findings.push(`a router forgot the flow of the ${socket.role} socket ${at} s after its last segment, outside [${low}, ${high}] s`);
|
|
1045
1092
|
}
|
|
@@ -1052,12 +1099,12 @@ const measureDriver = (cell, run) => {
|
|
|
1052
1099
|
findings.push(line === void 0 ? `${step.what}: ${wordsOf(step.ends)} never came` : `${step.what}: its reference (${step.from}) never came`);
|
|
1053
1100
|
return {
|
|
1054
1101
|
what: step.what,
|
|
1055
|
-
name: line === void 0 ? null : nameOf(line, step.ends),
|
|
1102
|
+
name: line === void 0 ? null : nameOf$1(line, step.ends),
|
|
1056
1103
|
elapsed: null
|
|
1057
1104
|
};
|
|
1058
1105
|
}
|
|
1059
|
-
const elapsed = between$
|
|
1060
|
-
const name = nameOf(line, step.ends);
|
|
1106
|
+
const elapsed = between$3(from, line.t);
|
|
1107
|
+
const name = nameOf$1(line, step.ends);
|
|
1061
1108
|
if (step.name !== "OK" && name !== step.name) findings.push(`${step.what}: it ended with ${name}, not ${step.name}`);
|
|
1062
1109
|
const [low, high] = [round(valueIn(step.low, "s") - noise), round(valueIn(step.high, "s") + noise)];
|
|
1063
1110
|
if (elapsed.value < low || elapsed.value > high) findings.push(`${step.what}: ${elapsed.value} s is outside [${low}, ${high}] s`);
|
|
@@ -1090,7 +1137,7 @@ const measureDriver = (cell, run) => {
|
|
|
1090
1137
|
...nothing,
|
|
1091
1138
|
...stepped,
|
|
1092
1139
|
outcome: { kind: "answered" },
|
|
1093
|
-
elapsed: between$
|
|
1140
|
+
elapsed: between$3(from, line.t),
|
|
1094
1141
|
void: voided,
|
|
1095
1142
|
finding: findings[0] ?? null
|
|
1096
1143
|
};
|
|
@@ -1107,7 +1154,7 @@ const measureDriver = (cell, run) => {
|
|
|
1107
1154
|
const ended = line?.t ?? report?.at;
|
|
1108
1155
|
const outcome = line !== void 0 && driver.ends !== void 0 ? {
|
|
1109
1156
|
kind: "error",
|
|
1110
|
-
errno: nameOf(line, driver.ends)
|
|
1157
|
+
errno: nameOf$1(line, driver.ends)
|
|
1111
1158
|
} : report?.outcome ?? { kind: "silent" };
|
|
1112
1159
|
if (ended === void 0) return {
|
|
1113
1160
|
...base,
|
|
@@ -1133,15 +1180,431 @@ const measureDriver = (cell, run) => {
|
|
|
1133
1180
|
...base,
|
|
1134
1181
|
...stepped,
|
|
1135
1182
|
outcome,
|
|
1136
|
-
elapsed: between$
|
|
1137
|
-
socket: seen === void 0 ? null : between$
|
|
1138
|
-
noticed: report === void 0 || seen === void 0 ? null : between$
|
|
1139
|
-
steps: (prediction.from === "last-received" ? records.filter((record) => record.ev === "probe" && record.what === "keepalive" && record.mono > reference) : []).map((record) => between$
|
|
1183
|
+
elapsed: between$3(reference, ended),
|
|
1184
|
+
socket: seen === void 0 ? null : between$3(reference, seen.t),
|
|
1185
|
+
noticed: report === void 0 || seen === void 0 ? null : between$3(report.at, seen.t),
|
|
1186
|
+
steps: (prediction.from === "last-received" ? records.filter((record) => record.ev === "probe" && record.what === "keepalive" && record.mono > reference) : []).map((record) => between$3(reference, record.mono)),
|
|
1140
1187
|
void: voided,
|
|
1141
1188
|
finding: findings[0] ?? null
|
|
1142
1189
|
};
|
|
1143
1190
|
};
|
|
1144
1191
|
//#endregion
|
|
1192
|
+
//#region src/queue.ts
|
|
1193
|
+
/** This bit is the SYN of the flags of an `rx` record of the prober. */
|
|
1194
|
+
const SYN = 2;
|
|
1195
|
+
/** This function gives the time between two stamps of CLOCK_MONOTONIC. */
|
|
1196
|
+
const between$2 = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
|
|
1197
|
+
const numberOf$1 = (line, field) => {
|
|
1198
|
+
const value = line[field];
|
|
1199
|
+
return typeof value === "number" ? value : 0;
|
|
1200
|
+
};
|
|
1201
|
+
/** This function says if a time is inside a band. */
|
|
1202
|
+
const inside = (time, band) => {
|
|
1203
|
+
const seconds = valueIn(time, "s");
|
|
1204
|
+
return seconds >= valueIn(band.low, "s") && seconds <= valueIn(band.high, "s");
|
|
1205
|
+
};
|
|
1206
|
+
/** This function gives the words of a band. */
|
|
1207
|
+
const bandWords = (band) => `[${valueIn(band.low, "s")}, ${valueIn(band.high, "s")}] s`;
|
|
1208
|
+
const waitsOf = (from, stamps) => stamps.map((stamp, at) => between$2(at === 0 ? from : stamps[at - 1], stamp));
|
|
1209
|
+
/** This function finds the records of the sockets of the client that had this local address. */
|
|
1210
|
+
const socketsAt = (run, local) => {
|
|
1211
|
+
const cookies = new Set(run.kernel.filter((record) => record.netns === run.netns && record.src === local).map((record) => record.cookie));
|
|
1212
|
+
return run.kernel.filter((record) => record.netns === run.netns && cookies.has(record.cookie));
|
|
1213
|
+
};
|
|
1214
|
+
const readConnection = (name, run, expected) => {
|
|
1215
|
+
const lineOf = (ev) => run.events.find((event) => event.ev === ev && event["name"] === name);
|
|
1216
|
+
const dial = lineOf("dial");
|
|
1217
|
+
const connected = lineOf("connected");
|
|
1218
|
+
const failed = lineOf("failed");
|
|
1219
|
+
const state = lineOf("state");
|
|
1220
|
+
const socket = dial === void 0 ? [] : socketsAt(run, dial["local"]);
|
|
1221
|
+
const sent = socket.filter((record) => record.ev === "retrans").map((record) => record.mono);
|
|
1222
|
+
const rx = socket.filter((record) => record.ev === "rx" && ((record.seg?.flags ?? 0) & SYN) !== 0);
|
|
1223
|
+
const from = dial?.t ?? 0;
|
|
1224
|
+
const start = expected?.ends === null || expected?.ends === void 0 ? void 0 : lineOf(expected.ends.from);
|
|
1225
|
+
const last = sent.at(-1);
|
|
1226
|
+
return {
|
|
1227
|
+
name,
|
|
1228
|
+
connected: connected !== void 0,
|
|
1229
|
+
syns: waitsOf(from, connected === void 0 ? sent : sent.filter((stamp) => stamp < connected.t)),
|
|
1230
|
+
synAcks: waitsOf(from, rx.map((record) => record.mono)),
|
|
1231
|
+
retransmissions: sent.length,
|
|
1232
|
+
errno: failed === void 0 ? null : String(failed["errno"]),
|
|
1233
|
+
ended: failed === void 0 || start === void 0 ? null : between$2(start.t, failed.t),
|
|
1234
|
+
afterLast: failed === void 0 || last === void 0 ? null : between$2(last, failed.t),
|
|
1235
|
+
unacked: state === void 0 ? null : numberOf$1(state, "unacked")
|
|
1236
|
+
};
|
|
1237
|
+
};
|
|
1238
|
+
/** This function reads one run against the band of each wait in a list, and gives the words of each miss. */
|
|
1239
|
+
const waitFindings = (what, seen, bands) => {
|
|
1240
|
+
if (seen.length !== bands.length) return [`${what}: ${seen.length} waits, not ${bands.length}`];
|
|
1241
|
+
return seen.flatMap((wait, at) => inside(wait, bands[at]) ? [] : [`${what}: wait ${at} of ${valueIn(wait, "s")} s, outside ${bandWords(bands[at])}`]);
|
|
1242
|
+
};
|
|
1243
|
+
const connectionFindings = (expected, read, expiredAt, stamps) => {
|
|
1244
|
+
const { name, ends } = expected;
|
|
1245
|
+
const found = [];
|
|
1246
|
+
if (read.connected !== expected.connects) found.push(`${name}: ${read.connected ? "connected" : "did not connect"}, against the prediction`);
|
|
1247
|
+
if (expected.syns !== void 0) found.push(...waitFindings(`${name} SYN`, read.syns, expected.syns));
|
|
1248
|
+
if (expected.synAcks !== void 0) found.push(...waitFindings(`${name} SYN-ACK`, read.synAcks, expected.synAcks));
|
|
1249
|
+
if (expected.unacked !== void 0 && read.unacked !== expected.unacked) found.push(`${name}: ${read.unacked ?? "no"} segments without an ACK, not ${expected.unacked}`);
|
|
1250
|
+
if (ends === null) {
|
|
1251
|
+
if (read.errno !== null) found.push(`${name}: ended with ${read.errno}, and the prediction has no end`);
|
|
1252
|
+
return found;
|
|
1253
|
+
}
|
|
1254
|
+
if (read.errno !== ends.errno) found.push(`${name}: ended with ${read.errno ?? "nothing"}, not ${ends.errno}`);
|
|
1255
|
+
if (read.ended === null) return [...found, `${name}: no time for its end`];
|
|
1256
|
+
if (!inside(read.ended, ends.within)) found.push(`${name}: its end came at ${valueIn(read.ended, "s")} s, outside ${bandWords(ends.within)}`);
|
|
1257
|
+
if (ends.retransmitted === void 0) return found;
|
|
1258
|
+
if (read.afterLast === null || !inside(read.afterLast, ends.retransmitted)) found.push(`${name}: its end came ${read.afterLast === null ? "with no retransmission" : `${valueIn(read.afterLast, "s")} s after its last retransmission`}, outside ${bandWords(ends.retransmitted)}`);
|
|
1259
|
+
if (expiredAt === null) return found;
|
|
1260
|
+
const before = stamps.at(-2);
|
|
1261
|
+
const last = stamps.at(-1);
|
|
1262
|
+
if (last === void 0 || last < expiredAt || before !== void 0 && before > expiredAt) found.push(`${name}: its last two retransmissions do not span the expiry`);
|
|
1263
|
+
return found;
|
|
1264
|
+
};
|
|
1265
|
+
/**
|
|
1266
|
+
* This function reads one run of a queue cell. The client lines give the connections and the
|
|
1267
|
+
* counters at the two ends of the run. The target lines give the count of the queue. The records of
|
|
1268
|
+
* the prober give the waits of each socket.
|
|
1269
|
+
*/
|
|
1270
|
+
const measureQueue = (run, expected) => {
|
|
1271
|
+
const { events, kernel } = run;
|
|
1272
|
+
const lineOf = (ev) => events.find((event) => event.ev === ev);
|
|
1273
|
+
const base = {
|
|
1274
|
+
environment: run.environment,
|
|
1275
|
+
index: run.index,
|
|
1276
|
+
outcome: { kind: "answered" },
|
|
1277
|
+
elapsed: null,
|
|
1278
|
+
socket: null,
|
|
1279
|
+
noticed: null,
|
|
1280
|
+
steps: [],
|
|
1281
|
+
probes: 0,
|
|
1282
|
+
retransmissions: kernel.filter((record) => record.netns === run.netns && record.ev === "retrans").length,
|
|
1283
|
+
acks: 0
|
|
1284
|
+
};
|
|
1285
|
+
const voided = (why) => ({
|
|
1286
|
+
...base,
|
|
1287
|
+
void: why,
|
|
1288
|
+
finding: null
|
|
1289
|
+
});
|
|
1290
|
+
const opened = lineOf("opened");
|
|
1291
|
+
const stats = lineOf("stats");
|
|
1292
|
+
if (opened === void 0) return voided("the target did not open its listener");
|
|
1293
|
+
if (stats === void 0 || lineOf("done") === void 0) return voided("the client did not reach its end");
|
|
1294
|
+
if (run.lost === null) return voided("the prober wrote no clock line after the run, so nothing says what it dropped");
|
|
1295
|
+
if (run.lost > 0) return voided(`the prober dropped ${run.lost} records`);
|
|
1296
|
+
const lines = (run.server ?? []).filter((line) => line.ev === "queue" && line.t >= opened.t);
|
|
1297
|
+
if (lines.length === 0) return voided("the target wrote no queue line in the run");
|
|
1298
|
+
const named = [...new Set(events.filter((event) => event.ev === "dial").map((event) => String(event["name"])))];
|
|
1299
|
+
const predicted = new Map(expected.connections.map((each) => [each.name, each]));
|
|
1300
|
+
const connections = named.map((name) => readConnection(name, run, predicted.get(name)));
|
|
1301
|
+
const admitted = expected.admitted === void 0 ? void 0 : connections.find((each) => each.name === expected.admitted);
|
|
1302
|
+
if (expected.admitted !== void 0 && (admitted === void 0 || !admitted.connected || admitted.unacked !== 0)) return voided(`${expected.admitted} did not take the last place in the queue: the burst did not land in its sequence`);
|
|
1303
|
+
const change = (field) => numberOf$1(stats, field) - numberOf$1(opened, field);
|
|
1304
|
+
const burst = events.find((event) => event.ev === "dial" && event["name"] === "in")?.t ?? opened.t;
|
|
1305
|
+
const counted = expected.expiry?.timeouts;
|
|
1306
|
+
const expired = counted === void 0 ? void 0 : lines.find((line) => numberOf$1(line, "timeouts") - numberOf$1(opened, "timeouts") >= counted);
|
|
1307
|
+
const accepted = events.find((event) => event.ev === "accepted");
|
|
1308
|
+
const rise = expected.rise?.count ?? 0;
|
|
1309
|
+
const low = accepted === void 0 ? -1 : lines.findIndex((line) => line.t >= accepted.t && numberOf$1(line, "count") < rise);
|
|
1310
|
+
const risen = low < 0 ? void 0 : lines.slice(low).find((line) => numberOf$1(line, "count") >= rise);
|
|
1311
|
+
const drained = events.filter((event) => event.ev === "accepted").reduce((sum, event) => sum + (Array.isArray(event["peers"]) ? event["peers"].length : 0), 0);
|
|
1312
|
+
const queue = {
|
|
1313
|
+
max: numberOf$1(opened, "max"),
|
|
1314
|
+
held: Math.max(...lines.map((line) => numberOf$1(line, "count"))),
|
|
1315
|
+
connected: connections.filter((each) => each.connected).length,
|
|
1316
|
+
overflows: change("overflows"),
|
|
1317
|
+
drops: change("drops"),
|
|
1318
|
+
cookies: change("cookies"),
|
|
1319
|
+
timeouts: change("timeouts"),
|
|
1320
|
+
resets: change("resets"),
|
|
1321
|
+
drained,
|
|
1322
|
+
expiry: expired === void 0 ? null : between$2(burst, expired.t),
|
|
1323
|
+
rise: risen === void 0 ? null : between$2(burst, risen.t),
|
|
1324
|
+
connections
|
|
1325
|
+
};
|
|
1326
|
+
const found = [];
|
|
1327
|
+
if (queue.max !== expected.max) found.push(`the limit is ${queue.max}, not ${expected.max}`);
|
|
1328
|
+
if (queue.held !== expected.held) found.push(`the queue held ${queue.held}, not ${expected.held}`);
|
|
1329
|
+
if (queue.connected !== expected.connected) found.push(`${queue.connected} connected, not ${expected.connected}`);
|
|
1330
|
+
if (queue.overflows < expected.overflows.low || queue.overflows > expected.overflows.high) found.push(`${queue.overflows} overflows, outside [${expected.overflows.low}, ${expected.overflows.high}]`);
|
|
1331
|
+
if (queue.resets !== expected.resets) found.push(`${queue.resets} resets, not ${expected.resets}`);
|
|
1332
|
+
if (queue.cookies !== 0) found.push(`${queue.cookies} SYN cookies, and the law has none`);
|
|
1333
|
+
if (expected.drained !== void 0 && queue.drained !== expected.drained) found.push(`${queue.drained} accepted, not ${expected.drained}`);
|
|
1334
|
+
if (expected.expiry !== void 0 && (queue.expiry === null || !inside(queue.expiry, expected.expiry.within))) found.push(`the expiry came at ${queue.expiry === null ? "no time" : `${valueIn(queue.expiry, "s")} s`}, outside ${bandWords(expected.expiry.within)}`);
|
|
1335
|
+
if (expected.rise !== void 0 && (queue.rise === null || !inside(queue.rise, expected.rise.within))) found.push(`the count got back to ${expected.rise.count} at ${queue.rise === null ? "no time" : `${valueIn(queue.rise, "s")} s`}, outside ${bandWords(expected.rise.within)}`);
|
|
1336
|
+
for (const each of expected.connections) {
|
|
1337
|
+
const read = connections.find((connection) => connection.name === each.name);
|
|
1338
|
+
if (read === void 0) {
|
|
1339
|
+
found.push(`${each.name}: the client did not dial it`);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
const dial = events.find((event) => event.ev === "dial" && event["name"] === each.name);
|
|
1343
|
+
const stamps = socketsAt(run, dial?.["local"]).filter((record) => record.ev === "retrans").map((record) => record.mono);
|
|
1344
|
+
found.push(...connectionFindings(each, read, expired?.t ?? null, stamps));
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
...base,
|
|
1348
|
+
void: null,
|
|
1349
|
+
finding: found.length === 0 ? null : found.join(". "),
|
|
1350
|
+
queue
|
|
1351
|
+
};
|
|
1352
|
+
};
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/ports.ts
|
|
1355
|
+
/** This errno ends a search that found no port. */
|
|
1356
|
+
const NO_PORT = "EADDRNOTAVAIL";
|
|
1357
|
+
const portsOf = (line) => Array.isArray(line["ports"]) ? line["ports"].filter((port) => typeof port === "number") : [];
|
|
1358
|
+
/** This function says if the ports of the parity of the low port come first in one search. */
|
|
1359
|
+
const lowFirst = (ports, low) => {
|
|
1360
|
+
const odd = ports.findIndex((port) => (port - low) % 2 !== 0);
|
|
1361
|
+
return odd < 0 || ports.slice(odd).every((port) => (port - low) % 2 !== 0);
|
|
1362
|
+
};
|
|
1363
|
+
/**
|
|
1364
|
+
* This function reads one run of a port cell. The `round` lines give each search. The `closing` and
|
|
1365
|
+
* `freed` lines give when each held port comes back. The records of the prober give the
|
|
1366
|
+
* retransmissions.
|
|
1367
|
+
*/
|
|
1368
|
+
const measurePorts = (run, expected) => {
|
|
1369
|
+
const { events, kernel } = run;
|
|
1370
|
+
const retransmissions = kernel.filter((record) => record.netns === run.netns && record.ev === "retrans").length;
|
|
1371
|
+
const base = {
|
|
1372
|
+
environment: run.environment,
|
|
1373
|
+
index: run.index,
|
|
1374
|
+
outcome: { kind: "answered" },
|
|
1375
|
+
elapsed: null,
|
|
1376
|
+
socket: null,
|
|
1377
|
+
noticed: null,
|
|
1378
|
+
steps: [],
|
|
1379
|
+
probes: 0,
|
|
1380
|
+
retransmissions,
|
|
1381
|
+
acks: 0
|
|
1382
|
+
};
|
|
1383
|
+
const voided = (why) => ({
|
|
1384
|
+
...base,
|
|
1385
|
+
void: why,
|
|
1386
|
+
finding: null
|
|
1387
|
+
});
|
|
1388
|
+
if (events.find((event) => event.ev === "done") === void 0) return voided("the client did not reach its end");
|
|
1389
|
+
if (run.lost === null) return voided("the prober wrote no clock line after the run, so nothing says what it dropped");
|
|
1390
|
+
if (run.lost > 0) return voided(`the prober dropped ${run.lost} records`);
|
|
1391
|
+
const rounds = events.filter((event) => event.ev === "round");
|
|
1392
|
+
const closing = events.find((event) => event.ev === "closing");
|
|
1393
|
+
const freedLines = events.filter((event) => event.ev === "freed");
|
|
1394
|
+
const counts = {};
|
|
1395
|
+
for (const round of rounds) {
|
|
1396
|
+
const count = String(portsOf(round).length);
|
|
1397
|
+
counts[count] = (counts[count] ?? 0) + 1;
|
|
1398
|
+
}
|
|
1399
|
+
const seen = [...new Set(rounds.flatMap((round) => portsOf(round).map((port) => port - expected.low)))].sort((a, b) => a - b);
|
|
1400
|
+
const refusal = Math.max(0, ...rounds.map((round) => typeof round["refusal"] === "number" ? round["refusal"] : 0));
|
|
1401
|
+
const first = freedLines[0];
|
|
1402
|
+
const last = freedLines.at(-1);
|
|
1403
|
+
const ports = {
|
|
1404
|
+
rounds: rounds.length,
|
|
1405
|
+
counts,
|
|
1406
|
+
seen,
|
|
1407
|
+
errnos: [...new Set(rounds.map((round) => String(round["errno"])))],
|
|
1408
|
+
refusal: quantity(Math.round(refusal / 1e3) / 1e6, "s"),
|
|
1409
|
+
firstFreed: closing === void 0 || first === void 0 ? null : between$2(closing.t, first.t),
|
|
1410
|
+
lastFreed: closing === void 0 || last === void 0 ? null : between$2(closing.t, last.t),
|
|
1411
|
+
freed: freedLines.length,
|
|
1412
|
+
tries: last === void 0 || typeof last["tries"] !== "number" ? 0 : last["tries"]
|
|
1413
|
+
};
|
|
1414
|
+
const found = [];
|
|
1415
|
+
const searches = expected.rounds * expected.destinations;
|
|
1416
|
+
if (ports.rounds !== searches) found.push(`${ports.rounds} searches, not ${searches}`);
|
|
1417
|
+
const stray = Object.keys(counts).filter((count) => !expected.counts.includes(Number(count)));
|
|
1418
|
+
if (stray.length > 0) found.push(`a search gave ${stray.join(" or ")} connects, outside ${expected.counts.join(" or ")}`);
|
|
1419
|
+
if (seen.join() !== expected.seen.join()) found.push(`the ports at offsets ${seen.join(" ")} were used, not ${expected.seen.join(" ")}`);
|
|
1420
|
+
if (ports.errnos.join() !== NO_PORT) found.push(`the searches ended with ${ports.errnos.join(" and ")}, not ${NO_PORT}`);
|
|
1421
|
+
if (valueIn(ports.refusal, "s") > valueIn(expected.refusedWithin, "s")) found.push(`a refused connect took ${valueIn(ports.refusal, "s")} s, above ${valueIn(expected.refusedWithin, "s")} s`);
|
|
1422
|
+
if (expected.share !== void 0) {
|
|
1423
|
+
const { count, low, high } = expected.share;
|
|
1424
|
+
const share = (counts[String(count)] ?? 0) / Math.max(1, ports.rounds);
|
|
1425
|
+
if (share < low || share > high) found.push(`the share of ${count} connects is ${share}, outside [${low}, ${high}]`);
|
|
1426
|
+
}
|
|
1427
|
+
if (expected.lowFirst === true && !rounds.every((round) => lowFirst(portsOf(round), expected.low))) found.push("a search got a port of the other parity before each port of the parity of the low port");
|
|
1428
|
+
if (expected.retransmissions !== void 0 && retransmissions !== expected.retransmissions) found.push(`${retransmissions} retransmissions, not ${expected.retransmissions}`);
|
|
1429
|
+
if (expected.freed !== void 0) {
|
|
1430
|
+
const held = Math.max(...expected.counts);
|
|
1431
|
+
if (ports.freed !== held) found.push(`${ports.freed} ports came back, not ${held}`);
|
|
1432
|
+
for (const [name, time] of [["first", ports.firstFreed], ["last", ports.lastFreed]]) if (time === null || !inside(time, expected.freed)) found.push(`the ${name} port came back at ${time === null ? "no time" : `${valueIn(time, "s")} s`}, outside ${bandWords(expected.freed)}`);
|
|
1433
|
+
if (ports.tries === 0) found.push("no connect was refused before the ports came back");
|
|
1434
|
+
}
|
|
1435
|
+
return {
|
|
1436
|
+
...base,
|
|
1437
|
+
void: null,
|
|
1438
|
+
finding: found.length === 0 ? null : found.join(". "),
|
|
1439
|
+
ports
|
|
1440
|
+
};
|
|
1441
|
+
};
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/recovery.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* This module holds the pure parts of a recovery drill. These are the scripts of the shell, the reader
|
|
1446
|
+
* of the output of `bsondump`, and the set check of a recovery. It has no container, thus unit tests
|
|
1447
|
+
* run it. `drill.ts` does the drill on a lab.
|
|
1448
|
+
*
|
|
1449
|
+
* A dump with `--oplog` reads the oplog from the start of the dump to its end. A slice reads the
|
|
1450
|
+
* oplog after the last entry of the backup before it. Thus the backups together hold each write up
|
|
1451
|
+
* to the last entry of the last backup, and no write after it. One writer sends the writes one at a
|
|
1452
|
+
* time, thus the highest sequence number is also the latest write.
|
|
1453
|
+
*/
|
|
1454
|
+
/** This size is one document of generated data. A tenant holds as many documents as its size needs. */
|
|
1455
|
+
const SEED_DOCUMENT = 65536;
|
|
1456
|
+
/** This count is how far the lost writes can be from the writes after the last backup: the writes in flight at the fault. */
|
|
1457
|
+
const LOSS_MARGIN = 2;
|
|
1458
|
+
/** This time is how long a script waits for a server to stop, in tenths of a second. */
|
|
1459
|
+
const STOP_TENTHS = 600;
|
|
1460
|
+
/** This function gives the time of a drill from the start of the backups to the fault, in seconds. */
|
|
1461
|
+
const drillSpan = (drill) => drill.slices * valueIn(drill.sliceEvery, "s") + valueIn(drill.age, "s");
|
|
1462
|
+
/**
|
|
1463
|
+
* This script writes the generated data of each tenant into the collection `tenants`. Each write
|
|
1464
|
+
* waits for `copies` members, thus each member holds the data when the script ends.
|
|
1465
|
+
*/
|
|
1466
|
+
const seedScript = (drill, copies) => {
|
|
1467
|
+
const documents = Math.ceil(valueIn(drill.perTenant, "B") / SEED_DOCUMENT);
|
|
1468
|
+
const blob = `BinData(0, require("crypto").randomBytes(${SEED_DOCUMENT}).toString("base64"))`;
|
|
1469
|
+
return `const c = db.getSiblingDB("cells"); c.tenants.drop({ writeConcern: { w: ${copies} } }); for (let t = 1; t <= ${drill.tenants}; t += 1) { const docs = []; for (let d = 1; d <= ${documents}; d += 1) docs.push({ _id: "t" + t + ".d" + d, tenant: t, blob: ${blob} }); c.tenants.insertMany(docs, { writeConcern: { w: ${copies}, wtimeout: 60000 } }); } print(JSON.stringify({ ok: true }))`;
|
|
1470
|
+
};
|
|
1471
|
+
/** This script prints the hash of the collection `tenants`. It takes locks, thus a run reads it with no writer. */
|
|
1472
|
+
const HASH_SCRIPT = "print(JSON.stringify({ hash: db.getSiblingDB(\"cells\").runCommand({ dbHash: 1, collections: [\"tenants\"] }).collections.tenants }))";
|
|
1473
|
+
/** This function gives the hash from the output of `HASH_SCRIPT`. */
|
|
1474
|
+
const hashOf = (text) => {
|
|
1475
|
+
const line = text.split("\n").filter((each) => each.trim() !== "").at(-1) ?? "";
|
|
1476
|
+
const { hash } = JSON.parse(line);
|
|
1477
|
+
if (typeof hash !== "string" || hash === "") throw new Error(`no hash of the tenants in: ${line}`);
|
|
1478
|
+
return hash;
|
|
1479
|
+
};
|
|
1480
|
+
/** This function gives the command of a server with no set, on its own port. A client of the set cannot reach that server, and a restore writes to it. */
|
|
1481
|
+
const standaloneCommand = (set, port) => `${set.programs.server} --bind_ip_all --port ${port} ${(set.args ?? []).join(" ")}`.trim();
|
|
1482
|
+
/**
|
|
1483
|
+
* This script waits for the end of the server of a box, and it can stop the server first. `kill -0`
|
|
1484
|
+
* also succeeds on a zombie, and the shell of the box can leave its child as a zombie for a long
|
|
1485
|
+
* time. Thus the script reads the state of the process, and a zombie has ended.
|
|
1486
|
+
*/
|
|
1487
|
+
const stopScript = (terminate) => "pid=$(cat /tmp/server.pid); " + (terminate ? "kill -TERM \"$pid\"; " : "") + `for i in $(seq 1 ${STOP_TENTHS}); do s=$(cut -d' ' -f3 /proc/$pid/stat 2>/dev/null); if [ -z "\$s" ] || [ "\$s" = Z ]; then exit 0; fi; sleep 0.1; done; exit 1`;
|
|
1488
|
+
/** This function gives a number from Extended JSON, in its relaxed form or in its canonical form, or null. */
|
|
1489
|
+
const numberOf = (value) => {
|
|
1490
|
+
if (typeof value === "number") return value;
|
|
1491
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1492
|
+
const wrapped = value;
|
|
1493
|
+
const text = wrapped.$numberInt ?? wrapped.$numberLong ?? wrapped.$numberDouble;
|
|
1494
|
+
return typeof text === "string" && text !== "" && Number.isFinite(Number(text)) ? Number(text) : null;
|
|
1495
|
+
};
|
|
1496
|
+
/** This function gives each document of the output of `bsondump`. A line that is not a document does not count. */
|
|
1497
|
+
const documentsOf = (text) => text.split("\n").flatMap((line) => {
|
|
1498
|
+
if (!line.trim().startsWith("{")) return [];
|
|
1499
|
+
try {
|
|
1500
|
+
return [JSON.parse(line)];
|
|
1501
|
+
} catch {
|
|
1502
|
+
return [];
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
/** This function gives the `ts` of an oplog entry, or null. */
|
|
1506
|
+
const timeOf = (entry) => {
|
|
1507
|
+
const stamp = entry["ts"]?.$timestamp;
|
|
1508
|
+
const t = numberOf(stamp?.t);
|
|
1509
|
+
const i = numberOf(stamp?.i);
|
|
1510
|
+
return t === null || i === null ? null : {
|
|
1511
|
+
t,
|
|
1512
|
+
i
|
|
1513
|
+
};
|
|
1514
|
+
};
|
|
1515
|
+
/** This function reads an oplog file from the output of `bsondump`. Only an insert into `cells.w` is a write of a client. */
|
|
1516
|
+
const readOplog = (text) => {
|
|
1517
|
+
let last = null;
|
|
1518
|
+
let highest = null;
|
|
1519
|
+
for (const entry of documentsOf(text)) {
|
|
1520
|
+
const time = timeOf(entry);
|
|
1521
|
+
if (time !== null && (last === null || time.t > last.t || time.t === last.t && time.i > last.i)) last = time;
|
|
1522
|
+
if (entry["op"] !== "i" || entry["ns"] !== "cells.w") continue;
|
|
1523
|
+
const n = numberOf(entry["o"]?.n);
|
|
1524
|
+
if (n !== null && (highest === null || n > highest)) highest = n;
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
last,
|
|
1528
|
+
highest
|
|
1529
|
+
};
|
|
1530
|
+
};
|
|
1531
|
+
/** This function gives the highest sequence number of the file of the writes in a dump, from the output of `bsondump`, or null. */
|
|
1532
|
+
const highestHeld = (text) => {
|
|
1533
|
+
const held = documentsOf(text).flatMap((document) => {
|
|
1534
|
+
const n = numberOf(document["n"]);
|
|
1535
|
+
return n === null ? [] : [n];
|
|
1536
|
+
});
|
|
1537
|
+
return held.length === 0 ? null : Math.max(...held);
|
|
1538
|
+
};
|
|
1539
|
+
/** This function gives the query of a slice of the oplog: each entry after an oplog time, in Extended JSON. */
|
|
1540
|
+
const sliceQuery = (after) => JSON.stringify({ ts: { $gt: { $timestamp: {
|
|
1541
|
+
t: after.t,
|
|
1542
|
+
i: after.i
|
|
1543
|
+
} } } });
|
|
1544
|
+
const rounded = (value) => Math.round(value * 1e6) / 1e6;
|
|
1545
|
+
/** This function gives the acknowledgement time of each accepted write of the first client of a run. */
|
|
1546
|
+
const firstAcks = (events, second) => {
|
|
1547
|
+
const acks = /* @__PURE__ */ new Map();
|
|
1548
|
+
for (const event of events) {
|
|
1549
|
+
const n = Number(event["n"]);
|
|
1550
|
+
if (event.ev === "ack" && (second === null || n < second)) acks.set(n, event.t);
|
|
1551
|
+
}
|
|
1552
|
+
return acks;
|
|
1553
|
+
};
|
|
1554
|
+
/** This function gives the measurement of a drill, from its run. `t0` is the fault. */
|
|
1555
|
+
const recoveryOf = (drill, run, t0) => {
|
|
1556
|
+
const record = run.group?.drill;
|
|
1557
|
+
const sameData = record === void 0 || record.after === null ? null : record.after === record.before;
|
|
1558
|
+
if (drill.recovery === "election" || record === void 0) return {
|
|
1559
|
+
rpo: null,
|
|
1560
|
+
excess: null,
|
|
1561
|
+
holes: [],
|
|
1562
|
+
afterBackup: null,
|
|
1563
|
+
sameData
|
|
1564
|
+
};
|
|
1565
|
+
const acks = firstAcks(run.events, record.second);
|
|
1566
|
+
const accepted = [...acks.keys()].sort((a, b) => a - b);
|
|
1567
|
+
const cutoff = record.backups.at(-1)?.last?.n ?? 0;
|
|
1568
|
+
const afterBackup = accepted.filter((n) => n > cutoff).length;
|
|
1569
|
+
const holds = run.group?.holds ?? null;
|
|
1570
|
+
if (holds === null) return {
|
|
1571
|
+
rpo: null,
|
|
1572
|
+
excess: null,
|
|
1573
|
+
holes: [],
|
|
1574
|
+
afterBackup,
|
|
1575
|
+
sameData
|
|
1576
|
+
};
|
|
1577
|
+
const kept = new Set(holds);
|
|
1578
|
+
const ownKept = holds.filter((n) => record.second === null || n < record.second);
|
|
1579
|
+
const highest = ownKept.length === 0 ? 0 : Math.max(...ownKept);
|
|
1580
|
+
const holes = accepted.filter((n) => n < highest && !kept.has(n));
|
|
1581
|
+
const lastKept = accepted.filter((n) => kept.has(n)).at(-1);
|
|
1582
|
+
const rpo = lastKept === void 0 ? null : rounded((t0 - acks.get(lastKept)) / 1e9);
|
|
1583
|
+
return {
|
|
1584
|
+
rpo: rpo === null ? null : {
|
|
1585
|
+
value: rpo,
|
|
1586
|
+
unit: "s"
|
|
1587
|
+
},
|
|
1588
|
+
excess: rpo === null || drill.quiet === true ? null : {
|
|
1589
|
+
value: rounded(rpo - valueIn(drill.age, "s")),
|
|
1590
|
+
unit: "s"
|
|
1591
|
+
},
|
|
1592
|
+
holes,
|
|
1593
|
+
afterBackup,
|
|
1594
|
+
sameData
|
|
1595
|
+
};
|
|
1596
|
+
};
|
|
1597
|
+
/** This function gives the findings of a drill: each rule of a recovery that its run broke. */
|
|
1598
|
+
const recoveryFindings = (measured, writes) => {
|
|
1599
|
+
const findings = [];
|
|
1600
|
+
if (measured.sameData === null) findings.push("the set did not converge, thus nothing compared the data of the tenants");
|
|
1601
|
+
else if (!measured.sameData) findings.push("the data of the tenants changed in the recovery");
|
|
1602
|
+
if (measured.holes.length > 0) findings.push(`the restore lost ${measured.holes.length} accepted writes below a write that it kept, the first at ${measured.holes[0]}`);
|
|
1603
|
+
const lost = writes.lost?.length ?? null;
|
|
1604
|
+
if (measured.afterBackup !== null && lost !== null && Math.abs(lost - measured.afterBackup) > 2) findings.push(`the restore lost ${lost} accepted writes, and ${measured.afterBackup} came after the last backup`);
|
|
1605
|
+
return findings;
|
|
1606
|
+
};
|
|
1607
|
+
//#endregion
|
|
1145
1608
|
//#region src/timeline.ts
|
|
1146
1609
|
/**
|
|
1147
1610
|
* This module measures a run of a group cell. It gives the first write that a primary accepted
|
|
@@ -1348,12 +1811,32 @@ const writesOf = (run, after, primary) => {
|
|
|
1348
1811
|
held: kept === null ? null : kept.has(n)
|
|
1349
1812
|
};
|
|
1350
1813
|
});
|
|
1814
|
+
const duplicates = holds === null ? null : [...holds].sort((a, b) => a - b).filter((n, at, all) => all[at - 1] === n);
|
|
1815
|
+
const oldHolds = run.group?.oldHolds;
|
|
1351
1816
|
return {
|
|
1352
1817
|
sent: sent.length,
|
|
1353
1818
|
acked: acks.size,
|
|
1354
1819
|
old,
|
|
1355
1820
|
lost,
|
|
1356
|
-
unknown
|
|
1821
|
+
unknown,
|
|
1822
|
+
duplicates,
|
|
1823
|
+
...oldHolds === void 0 ? {} : { sets: setsOf(run, acks.keys(), unknown, new Set(oldHolds)) }
|
|
1824
|
+
};
|
|
1825
|
+
};
|
|
1826
|
+
/**
|
|
1827
|
+
* This function splits the writes of a pair by its two final reads. A write with no reply is `kept`
|
|
1828
|
+
* on the new primary. It is `stranded` on the old primary alone, or `absent` from each server.
|
|
1829
|
+
*/
|
|
1830
|
+
const setsOf = (run, acked, unknown, old) => {
|
|
1831
|
+
const kept = unknown.filter((each) => each.held === true).length;
|
|
1832
|
+
const stranded = unknown.filter((each) => each.held !== true && old.has(each.n)).length;
|
|
1833
|
+
return {
|
|
1834
|
+
unknown: unknown.length,
|
|
1835
|
+
kept,
|
|
1836
|
+
stranded,
|
|
1837
|
+
absent: unknown.length - kept - stranded,
|
|
1838
|
+
missing: [...acked].filter((n) => !old.has(n)).length,
|
|
1839
|
+
warnings: run.events.filter((event) => event.ev === "notice" && event["severity"] === "WARNING").length
|
|
1357
1840
|
};
|
|
1358
1841
|
};
|
|
1359
1842
|
/** This function gives the errors that the client met after an instant: each kind, its first time, and its count. */
|
|
@@ -1464,9 +1947,10 @@ const measureGroup = (cell, run) => {
|
|
|
1464
1947
|
const heal = run.heal ?? null;
|
|
1465
1948
|
const lands = expected?.lands ?? "new";
|
|
1466
1949
|
const cluster = cell.cluster !== void 0;
|
|
1467
|
-
const
|
|
1950
|
+
const kafka = cell.kafka !== void 0;
|
|
1951
|
+
const replica = cell.replicaSet !== void 0 || cluster || kafka;
|
|
1468
1952
|
const stall = prediction.detects === false && prediction.recovers !== void 0;
|
|
1469
|
-
const stages = cluster ? clusterStagesOf(run, t0, run.fault.t1) : replica ? replicaSetStagesOf(run, t0, run.fault.t1, lands) : stagesOf(timeline, t0);
|
|
1953
|
+
const stages = kafka ? {} : cluster ? clusterStagesOf(run, t0, run.fault.t1) : replica ? replicaSetStagesOf(run, t0, run.fault.t1, lands) : stagesOf(timeline, t0);
|
|
1470
1954
|
const after = lands === "same" && heal !== null && (!replica || stall) ? heal.t0 : t0;
|
|
1471
1955
|
const sentAt = /* @__PURE__ */ new Map();
|
|
1472
1956
|
for (const event of events) if (event.ev === "sent") sentAt.set(Number(event["n"]), event.t);
|
|
@@ -1487,7 +1971,8 @@ const measureGroup = (cell, run) => {
|
|
|
1487
1971
|
stages,
|
|
1488
1972
|
writes: writesOf(run, t0, cluster ? "" : primary),
|
|
1489
1973
|
errors: errorsOf(events, t0),
|
|
1490
|
-
...cell.calibrates === "sentinels" ? { lags: lagsOf(run, kernel) } : {}
|
|
1974
|
+
...cell.calibrates === "sentinels" ? { lags: lagsOf(run, kernel) } : {},
|
|
1975
|
+
...cell.drill === void 0 ? {} : { recovery: recoveryOf(cell.drill, run, t0) }
|
|
1491
1976
|
};
|
|
1492
1977
|
let voided = null;
|
|
1493
1978
|
const tilt = timeline.find((line) => line.ev === "sentinel" && line.event === "+tilt" && line.t >= t0);
|
|
@@ -1497,14 +1982,14 @@ const measureGroup = (cell, run) => {
|
|
|
1497
1982
|
if (voided === null && split !== void 0 && (elected === void 0 || elected.t > split.t)) voided = "the election split its votes and runs again after 2 × failover-timeout, which the band does not bound";
|
|
1498
1983
|
const odown = stages["+odown"];
|
|
1499
1984
|
if (voided === null && odown !== void 0 && stages["+try-failover"] === void 0) voided = "no Sentinel started a failover after +odown: each was in the hold-off of 2 × failover-timeout after an earlier attempt";
|
|
1500
|
-
if (voided === null && cell.hold !== void 0 && prediction.detects !== null) {
|
|
1985
|
+
if (voided === null && cell.hold !== void 0 && (prediction.detects !== null || kafka)) {
|
|
1501
1986
|
if (heal === null) voided = "the fault never lifted";
|
|
1502
1987
|
else {
|
|
1503
1988
|
const late = (heal.t0 - t0) / 1e9 - valueIn(cell.hold, "s");
|
|
1504
1989
|
if (late > valueIn(INJECT, "s")) voided = `the lift came ${Math.round(late * 1e4) / 10} ms after its hold: the injector was late, not the group`;
|
|
1505
1990
|
}
|
|
1506
1991
|
}
|
|
1507
|
-
const firstAddress = cluster ? String(events.find((event) => event.ev === "connected")?.["at"] ?? "") : `${run.group?.members[primary]?.ip}:${(cell.group ?? cell.replicaSet)?.port}`;
|
|
1992
|
+
const firstAddress = cluster ? String(events.find((event) => event.ev === "connected")?.["at"] ?? "") : `${run.group?.members[primary]?.ip}:${(cell.group ?? cell.replicaSet ?? cell.kafka)?.port}`;
|
|
1508
1993
|
const assumed = prediction.detects === null ? void 0 : prediction.rtt;
|
|
1509
1994
|
const first = kernel.find((record) => record.netns === run.netns && record.dst === firstAddress && record.tcp?.srtt !== void 0);
|
|
1510
1995
|
const rtt = first?.tcp?.srtt === void 0 ? null : valueIn(first.tcp.srtt, "us") / 1e3;
|
|
@@ -1524,11 +2009,13 @@ const measureGroup = (cell, run) => {
|
|
|
1524
2009
|
const findings = [];
|
|
1525
2010
|
const switched = replica ? stages["elected"] : stages["+switch-master"];
|
|
1526
2011
|
if (expected?.failover === false && switched !== void 0) findings.push(`the group failed over ${switched.value} s after the fault, where it should not`);
|
|
2012
|
+
const leaderAfter = run.group?.after ?? null;
|
|
2013
|
+
if (kafka && expected !== void 0 && leaderAfter !== null && expected.failover !== (leaderAfter !== primary)) findings.push(expected.failover ? `the leader stayed on ${primary}, where it should change` : `the leader moved to ${leaderAfter}, where it should stay on ${primary}`);
|
|
1527
2014
|
for (const stage of expected?.quiet ?? []) {
|
|
1528
2015
|
const at = stages[stage];
|
|
1529
2016
|
if (at !== void 0) findings.push(`${stage} came ${at.value} s after the fault, where it should not`);
|
|
1530
2017
|
}
|
|
1531
|
-
if (accepted !== void 0 && lands === "same" && !cluster && to !== primary) findings.push(`the client wrote to ${to ?? String(accepted["at"])}, where it should stay on ${primary}`);
|
|
2018
|
+
if (accepted !== void 0 && lands === "same" && !cluster && !kafka && to !== primary) findings.push(`the client wrote to ${to ?? String(accepted["at"])}, where it should stay on ${primary}`);
|
|
1532
2019
|
for (const error of expected?.errors ?? []) {
|
|
1533
2020
|
const met = group.errors.find((each) => each.name === error.name);
|
|
1534
2021
|
const noise = valueIn(noiseOf(cell), "s");
|
|
@@ -1541,8 +2028,22 @@ const measureGroup = (cell, run) => {
|
|
|
1541
2028
|
if (lost === null) findings.push("the group did not converge, thus the count of its lost writes is not known");
|
|
1542
2029
|
else if (lost.length < low || lost.length > high) findings.push(`the group lost ${lost.length} accepted writes, outside [${low}, ${high}]`);
|
|
1543
2030
|
}
|
|
2031
|
+
if (expected?.duplicates !== void 0) {
|
|
2032
|
+
const { low, high } = expected.duplicates;
|
|
2033
|
+
const copies = group.writes.duplicates ?? null;
|
|
2034
|
+
if (copies === null) findings.push("the group did not converge, thus the count of its extra copies is not known");
|
|
2035
|
+
else if (run.group?.holds?.length === 0) findings.push("the group holds no write, thus the count of its extra copies says nothing");
|
|
2036
|
+
else if (copies.length < low || copies.length > high) findings.push(`the group holds ${copies.length} extra copies, outside [${low}, ${high}]`);
|
|
2037
|
+
}
|
|
2038
|
+
const { sets } = group.writes;
|
|
2039
|
+
for (const [name, band] of Object.entries(expected?.outcomes ?? {})) {
|
|
2040
|
+
const count = sets?.[name];
|
|
2041
|
+
if (count === void 0) findings.push(`the run has no count of ${name}`);
|
|
2042
|
+
else if (count < band.low || count > band.high) findings.push(`the count of ${name} is ${count}, outside [${band.low}, ${band.high}]`);
|
|
2043
|
+
}
|
|
2044
|
+
if (group.recovery !== void 0) findings.push(...recoveryFindings(group.recovery, group.writes));
|
|
1544
2045
|
if (accepted === void 0) {
|
|
1545
|
-
if (prediction.detects !== null) findings.push(lands === "new" ? "the client never wrote to a new primary in the window" : "the client never wrote again in the window");
|
|
2046
|
+
if (prediction.detects !== null || kafka) findings.push(lands === "new" ? "the client never wrote to a new primary in the window" : "the client never wrote again in the window");
|
|
1546
2047
|
return {
|
|
1547
2048
|
...silent,
|
|
1548
2049
|
void: voided,
|
|
@@ -1811,7 +2312,7 @@ const requestOf = (events) => {
|
|
|
1811
2312
|
};
|
|
1812
2313
|
/** This function reads the socket of one run against its cell, as `measure` does without the second request of an HTTP client. */
|
|
1813
2314
|
const measureSocket = (cell, run) => {
|
|
1814
|
-
if (cell.group !== void 0 || cell.replicaSet !== void 0 || cell.cluster !== void 0) {
|
|
2315
|
+
if (cell.group !== void 0 || cell.replicaSet !== void 0 || cell.cluster !== void 0 || cell.pair !== void 0 || cell.kafka !== void 0) {
|
|
1815
2316
|
const read = measureGroup(cell, run);
|
|
1816
2317
|
return {
|
|
1817
2318
|
environment: run.environment,
|
|
@@ -1829,6 +2330,8 @@ const measureSocket = (cell, run) => {
|
|
|
1829
2330
|
group: read.group
|
|
1830
2331
|
};
|
|
1831
2332
|
}
|
|
2333
|
+
if (cell.prediction.detects === null && cell.prediction.queue !== void 0) return measureQueue(run, cell.prediction.queue);
|
|
2334
|
+
if (cell.prediction.detects === null && cell.prediction.ports !== void 0) return measurePorts(run, cell.prediction.ports);
|
|
1832
2335
|
if (cell.prediction.detects !== null && cell.prediction.driver !== void 0) return measureDriver(cell, run);
|
|
1833
2336
|
const { events, kernel } = run;
|
|
1834
2337
|
const ready = events.find((event) => event.ev === "ready");
|
|
@@ -1985,12 +2488,13 @@ const measureSocket = (cell, run) => {
|
|
|
1985
2488
|
}, run)) };
|
|
1986
2489
|
const reconnect = prediction.reconnects === void 0 || ended === void 0 ? void 0 : reconnectOf(run, ended, cookie, drop?.line.t ?? ended);
|
|
1987
2490
|
const late = reconnect === void 0 || prediction.reconnects === void 0 ? null : reconnectFinding(prediction.reconnects, reconnect, noiseOf(cell));
|
|
2491
|
+
const counted = prediction.counts === void 0 ? null : countFinding(prediction.counts, run.server ?? []);
|
|
1988
2492
|
return {
|
|
1989
2493
|
...base,
|
|
1990
2494
|
...nothing,
|
|
1991
2495
|
...chain,
|
|
1992
2496
|
...reconnect === void 0 ? {} : { reconnect },
|
|
1993
|
-
finding: late,
|
|
2497
|
+
finding: late ?? counted,
|
|
1994
2498
|
outcome: ended !== void 0 ? outcome : { kind: "answered" },
|
|
1995
2499
|
...from === void 0 || until === void 0 ? {} : { elapsed: between(from, until) },
|
|
1996
2500
|
noticed: prediction.from === void 0 ? null : noticed,
|
|
@@ -2169,6 +2673,440 @@ const measureSocket = (cell, run) => {
|
|
|
2169
2673
|
};
|
|
2170
2674
|
};
|
|
2171
2675
|
//#endregion
|
|
2676
|
+
//#region src/fabric.ts
|
|
2677
|
+
/**
|
|
2678
|
+
* A fabric is a routed network between the client and the target, on layer E3 (docs/S7.md). Four
|
|
2679
|
+
* routers of FRRouting, two leaves and two spines, run real BGP over veth pairs that containerlab
|
|
2680
|
+
* makes. The client hangs off leaf1 and the target off leaf2. The path between them goes through
|
|
2681
|
+
* spine1 by a weight, and spine2 holds the other path. A cell silences spine1, and the routes of
|
|
2682
|
+
* the leaves move to spine2 when a detector of the routers fires.
|
|
2683
|
+
*
|
|
2684
|
+
* This file holds the plan of the fabric as pure functions, and `openFabric` that starts it. A lab
|
|
2685
|
+
* opens a fabric with `Topology.fabric`. Thus the client, the target, the prober and the verdict
|
|
2686
|
+
* stay the same as on layer E1. The fabric needs a Linux host with containerlab and sudo.
|
|
2687
|
+
*/
|
|
2688
|
+
/** This image is FRRouting 10.7.1, pinned by digest. */
|
|
2689
|
+
const FRR_IMAGE = "quay.io/frrouting/frr:10.7.1@sha256:e995beaa50fdc9edb35eadcfefa29b7f062cc06f2b812613789b68fa541554d2";
|
|
2690
|
+
/** This device is the side of the fabric in the client and in the target. Docker keeps eth0. */
|
|
2691
|
+
const FABRIC_DEVICE = "eth1";
|
|
2692
|
+
/** This address is the client on its network behind leaf1. */
|
|
2693
|
+
const FABRIC_CLIENT = "10.1.0.2";
|
|
2694
|
+
/** This address is the target on its network behind leaf2. */
|
|
2695
|
+
const FABRIC_TARGET = "10.2.0.2";
|
|
2696
|
+
/** This device of a leaf goes to spine1, thus a route on it is on the preferred path. */
|
|
2697
|
+
const PREFERRED_DEVICE = "eth1";
|
|
2698
|
+
/** This limit is the time that the sessions and the routes get to come back before a run. */
|
|
2699
|
+
const READY_MS = 9e4;
|
|
2700
|
+
const READY_POLL_MS = 250;
|
|
2701
|
+
const CLIENT_NET = "10.1.0.0/24";
|
|
2702
|
+
const TARGET_NET = "10.2.0.0/24";
|
|
2703
|
+
/** These are the routers. Each leaf prefers spine1 by a weight of 100, thus the path of each run is known. */
|
|
2704
|
+
const ROUTERS = [
|
|
2705
|
+
{
|
|
2706
|
+
name: "leaf1",
|
|
2707
|
+
as: 65001,
|
|
2708
|
+
id: "10.255.0.1",
|
|
2709
|
+
interfaces: {
|
|
2710
|
+
eth1: "10.0.1.0/31",
|
|
2711
|
+
eth2: "10.0.2.0/31",
|
|
2712
|
+
eth3: "10.1.0.1/24"
|
|
2713
|
+
},
|
|
2714
|
+
neighbors: [{
|
|
2715
|
+
address: "10.0.1.1",
|
|
2716
|
+
as: 65101,
|
|
2717
|
+
weight: 100
|
|
2718
|
+
}, {
|
|
2719
|
+
address: "10.0.2.1",
|
|
2720
|
+
as: 65102
|
|
2721
|
+
}],
|
|
2722
|
+
network: CLIENT_NET,
|
|
2723
|
+
remote: TARGET_NET
|
|
2724
|
+
},
|
|
2725
|
+
{
|
|
2726
|
+
name: "leaf2",
|
|
2727
|
+
as: 65002,
|
|
2728
|
+
id: "10.255.0.2",
|
|
2729
|
+
interfaces: {
|
|
2730
|
+
eth1: "10.0.3.0/31",
|
|
2731
|
+
eth2: "10.0.4.0/31",
|
|
2732
|
+
eth3: "10.2.0.1/24"
|
|
2733
|
+
},
|
|
2734
|
+
neighbors: [{
|
|
2735
|
+
address: "10.0.3.1",
|
|
2736
|
+
as: 65101,
|
|
2737
|
+
weight: 100
|
|
2738
|
+
}, {
|
|
2739
|
+
address: "10.0.4.1",
|
|
2740
|
+
as: 65102
|
|
2741
|
+
}],
|
|
2742
|
+
network: TARGET_NET,
|
|
2743
|
+
remote: CLIENT_NET
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
name: "spine1",
|
|
2747
|
+
as: 65101,
|
|
2748
|
+
id: "10.255.1.1",
|
|
2749
|
+
interfaces: {
|
|
2750
|
+
eth1: "10.0.1.1/31",
|
|
2751
|
+
eth2: "10.0.3.1/31"
|
|
2752
|
+
},
|
|
2753
|
+
neighbors: [{
|
|
2754
|
+
address: "10.0.1.0",
|
|
2755
|
+
as: 65001
|
|
2756
|
+
}, {
|
|
2757
|
+
address: "10.0.3.0",
|
|
2758
|
+
as: 65002
|
|
2759
|
+
}]
|
|
2760
|
+
},
|
|
2761
|
+
{
|
|
2762
|
+
name: "spine2",
|
|
2763
|
+
as: 65102,
|
|
2764
|
+
id: "10.255.1.2",
|
|
2765
|
+
interfaces: {
|
|
2766
|
+
eth1: "10.0.2.1/31",
|
|
2767
|
+
eth2: "10.0.4.1/31"
|
|
2768
|
+
},
|
|
2769
|
+
neighbors: [{
|
|
2770
|
+
address: "10.0.2.0",
|
|
2771
|
+
as: 65001
|
|
2772
|
+
}, {
|
|
2773
|
+
address: "10.0.4.0",
|
|
2774
|
+
as: 65002
|
|
2775
|
+
}]
|
|
2776
|
+
}
|
|
2777
|
+
];
|
|
2778
|
+
const LEAVES = ROUTERS.filter((router) => router.remote !== void 0);
|
|
2779
|
+
/** These are the links between the routers. The links to the client and the target come from `topologyOf`. */
|
|
2780
|
+
const LINKS = [
|
|
2781
|
+
["leaf1:eth1", "spine1:eth1"],
|
|
2782
|
+
["leaf1:eth2", "spine2:eth1"],
|
|
2783
|
+
["leaf2:eth1", "spine1:eth2"],
|
|
2784
|
+
["leaf2:eth2", "spine2:eth2"]
|
|
2785
|
+
];
|
|
2786
|
+
/** This text is the `daemons` file of each router: BGP and BFD, on top of what FRRouting always starts. */
|
|
2787
|
+
const DAEMONS = [
|
|
2788
|
+
"bgpd=yes",
|
|
2789
|
+
"bfdd=yes",
|
|
2790
|
+
"vtysh_enable=yes",
|
|
2791
|
+
"zebra_options=\" -A 127.0.0.1 -s 90000000\"",
|
|
2792
|
+
"mgmtd_options=\" -A 127.0.0.1\"",
|
|
2793
|
+
"bgpd_options=\" -A 127.0.0.1\"",
|
|
2794
|
+
"bfdd_options=\" -A 127.0.0.1\"",
|
|
2795
|
+
"staticd_options=\"-A 127.0.0.1\"",
|
|
2796
|
+
""
|
|
2797
|
+
].join("\n");
|
|
2798
|
+
/**
|
|
2799
|
+
* This function gives the configuration of a router. Graceful restart is off: after a hold expiry,
|
|
2800
|
+
* a helper can keep the stale routes, and the move that a cell measures does not come.
|
|
2801
|
+
* `maximum-paths 1` keeps one path, thus a move is one change of one route.
|
|
2802
|
+
*/
|
|
2803
|
+
const frrConf = (router, fabric) => {
|
|
2804
|
+
const keepalive = valueIn(fabric.keepalive, "s");
|
|
2805
|
+
const hold = valueIn(fabric.hold, "s");
|
|
2806
|
+
const out = [
|
|
2807
|
+
"frr defaults traditional",
|
|
2808
|
+
`hostname ${router.name}`,
|
|
2809
|
+
"ip forwarding",
|
|
2810
|
+
"!"
|
|
2811
|
+
];
|
|
2812
|
+
for (const [name, address] of Object.entries(router.interfaces)) out.push(`interface ${name}`, ` ip address ${address}`, "!");
|
|
2813
|
+
if (fabric.bfd !== null) {
|
|
2814
|
+
const interval = valueIn(fabric.bfd.interval, "ms");
|
|
2815
|
+
out.push("bfd", " profile fast", ` receive-interval ${interval}`, ` transmit-interval ${interval}`, ` detect-multiplier ${fabric.bfd.multiplier}`, " exit", "exit", "!");
|
|
2816
|
+
}
|
|
2817
|
+
out.push(`router bgp ${router.as}`, ` bgp router-id ${router.id}`, " no bgp ebgp-requires-policy", " no bgp network import-check", " bgp graceful-restart-disable");
|
|
2818
|
+
for (const neighbor of router.neighbors) {
|
|
2819
|
+
out.push(` neighbor ${neighbor.address} remote-as ${neighbor.as}`, ` neighbor ${neighbor.address} timers ${keepalive} ${hold}`, ` neighbor ${neighbor.address} timers connect 1`, ` neighbor ${neighbor.address} advertisement-interval 0`);
|
|
2820
|
+
if (fabric.bfd !== null) out.push(` neighbor ${neighbor.address} bfd profile fast`);
|
|
2821
|
+
}
|
|
2822
|
+
out.push(" !", " address-family ipv4 unicast");
|
|
2823
|
+
if (router.network !== void 0) out.push(` network ${router.network}`);
|
|
2824
|
+
out.push(" maximum-paths 1");
|
|
2825
|
+
for (const neighbor of router.neighbors) if (neighbor.weight !== void 0) out.push(` neighbor ${neighbor.address} weight ${neighbor.weight}`);
|
|
2826
|
+
out.push(" exit-address-family", "exit", "");
|
|
2827
|
+
return out.join("\n");
|
|
2828
|
+
};
|
|
2829
|
+
/**
|
|
2830
|
+
* This function gives the topology file of containerlab. The client and the target are containers
|
|
2831
|
+
* of the lab, and the fabric adds a device to each. Each link has the MTU of a Docker network, thus
|
|
2832
|
+
* a segment is as long as on layer E1. The default of containerlab is 9500. The management network
|
|
2833
|
+
* belongs to the lab, and Docker chooses its subnet.
|
|
2834
|
+
*/
|
|
2835
|
+
const topologyOf = (lab, dir, client, target) => {
|
|
2836
|
+
const nodes = ROUTERS.flatMap((router) => [
|
|
2837
|
+
` ${router.name}:`,
|
|
2838
|
+
" kind: linux",
|
|
2839
|
+
` image: ${FRR_IMAGE}`,
|
|
2840
|
+
" binds:",
|
|
2841
|
+
` - ${dir}/${router.name}.conf:/etc/frr/frr.conf`,
|
|
2842
|
+
` - ${dir}/daemons:/etc/frr/daemons`,
|
|
2843
|
+
" sysctls:",
|
|
2844
|
+
" net.ipv4.ip_forward: 1",
|
|
2845
|
+
" net.ipv4.conf.all.rp_filter: 0",
|
|
2846
|
+
" net.ipv4.conf.default.rp_filter: 0"
|
|
2847
|
+
]);
|
|
2848
|
+
const links = [
|
|
2849
|
+
...LINKS,
|
|
2850
|
+
["leaf1:eth3", `${client}:${FABRIC_DEVICE}`],
|
|
2851
|
+
["leaf2:eth3", `${target}:${FABRIC_DEVICE}`]
|
|
2852
|
+
];
|
|
2853
|
+
return [
|
|
2854
|
+
`name: ${lab}`,
|
|
2855
|
+
"mgmt:",
|
|
2856
|
+
` network: clab-${lab}`,
|
|
2857
|
+
" ipv4-subnet: auto",
|
|
2858
|
+
"topology:",
|
|
2859
|
+
" nodes:",
|
|
2860
|
+
...nodes,
|
|
2861
|
+
` ${client}:`,
|
|
2862
|
+
" kind: ext-container",
|
|
2863
|
+
` ${target}:`,
|
|
2864
|
+
" kind: ext-container",
|
|
2865
|
+
" links:",
|
|
2866
|
+
...links.flatMap(([a, b]) => [` - endpoints: ["${a}", "${b}"]`, " mtu: 1500"]),
|
|
2867
|
+
""
|
|
2868
|
+
].join("\n");
|
|
2869
|
+
};
|
|
2870
|
+
/** This function reads a line of `ip -ts -o monitor route`, or it gives null for another line. The clock of a container is UTC. */
|
|
2871
|
+
const routeLine = (text) => {
|
|
2872
|
+
const line = text.trim();
|
|
2873
|
+
const found = /^\[(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d{6})\] /.exec(line);
|
|
2874
|
+
if (found === null) return null;
|
|
2875
|
+
const [stamp, year, month, day, hour, minute, second, micro] = found;
|
|
2876
|
+
const words = line.slice(stamp.length).split(" ").filter((word) => word !== "");
|
|
2877
|
+
const change = words[0] === "Deleted" || words[0] === "Replaced" ? words[0] : null;
|
|
2878
|
+
const prefix = words[change === null ? 0 : 1];
|
|
2879
|
+
if (prefix === void 0) return null;
|
|
2880
|
+
const after = (name) => {
|
|
2881
|
+
const at = words.indexOf(name);
|
|
2882
|
+
return at === -1 ? null : words[at + 1] ?? null;
|
|
2883
|
+
};
|
|
2884
|
+
return {
|
|
2885
|
+
wall: Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)) + Number(micro) / 1e3,
|
|
2886
|
+
deleted: change === "Deleted",
|
|
2887
|
+
prefix,
|
|
2888
|
+
via: after("via"),
|
|
2889
|
+
dev: after("dev")
|
|
2890
|
+
};
|
|
2891
|
+
};
|
|
2892
|
+
/**
|
|
2893
|
+
* This function takes a time on the wall clock to the monotonic clock. The harness runs on the host
|
|
2894
|
+
* of the containers, thus `process.hrtime` reads the CLOCK_MONOTONIC of the injector and the
|
|
2895
|
+
* prober. The error of the pair is approximately 1 ms, the step of `Date.now`.
|
|
2896
|
+
*/
|
|
2897
|
+
const monotonicAt = (wall, pair) => pair.mono + (wall - pair.wall) * 1e6;
|
|
2898
|
+
/** This function gives the state of the BGP sessions from `show bgp neighbors json`. */
|
|
2899
|
+
const sessionsOf = (json) => {
|
|
2900
|
+
const views = Object.values(JSON.parse(json));
|
|
2901
|
+
return {
|
|
2902
|
+
up: views.filter((view) => view.bgpState === "Established").length,
|
|
2903
|
+
established: views.reduce((sum, view) => sum + (view.connectionsEstablished ?? 0), 0)
|
|
2904
|
+
};
|
|
2905
|
+
};
|
|
2906
|
+
/**
|
|
2907
|
+
* This function tells what a leaf still lacks before a run, or it gives null. The two sessions must
|
|
2908
|
+
* be up and the route must be on spine1. Spine2 must hold the other path. Without these, the fault
|
|
2909
|
+
* of a run acts on another path, or nothing can take over.
|
|
2910
|
+
*/
|
|
2911
|
+
const leafLacks = (views) => {
|
|
2912
|
+
const { up } = sessionsOf(views.neighbors);
|
|
2913
|
+
if (up < 2) return `${up} of 2 BGP sessions are up`;
|
|
2914
|
+
if (!views.route.includes(` dev ${PREFERRED_DEVICE} `)) return `the route is not on spine1: ${views.route || "no route"}`;
|
|
2915
|
+
const paths = JSON.parse(views.paths).paths?.length ?? 0;
|
|
2916
|
+
if (paths < 2) return `BGP holds ${paths} of 2 paths`;
|
|
2917
|
+
if (views.bfd !== null) {
|
|
2918
|
+
const live = JSON.parse(views.bfd).filter((peer) => peer.status === "up").length;
|
|
2919
|
+
if (live < 2) return `${live} of 2 BFD peers are up`;
|
|
2920
|
+
}
|
|
2921
|
+
return null;
|
|
2922
|
+
};
|
|
2923
|
+
/**
|
|
2924
|
+
* This function gives the later move of the leaves after an instant: for each leaf, the first route
|
|
2925
|
+
* to its far network that leaves spine1. It gives null when a leaf has no move. The later move ends
|
|
2926
|
+
* the outage in the two directions.
|
|
2927
|
+
*/
|
|
2928
|
+
const laterMove = (routes, after) => {
|
|
2929
|
+
const firsts = LEAVES.map((leaf) => routes.find((route) => route.leaf === leaf.name && route.prefix === leaf.remote && route.t > after && !route.deleted && route.dev !== PREFERRED_DEVICE)?.t);
|
|
2930
|
+
return firsts.every((t) => t !== void 0) ? Math.max(...firsts) : null;
|
|
2931
|
+
};
|
|
2932
|
+
/** This function runs a program on the host and gives its output. Its error holds the stderr of the program. */
|
|
2933
|
+
const host = (program, args) => new Promise((resolve, reject) => {
|
|
2934
|
+
execFile(program, [...args], { maxBuffer: 16777216 }, (error, stdout, stderr) => {
|
|
2935
|
+
if (error === null) resolve(stdout);
|
|
2936
|
+
else reject(/* @__PURE__ */ new Error(`${program} ${args.join(" ")}: ${error.message}\n${stderr}`));
|
|
2937
|
+
});
|
|
2938
|
+
});
|
|
2939
|
+
const nameOf = (container) => container.getName().replace(/^\//, "");
|
|
2940
|
+
/** This function gives a clock pair at this instant. */
|
|
2941
|
+
const clockPair = () => ({
|
|
2942
|
+
mono: Number(process.hrtime.bigint()),
|
|
2943
|
+
wall: Date.now()
|
|
2944
|
+
});
|
|
2945
|
+
/**
|
|
2946
|
+
* This function deploys the routers of a lab and joins the client and the target to them. It also
|
|
2947
|
+
* starts the sidecars: the injector of spine1 and a route monitor on each leaf. The caller stops the
|
|
2948
|
+
* sidecars with the other containers of the lab, and it calls `close` first.
|
|
2949
|
+
*/
|
|
2950
|
+
const openFabric = async (parts) => {
|
|
2951
|
+
const { fabric, image, admin, injector, opened, output } = parts;
|
|
2952
|
+
const { GenericContainer } = await import("testcontainers");
|
|
2953
|
+
const lab = `e3-${randomBytes(4).toString("hex")}`;
|
|
2954
|
+
const dir = await mkdtemp(join(tmpdir(), "fabric-"));
|
|
2955
|
+
const file = join(dir, "lab.clab.yml");
|
|
2956
|
+
await writeFile(join(dir, "daemons"), DAEMONS);
|
|
2957
|
+
for (const router of ROUTERS) await writeFile(join(dir, `${router.name}.conf`), frrConf(router, fabric));
|
|
2958
|
+
await writeFile(file, topologyOf(lab, dir, nameOf(parts.client), nameOf(parts.server)));
|
|
2959
|
+
const close = async () => {
|
|
2960
|
+
await host("sudo", [
|
|
2961
|
+
"containerlab",
|
|
2962
|
+
"destroy",
|
|
2963
|
+
"-t",
|
|
2964
|
+
file,
|
|
2965
|
+
"--cleanup"
|
|
2966
|
+
]).catch(() => void 0);
|
|
2967
|
+
await rm(dir, {
|
|
2968
|
+
recursive: true,
|
|
2969
|
+
force: true
|
|
2970
|
+
}).catch(() => void 0);
|
|
2971
|
+
};
|
|
2972
|
+
const node = (name) => `clab-${lab}-${name}`;
|
|
2973
|
+
try {
|
|
2974
|
+
await host("sudo", [
|
|
2975
|
+
"containerlab",
|
|
2976
|
+
"deploy",
|
|
2977
|
+
"-t",
|
|
2978
|
+
file
|
|
2979
|
+
]);
|
|
2980
|
+
const version = /version:\s*(\S+)/.exec(await host("containerlab", ["version"]))?.[1] ?? "unknown";
|
|
2981
|
+
await output(admin, [
|
|
2982
|
+
"ip",
|
|
2983
|
+
"addr",
|
|
2984
|
+
"add",
|
|
2985
|
+
`${FABRIC_CLIENT}/24`,
|
|
2986
|
+
"dev",
|
|
2987
|
+
FABRIC_DEVICE
|
|
2988
|
+
]);
|
|
2989
|
+
await output(admin, [
|
|
2990
|
+
"ip",
|
|
2991
|
+
"route",
|
|
2992
|
+
"replace",
|
|
2993
|
+
TARGET_NET,
|
|
2994
|
+
"via",
|
|
2995
|
+
"10.1.0.1",
|
|
2996
|
+
"dev",
|
|
2997
|
+
FABRIC_DEVICE
|
|
2998
|
+
]);
|
|
2999
|
+
await output(injector, [
|
|
3000
|
+
"ip",
|
|
3001
|
+
"addr",
|
|
3002
|
+
"add",
|
|
3003
|
+
`${FABRIC_TARGET}/24`,
|
|
3004
|
+
"dev",
|
|
3005
|
+
FABRIC_DEVICE
|
|
3006
|
+
]);
|
|
3007
|
+
await output(injector, [
|
|
3008
|
+
"ip",
|
|
3009
|
+
"route",
|
|
3010
|
+
"replace",
|
|
3011
|
+
CLIENT_NET,
|
|
3012
|
+
"via",
|
|
3013
|
+
"10.2.0.1",
|
|
3014
|
+
"dev",
|
|
3015
|
+
FABRIC_DEVICE
|
|
3016
|
+
]);
|
|
3017
|
+
const silencer = await new GenericContainer(image).withNetworkMode(`container:${node("spine1")}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
|
|
3018
|
+
opened.push(silencer);
|
|
3019
|
+
const routes = [];
|
|
3020
|
+
const monitors = /* @__PURE__ */ new Map();
|
|
3021
|
+
for (const leaf of LEAVES) {
|
|
3022
|
+
const monitor = await new GenericContainer(image).withNetworkMode(`container:${node(leaf.name)}`).withCommand([
|
|
3023
|
+
"ip",
|
|
3024
|
+
"-ts",
|
|
3025
|
+
"-o",
|
|
3026
|
+
"monitor",
|
|
3027
|
+
"route"
|
|
3028
|
+
]).withLogConsumer((stream) => {
|
|
3029
|
+
let rest = "";
|
|
3030
|
+
stream.on("data", (chunk) => {
|
|
3031
|
+
const text = (rest + chunk.toString()).split("\n");
|
|
3032
|
+
rest = text.pop() ?? "";
|
|
3033
|
+
for (const each of text) {
|
|
3034
|
+
const line = routeLine(each);
|
|
3035
|
+
if (line === null) continue;
|
|
3036
|
+
const { wall, ...route } = line;
|
|
3037
|
+
routes.push({
|
|
3038
|
+
...route,
|
|
3039
|
+
leaf: leaf.name,
|
|
3040
|
+
t: monotonicAt(wall, clockPair())
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
});
|
|
3044
|
+
}).start();
|
|
3045
|
+
opened.push(monitor);
|
|
3046
|
+
monitors.set(leaf.name, monitor);
|
|
3047
|
+
}
|
|
3048
|
+
const stamp = async (action) => {
|
|
3049
|
+
const last = (await output(silencer, ["/opt/harness/inject", action])).split("\n").at(-1);
|
|
3050
|
+
const { t0, t1 } = JSON.parse(last);
|
|
3051
|
+
return {
|
|
3052
|
+
t0,
|
|
3053
|
+
t1
|
|
3054
|
+
};
|
|
3055
|
+
};
|
|
3056
|
+
const vtysh = (leaf, view) => host("docker", [
|
|
3057
|
+
"exec",
|
|
3058
|
+
node(leaf),
|
|
3059
|
+
"vtysh",
|
|
3060
|
+
"-c",
|
|
3061
|
+
view
|
|
3062
|
+
]);
|
|
3063
|
+
return {
|
|
3064
|
+
routes,
|
|
3065
|
+
silence: () => stamp("silence"),
|
|
3066
|
+
clear: () => stamp("clear-silence"),
|
|
3067
|
+
async ready() {
|
|
3068
|
+
const deadline = Date.now() + READY_MS;
|
|
3069
|
+
for (;;) {
|
|
3070
|
+
const lacks = [];
|
|
3071
|
+
for (const leaf of LEAVES) {
|
|
3072
|
+
const prefix = leaf.remote;
|
|
3073
|
+
const lacking = await (async () => leafLacks({
|
|
3074
|
+
neighbors: await vtysh(leaf.name, "show bgp neighbors json"),
|
|
3075
|
+
route: `${await output(monitors.get(leaf.name), [
|
|
3076
|
+
"ip",
|
|
3077
|
+
"-o",
|
|
3078
|
+
"route",
|
|
3079
|
+
"show",
|
|
3080
|
+
prefix
|
|
3081
|
+
])} `,
|
|
3082
|
+
paths: await vtysh(leaf.name, `show bgp ipv4 unicast ${prefix} json`),
|
|
3083
|
+
bfd: fabric.bfd === null ? null : await vtysh(leaf.name, "show bfd peers json")
|
|
3084
|
+
}))().catch((error) => String(error));
|
|
3085
|
+
if (lacking !== null) lacks.push(`${leaf.name}: ${lacking}`);
|
|
3086
|
+
}
|
|
3087
|
+
if (lacks.length === 0) return;
|
|
3088
|
+
if (Date.now() > deadline) throw new Error(`the fabric is not ready: ${lacks.join("; ")}`);
|
|
3089
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
|
|
3090
|
+
}
|
|
3091
|
+
},
|
|
3092
|
+
async sessions() {
|
|
3093
|
+
const out = {};
|
|
3094
|
+
for (const leaf of LEAVES) out[leaf.name] = sessionsOf(await vtysh(leaf.name, "show bgp neighbors json")).established;
|
|
3095
|
+
return out;
|
|
3096
|
+
},
|
|
3097
|
+
manifest: () => ({
|
|
3098
|
+
...fabric,
|
|
3099
|
+
image: FRR_IMAGE,
|
|
3100
|
+
containerlab: version
|
|
3101
|
+
}),
|
|
3102
|
+
close
|
|
3103
|
+
};
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
await close();
|
|
3106
|
+
throw error;
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
//#endregion
|
|
2172
3110
|
//#region src/substrate.ts
|
|
2173
3111
|
/** GitHub announced Linux jobs on AWS on this date. Each earlier hosted job ran on Azure. */
|
|
2174
3112
|
const AWS_FROM = "2026-10-28";
|
|
@@ -2207,6 +3145,41 @@ const substrateOn = (layer, host = readHost()) => ({
|
|
|
2207
3145
|
...host
|
|
2208
3146
|
});
|
|
2209
3147
|
/**
|
|
3148
|
+
* This function gives the substrate of a lab at this place. A guest cannot read its own runner: its
|
|
3149
|
+
* DMI vendor is QEMU, and `ImageOS` is not set. Thus the runner writes its host to `LAB_RUNNER`, and
|
|
3150
|
+
* the guest keeps only its own kernel and HZ. A value of that variable makes the layer E4.
|
|
3151
|
+
*/
|
|
3152
|
+
const substrateHere = (layer, host = readHost(), env = process.env) => {
|
|
3153
|
+
const runner = env["LAB_RUNNER"];
|
|
3154
|
+
if (runner === void 0 || runner === "") return substrateOn(layer, host);
|
|
3155
|
+
return substrateOn("E4", {
|
|
3156
|
+
...JSON.parse(runner),
|
|
3157
|
+
kernel: host.kernel,
|
|
3158
|
+
hz: host.hz
|
|
3159
|
+
});
|
|
3160
|
+
};
|
|
3161
|
+
/** This function reads the CPU time of this machine, or gives null without `/proc/stat`. `root` is for tests. */
|
|
3162
|
+
const readCpu = (root = "/") => {
|
|
3163
|
+
const line = textOf(join(root, "proc", "stat"))?.split("\n")[0];
|
|
3164
|
+
if (line === void 0 || !line.startsWith("cpu ")) return null;
|
|
3165
|
+
const ticks = line.trim().split(/\s+/).slice(1, 9).map(Number);
|
|
3166
|
+
const steal = ticks[7];
|
|
3167
|
+
if (steal === void 0 || ticks.some(Number.isNaN)) return null;
|
|
3168
|
+
return {
|
|
3169
|
+
steal,
|
|
3170
|
+
total: ticks.reduce((sum, tick) => sum + tick, 0)
|
|
3171
|
+
};
|
|
3172
|
+
};
|
|
3173
|
+
/**
|
|
3174
|
+
* This function gives the share of the CPU time between two readings that the host took. The share
|
|
3175
|
+
* goes from zero to one. It is null if no tick passed. A guest sees only the host above it. On a hosted
|
|
3176
|
+
* runner that host is itself a guest, and this number does not show the contention of the layer below it.
|
|
3177
|
+
*/
|
|
3178
|
+
const stealShare = (before, after) => {
|
|
3179
|
+
const total = after.total - before.total;
|
|
3180
|
+
return total <= 0 ? null : (after.steal - before.steal) / total;
|
|
3181
|
+
};
|
|
3182
|
+
/**
|
|
2210
3183
|
* This function reads the substrate of a record, the upgrade of a record from before this block.
|
|
2211
3184
|
* Nothing rewrites such a record. It reads as the given layer. Its cloud is Azure if the record is
|
|
2212
3185
|
* from before `AWS_FROM`, and unknown after that date, because its runner did not record the cloud.
|
|
@@ -2279,7 +3252,7 @@ const TAIL_LINES = 60;
|
|
|
2279
3252
|
const OUTPUT_LINES = 2e3;
|
|
2280
3253
|
/** This time is how long a write of a hop waits for the line that confirms it, in milliseconds. */
|
|
2281
3254
|
const CONFIRM_MS = 1e4;
|
|
2282
|
-
const sleep$
|
|
3255
|
+
const sleep$6 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2283
3256
|
/** This function gives the arguments of the injector for the options of a fault. */
|
|
2284
3257
|
const optionArgs = (options) => [
|
|
2285
3258
|
...options.direction === void 0 ? [] : ["--direction", options.direction],
|
|
@@ -2442,6 +3415,25 @@ const netnsOf = async (container) => {
|
|
|
2442
3415
|
if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
|
|
2443
3416
|
return Number(inode);
|
|
2444
3417
|
};
|
|
3418
|
+
/** This function gives the build record of a source as a part of a manifest: `{ build }`, or nothing for an image that the harness pulls. */
|
|
3419
|
+
const buildOf = (source) => {
|
|
3420
|
+
const build = buildRecordOf(source);
|
|
3421
|
+
return build === void 0 ? {} : { build };
|
|
3422
|
+
};
|
|
3423
|
+
/** This function gives the version of the harness and the build of its image, as a part of the harness record of a manifest. */
|
|
3424
|
+
const harnessRecord = () => ({
|
|
3425
|
+
version: harnessVersion(),
|
|
3426
|
+
...buildOf(shippedSource("harness"))
|
|
3427
|
+
});
|
|
3428
|
+
/**
|
|
3429
|
+
* This function gives the record of the client of a run that the harness ships, for a manifest:
|
|
3430
|
+
* its kind, its image, and its build.
|
|
3431
|
+
*/
|
|
3432
|
+
const shippedClientRecord = (kind, image) => ({
|
|
3433
|
+
kind,
|
|
3434
|
+
image,
|
|
3435
|
+
...buildOf(SHIPPED_CLIENTS[kind].image)
|
|
3436
|
+
});
|
|
2445
3437
|
/**
|
|
2446
3438
|
* This function gives the image and the command of the client of each run, its kind for the
|
|
2447
3439
|
* manifest, and the source of its image. A client by its name is a client that the harness ships
|
|
@@ -2492,7 +3484,9 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2492
3484
|
const opened = [];
|
|
2493
3485
|
const networks = [];
|
|
2494
3486
|
const serverLines = lines();
|
|
3487
|
+
let fabric;
|
|
2495
3488
|
const close = async () => {
|
|
3489
|
+
await fabric?.close();
|
|
2496
3490
|
for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
|
|
2497
3491
|
for (const network of networks.reverse()) await network.stop().catch(() => void 0);
|
|
2498
3492
|
};
|
|
@@ -2633,7 +3627,18 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2633
3627
|
opened.push(admin);
|
|
2634
3628
|
const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
|
|
2635
3629
|
opened.push(injector);
|
|
2636
|
-
|
|
3630
|
+
if (topology?.fabric !== void 0) fabric = await openFabric({
|
|
3631
|
+
fabric: topology.fabric,
|
|
3632
|
+
image,
|
|
3633
|
+
client,
|
|
3634
|
+
server,
|
|
3635
|
+
admin,
|
|
3636
|
+
injector,
|
|
3637
|
+
opened,
|
|
3638
|
+
output
|
|
3639
|
+
});
|
|
3640
|
+
const device = fabric === void 0 ? "eth0" : FABRIC_DEVICE;
|
|
3641
|
+
const clientIp = fabric === void 0 ? await ipOn(client, networks[0]) : FABRIC_CLIENT;
|
|
2637
3642
|
for (let at = 0; at < inChain.length; at += 1) {
|
|
2638
3643
|
const before = at === 0 ? clientIp : inChain[at - 1].ip.target;
|
|
2639
3644
|
inChain[at] = {
|
|
@@ -2716,7 +3721,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2716
3721
|
const begun = await records.until(["begin", "fail"], 3e4, "prober");
|
|
2717
3722
|
if (begun.ev === "fail") throw new Error(`the prober failed: ${String(begun["why"])}`);
|
|
2718
3723
|
const first = endpointAfter(hops, 0);
|
|
2719
|
-
const targetIp = first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
|
|
3724
|
+
const targetIp = fabric !== void 0 ? FABRIC_TARGET : first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
|
|
2720
3725
|
const targetPort = first === hops.length + 1 ? target.port : inChain[first - 1].hop.port;
|
|
2721
3726
|
const targetPeer = inChain.length === 0 ? clientIp : inChain.at(-1).ip.target;
|
|
2722
3727
|
const placed = inChain.length === 0 ? null : {
|
|
@@ -2769,8 +3774,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2769
3774
|
};
|
|
2770
3775
|
/** This function gives the namespace and the device of netem at a place: an end, or the device of a hop toward a side. */
|
|
2771
3776
|
const deviceAt = (place) => {
|
|
2772
|
-
if (place === "client") return [admin,
|
|
2773
|
-
if (place === "target") return [injector,
|
|
3777
|
+
if (place === "client") return [admin, device];
|
|
3778
|
+
if (place === "target") return [injector, device];
|
|
2774
3779
|
const at = hopPlace(place);
|
|
2775
3780
|
const hop = hopNamed(at.hop);
|
|
2776
3781
|
return [hop.injector, hop.devices[at.toward]];
|
|
@@ -2789,6 +3794,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2789
3794
|
};
|
|
2790
3795
|
const proxies = inChain.filter((hop) => hop.hop.kind === "proxy");
|
|
2791
3796
|
return {
|
|
3797
|
+
serverIp,
|
|
2792
3798
|
targetIp,
|
|
2793
3799
|
targetPort,
|
|
2794
3800
|
clientIp,
|
|
@@ -2797,6 +3803,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2797
3803
|
middlebox,
|
|
2798
3804
|
server: serverLines.seen,
|
|
2799
3805
|
kernel: records.seen,
|
|
3806
|
+
...fabric === void 0 ? {} : { fabric },
|
|
2800
3807
|
async sysctl(values) {
|
|
2801
3808
|
const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
|
2802
3809
|
if (settings.length > 0) await output(admin, [
|
|
@@ -2852,7 +3859,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2852
3859
|
return stamped(where, [
|
|
2853
3860
|
"/opt/harness/inject",
|
|
2854
3861
|
kind,
|
|
2855
|
-
peer,
|
|
3862
|
+
options.peers?.join(",") ?? peer,
|
|
2856
3863
|
...optionArgs(options),
|
|
2857
3864
|
...via
|
|
2858
3865
|
]);
|
|
@@ -2862,7 +3869,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2862
3869
|
await output(where, [
|
|
2863
3870
|
"/opt/harness/inject",
|
|
2864
3871
|
LIFT[kind],
|
|
2865
|
-
peer,
|
|
3872
|
+
options.peers?.join(",") ?? peer,
|
|
2866
3873
|
...optionArgs(options),
|
|
2867
3874
|
...via
|
|
2868
3875
|
]);
|
|
@@ -2914,6 +3921,18 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2914
3921
|
if (target.close === void 0) throw new Error(`${target.image} names no way to close its clients`);
|
|
2915
3922
|
await output(server, [...target.close]);
|
|
2916
3923
|
},
|
|
3924
|
+
async exec(argv) {
|
|
3925
|
+
return output(server, [...argv]);
|
|
3926
|
+
},
|
|
3927
|
+
async sibling(siblingImage, argv, ready) {
|
|
3928
|
+
const started = await new GenericContainer(siblingImage).withNetwork(last).withWaitStrategy(Wait.forLogMessage(ready)).withCommand([...argv]).start();
|
|
3929
|
+
opened.push(started);
|
|
3930
|
+
return {
|
|
3931
|
+
ip: await ipOn(started, last),
|
|
3932
|
+
netns: await netnsOf(started),
|
|
3933
|
+
exec: (command) => output(started, [...command])
|
|
3934
|
+
};
|
|
3935
|
+
},
|
|
2917
3936
|
async agent(agentImage, argv, options = {}) {
|
|
2918
3937
|
const Shared = shared(GenericContainer);
|
|
2919
3938
|
const seen = lines();
|
|
@@ -3054,7 +4073,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3054
4073
|
};
|
|
3055
4074
|
while (!confirmed()) {
|
|
3056
4075
|
if (Date.now() - started > CONFIRM_MS) throw new Error(`the hop ${event.hop} did not confirm the write of ${event.write.path}: no line "${confirm}" in ${CONFIRM_MS / 1e3} s`);
|
|
3057
|
-
await sleep$
|
|
4076
|
+
await sleep$6(50);
|
|
3058
4077
|
}
|
|
3059
4078
|
}
|
|
3060
4079
|
return stamp;
|
|
@@ -3146,7 +4165,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3146
4165
|
})(),
|
|
3147
4166
|
boot_id: await output(client, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
3148
4167
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
3149
|
-
substrate:
|
|
4168
|
+
substrate: substrateHere(fabric === void 0 ? "E1" : "E3"),
|
|
4169
|
+
...fabric === void 0 ? {} : { fabric: fabric.manifest() },
|
|
3150
4170
|
target: {
|
|
3151
4171
|
image: targetImage,
|
|
3152
4172
|
...target.subject === void 0 ? {} : {
|
|
@@ -3157,7 +4177,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3157
4177
|
settings,
|
|
3158
4178
|
sysctls: targetSysctls,
|
|
3159
4179
|
...target.files === void 0 ? {} : { files: Object.fromEntries(Object.entries(target.files).map(([path, content]) => [path, createHash("sha256").update(content).digest("hex")])) },
|
|
3160
|
-
...serverLines.seen[0] === void 0 ? {} : { first: serverLines.seen[0] }
|
|
4180
|
+
...serverLines.seen[0] === void 0 ? {} : { first: serverLines.seen[0] },
|
|
4181
|
+
...buildOf(targetSourceOf(target))
|
|
3161
4182
|
},
|
|
3162
4183
|
harness: {
|
|
3163
4184
|
image,
|
|
@@ -3166,12 +4187,16 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3166
4187
|
"apk",
|
|
3167
4188
|
"info",
|
|
3168
4189
|
"-v"
|
|
3169
|
-
])).split("\n").sort()
|
|
4190
|
+
])).split("\n").sort(),
|
|
4191
|
+
...harnessRecord()
|
|
3170
4192
|
},
|
|
3171
4193
|
client: {
|
|
3172
4194
|
kind: clientKind,
|
|
3173
4195
|
image: clientImage,
|
|
3174
|
-
...source === void 0 ? {} : {
|
|
4196
|
+
...source === void 0 ? {} : {
|
|
4197
|
+
source,
|
|
4198
|
+
...buildOf(source)
|
|
4199
|
+
}
|
|
3175
4200
|
},
|
|
3176
4201
|
netns,
|
|
3177
4202
|
sysctls,
|
|
@@ -3186,46 +4211,316 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3186
4211
|
}
|
|
3187
4212
|
};
|
|
3188
4213
|
//#endregion
|
|
3189
|
-
//#region src/
|
|
3190
|
-
/** This function gives the configuration file of a member: its port, its settings, and the primary that a replica follows. */
|
|
3191
|
-
const memberConfig = (group, primary) => [
|
|
3192
|
-
`port ${group.port}`,
|
|
3193
|
-
...Object.entries(group.memberSettings).map(([key, value]) => `${key} ${value}`),
|
|
3194
|
-
...primary === null ? [] : [`replicaof ${primary} ${group.port}`],
|
|
3195
|
-
""
|
|
3196
|
-
].join("\n");
|
|
3197
|
-
/** This function gives the configuration file of a Sentinel: its port, the group that it monitors, and the settings of the group. */
|
|
3198
|
-
const sentinelConfig = (group, primary) => [
|
|
3199
|
-
`port ${group.sentinelPort}`,
|
|
3200
|
-
`sentinel monitor ${group.name} ${primary} ${group.port} ${group.quorum}`,
|
|
3201
|
-
...Object.entries(group.sentinelSettings).map(([key, value]) => `sentinel ${key} ${group.name} ${value}`),
|
|
3202
|
-
""
|
|
3203
|
-
].join("\n");
|
|
4214
|
+
//#region src/standby.ts
|
|
3204
4215
|
/**
|
|
3205
|
-
* This
|
|
3206
|
-
*
|
|
3207
|
-
*
|
|
3208
|
-
*
|
|
4216
|
+
* This module is the second server of a pair (`Cell.pair`). The lab of the cell has the primary, the
|
|
4217
|
+
* topology in front of it, the injector and the prober. This module adds one standby on the network
|
|
4218
|
+
* of the primary, and it does one run:
|
|
4219
|
+
*
|
|
4220
|
+
* 1. The client writes a stream of sequence numbers through the topology.
|
|
4221
|
+
* 2. The fault cuts the primary from the standby, or from each peer. The cut stays to the end.
|
|
4222
|
+
* 3. At the end of the hold, the injector stops each process of the primary with SIGKILL.
|
|
4223
|
+
* 4. The harness promotes the standby, and reads what it holds.
|
|
4224
|
+
* 5. The harness starts the old primary, and reads what its crash recovery gives.
|
|
4225
|
+
*
|
|
4226
|
+
* The standby has no way to the WAL of the old primary, because the primary stops under the cut. A
|
|
4227
|
+
* promotion cannot be undone, thus each run has its own environment (`Cell.fresh`).
|
|
3209
4228
|
*/
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
4229
|
+
/** This table holds one row for each write of the client. */
|
|
4230
|
+
const TABLE = "writes";
|
|
4231
|
+
/** This time bounds each wait of the setup of the pair, in ms. */
|
|
4232
|
+
const SETUP_MS = 6e4;
|
|
4233
|
+
/** This count is the acknowledged writes before the fault: the stream is steady. */
|
|
4234
|
+
const STEADY$4 = 20;
|
|
4235
|
+
/** This time is the wait after the stop of the primary, in ms: each writer sends its last write in it. */
|
|
4236
|
+
const LAST_WRITE_MS = 2e3;
|
|
4237
|
+
/** This time bounds the wait for the last line of the client, in ms. */
|
|
4238
|
+
const STOP_MS = 5e3;
|
|
4239
|
+
/** This text is the line of the log of a standby that serves reads (src/backend/postmaster/postmaster.c:3740). */
|
|
4240
|
+
const STANDBY_READY = "database system is ready to accept read-only connections";
|
|
4241
|
+
/** This text is the write of the client, with its sequence number. */
|
|
4242
|
+
const INSERT = `INSERT INTO ${TABLE} VALUES ($n)`;
|
|
4243
|
+
const sleep$5 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4244
|
+
/** This function gives the argv of one statement in psql, on the server in the same container. */
|
|
4245
|
+
const psql = (statement) => [
|
|
4246
|
+
"psql",
|
|
4247
|
+
"-h",
|
|
4248
|
+
"127.0.0.1",
|
|
4249
|
+
"-U",
|
|
4250
|
+
"postgres",
|
|
4251
|
+
"-At",
|
|
4252
|
+
"-c",
|
|
4253
|
+
statement
|
|
4254
|
+
];
|
|
4255
|
+
/** This function gives the command of a standby: a base backup of the primary, then the server in recovery. */
|
|
4256
|
+
const standbyCommand = (primary) => [
|
|
4257
|
+
"sh",
|
|
4258
|
+
"-c",
|
|
4259
|
+
`chown postgres:postgres "$PGDATA"; chmod 0700 "$PGDATA"; until gosu postgres pg_basebackup -h ${primary} -U postgres -D "$PGDATA" -X stream -c fast -R; do gosu postgres sh -c 'rm -rf "$PGDATA"/*'; sleep 0.5; done; exec gosu postgres postgres`
|
|
4260
|
+
];
|
|
4261
|
+
/** This command starts the old primary again, after the stop of each of its processes. Its crash recovery runs before the wait ends. */
|
|
4262
|
+
const RESTART = [
|
|
4263
|
+
"sh",
|
|
4264
|
+
"-c",
|
|
4265
|
+
"exec gosu postgres pg_ctl -D \"$PGDATA\" -l /tmp/restart.log -w -t 60 start"
|
|
4266
|
+
];
|
|
4267
|
+
/** This function reads the output of a query for sequence numbers into a sorted list. */
|
|
4268
|
+
const numbersOf = (text) => text.split("\n").map((line) => line.trim()).filter((line) => line !== "").map(Number).sort((a, b) => a - b);
|
|
4269
|
+
/** This function gives the arguments of the client of a run. */
|
|
4270
|
+
const pairArgs = (cell, lab) => {
|
|
4271
|
+
const pair = cell.pair;
|
|
4272
|
+
const hold = cell.hold === void 0 ? 0 : valueIn(cell.hold, "s");
|
|
4273
|
+
return [
|
|
4274
|
+
lab.targetIp,
|
|
4275
|
+
String(lab.targetPort),
|
|
4276
|
+
"--mode",
|
|
4277
|
+
"stream",
|
|
4278
|
+
"--interval-ms",
|
|
4279
|
+
String(valueIn(pair.interval, "ms")),
|
|
4280
|
+
"--writers",
|
|
4281
|
+
String(pair.writers),
|
|
4282
|
+
"--query",
|
|
4283
|
+
INSERT,
|
|
4284
|
+
"--max-s",
|
|
4285
|
+
String(Math.ceil(hold) + 60),
|
|
4286
|
+
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)]
|
|
4287
|
+
];
|
|
4288
|
+
};
|
|
4289
|
+
/** This function gives the peers of the cut of a pair: the standby, or the standby and the peer of the primary in the topology. */
|
|
4290
|
+
const peersOf$1 = (pair, standby, lab) => pair.cuts === "standby" ? [standby] : [standby, lab.topology?.targetPeer ?? lab.clientIp];
|
|
4291
|
+
/** This function gives the instants of the cancels of a hold, in ns from the fault: equal parts of the hold. */
|
|
4292
|
+
const cancelsOf = (pair, holdNs) => {
|
|
4293
|
+
const count = pair.cancels ?? 0;
|
|
4294
|
+
return Array.from({ length: count }, (_, at) => (at + 1) * holdNs / (count + 1));
|
|
4295
|
+
};
|
|
4296
|
+
/** This function asks until the primary reports the standby in a state of replication. */
|
|
4297
|
+
const untilStreaming = async (lab, state) => {
|
|
4298
|
+
const started = Date.now();
|
|
4299
|
+
for (;;) {
|
|
4300
|
+
const seen = await lab.exec(psql("SELECT sync_state FROM pg_stat_replication WHERE state = 'streaming'"));
|
|
4301
|
+
if (seen === state) return;
|
|
4302
|
+
if (Date.now() - started > SETUP_MS) throw new Error(`the standby did not stream as ${state} in ${SETUP_MS} ms: it reports "${seen}"`);
|
|
4303
|
+
await sleep$5(100);
|
|
4304
|
+
}
|
|
4305
|
+
};
|
|
4306
|
+
/**
|
|
4307
|
+
* This function opens the pair of an environment. The table comes first and the standby next: the
|
|
4308
|
+
* base backup has it. A synchronous standby is named last, thus no setup step waits for it.
|
|
4309
|
+
*/
|
|
4310
|
+
const openStandby = async (lab, cell) => {
|
|
4311
|
+
const pair = cell.pair;
|
|
4312
|
+
await lab.exec([
|
|
4313
|
+
"sh",
|
|
4314
|
+
"-c",
|
|
4315
|
+
"echo \"host replication all all trust\" >> \"$PGDATA/pg_hba.conf\""
|
|
4316
|
+
]);
|
|
4317
|
+
await lab.exec(psql("SELECT pg_reload_conf()"));
|
|
4318
|
+
await lab.exec(psql(`CREATE TABLE ${TABLE} (n integer PRIMARY KEY)`));
|
|
4319
|
+
const standby = await lab.sibling(await targetImageOf(cell.target), standbyCommand(lab.serverIp), STANDBY_READY);
|
|
4320
|
+
await untilStreaming(lab, "async");
|
|
4321
|
+
if (pair.synchronous) {
|
|
4322
|
+
await lab.exec(psql("ALTER SYSTEM SET synchronous_standby_names = '*'"));
|
|
4323
|
+
await lab.exec(psql("SELECT pg_reload_conf()"));
|
|
4324
|
+
await untilStreaming(lab, "sync");
|
|
4325
|
+
}
|
|
4326
|
+
return standby;
|
|
4327
|
+
};
|
|
4328
|
+
/** This function waits for a count of acknowledged writes. */
|
|
4329
|
+
const untilSteady = async (probe) => {
|
|
4330
|
+
const started = Date.now();
|
|
4331
|
+
while (probe.events.filter((event) => event.ev === "ack").length < STEADY$4) {
|
|
4332
|
+
if (Date.now() - started > SETUP_MS) throw new Error(`the client did not write ${STEADY$4} times in ${SETUP_MS} ms`);
|
|
4333
|
+
await sleep$5(10);
|
|
4334
|
+
}
|
|
4335
|
+
};
|
|
4336
|
+
/** This function gives the count of lost records in the latest clock line of the prober. */
|
|
4337
|
+
const droppedSoFar$5 = (records) => {
|
|
4338
|
+
for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
|
|
4339
|
+
return 0;
|
|
4340
|
+
};
|
|
4341
|
+
/**
|
|
4342
|
+
* This function does one run of a pair. A dry run writes and stops, with no fault: the warm-up of the
|
|
4343
|
+
* environment. It removes its rows, thus the numbers of the run start on an empty table.
|
|
4344
|
+
*/
|
|
4345
|
+
const pairOnce = async (lab, standby, cell, environment, index, dry = false) => {
|
|
4346
|
+
const pair = cell.pair;
|
|
4347
|
+
await lab.forget();
|
|
4348
|
+
const from = lab.kernel.length;
|
|
4349
|
+
const lostBefore = droppedSoFar$5(lab.kernel);
|
|
4350
|
+
const probe = await lab.probe(pairArgs(cell, lab));
|
|
4351
|
+
const holdNs = cell.hold === void 0 ? 0 : valueIn(cell.hold, "s") * 1e9;
|
|
4352
|
+
let fault = null;
|
|
4353
|
+
let heal = null;
|
|
4354
|
+
try {
|
|
4355
|
+
await probe.until(["ready"], 15e3);
|
|
4356
|
+
await untilSteady(probe);
|
|
4357
|
+
if (!dry) {
|
|
4358
|
+
fault = await lab.fault("blackhole", "target", { peers: peersOf$1(pair, standby.ip, lab) });
|
|
4359
|
+
const started = Date.now();
|
|
4360
|
+
for (const at of cancelsOf(pair, holdNs)) {
|
|
4361
|
+
await sleep$5(Math.max(0, at / 1e6 - (Date.now() - started)));
|
|
4362
|
+
await lab.exec(psql("SELECT count(pg_cancel_backend(pid)) FROM pg_stat_activity WHERE wait_event = 'SyncRep'"));
|
|
4363
|
+
}
|
|
4364
|
+
heal = await lab.signal("KILL", fault.t0 + holdNs);
|
|
4365
|
+
await sleep$5(LAST_WRITE_MS);
|
|
4366
|
+
if (cell.target.kill !== void 0) await lab.exec(cell.target.kill);
|
|
4367
|
+
}
|
|
4368
|
+
await probe.go().catch(() => void 0);
|
|
4369
|
+
await probe.until(["done"], STOP_MS);
|
|
4370
|
+
} finally {
|
|
4371
|
+
await probe.stop();
|
|
4372
|
+
}
|
|
4373
|
+
if (dry) await lab.exec(psql(`TRUNCATE ${TABLE}`));
|
|
4374
|
+
const settled = await lab.settle();
|
|
4375
|
+
const lost = settled === null ? null : settled - lostBefore;
|
|
4376
|
+
const read = `SELECT n FROM ${TABLE} ORDER BY n`;
|
|
4377
|
+
let holds = null;
|
|
4378
|
+
let oldHolds = [];
|
|
4379
|
+
if (!dry) {
|
|
4380
|
+
await standby.exec(psql("SELECT pg_promote(true, 60)"));
|
|
4381
|
+
holds = numbersOf(await standby.exec(psql(read)));
|
|
4382
|
+
await lab.exec([...RESTART]);
|
|
4383
|
+
oldHolds = numbersOf(await lab.exec(psql(read)));
|
|
4384
|
+
}
|
|
4385
|
+
return {
|
|
4386
|
+
environment,
|
|
4387
|
+
index,
|
|
4388
|
+
events: [...probe.events],
|
|
4389
|
+
fault,
|
|
4390
|
+
...heal === null ? {} : { heal },
|
|
4391
|
+
kernel: lab.kernel.slice(from),
|
|
4392
|
+
netns: lab.netns.client,
|
|
4393
|
+
targetNetns: lab.netns.target,
|
|
4394
|
+
lost,
|
|
4395
|
+
...lab.topology === null ? {} : {
|
|
4396
|
+
topology: lab.topology,
|
|
4397
|
+
middlebox: []
|
|
4398
|
+
},
|
|
4399
|
+
group: {
|
|
4400
|
+
members: {
|
|
4401
|
+
primary: {
|
|
4402
|
+
ip: lab.serverIp,
|
|
4403
|
+
netns: lab.netns.target
|
|
4404
|
+
},
|
|
4405
|
+
standby: {
|
|
4406
|
+
ip: standby.ip,
|
|
4407
|
+
netns: standby.netns
|
|
4408
|
+
}
|
|
4409
|
+
},
|
|
4410
|
+
sentinels: {},
|
|
4411
|
+
primary: "primary",
|
|
4412
|
+
at: "primary",
|
|
4413
|
+
timeline: [],
|
|
4414
|
+
after: dry ? "primary" : "standby",
|
|
4415
|
+
holds,
|
|
4416
|
+
oldHolds
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
};
|
|
4420
|
+
//#endregion
|
|
4421
|
+
//#region src/pins.ts
|
|
4422
|
+
/**
|
|
4423
|
+
* This module tells if a manifest pins each program that a run operated. A record that does not pin
|
|
4424
|
+
* them is not a record. A later run cannot operate the same programs, thus nobody can reproduce
|
|
4425
|
+
* its numbers (SYS-101). Each image is pulled by its digest, or the harness builds it. A build
|
|
4426
|
+
* records its bases by digest, the sha256 of each lockfile, and the versions of the packages that
|
|
4427
|
+
* its manifests name. The commit of the code that operated is a field of the record of a cell, and
|
|
4428
|
+
* `commitProblems` examines it.
|
|
4429
|
+
*/
|
|
4430
|
+
/** This pattern matches the id of an image: `sha256:` and 64 hex digits. */
|
|
4431
|
+
const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
|
|
4432
|
+
/** This pattern matches the tag of a build of the harness: its name, then 12 hex digits of the sha256 of its context. */
|
|
4433
|
+
const BUILD_TAG = /^[a-z0-9][a-z0-9._\-/]*:[0-9a-f]{12}$/;
|
|
4434
|
+
/** This pattern matches a commit of git: 40 hex digits. */
|
|
4435
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
4436
|
+
/** This function gives each problem of the record of a build. A problem is a base that no digest pins, or a tag of another form. */
|
|
4437
|
+
const buildProblems = (what, build) => [...BUILD_TAG.test(build.tag) ? [] : [`${what}: the build has the tag ${build.tag}, which is not a tag of a build`], ...build.bases.filter((base) => !PINNED.test(base)).map((base) => `${what}: the base ${base} is not pinned by digest`)];
|
|
4438
|
+
/**
|
|
4439
|
+
* This function gives each problem of an image of a manifest. A pulled image needs a digest. An
|
|
4440
|
+
* image that the harness builds needs its build record when the manifest can hold one (`build`).
|
|
4441
|
+
* Otherwise, it needs the tag of a build and the id of its image.
|
|
4442
|
+
*/
|
|
4443
|
+
const pinProblems = (what, image, id, build, needsBuild) => {
|
|
4444
|
+
if (PINNED.test(image)) return [];
|
|
4445
|
+
if (build !== void 0) return buildProblems(what, build);
|
|
4446
|
+
if (BUILD_TAG.test(image) && !needsBuild && id !== void 0 && IMAGE_ID.test(id)) return [];
|
|
4447
|
+
return BUILD_TAG.test(image) && needsBuild ? [`${what}: the image ${image} is a build with no build record`] : [`${what}: the image ${image} has no digest`];
|
|
4448
|
+
};
|
|
4449
|
+
/**
|
|
4450
|
+
* This function gives each problem of a manifest that stops it from pinning the programs of its
|
|
4451
|
+
* run. An empty list means that the manifest pins each program.
|
|
4452
|
+
* - the target: an image by its digest, or the build of a subject with its build record and its base
|
|
4453
|
+
* by digest.
|
|
4454
|
+
* - the harness: the version of its package, and the build record of its image.
|
|
4455
|
+
* - the client: an image by its digest, or a build with its build record.
|
|
4456
|
+
* - each hop: an image by its digest, or the tag of a build and the id of its image.
|
|
4457
|
+
*/
|
|
4458
|
+
const manifestProblems = (manifest) => [
|
|
4459
|
+
...pinProblems("the target", manifest.target.image, manifest.target.id, manifest.target.build, manifest.target.subject !== void 0),
|
|
4460
|
+
...manifest.target.subject !== void 0 && (manifest.target.base === void 0 || !PINNED.test(manifest.target.base)) ? [`the target: the base ${manifest.target.base ?? "(none)"} of its subject is not pinned by digest`] : [],
|
|
4461
|
+
...manifest.harness.version === void 0 ? ["the harness: the manifest has no version of the harness"] : [],
|
|
4462
|
+
...manifest.harness.build === void 0 ? ["the harness: the image has no build record"] : buildProblems("the harness", manifest.harness.build),
|
|
4463
|
+
...pinProblems("the client", manifest.client.image, void 0, manifest.client.build, true),
|
|
4464
|
+
...(manifest.hops ?? []).flatMap((hop) => pinProblems(`the hop ${hop.name}`, hop.image, hop.id, void 0, false))
|
|
4465
|
+
];
|
|
4466
|
+
/** This function gives the problem of the commit of a record: no commit, or a text that is not a commit of git. */
|
|
4467
|
+
const commitProblems = (commit) => commit === void 0 ? ["the record has no commit of the code that operated"] : COMMIT.test(commit) ? [] : [`the record has the commit ${commit}, which is not 40 hex digits`];
|
|
4468
|
+
/** This problem is the problem of a checkout with changes: its commit does not hold the code that operated. */
|
|
4469
|
+
const DIRTY = "the checkout has changes to tracked files that no commit holds";
|
|
4470
|
+
/** This problem is the problem of a record of a local debug run. */
|
|
4471
|
+
const DEBUG = "the record is of a local debug run, and no page reads it";
|
|
4472
|
+
/**
|
|
4473
|
+
* This function gives each problem that stops a record from pinning the programs of its runs. A
|
|
4474
|
+
* record with a problem is not a record (SYS-101). The writer of the cells refuses it, and the read
|
|
4475
|
+
* of a schedule refuses it. A commit with changes to tracked files is not a pin.
|
|
4476
|
+
*/
|
|
4477
|
+
const recordProblems = (record) => [.../* @__PURE__ */ new Set([
|
|
4478
|
+
...commitProblems(record.code?.commit),
|
|
4479
|
+
...record.code?.dirty === true ? [DIRTY] : [],
|
|
4480
|
+
...record.manifests.flatMap((manifest) => manifestProblems(manifest)),
|
|
4481
|
+
...record.debug === true ? [DEBUG] : []
|
|
4482
|
+
])];
|
|
4483
|
+
//#endregion
|
|
4484
|
+
//#region src/group.ts
|
|
4485
|
+
/** This function gives the configuration file of a member: its port, its settings, and the primary that a replica follows. */
|
|
4486
|
+
const memberConfig = (group, primary) => [
|
|
4487
|
+
`port ${group.port}`,
|
|
4488
|
+
...Object.entries(group.memberSettings).map(([key, value]) => `${key} ${value}`),
|
|
4489
|
+
...primary === null ? [] : [`replicaof ${primary} ${group.port}`],
|
|
4490
|
+
""
|
|
4491
|
+
].join("\n");
|
|
4492
|
+
/** This function gives the configuration file of a Sentinel: its port, the group that it monitors, and the settings of the group. */
|
|
4493
|
+
const sentinelConfig = (group, primary) => [
|
|
4494
|
+
`port ${group.sentinelPort}`,
|
|
4495
|
+
`sentinel monitor ${group.name} ${primary} ${group.port} ${group.quorum}`,
|
|
4496
|
+
...Object.entries(group.sentinelSettings).map(([key, value]) => `sentinel ${key} ${group.name} ${value}`),
|
|
4497
|
+
""
|
|
4498
|
+
].join("\n");
|
|
4499
|
+
/**
|
|
4500
|
+
* This function gives the peers that a fault on a part cuts it from, by their addresses. `all` is
|
|
4501
|
+
* each other part and the client, `group` is the other parts, and `clients` and `to-clients` are
|
|
4502
|
+
* the client. `one` and `one-way` are the first other member. The observer is not a peer, thus the timeline of the
|
|
4503
|
+
* group stays whole.
|
|
4504
|
+
*/
|
|
4505
|
+
const peersOf = (parts, client, part, cuts) => {
|
|
4506
|
+
const others = [...Object.entries(parts.members), ...Object.entries(parts.sentinels)].filter(([name]) => name !== part).map(([, at]) => at.ip);
|
|
4507
|
+
if (cuts === "clients" || cuts === "to-clients") return [client];
|
|
4508
|
+
if (cuts === "group") return others;
|
|
4509
|
+
if (cuts === "one" || cuts === "one-way") return Object.entries(parts.members).filter(([name]) => name !== part).slice(0, 1).map(([, at]) => at.ip);
|
|
4510
|
+
return [...others, client];
|
|
4511
|
+
};
|
|
4512
|
+
/** This function gives the direction of the rules of a cut: `one-way` and `to-clients` drop only the segments from the part. */
|
|
4513
|
+
const directionOf = (cuts) => cuts === "one-way" || cuts === "to-clients" ? ["--direction", "out"] : [];
|
|
4514
|
+
/** This function reads the flat reply of a command line client into its fields: name, value, name, value. */
|
|
4515
|
+
const fieldsOf = (text) => {
|
|
4516
|
+
const out = [];
|
|
4517
|
+
const words = text.split("\n").map((line) => line.replace(/\r$/, ""));
|
|
4518
|
+
let current = null;
|
|
4519
|
+
for (let at = 0; at + 1 < words.length; at += 2) {
|
|
4520
|
+
const [key, value] = [words[at], words[at + 1]];
|
|
4521
|
+
if (key === "name" || current === null) {
|
|
4522
|
+
current = {};
|
|
4523
|
+
out.push(current);
|
|
3229
4524
|
}
|
|
3230
4525
|
current[key] = value;
|
|
3231
4526
|
}
|
|
@@ -3308,7 +4603,7 @@ const viewOf = (group, ips, sentinels, members) => {
|
|
|
3308
4603
|
why: "healthy"
|
|
3309
4604
|
};
|
|
3310
4605
|
};
|
|
3311
|
-
const sleep$
|
|
4606
|
+
const sleep$4 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3312
4607
|
/** This time is the longest that a group takes to converge before a run, or after it. */
|
|
3313
4608
|
const CONVERGE_MS = 24e4;
|
|
3314
4609
|
/**
|
|
@@ -3503,7 +4798,7 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3503
4798
|
}));
|
|
3504
4799
|
calm = last.healthy ? calm + 1 : 0;
|
|
3505
4800
|
if (calm >= 2 && last.primary !== null) return last.primary;
|
|
3506
|
-
await sleep$
|
|
4801
|
+
await sleep$4(1e3);
|
|
3507
4802
|
}
|
|
3508
4803
|
throw new Error(`the group did not converge in ${within} ms: ${last.why}`);
|
|
3509
4804
|
},
|
|
@@ -3524,11 +4819,16 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3524
4819
|
]);
|
|
3525
4820
|
},
|
|
3526
4821
|
async holds(primary) {
|
|
3527
|
-
return (await cli(primary, [
|
|
4822
|
+
return holdsOf(await cli(primary, [
|
|
3528
4823
|
"--scan",
|
|
3529
4824
|
"--pattern",
|
|
3530
4825
|
"w:*"
|
|
3531
|
-
])
|
|
4826
|
+
]), await cli(primary, [
|
|
4827
|
+
"LRANGE",
|
|
4828
|
+
"w",
|
|
4829
|
+
"0",
|
|
4830
|
+
"-1"
|
|
4831
|
+
]));
|
|
3532
4832
|
},
|
|
3533
4833
|
partOf: roles,
|
|
3534
4834
|
async client(args) {
|
|
@@ -3583,7 +4883,7 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3583
4883
|
const started = Date.now();
|
|
3584
4884
|
while (await cli(member, ["PING"]).catch(() => "") !== "PONG") {
|
|
3585
4885
|
if (Date.now() - started > 3e4) throw new Error(`${member} did not start again`);
|
|
3586
|
-
await sleep$
|
|
4886
|
+
await sleep$4(200);
|
|
3587
4887
|
}
|
|
3588
4888
|
},
|
|
3589
4889
|
failover(coordinated) {
|
|
@@ -3675,12 +4975,10 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3675
4975
|
"apk",
|
|
3676
4976
|
"info",
|
|
3677
4977
|
"-v"
|
|
3678
|
-
])).split("\n").sort()
|
|
3679
|
-
|
|
3680
|
-
client: {
|
|
3681
|
-
kind: "ioredis",
|
|
3682
|
-
image: node
|
|
4978
|
+
])).split("\n").sort(),
|
|
4979
|
+
...harnessRecord()
|
|
3683
4980
|
},
|
|
4981
|
+
client: shippedClientRecord("ioredis", node),
|
|
3684
4982
|
netns: {
|
|
3685
4983
|
client: clientNetns,
|
|
3686
4984
|
target: parts.members[first].netns
|
|
@@ -3701,7 +4999,7 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3701
4999
|
}
|
|
3702
5000
|
};
|
|
3703
5001
|
/** This count is the number of accepted writes that a run waits for before its fault: the stream is steady then. */
|
|
3704
|
-
const STEADY$
|
|
5002
|
+
const STEADY$3 = 20;
|
|
3705
5003
|
/** This time is how long a run continues after its outcome, so that the record shows what came next. */
|
|
3706
5004
|
const TAIL_MS$3 = 2e3;
|
|
3707
5005
|
/** This function gives the address of a member, with the port of the group. */
|
|
@@ -3711,22 +5009,32 @@ const deadlineOf = (cell) => {
|
|
|
3711
5009
|
const { prediction } = cell;
|
|
3712
5010
|
return (prediction.detects === true ? valueIn(prediction.high, "s") : prediction.detects === false && prediction.recovers !== void 0 ? valueIn(prediction.recovers.high, "s") : valueIn(prediction.window, "s")) * 1e3 + 1e4;
|
|
3713
5011
|
};
|
|
5012
|
+
/**
|
|
5013
|
+
* This function gives the sequence numbers that a group holds, in order, from the output of its
|
|
5014
|
+
* command line. They are the keys `w:<n>` of a stream of SET, and the entries of the list `w` of a
|
|
5015
|
+
* stream of RPUSH. A list keeps each copy, thus a number can repeat.
|
|
5016
|
+
*/
|
|
5017
|
+
const holdsOf = (keys, list) => [...keys.split("\n").map((key) => Number(key.trim().slice(2))), ...list.split("\n").map((entry) => Number(entry.trim()))].filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b);
|
|
3714
5018
|
/** This function gives the arguments of the client of a group cell. */
|
|
3715
5019
|
const clientArgs = (cell, lab) => {
|
|
5020
|
+
const sentinels = cell.group.sentinels.map((name) => `${lab.sentinels[name].ip}:${cell.group.sentinelPort}`);
|
|
5021
|
+
const driver = cell.probe.driver?.name === "ioredis" ? cell.probe.driver : void 0;
|
|
3716
5022
|
return [
|
|
3717
5023
|
"--sentinels",
|
|
3718
|
-
|
|
5024
|
+
sentinels.join(","),
|
|
3719
5025
|
"--name",
|
|
3720
5026
|
cell.group.name,
|
|
3721
5027
|
"--interval-ms",
|
|
3722
5028
|
String(cell.probe.interval === void 0 ? 50 : valueIn(cell.probe.interval, "ms")),
|
|
3723
5029
|
"--max-s",
|
|
3724
5030
|
String(Math.ceil(deadlineOf(cell) / 1e3 + 120)),
|
|
3725
|
-
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)]
|
|
5031
|
+
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)],
|
|
5032
|
+
...driver?.op === void 0 ? [] : ["--op", driver.op],
|
|
5033
|
+
...driver?.window === void 0 ? [] : ["--window", String(driver.window)]
|
|
3726
5034
|
];
|
|
3727
5035
|
};
|
|
3728
5036
|
/** This function waits until a condition on the lines of the client holds, or for `within` ms. It gives the first line that matches. */
|
|
3729
|
-
const waitFor$
|
|
5037
|
+
const waitFor$3 = (client, match, within) => new Promise((resolve) => {
|
|
3730
5038
|
const started = Date.now();
|
|
3731
5039
|
const look = () => {
|
|
3732
5040
|
const found = client.events.find(match);
|
|
@@ -3742,7 +5050,7 @@ const subscriptionsOf = (timeline) => {
|
|
|
3742
5050
|
return [...latest.values()];
|
|
3743
5051
|
};
|
|
3744
5052
|
/** This function gives the count of lost records in the latest clock line of the prober. */
|
|
3745
|
-
const droppedSoFar$
|
|
5053
|
+
const droppedSoFar$4 = (records) => {
|
|
3746
5054
|
for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
|
|
3747
5055
|
return 0;
|
|
3748
5056
|
};
|
|
@@ -3764,7 +5072,7 @@ const groupOnce = async (lab, cell, environment, index) => {
|
|
|
3764
5072
|
await lab.forget();
|
|
3765
5073
|
const from = lab.kernel.length;
|
|
3766
5074
|
const timelineFrom = lab.timeline.length;
|
|
3767
|
-
const lostBefore = droppedSoFar$
|
|
5075
|
+
const lostBefore = droppedSoFar$4(lab.kernel);
|
|
3768
5076
|
const client = await lab.client(clientArgs(cell, lab));
|
|
3769
5077
|
const lands = cell.prediction.group?.lands ?? "new";
|
|
3770
5078
|
const place = cell.faultAt ?? { role: "primary" };
|
|
@@ -3774,7 +5082,7 @@ const groupOnce = async (lab, cell, environment, index) => {
|
|
|
3774
5082
|
const undo = [];
|
|
3775
5083
|
try {
|
|
3776
5084
|
await client.until(["ready"], 6e4);
|
|
3777
|
-
if (await waitFor$
|
|
5085
|
+
if (await waitFor$3(client, (event) => event.ev === "ack" && Number(event["n"]) >= STEADY$3, 3e4) === void 0) throw new Error(`the client did not write ${STEADY$3} times in 30 s`);
|
|
3778
5086
|
let healing = null;
|
|
3779
5087
|
switch (cell.fault) {
|
|
3780
5088
|
case "blackhole":
|
|
@@ -3788,7 +5096,7 @@ const groupOnce = async (lab, cell, environment, index) => {
|
|
|
3788
5096
|
undo.push(() => lab.lift(kind, part, cuts));
|
|
3789
5097
|
if (cell.stall !== void 0) {
|
|
3790
5098
|
const t1 = fault.t1;
|
|
3791
|
-
const sent = await waitFor$
|
|
5099
|
+
const sent = await waitFor$3(client, (event) => event.ev === "sent" && event.t > t1, 5e3);
|
|
3792
5100
|
if (sent !== void 0) healing = lab.lift(kind, part, cuts, sent.t + valueIn(cell.stall, "s") * 1e9);
|
|
3793
5101
|
} else if (cell.hold !== void 0) healing = lab.lift(kind, part, cuts, fault.t0 + valueIn(cell.hold, "s") * 1e9);
|
|
3794
5102
|
break;
|
|
@@ -3822,11 +5130,11 @@ const groupOnce = async (lab, cell, environment, index) => {
|
|
|
3822
5130
|
const t0 = fault.t0;
|
|
3823
5131
|
const old = addressOf(lab, primary);
|
|
3824
5132
|
const deadline = deadlineOf(cell);
|
|
3825
|
-
if ((lands === "new" ? await waitFor$
|
|
5133
|
+
if ((lands === "new" ? await waitFor$3(client, (event) => event.ev === "ack" && event.t > t0 && typeof event["at"] === "string" && event["at"] !== old, deadline) : await (async () => {
|
|
3826
5134
|
await healed;
|
|
3827
5135
|
const after = heal?.t0 ?? t0;
|
|
3828
|
-
return waitFor$
|
|
3829
|
-
})()) !== void 0) await sleep$
|
|
5136
|
+
return waitFor$3(client, (event) => event.ev === "ack" && event.t > after, deadline);
|
|
5137
|
+
})()) !== void 0) await sleep$4(TAIL_MS$3);
|
|
3830
5138
|
await healed;
|
|
3831
5139
|
} finally {
|
|
3832
5140
|
for (const step of undo.reverse()) await step().catch(() => void 0);
|
|
@@ -3919,6 +5227,10 @@ const cutOf = (set, members, client, part, cuts) => {
|
|
|
3919
5227
|
peers: others.slice(0, 1),
|
|
3920
5228
|
direction: "out"
|
|
3921
5229
|
};
|
|
5230
|
+
if (cuts === "to-clients") return {
|
|
5231
|
+
peers: [client],
|
|
5232
|
+
direction: "out"
|
|
5233
|
+
};
|
|
3922
5234
|
return { peers: [...others, client] };
|
|
3923
5235
|
};
|
|
3924
5236
|
/** This function gives the command of the server of each member. The server is the child of a shell, thus a fin can stop it and the container stays up. */
|
|
@@ -3989,7 +5301,8 @@ const statusViewOf = (set, ips, status) => {
|
|
|
3989
5301
|
why: "healthy"
|
|
3990
5302
|
};
|
|
3991
5303
|
};
|
|
3992
|
-
|
|
5304
|
+
/** This function waits for a time, in ms. */
|
|
5305
|
+
const sleep$3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3993
5306
|
/** These are the arguments of replSetStepDown: the time that the old primary cannot stand again, and the wait for a secondary to catch up. */
|
|
3994
5307
|
const STEP_DOWN = {
|
|
3995
5308
|
seconds: 60,
|
|
@@ -4004,8 +5317,9 @@ const STEP_DOWN = {
|
|
|
4004
5317
|
* - an injector sidecar for each member, in its network and process namespaces.
|
|
4005
5318
|
* - a client that holds its own network namespace, with a privileged sidecar for its sysctls.
|
|
4006
5319
|
* - the prober, which watches the namespace of the client.
|
|
5320
|
+
* - a backup host for a recovery drill: the stock image, for its tools, with no server.
|
|
4007
5321
|
*/
|
|
4008
|
-
const openReplicaSetLab = async (set) => {
|
|
5322
|
+
const openReplicaSetLab = async (set, backups = false) => {
|
|
4009
5323
|
const image = await harnessImage();
|
|
4010
5324
|
const mongo = await mongoClientImage();
|
|
4011
5325
|
const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
|
|
@@ -4064,6 +5378,12 @@ const openReplicaSetLab = async (set) => {
|
|
|
4064
5378
|
"while :; do sleep 3600; done"
|
|
4065
5379
|
]).start();
|
|
4066
5380
|
opened.push(commander);
|
|
5381
|
+
const backupHost = backups ? await new GenericContainer(set.image).withNetwork(network).withCommand([
|
|
5382
|
+
"sh",
|
|
5383
|
+
"-c",
|
|
5384
|
+
"while :; do sleep 3600; done"
|
|
5385
|
+
]).start() : null;
|
|
5386
|
+
if (backupHost !== null) opened.push(backupHost);
|
|
4067
5387
|
const clientNetns = await netnsOf(admin);
|
|
4068
5388
|
const records = lines();
|
|
4069
5389
|
const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand(["/opt/harness/prober", String(clientNetns)]).withLogConsumer(records.consume).start();
|
|
@@ -4081,11 +5401,11 @@ const openReplicaSetLab = async (set) => {
|
|
|
4081
5401
|
return found;
|
|
4082
5402
|
};
|
|
4083
5403
|
/** This function starts a script of the shell on a member, against its own server only. */
|
|
4084
|
-
const shell = (name, script) => output(box(name), [
|
|
5404
|
+
const shell = (name, script, port = set.port) => output(box(name), [
|
|
4085
5405
|
set.programs.shell,
|
|
4086
5406
|
"--quiet",
|
|
4087
5407
|
"--norc",
|
|
4088
|
-
`mongodb://127.0.0.1:${
|
|
5408
|
+
`mongodb://127.0.0.1:${port}/?directConnection=true&serverSelectionTimeoutMS=3000`,
|
|
4089
5409
|
"--eval",
|
|
4090
5410
|
script
|
|
4091
5411
|
]);
|
|
@@ -4130,7 +5450,7 @@ const openReplicaSetLab = async (set) => {
|
|
|
4130
5450
|
calm = last.healthy ? calm + 1 : 0;
|
|
4131
5451
|
if (last.primary !== null) known = last.primary;
|
|
4132
5452
|
if (calm >= 2 && last.primary !== null) return last.primary;
|
|
4133
|
-
await sleep$
|
|
5453
|
+
await sleep$3(1e3);
|
|
4134
5454
|
}
|
|
4135
5455
|
throw new Error(`the set did not converge in ${within} ms: ${last.why}`);
|
|
4136
5456
|
};
|
|
@@ -4236,9 +5556,17 @@ const openReplicaSetLab = async (set) => {
|
|
|
4236
5556
|
const started = Date.now();
|
|
4237
5557
|
while (!(await shell(member, "print(db.hello().ok)").catch(() => "")).includes("1")) {
|
|
4238
5558
|
if (Date.now() - started > 6e4) throw new Error(`${member} did not start again`);
|
|
4239
|
-
await sleep$
|
|
5559
|
+
await sleep$3(500);
|
|
4240
5560
|
}
|
|
4241
5561
|
},
|
|
5562
|
+
exec(member, argv) {
|
|
5563
|
+
return output(box(member), argv);
|
|
5564
|
+
},
|
|
5565
|
+
script: shell,
|
|
5566
|
+
vault(argv) {
|
|
5567
|
+
if (backupHost === null) throw new Error("this lab has no backup host");
|
|
5568
|
+
return output(backupHost, argv);
|
|
5569
|
+
},
|
|
4242
5570
|
stepDown(primary) {
|
|
4243
5571
|
return stamped(commander, [
|
|
4244
5572
|
"node",
|
|
@@ -4291,7 +5619,7 @@ const openReplicaSetLab = async (set) => {
|
|
|
4291
5619
|
architecture: await output(holder, ["uname", "-m"]),
|
|
4292
5620
|
hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
|
|
4293
5621
|
boot_id: await output(holder, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
4294
|
-
substrate:
|
|
5622
|
+
substrate: substrateHere("E1"),
|
|
4295
5623
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
4296
5624
|
target: {
|
|
4297
5625
|
image: set.image,
|
|
@@ -4306,12 +5634,10 @@ const openReplicaSetLab = async (set) => {
|
|
|
4306
5634
|
"apk",
|
|
4307
5635
|
"info",
|
|
4308
5636
|
"-v"
|
|
4309
|
-
])).split("\n").sort()
|
|
4310
|
-
|
|
4311
|
-
client: {
|
|
4312
|
-
kind: "mongodb",
|
|
4313
|
-
image: mongo
|
|
5637
|
+
])).split("\n").sort(),
|
|
5638
|
+
...harnessRecord()
|
|
4314
5639
|
},
|
|
5640
|
+
client: shippedClientRecord("mongodb", mongo),
|
|
4315
5641
|
netns: {
|
|
4316
5642
|
client: clientNetns,
|
|
4317
5643
|
target: members[set.members[0]].netns
|
|
@@ -4331,10 +5657,10 @@ const openReplicaSetLab = async (set) => {
|
|
|
4331
5657
|
throw error;
|
|
4332
5658
|
}
|
|
4333
5659
|
};
|
|
4334
|
-
/** This count is the number of accepted writes that a run waits for before its fault: the stream is steady then. */
|
|
4335
|
-
const STEADY$1 = 20;
|
|
4336
5660
|
/** This time is how long a run continues after its outcome, so that the record shows what came next. */
|
|
4337
5661
|
const TAIL_MS$2 = 2e3;
|
|
5662
|
+
/** This function gives how long a run of a cell continues after its lift, in ms: the tail, and the watch of the cell. */
|
|
5663
|
+
const afterLiftOf = (cell) => TAIL_MS$2 + (cell.watch === void 0 ? 0 : valueIn(cell.watch, "s") * 1e3);
|
|
4338
5664
|
/** This function gives the arguments of the client of a replica set cell: each member, and the stream of writes. */
|
|
4339
5665
|
const replicaSetArgs = (cell, members) => {
|
|
4340
5666
|
const set = cell.replicaSet;
|
|
@@ -4351,14 +5677,14 @@ const replicaSetArgs = (cell, members) => {
|
|
|
4351
5677
|
"--interval-ms",
|
|
4352
5678
|
String(cell.probe.interval === void 0 ? 50 : valueIn(cell.probe.interval, "ms")),
|
|
4353
5679
|
"--max-s",
|
|
4354
|
-
String(Math.ceil(deadlineOf(cell) / 1e3 + (cell.hold === void 0 ? 0 : valueIn(cell.hold, "s")) + 120)),
|
|
5680
|
+
String(Math.ceil(deadlineOf(cell) / 1e3 + (cell.hold === void 0 ? 0 : valueIn(cell.hold, "s")) + (cell.drill === void 0 ? 0 : drillSpan(cell.drill)) + 120)),
|
|
4355
5681
|
"--w",
|
|
4356
5682
|
String(cell.writeConcern ?? "majority"),
|
|
4357
5683
|
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)]
|
|
4358
5684
|
];
|
|
4359
5685
|
};
|
|
4360
5686
|
/** This function waits until a condition on the lines of the client holds, or for `within` ms. It gives the first line that matches. */
|
|
4361
|
-
const waitFor$
|
|
5687
|
+
const waitFor$2 = (client, match, within) => new Promise((resolve) => {
|
|
4362
5688
|
const started = Date.now();
|
|
4363
5689
|
const look = () => {
|
|
4364
5690
|
const found = client.events.find(match);
|
|
@@ -4368,7 +5694,7 @@ const waitFor$1 = (client, match, within) => new Promise((resolve) => {
|
|
|
4368
5694
|
look();
|
|
4369
5695
|
});
|
|
4370
5696
|
/** This function gives the count of lost records in the latest clock line of the prober. */
|
|
4371
|
-
const droppedSoFar$
|
|
5697
|
+
const droppedSoFar$3 = (records) => {
|
|
4372
5698
|
for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
|
|
4373
5699
|
return 0;
|
|
4374
5700
|
};
|
|
@@ -4393,7 +5719,7 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4393
5719
|
await lab.forget();
|
|
4394
5720
|
const from = lab.kernel.length;
|
|
4395
5721
|
const logFrom = lab.log.length;
|
|
4396
|
-
const lostBefore = droppedSoFar$
|
|
5722
|
+
const lostBefore = droppedSoFar$3(lab.kernel);
|
|
4397
5723
|
const client = await lab.client(replicaSetArgs(cell, lab.members));
|
|
4398
5724
|
const lands = cell.prediction.group?.lands ?? "new";
|
|
4399
5725
|
const place = cell.faultAt ?? { role: "primary" };
|
|
@@ -4403,7 +5729,7 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4403
5729
|
const undo = [];
|
|
4404
5730
|
try {
|
|
4405
5731
|
await client.until(["ready"], 6e4);
|
|
4406
|
-
if (await waitFor$
|
|
5732
|
+
if (await waitFor$2(client, (event) => event.ev === "ack" && Number(event["n"]) >= 20, 3e4) === void 0) throw new Error(`the client did not write 20 times in 30 s`);
|
|
4407
5733
|
let healing = null;
|
|
4408
5734
|
switch (cell.fault) {
|
|
4409
5735
|
case "blackhole":
|
|
@@ -4417,7 +5743,7 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4417
5743
|
undo.push(() => lab.lift(kind, part, cuts));
|
|
4418
5744
|
if (cell.stall !== void 0) {
|
|
4419
5745
|
const t1 = fault.t1;
|
|
4420
|
-
const sent = await waitFor$
|
|
5746
|
+
const sent = await waitFor$2(client, (event) => event.ev === "sent" && event.t > t1, 5e3);
|
|
4421
5747
|
healing = lab.lift(kind, part, cuts, (sent?.t ?? t1) + valueIn(cell.stall, "s") * 1e9);
|
|
4422
5748
|
} else if (cell.hold !== void 0) healing = lab.lift(kind, part, cuts, fault.t0 + valueIn(cell.hold, "s") * 1e9);
|
|
4423
5749
|
break;
|
|
@@ -4452,10 +5778,10 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4452
5778
|
const t1 = fault.t1;
|
|
4453
5779
|
const old = `${lab.members[primary].ip}:${set.port}`;
|
|
4454
5780
|
const sentAt = (n) => client.events.find((event) => event.ev === "sent" && Number(event["n"]) === n)?.t ?? -Infinity;
|
|
4455
|
-
if (await waitFor$
|
|
5781
|
+
if (await waitFor$2(client, (event) => event.ev === "ack" && sentAt(Number(event["n"])) > t1 && (lands === "same" || typeof event["at"] === "string" && event["at"] !== old), deadlineOf(cell)) !== void 0) await sleep$3(TAIL_MS$2);
|
|
4456
5782
|
if (healing !== null) {
|
|
4457
5783
|
await healed.catch(() => void 0);
|
|
4458
|
-
await sleep$
|
|
5784
|
+
await sleep$3(afterLiftOf(cell));
|
|
4459
5785
|
}
|
|
4460
5786
|
} finally {
|
|
4461
5787
|
for (const step of undo.reverse()) await step().catch(() => void 0);
|
|
@@ -4487,6 +5813,296 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4487
5813
|
};
|
|
4488
5814
|
};
|
|
4489
5815
|
//#endregion
|
|
5816
|
+
//#region src/drill.ts
|
|
5817
|
+
/**
|
|
5818
|
+
* This module does one run of a recovery drill on the lab of a replica set. A drill stops the store
|
|
5819
|
+
* during writes, removes its disk, and recovers from real backups, or by an election.
|
|
5820
|
+
*
|
|
5821
|
+
* A backup is a real `mongodump`, on a host of its own. The dump has the oplog of its own time, and
|
|
5822
|
+
* each slice has the oplog after the backup before it. The fault comes at the age of the newest
|
|
5823
|
+
* backup, on the clock of the VM. Thus a run measures the worst case of its interval by construction.
|
|
5824
|
+
*
|
|
5825
|
+
* A restore cannot have a client of the set: the client writes to any primary. So the restore writes
|
|
5826
|
+
* to a server with no set, on its own port. That server then stops, each member starts with an empty
|
|
5827
|
+
* set, and the old primary starts the set. Only that member has the data, thus it wins.
|
|
5828
|
+
*
|
|
5829
|
+
* The driver refuses a primary with an election id under the highest one that it saw, and a new
|
|
5830
|
+
* set starts its ids again. So a restore has two clients. The first one writes up to the fault, and
|
|
5831
|
+
* the second one starts after the restore, with the sequence numbers that follow the first.
|
|
5832
|
+
*/
|
|
5833
|
+
/** This folder is the store of the backups, on the backup host. */
|
|
5834
|
+
const VAULT = "/backup";
|
|
5835
|
+
/** This time is the longest wait for a server to answer, in ms. */
|
|
5836
|
+
const STARTS_WITHIN = 6e4;
|
|
5837
|
+
/** This function waits until a check holds, or it stops the run after `within` ms. */
|
|
5838
|
+
const until = async (what, check, within) => {
|
|
5839
|
+
const started = Date.now();
|
|
5840
|
+
while (!await check().catch(() => false)) {
|
|
5841
|
+
if (Date.now() - started > within) throw new Error(`${what} did not come in ${within} ms`);
|
|
5842
|
+
await sleep$3(500);
|
|
5843
|
+
}
|
|
5844
|
+
};
|
|
5845
|
+
/** This function gives the time that the client sent a write. Zero is no write. */
|
|
5846
|
+
const writeOf = (events, n) => {
|
|
5847
|
+
const sent = n === 0 ? void 0 : events.find((event) => event.ev === "sent" && Number(event["n"]) === n);
|
|
5848
|
+
return sent === void 0 ? null : {
|
|
5849
|
+
n,
|
|
5850
|
+
t: sent.t
|
|
5851
|
+
};
|
|
5852
|
+
};
|
|
5853
|
+
/** This function gives the size of a folder of the backup host. */
|
|
5854
|
+
const sizeOf = async (lab, path) => {
|
|
5855
|
+
const [bytes] = (await lab.vault([
|
|
5856
|
+
"du",
|
|
5857
|
+
"-sb",
|
|
5858
|
+
path
|
|
5859
|
+
])).split(/\s+/);
|
|
5860
|
+
return {
|
|
5861
|
+
value: Number(bytes),
|
|
5862
|
+
unit: "B"
|
|
5863
|
+
};
|
|
5864
|
+
};
|
|
5865
|
+
/** This function takes the dump with its oplog, and each slice of the oplog at its interval. It gives each backup in sequence. */
|
|
5866
|
+
const backupsOf = async (lab, drill, primary, writer) => {
|
|
5867
|
+
const host = `${lab.members[primary].ip}:${lab.set.port}`;
|
|
5868
|
+
const started = Date.now();
|
|
5869
|
+
await lab.vault([
|
|
5870
|
+
"mongodump",
|
|
5871
|
+
"--host",
|
|
5872
|
+
host,
|
|
5873
|
+
"--oplog",
|
|
5874
|
+
"--out",
|
|
5875
|
+
`${VAULT}/dump-0`
|
|
5876
|
+
]);
|
|
5877
|
+
const ended = Date.now();
|
|
5878
|
+
const oplog = readOplog(await lab.vault([
|
|
5879
|
+
"bsondump",
|
|
5880
|
+
"--quiet",
|
|
5881
|
+
`${VAULT}/dump-0/oplog.bson`
|
|
5882
|
+
]));
|
|
5883
|
+
const held = highestHeld(await lab.vault([
|
|
5884
|
+
"bsondump",
|
|
5885
|
+
"--quiet",
|
|
5886
|
+
`${VAULT}/dump-0/cells/w.bson`
|
|
5887
|
+
]));
|
|
5888
|
+
if (oplog.last === null) throw new Error("the oplog of the dump has no entry");
|
|
5889
|
+
let highest = Math.max(oplog.highest ?? 0, held ?? 0);
|
|
5890
|
+
let from = oplog.last;
|
|
5891
|
+
const backups = [{
|
|
5892
|
+
kind: "dump",
|
|
5893
|
+
started,
|
|
5894
|
+
ended,
|
|
5895
|
+
last: writeOf(writer.events, highest),
|
|
5896
|
+
size: await sizeOf(lab, `${VAULT}/dump-0`)
|
|
5897
|
+
}];
|
|
5898
|
+
const every = valueIn(drill.sliceEvery, "s") * 1e3;
|
|
5899
|
+
for (let slice = 1; slice <= drill.slices; slice += 1) {
|
|
5900
|
+
await sleep$3(Math.max(0, ended + slice * every - Date.now()));
|
|
5901
|
+
const out = `${VAULT}/slice-${slice}`;
|
|
5902
|
+
const begun = Date.now();
|
|
5903
|
+
await lab.vault([
|
|
5904
|
+
"mongodump",
|
|
5905
|
+
"--host",
|
|
5906
|
+
host,
|
|
5907
|
+
"--db",
|
|
5908
|
+
"local",
|
|
5909
|
+
"--collection",
|
|
5910
|
+
"oplog.rs",
|
|
5911
|
+
"--query",
|
|
5912
|
+
sliceQuery(from),
|
|
5913
|
+
"--out",
|
|
5914
|
+
out
|
|
5915
|
+
]);
|
|
5916
|
+
const finished = Date.now();
|
|
5917
|
+
const read = readOplog(await lab.vault([
|
|
5918
|
+
"bsondump",
|
|
5919
|
+
"--quiet",
|
|
5920
|
+
`${out}/local/oplog.rs.bson`
|
|
5921
|
+
]));
|
|
5922
|
+
highest = Math.max(highest, read.highest ?? 0);
|
|
5923
|
+
from = read.last ?? from;
|
|
5924
|
+
backups.push({
|
|
5925
|
+
kind: "slice",
|
|
5926
|
+
started: begun,
|
|
5927
|
+
ended: finished,
|
|
5928
|
+
last: writeOf(writer.events, highest),
|
|
5929
|
+
size: await sizeOf(lab, out)
|
|
5930
|
+
});
|
|
5931
|
+
}
|
|
5932
|
+
return backups;
|
|
5933
|
+
};
|
|
5934
|
+
/**
|
|
5935
|
+
* This function stops the server of each member that the drill loses, and it removes each disk. A
|
|
5936
|
+
* restore loses each member, and an election loses the primary. The fault comes at the age of the
|
|
5937
|
+
* drill after the last write of the newest backup. A control has no later write, and an election
|
|
5938
|
+
* has no backup: their age counts from this point. The stamp is the one of the primary.
|
|
5939
|
+
*/
|
|
5940
|
+
const lose = async (lab, drill, primary, last) => {
|
|
5941
|
+
const parts = drill.recovery === "restore" ? [primary, ...lab.set.members.filter((name) => name !== primary)] : [primary];
|
|
5942
|
+
const age = valueIn(drill.age, "s");
|
|
5943
|
+
const at = last === null || drill.quiet === true ? void 0 : last.t + age * 1e9;
|
|
5944
|
+
if (at === void 0) await sleep$3(age * 1e3);
|
|
5945
|
+
const stamps = await Promise.all(parts.map((part) => lab.signal(part, "KILL", at)));
|
|
5946
|
+
for (const part of parts) {
|
|
5947
|
+
await lab.exec(part, [
|
|
5948
|
+
"sh",
|
|
5949
|
+
"-c",
|
|
5950
|
+
stopScript(false)
|
|
5951
|
+
]);
|
|
5952
|
+
await lab.exec(part, [
|
|
5953
|
+
"find",
|
|
5954
|
+
"/data/db",
|
|
5955
|
+
"-mindepth",
|
|
5956
|
+
"1",
|
|
5957
|
+
"-delete"
|
|
5958
|
+
]);
|
|
5959
|
+
}
|
|
5960
|
+
return stamps[0];
|
|
5961
|
+
};
|
|
5962
|
+
/**
|
|
5963
|
+
* This function restores the old primary from the backups and starts the set again:
|
|
5964
|
+
* - The slices go after the oplog of the dump, in their sequence, thus one replay does the whole restore.
|
|
5965
|
+
* - The restore writes to a server of the old primary with no set, on its own port.
|
|
5966
|
+
* - Each member that lost its disk starts before the set, because `rs.initiate` asks each member.
|
|
5967
|
+
* - The old primary starts the set. It has the data, and the other members make their first sync from it.
|
|
5968
|
+
*/
|
|
5969
|
+
const restore = async (lab, drill, primary) => {
|
|
5970
|
+
const { set } = lab;
|
|
5971
|
+
const started = Date.now();
|
|
5972
|
+
const port = set.port + 1;
|
|
5973
|
+
const slices = Array.from({ length: drill.slices }, (_, at) => `${VAULT}/slice-${at + 1}/local/oplog.rs.bson`);
|
|
5974
|
+
if (slices.length > 0) await lab.vault([
|
|
5975
|
+
"sh",
|
|
5976
|
+
"-c",
|
|
5977
|
+
`cat ${slices.join(" ")} >> ${VAULT}/dump-0/oplog.bson`
|
|
5978
|
+
]);
|
|
5979
|
+
await lab.exec(primary, [
|
|
5980
|
+
"sh",
|
|
5981
|
+
"-c",
|
|
5982
|
+
`${standaloneCommand(set, port)} > /proc/1/fd/1 2>&1 & echo $! > /tmp/server.pid`
|
|
5983
|
+
]);
|
|
5984
|
+
await until("the server of the restore", async () => (await lab.script(primary, "print(db.hello().ok)", port)).includes("1"), STARTS_WITHIN);
|
|
5985
|
+
await lab.vault([
|
|
5986
|
+
"mongorestore",
|
|
5987
|
+
"--host",
|
|
5988
|
+
`${lab.members[primary].ip}:${port}`,
|
|
5989
|
+
"--oplogReplay",
|
|
5990
|
+
`${VAULT}/dump-0`
|
|
5991
|
+
]);
|
|
5992
|
+
await lab.exec(primary, [
|
|
5993
|
+
"sh",
|
|
5994
|
+
"-c",
|
|
5995
|
+
stopScript(true)
|
|
5996
|
+
]);
|
|
5997
|
+
for (const name of set.members) if (name !== primary) await lab.restart(name);
|
|
5998
|
+
await lab.restart(primary);
|
|
5999
|
+
const ips = Object.fromEntries(Object.entries(lab.members).map(([name, at]) => [name, at.ip]));
|
|
6000
|
+
const initiated = await lab.script(primary, initiateScript(set, ips));
|
|
6001
|
+
if (!initiated.includes("\"ok\":1")) throw new Error(`rs.initiate failed after the restore: ${initiated}`);
|
|
6002
|
+
await until("the primary after the restore", async () => (await lab.script(primary, "print(db.hello().isWritablePrimary)")).includes("true"), STARTS_WITHIN);
|
|
6003
|
+
return {
|
|
6004
|
+
started,
|
|
6005
|
+
ended: Date.now()
|
|
6006
|
+
};
|
|
6007
|
+
};
|
|
6008
|
+
/**
|
|
6009
|
+
* This function does one run of a recovery drill:
|
|
6010
|
+
* - The set converges, and it loses its writes. The tenants get their data, and a hash reads it.
|
|
6011
|
+
* - The client writes until its stream is steady. A control stops it here.
|
|
6012
|
+
* - The backups go, each at its interval. The fault comes at the age of the newest backup.
|
|
6013
|
+
* - An election continues the same client. A restore stops the client, and it starts a second one.
|
|
6014
|
+
* - The set converges again. The writes that it then holds tell which writes it kept, and a second
|
|
6015
|
+
* hash tells if the tenants have the same data.
|
|
6016
|
+
*/
|
|
6017
|
+
const drillOnce = async (lab, cell, environment, index) => {
|
|
6018
|
+
const drill = cell.drill;
|
|
6019
|
+
const { set } = lab;
|
|
6020
|
+
const primary = await lab.converge();
|
|
6021
|
+
await lab.flush(primary);
|
|
6022
|
+
await lab.unfreeze(primary);
|
|
6023
|
+
await lab.forget();
|
|
6024
|
+
await lab.vault([
|
|
6025
|
+
"sh",
|
|
6026
|
+
"-c",
|
|
6027
|
+
`rm -rf ${VAULT} && mkdir -p ${VAULT}`
|
|
6028
|
+
]);
|
|
6029
|
+
const seeded = await lab.script(primary, seedScript(drill, set.members.length));
|
|
6030
|
+
if (!seeded.includes("\"ok\":true")) throw new Error(`the seed failed: ${seeded}`);
|
|
6031
|
+
const before = hashOf(await lab.script(primary, HASH_SCRIPT));
|
|
6032
|
+
const tool = (await lab.vault(["mongodump", "--version"])).split("\n")[0].trim();
|
|
6033
|
+
const from = lab.kernel.length;
|
|
6034
|
+
const logFrom = lab.log.length;
|
|
6035
|
+
const lostBefore = droppedSoFar$3(lab.kernel);
|
|
6036
|
+
const writer = await lab.client(replicaSetArgs(cell, lab.members));
|
|
6037
|
+
const restores = drill.recovery === "restore";
|
|
6038
|
+
let successor = null;
|
|
6039
|
+
let second = null;
|
|
6040
|
+
let fault = null;
|
|
6041
|
+
let restored = null;
|
|
6042
|
+
const backups = [];
|
|
6043
|
+
try {
|
|
6044
|
+
await writer.until(["ready"], 6e4);
|
|
6045
|
+
if (await waitFor$2(writer, (event) => event.ev === "ack" && Number(event["n"]) >= 20, 3e4) === void 0) throw new Error(`the client did not write 20 times in 30 s`);
|
|
6046
|
+
if (drill.quiet === true) await writer.stop();
|
|
6047
|
+
if (restores) backups.push(...await backupsOf(lab, drill, primary, writer));
|
|
6048
|
+
const stamp = await lose(lab, drill, primary, backups.at(-1)?.last ?? null);
|
|
6049
|
+
fault = stamp;
|
|
6050
|
+
if (restores) {
|
|
6051
|
+
await writer.stop();
|
|
6052
|
+
restored = await restore(lab, drill, primary);
|
|
6053
|
+
second = Math.max(0, ...writer.events.filter((event) => event.ev === "sent").map((event) => Number(event["n"]))) + 1;
|
|
6054
|
+
successor = await lab.client([
|
|
6055
|
+
...replicaSetArgs(cell, lab.members),
|
|
6056
|
+
"--from",
|
|
6057
|
+
String(second)
|
|
6058
|
+
]);
|
|
6059
|
+
if (await waitFor$2(successor, (event) => event.ev === "ack", deadlineOf(cell)) !== void 0) await sleep$3(TAIL_MS$2);
|
|
6060
|
+
} else {
|
|
6061
|
+
const old = `${lab.members[primary].ip}:${set.port}`;
|
|
6062
|
+
const sentAt = (n) => writer.events.find((event) => event.ev === "sent" && Number(event["n"]) === n)?.t ?? -Infinity;
|
|
6063
|
+
if (await waitFor$2(writer, (event) => event.ev === "ack" && sentAt(Number(event["n"])) > stamp.t1 && typeof event["at"] === "string" && event["at"] !== old, deadlineOf(cell)) !== void 0) await sleep$3(TAIL_MS$2);
|
|
6064
|
+
await lab.restart(primary);
|
|
6065
|
+
}
|
|
6066
|
+
} finally {
|
|
6067
|
+
await writer.stop();
|
|
6068
|
+
await successor?.stop();
|
|
6069
|
+
}
|
|
6070
|
+
const settled = await lab.settle();
|
|
6071
|
+
const lost = settled === null ? null : settled - lostBefore;
|
|
6072
|
+
const after = await lab.converge().catch(() => null);
|
|
6073
|
+
const holds = after === null ? null : await lab.holds(after);
|
|
6074
|
+
const hash = after === null ? null : hashOf(await lab.script(after, HASH_SCRIPT));
|
|
6075
|
+
const events = [...writer.events, ...successor?.events ?? []].sort((a, b) => a.t - b.t);
|
|
6076
|
+
return {
|
|
6077
|
+
environment,
|
|
6078
|
+
index,
|
|
6079
|
+
events,
|
|
6080
|
+
fault,
|
|
6081
|
+
kernel: lab.kernel.slice(from),
|
|
6082
|
+
netns: lab.netns.client,
|
|
6083
|
+
targetNetns: lab.members[primary].netns,
|
|
6084
|
+
lost,
|
|
6085
|
+
group: {
|
|
6086
|
+
members: lab.members,
|
|
6087
|
+
sentinels: {},
|
|
6088
|
+
primary,
|
|
6089
|
+
at: primary,
|
|
6090
|
+
timeline: onMonotonic(lab.log.slice(logFrom), clockOffsetOf(events)),
|
|
6091
|
+
after,
|
|
6092
|
+
holds,
|
|
6093
|
+
drill: {
|
|
6094
|
+
tool,
|
|
6095
|
+
seed: "generated",
|
|
6096
|
+
backups,
|
|
6097
|
+
before,
|
|
6098
|
+
after: hash,
|
|
6099
|
+
restored,
|
|
6100
|
+
second
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
};
|
|
6104
|
+
};
|
|
6105
|
+
//#endregion
|
|
4490
6106
|
//#region src/cluster.ts
|
|
4491
6107
|
/** This form is a line of the console log of RabbitMQ 4.3: the time with its offset, the level, the process and the message. */
|
|
4492
6108
|
const LINE = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d)\.(\d{3})(\d*)([+-]\d\d:\d\d) \[(\w+)\] <[^>]*> (.*)$/;
|
|
@@ -4606,6 +6222,7 @@ const clusterViewOf = (cluster, rows) => {
|
|
|
4606
6222
|
* This function gives the peers that a fault on a node cuts it from. `all` is each other node and the
|
|
4607
6223
|
* client, `group` is the other nodes, and `clients` is the client. `one` is the first other node in
|
|
4608
6224
|
* the sequence of the cluster, and `one-way` is the same node for the segments from the part only.
|
|
6225
|
+
* `to-clients` is the client, for the segments from the part only.
|
|
4609
6226
|
*/
|
|
4610
6227
|
const clusterCutOf = (cluster, members, client, part, cuts) => {
|
|
4611
6228
|
const others = cluster.members.filter((name) => name !== part).map((name) => members[name].ip);
|
|
@@ -4616,9 +6233,13 @@ const clusterCutOf = (cluster, members, client, part, cuts) => {
|
|
|
4616
6233
|
peers: others.slice(0, 1),
|
|
4617
6234
|
direction: "out"
|
|
4618
6235
|
};
|
|
6236
|
+
if (cuts === "to-clients") return {
|
|
6237
|
+
peers: [client],
|
|
6238
|
+
direction: "out"
|
|
6239
|
+
};
|
|
4619
6240
|
return { peers: [...others, client] };
|
|
4620
6241
|
};
|
|
4621
|
-
const sleep$
|
|
6242
|
+
const sleep$2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4622
6243
|
/**
|
|
4623
6244
|
* This function opens one environment of a cluster cell:
|
|
4624
6245
|
* - one new network, with each node, the client and the admin on it.
|
|
@@ -4780,7 +6401,7 @@ const openClusterLab = async (cluster) => {
|
|
|
4780
6401
|
calm = last.healthy ? calm + 1 : 0;
|
|
4781
6402
|
if (last.primary !== null) known = last.primary;
|
|
4782
6403
|
if (calm >= 2 && last.primary !== null) return last.primary;
|
|
4783
|
-
await sleep$
|
|
6404
|
+
await sleep$2(1e3);
|
|
4784
6405
|
}
|
|
4785
6406
|
throw new Error(`the queue did not converge in ${within} ms: ${last.why}`);
|
|
4786
6407
|
};
|
|
@@ -4796,7 +6417,7 @@ const openClusterLab = async (cluster) => {
|
|
|
4796
6417
|
const running = (text.trim() === "" ? {} : toolJsonOf(text)).running_nodes ?? [];
|
|
4797
6418
|
if (cluster.members.every((name) => running.includes(nodeNameOf(name)))) break;
|
|
4798
6419
|
if (Date.now() - started > 12e4) throw new Error(`the cluster did not form in 120 s: ${running.join(", ")} run`);
|
|
4799
|
-
await sleep$
|
|
6420
|
+
await sleep$2(1e3);
|
|
4800
6421
|
}
|
|
4801
6422
|
const declared = lastLine(await output(commander, [
|
|
4802
6423
|
"node",
|
|
@@ -4916,7 +6537,7 @@ const openClusterLab = async (cluster) => {
|
|
|
4916
6537
|
"check_running"
|
|
4917
6538
|
]).then(() => true).catch(() => false)) {
|
|
4918
6539
|
if (Date.now() - begun > 12e4) throw new Error(`${member} did not start again`);
|
|
4919
|
-
await sleep$
|
|
6540
|
+
await sleep$2(1e3);
|
|
4920
6541
|
}
|
|
4921
6542
|
},
|
|
4922
6543
|
stepDown(leader, to) {
|
|
@@ -4979,7 +6600,7 @@ const openClusterLab = async (cluster) => {
|
|
|
4979
6600
|
architecture: await output(holder, ["uname", "-m"]),
|
|
4980
6601
|
hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
|
|
4981
6602
|
boot_id: await output(holder, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
4982
|
-
substrate:
|
|
6603
|
+
substrate: substrateHere("E1"),
|
|
4983
6604
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
4984
6605
|
target: {
|
|
4985
6606
|
image: cluster.image,
|
|
@@ -4994,12 +6615,10 @@ const openClusterLab = async (cluster) => {
|
|
|
4994
6615
|
"apk",
|
|
4995
6616
|
"info",
|
|
4996
6617
|
"-v"
|
|
4997
|
-
])).split("\n").sort()
|
|
4998
|
-
|
|
4999
|
-
client: {
|
|
5000
|
-
kind: "amqp",
|
|
5001
|
-
image: amqp
|
|
6618
|
+
])).split("\n").sort(),
|
|
6619
|
+
...harnessRecord()
|
|
5002
6620
|
},
|
|
6621
|
+
client: shippedClientRecord("amqp", amqp),
|
|
5003
6622
|
netns: {
|
|
5004
6623
|
client: clientNetns,
|
|
5005
6624
|
target: members[cluster.members[0]].netns
|
|
@@ -5020,7 +6639,7 @@ const openClusterLab = async (cluster) => {
|
|
|
5020
6639
|
}
|
|
5021
6640
|
};
|
|
5022
6641
|
/** This count is the number of confirmed publishes that a run waits for before its fault: the stream is steady then. */
|
|
5023
|
-
const STEADY = 20;
|
|
6642
|
+
const STEADY$1 = 20;
|
|
5024
6643
|
/** This time is how long a run continues after its outcome, so that the record shows what came next. */
|
|
5025
6644
|
const TAIL_MS$1 = 2e3;
|
|
5026
6645
|
/**
|
|
@@ -5049,7 +6668,7 @@ const clusterArgs = (cell, members, leader) => [
|
|
|
5049
6668
|
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)]
|
|
5050
6669
|
];
|
|
5051
6670
|
/** This function waits until a condition on the lines of the client holds, or for `within` ms. It gives the first line that matches. */
|
|
5052
|
-
const waitFor = (client, match, within) => new Promise((resolve) => {
|
|
6671
|
+
const waitFor$1 = (client, match, within) => new Promise((resolve) => {
|
|
5053
6672
|
const started = Date.now();
|
|
5054
6673
|
const look = () => {
|
|
5055
6674
|
const found = client.events.find(match);
|
|
@@ -5059,7 +6678,7 @@ const waitFor = (client, match, within) => new Promise((resolve) => {
|
|
|
5059
6678
|
look();
|
|
5060
6679
|
});
|
|
5061
6680
|
/** This function gives the count of lost records in the latest clock line of the prober. */
|
|
5062
|
-
const droppedSoFar$
|
|
6681
|
+
const droppedSoFar$2 = (records) => {
|
|
5063
6682
|
for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
|
|
5064
6683
|
return 0;
|
|
5065
6684
|
};
|
|
@@ -5089,7 +6708,7 @@ const stampOf = async (lab, client, logFrom, marks, wall) => {
|
|
|
5089
6708
|
},
|
|
5090
6709
|
line
|
|
5091
6710
|
};
|
|
5092
|
-
await sleep$
|
|
6711
|
+
await sleep$2(100);
|
|
5093
6712
|
}
|
|
5094
6713
|
return {
|
|
5095
6714
|
stamp: monotonicOf(wall, client.events),
|
|
@@ -5126,7 +6745,7 @@ const clusterOnce = async (lab, cell, environment, index) => {
|
|
|
5126
6745
|
await lab.forget();
|
|
5127
6746
|
const from = lab.kernel.length;
|
|
5128
6747
|
const logFrom = lab.log.length;
|
|
5129
|
-
const lostBefore = droppedSoFar$
|
|
6748
|
+
const lostBefore = droppedSoFar$2(lab.kernel);
|
|
5130
6749
|
const client = await lab.client(clusterArgs(cell, lab.members, leader));
|
|
5131
6750
|
const place = cell.faultAt ?? { role: "primary" };
|
|
5132
6751
|
let fault = null;
|
|
@@ -5137,7 +6756,7 @@ const clusterOnce = async (lab, cell, environment, index) => {
|
|
|
5137
6756
|
const undo = [];
|
|
5138
6757
|
try {
|
|
5139
6758
|
await client.until(["ready"], 6e4);
|
|
5140
|
-
if (await waitFor(client, (event) => event.ev === "ack" && Number(event["n"]) >= STEADY, 3e4) === void 0) throw new Error(`the client did not publish ${STEADY} times in 30 s`);
|
|
6759
|
+
if (await waitFor$1(client, (event) => event.ev === "ack" && Number(event["n"]) >= STEADY$1, 3e4) === void 0) throw new Error(`the client did not publish ${STEADY$1} times in 30 s`);
|
|
5141
6760
|
let healing = null;
|
|
5142
6761
|
switch (cell.fault) {
|
|
5143
6762
|
case "blackhole":
|
|
@@ -5191,10 +6810,10 @@ const clusterOnce = async (lab, cell, environment, index) => {
|
|
|
5191
6810
|
healed.catch(() => void 0);
|
|
5192
6811
|
const t1 = fault.t1;
|
|
5193
6812
|
const sentAt = (n) => client.events.find((event) => event.ev === "sent" && Number(event["n"]) === n)?.t ?? -Infinity;
|
|
5194
|
-
if (await waitFor(client, (event) => event.ev === "ack" && sentAt(Number(event["n"])) > t1, deadlineOf(cell)) !== void 0) await sleep$
|
|
6813
|
+
if (await waitFor$1(client, (event) => event.ev === "ack" && sentAt(Number(event["n"])) > t1, deadlineOf(cell)) !== void 0) await sleep$2(TAIL_MS$1);
|
|
5195
6814
|
if (healing !== null) {
|
|
5196
6815
|
await healed.catch(() => void 0);
|
|
5197
|
-
await sleep$
|
|
6816
|
+
await sleep$2(TAIL_MS$1);
|
|
5198
6817
|
}
|
|
5199
6818
|
} finally {
|
|
5200
6819
|
for (const step of undo.reverse()) await step().catch(() => void 0);
|
|
@@ -5226,15 +6845,513 @@ const clusterOnce = async (lab, cell, environment, index) => {
|
|
|
5226
6845
|
};
|
|
5227
6846
|
};
|
|
5228
6847
|
//#endregion
|
|
5229
|
-
//#region src/
|
|
6848
|
+
//#region src/kafka.ts
|
|
5230
6849
|
/**
|
|
5231
|
-
* This
|
|
5232
|
-
*
|
|
5233
|
-
*
|
|
6850
|
+
* This module is the lab of a group of Kafka brokers in KRaft mode with one controller. It opens one
|
|
6851
|
+
* environment for a Kafka cell, for the runs of the cell. Each node is a container from the stock
|
|
6852
|
+
* image, in its own network namespace, and an injector sidecar shares its network and process
|
|
6853
|
+
* namespaces. The image starts by its own command, and each setting comes from the environment.
|
|
6854
|
+
*
|
|
6855
|
+
* Each run makes one new topic, thus each run starts with an empty log and its client counts from 1.
|
|
6856
|
+
* The client is the producer of kafka-clients. The commander holds the admin, on the network but
|
|
6857
|
+
* not in the namespace that the prober watches. The record of a run is the lines of the client and
|
|
6858
|
+
* the whole partition, read to its high watermark after the group converged.
|
|
5234
6859
|
*/
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
6860
|
+
/** This path is the class path of the Kafka image: the JVM expands the star itself. */
|
|
6861
|
+
const CLASS_PATH = "/opt/harness/kafka/classes:/opt/harness/kafka/lib/*";
|
|
6862
|
+
/** This option is the heap of the client and of the admin. It has no source: the two hold a producer or a read of one partition. */
|
|
6863
|
+
const CLIENT_HEAP = "-Xmx256m";
|
|
6864
|
+
/** This function gives the node id of a name. A broker has its place in the list from 1, and the controller has the id after them. */
|
|
6865
|
+
const nodeIdOf = (kafka, name) => {
|
|
6866
|
+
const at = kafka.brokers.indexOf(name);
|
|
6867
|
+
if (at !== -1) return at + 1;
|
|
6868
|
+
if (name === kafka.controller) return kafka.brokers.length + 1;
|
|
6869
|
+
throw new Error(`the Kafka group has no node ${name}`);
|
|
6870
|
+
};
|
|
6871
|
+
/**
|
|
6872
|
+
* This function gives the variable of a server property for the image. The wrapper of the image
|
|
6873
|
+
* reverses this map (KafkaDockerWrapper.scala:188-203). After the prefix `KAFKA_`, `___` is a
|
|
6874
|
+
* hyphen, `__` is an underscore and `_` is a dot.
|
|
6875
|
+
*/
|
|
6876
|
+
const envOf = (property) => `KAFKA_${property.replace(/_/g, "__").replace(/-/g, "___").replace(/\./g, "_").toUpperCase()}`;
|
|
6877
|
+
/** This function gives the variables of each node: the listener names, the voters, the log and the settings of the group. */
|
|
6878
|
+
const commonEnv = (kafka) => ({
|
|
6879
|
+
CLUSTER_ID: kafka.clusterId,
|
|
6880
|
+
KAFKA_HEAP_OPTS: kafka.heap,
|
|
6881
|
+
KAFKA_CONTROLLER_QUORUM_VOTERS: `${nodeIdOf(kafka, kafka.controller)}@${kafka.controller}:${kafka.controllerPort}`,
|
|
6882
|
+
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER",
|
|
6883
|
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT",
|
|
6884
|
+
KAFKA_LOG_DIRS: "/var/lib/kafka/data",
|
|
6885
|
+
...Object.fromEntries(Object.entries(kafka.settings).map(([property, value]) => [envOf(property), value]))
|
|
6886
|
+
});
|
|
6887
|
+
/** This function gives the environment of a broker. */
|
|
6888
|
+
const brokerEnv = (kafka, name) => ({
|
|
6889
|
+
...commonEnv(kafka),
|
|
6890
|
+
KAFKA_PROCESS_ROLES: "broker",
|
|
6891
|
+
KAFKA_NODE_ID: String(nodeIdOf(kafka, name)),
|
|
6892
|
+
KAFKA_LISTENERS: `PLAINTEXT://0.0.0.0:${kafka.port}`,
|
|
6893
|
+
KAFKA_ADVERTISED_LISTENERS: `PLAINTEXT://${name}:${kafka.port}`,
|
|
6894
|
+
KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT"
|
|
6895
|
+
});
|
|
6896
|
+
/** This function gives the environment of the controller. The start script of the image stops on an advertised listener here (configure:43-48). */
|
|
6897
|
+
const controllerEnv = (kafka) => ({
|
|
6898
|
+
...commonEnv(kafka),
|
|
6899
|
+
KAFKA_PROCESS_ROLES: "controller",
|
|
6900
|
+
KAFKA_NODE_ID: String(nodeIdOf(kafka, kafka.controller)),
|
|
6901
|
+
KAFKA_LISTENERS: `CONTROLLER://0.0.0.0:${kafka.controllerPort}`
|
|
6902
|
+
});
|
|
6903
|
+
/**
|
|
6904
|
+
* This function judges a view of a partition: its leader, and if the group is healthy. A healthy
|
|
6905
|
+
* group has each broker not fenced, one leader, and each broker in the in-sync replicas.
|
|
6906
|
+
*/
|
|
6907
|
+
const kafkaViewOf = (kafka, view) => {
|
|
6908
|
+
const primary = view.leader === void 0 || view.leader === null ? null : kafka.brokers[view.leader - 1] ?? null;
|
|
6909
|
+
const all = kafka.brokers.length;
|
|
6910
|
+
if (view.brokers.length !== all) return {
|
|
6911
|
+
primary,
|
|
6912
|
+
healthy: false,
|
|
6913
|
+
why: `${view.brokers.length} brokers are not fenced`
|
|
6914
|
+
};
|
|
6915
|
+
if (primary === null) return {
|
|
6916
|
+
primary,
|
|
6917
|
+
healthy: false,
|
|
6918
|
+
why: "the partition has no leader"
|
|
6919
|
+
};
|
|
6920
|
+
if (view.replicas?.length !== all) return {
|
|
6921
|
+
primary,
|
|
6922
|
+
healthy: false,
|
|
6923
|
+
why: `the partition has ${view.replicas?.length ?? 0} replicas`
|
|
6924
|
+
};
|
|
6925
|
+
if (view.isr?.length !== all) return {
|
|
6926
|
+
primary,
|
|
6927
|
+
healthy: false,
|
|
6928
|
+
why: `the partition has ${view.isr?.length ?? 0} in-sync replicas`
|
|
6929
|
+
};
|
|
6930
|
+
return {
|
|
6931
|
+
primary,
|
|
6932
|
+
healthy: true,
|
|
6933
|
+
why: "healthy"
|
|
6934
|
+
};
|
|
6935
|
+
};
|
|
6936
|
+
/**
|
|
6937
|
+
* This function gives the peers that a fault on a broker cuts it from. Only `group` has a law: the
|
|
6938
|
+
* other brokers and the controller, and the client keeps its route to the broker.
|
|
6939
|
+
*/
|
|
6940
|
+
const kafkaCutOf = (kafka, members, controller, part, cuts) => {
|
|
6941
|
+
if (cuts !== "group") throw new Error(`a Kafka cell has no cut ${cuts}`);
|
|
6942
|
+
return { peers: [...kafka.brokers.filter((name) => name !== part).map((name) => members[name].ip), controller.ip] };
|
|
6943
|
+
};
|
|
6944
|
+
/** This function gives the addresses that the client and the admin bootstrap from. */
|
|
6945
|
+
const bootstrapOf = (kafka) => kafka.brokers.map((name) => `${name}:${kafka.port}`).join(",");
|
|
6946
|
+
/** This function gives the name of the topic of a run. A warm-up has the index -1. */
|
|
6947
|
+
const topicOf = (environment, index) => `cells-e${environment}-r${index + 1}`;
|
|
6948
|
+
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6949
|
+
/**
|
|
6950
|
+
* This function opens one environment of a Kafka cell:
|
|
6951
|
+
* - one new network, with each node, the client and the commander on it.
|
|
6952
|
+
* - the controller first, then each broker: the stock image with its own command. The brokers
|
|
6953
|
+
* register with the controller, thus they need it first.
|
|
6954
|
+
* - an injector sidecar for each node, in its network and process namespaces.
|
|
6955
|
+
* - a client that holds its own network namespace, with a privileged sidecar for its sysctls.
|
|
6956
|
+
* - the prober, which watches the namespace of the client.
|
|
6957
|
+
*/
|
|
6958
|
+
const openKafkaLab = async (kafka) => {
|
|
6959
|
+
const image = await harnessImage();
|
|
6960
|
+
const clientImage = await kafkaClientImage();
|
|
6961
|
+
const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
|
|
6962
|
+
const runtime = await getContainerRuntimeClient();
|
|
6963
|
+
const opened = [];
|
|
6964
|
+
let network;
|
|
6965
|
+
const close = async () => {
|
|
6966
|
+
for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
|
|
6967
|
+
await network?.stop().catch(() => void 0);
|
|
6968
|
+
};
|
|
6969
|
+
const ipOf = async (container) => {
|
|
6970
|
+
const ip = (await runtime.container.inspect(runtime.container.getById(container.getId()))).NetworkSettings.Networks[network.getName()]?.IPAddress;
|
|
6971
|
+
if (ip === void 0 || ip === "") throw new Error(`${container.getName()} has no address on ${network.getName()}`);
|
|
6972
|
+
return ip;
|
|
6973
|
+
};
|
|
6974
|
+
try {
|
|
6975
|
+
network = await new Network().start();
|
|
6976
|
+
const started = network;
|
|
6977
|
+
const boxes = /* @__PURE__ */ new Map();
|
|
6978
|
+
const injectors = /* @__PURE__ */ new Map();
|
|
6979
|
+
const parts = {};
|
|
6980
|
+
const raw = {};
|
|
6981
|
+
const openNode = async (name, environment) => {
|
|
6982
|
+
let partial = "";
|
|
6983
|
+
const box = await new GenericContainer(kafka.image).withNetwork(started).withNetworkAliases(name).withHostname(name).withEnvironment(environment).withWaitStrategy(Wait.forLogMessage(kafka.ready)).withStartupTimeout(18e4).withLogConsumer((stream) => stream.on("data", (chunk) => {
|
|
6984
|
+
partial += chunk.toString();
|
|
6985
|
+
const split = partial.split("\n");
|
|
6986
|
+
partial = split.pop() ?? "";
|
|
6987
|
+
for (const text of split) {
|
|
6988
|
+
const tail = raw[name] ??= [];
|
|
6989
|
+
tail.push(text);
|
|
6990
|
+
if (tail.length > 400) tail.shift();
|
|
6991
|
+
}
|
|
6992
|
+
})).start();
|
|
6993
|
+
opened.push(box);
|
|
6994
|
+
boxes.set(name, box);
|
|
6995
|
+
const injector = await withPid(new GenericContainer(image).withNetworkMode(`container:${box.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW"), `container:${box.getId()}`).start();
|
|
6996
|
+
opened.push(injector);
|
|
6997
|
+
injectors.set(name, injector);
|
|
6998
|
+
parts[name] = {
|
|
6999
|
+
ip: await ipOf(box),
|
|
7000
|
+
netns: await netnsOf(injector)
|
|
7001
|
+
};
|
|
7002
|
+
};
|
|
7003
|
+
await openNode(kafka.controller, controllerEnv(kafka));
|
|
7004
|
+
for (const name of kafka.brokers) await openNode(name, brokerEnv(kafka, name));
|
|
7005
|
+
const members = Object.fromEntries(kafka.brokers.map((name) => [name, parts[name]]));
|
|
7006
|
+
const controller = parts[kafka.controller];
|
|
7007
|
+
const holder = await new GenericContainer(image).withNetwork(network).start();
|
|
7008
|
+
opened.push(holder);
|
|
7009
|
+
const admin = await new GenericContainer(image).withNetworkMode(`container:${holder.getId()}`).withPrivilegedMode().start();
|
|
7010
|
+
opened.push(admin);
|
|
7011
|
+
const clientIp = await ipOf(holder);
|
|
7012
|
+
const commander = await new GenericContainer(clientImage).withNetwork(network).withCommand([
|
|
7013
|
+
"sh",
|
|
7014
|
+
"-c",
|
|
7015
|
+
"while :; do sleep 3600; done"
|
|
7016
|
+
]).start();
|
|
7017
|
+
opened.push(commander);
|
|
7018
|
+
const clientNetns = await netnsOf(admin);
|
|
7019
|
+
const records = lines();
|
|
7020
|
+
const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand(["/opt/harness/prober", String(clientNetns)]).withLogConsumer(records.consume).start();
|
|
7021
|
+
opened.push(prober);
|
|
7022
|
+
const begun = await records.until(["begin", "fail"], 3e4, "prober");
|
|
7023
|
+
if (begun.ev === "fail") throw new Error(`the prober failed: ${String(begun["why"])}`);
|
|
7024
|
+
const box = (name) => {
|
|
7025
|
+
const found = boxes.get(name);
|
|
7026
|
+
if (found === void 0) throw new Error(`the group has no node ${name}`);
|
|
7027
|
+
return found;
|
|
7028
|
+
};
|
|
7029
|
+
const injectorOf = (name) => {
|
|
7030
|
+
const found = injectors.get(name);
|
|
7031
|
+
if (found === void 0) throw new Error(`the group has no node ${name}`);
|
|
7032
|
+
return found;
|
|
7033
|
+
};
|
|
7034
|
+
const lastLine = (text) => text.split("\n").filter((line) => line.trim() !== "").at(-1) ?? "";
|
|
7035
|
+
const stamped = async (where, argv) => {
|
|
7036
|
+
const stamp = JSON.parse(lastLine(await output(where, argv)));
|
|
7037
|
+
return {
|
|
7038
|
+
t0: stamp.t0,
|
|
7039
|
+
t1: stamp.t1
|
|
7040
|
+
};
|
|
7041
|
+
};
|
|
7042
|
+
const after = (at) => at === void 0 ? [] : ["--at", String(Math.round(at))];
|
|
7043
|
+
const bootstrap = bootstrapOf(kafka);
|
|
7044
|
+
const tool = async (...argv) => lastLine(await output(commander, [
|
|
7045
|
+
"java",
|
|
7046
|
+
CLIENT_HEAP,
|
|
7047
|
+
"-cp",
|
|
7048
|
+
CLASS_PATH,
|
|
7049
|
+
"AdminKafka",
|
|
7050
|
+
bootstrap,
|
|
7051
|
+
...argv
|
|
7052
|
+
]));
|
|
7053
|
+
const view = async (topic) => JSON.parse(await tool("view", ...topic === void 0 ? [] : [topic]));
|
|
7054
|
+
return {
|
|
7055
|
+
kafka,
|
|
7056
|
+
clientIp,
|
|
7057
|
+
members,
|
|
7058
|
+
controller,
|
|
7059
|
+
netns: { client: clientNetns },
|
|
7060
|
+
kernel: records.seen,
|
|
7061
|
+
raw,
|
|
7062
|
+
async sysctl(values) {
|
|
7063
|
+
const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
|
7064
|
+
if (settings.length > 0) await output(admin, [
|
|
7065
|
+
"sysctl",
|
|
7066
|
+
"-q",
|
|
7067
|
+
"-w",
|
|
7068
|
+
...settings
|
|
7069
|
+
]);
|
|
7070
|
+
const read = {};
|
|
7071
|
+
for (const key of Object.keys(values)) read[key] = await output(admin, [
|
|
7072
|
+
"sysctl",
|
|
7073
|
+
"-n",
|
|
7074
|
+
key
|
|
7075
|
+
]);
|
|
7076
|
+
return read;
|
|
7077
|
+
},
|
|
7078
|
+
async brokersUp(within = CONVERGE_MS) {
|
|
7079
|
+
const begin = Date.now();
|
|
7080
|
+
let last = "no view yet";
|
|
7081
|
+
while (Date.now() - begin < within) {
|
|
7082
|
+
const read = await view().catch((error) => error.message.slice(0, 200));
|
|
7083
|
+
if (typeof read !== "string" && read.brokers.length === kafka.brokers.length) return;
|
|
7084
|
+
last = typeof read === "string" ? read : `${read.brokers.length} brokers are not fenced`;
|
|
7085
|
+
await sleep$1(1e3);
|
|
7086
|
+
}
|
|
7087
|
+
throw new Error(`the brokers did not come up in ${within} ms: ${last}`);
|
|
7088
|
+
},
|
|
7089
|
+
async create(topic) {
|
|
7090
|
+
const ids = kafka.brokers.map((name) => nodeIdOf(kafka, name)).join(",");
|
|
7091
|
+
const reply = await tool("create", topic, String(kafka.minInsync), ids);
|
|
7092
|
+
if (!reply.includes("\"ok\":true")) throw new Error(`the create of the topic failed: ${reply}`);
|
|
7093
|
+
},
|
|
7094
|
+
async converge(topic, within = CONVERGE_MS) {
|
|
7095
|
+
const begin = Date.now();
|
|
7096
|
+
let calm = 0;
|
|
7097
|
+
let last = {
|
|
7098
|
+
primary: null,
|
|
7099
|
+
healthy: false,
|
|
7100
|
+
why: "no view yet"
|
|
7101
|
+
};
|
|
7102
|
+
while (Date.now() - begin < within) {
|
|
7103
|
+
last = await view(topic).then((read) => kafkaViewOf(kafka, read), (error) => ({
|
|
7104
|
+
primary: null,
|
|
7105
|
+
healthy: false,
|
|
7106
|
+
why: error.message.slice(0, 200)
|
|
7107
|
+
}));
|
|
7108
|
+
calm = last.healthy ? calm + 1 : 0;
|
|
7109
|
+
if (calm >= 2 && last.primary !== null) return last.primary;
|
|
7110
|
+
await sleep$1(1e3);
|
|
7111
|
+
}
|
|
7112
|
+
throw new Error(`the partition did not converge in ${within} ms: ${last.why}`);
|
|
7113
|
+
},
|
|
7114
|
+
async holds(topic) {
|
|
7115
|
+
const reply = await tool("holds", topic);
|
|
7116
|
+
const { held } = JSON.parse(reply);
|
|
7117
|
+
if (held === void 0) throw new Error(`the read of the partition failed: ${reply}`);
|
|
7118
|
+
return held.map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
7119
|
+
},
|
|
7120
|
+
async client(args) {
|
|
7121
|
+
const events = lines();
|
|
7122
|
+
const container = await new GenericContainer(clientImage).withNetworkMode(`container:${holder.getId()}`).withCommand([
|
|
7123
|
+
"java",
|
|
7124
|
+
CLIENT_HEAP,
|
|
7125
|
+
"-cp",
|
|
7126
|
+
CLASS_PATH,
|
|
7127
|
+
"ClientKafka",
|
|
7128
|
+
bootstrap,
|
|
7129
|
+
...args
|
|
7130
|
+
]).withLogConsumer(events.consume).start();
|
|
7131
|
+
return {
|
|
7132
|
+
events: events.seen,
|
|
7133
|
+
until: (kinds, within) => events.until(kinds, within, "client"),
|
|
7134
|
+
stop: () => container.stop({ timeout: 2 }).then(() => void 0).catch(() => void 0)
|
|
7135
|
+
};
|
|
7136
|
+
},
|
|
7137
|
+
fault(kind, part, cuts, at) {
|
|
7138
|
+
const cut = kafkaCutOf(kafka, members, controller, part, cuts);
|
|
7139
|
+
return stamped(injectorOf(part), [
|
|
7140
|
+
"/opt/harness/inject",
|
|
7141
|
+
kind,
|
|
7142
|
+
cut.peers.join(","),
|
|
7143
|
+
...after(at)
|
|
7144
|
+
]);
|
|
7145
|
+
},
|
|
7146
|
+
lift(kind, part, cuts, at) {
|
|
7147
|
+
const cut = kafkaCutOf(kafka, members, controller, part, cuts);
|
|
7148
|
+
return stamped(injectorOf(part), [
|
|
7149
|
+
"/opt/harness/inject",
|
|
7150
|
+
kind === "rst" ? "clear-rst" : "clear",
|
|
7151
|
+
cut.peers.join(","),
|
|
7152
|
+
...after(at)
|
|
7153
|
+
]);
|
|
7154
|
+
},
|
|
7155
|
+
async forget() {
|
|
7156
|
+
await output(admin, [
|
|
7157
|
+
"ip",
|
|
7158
|
+
"tcp_metrics",
|
|
7159
|
+
"flush",
|
|
7160
|
+
"all"
|
|
7161
|
+
]);
|
|
7162
|
+
},
|
|
7163
|
+
async settle() {
|
|
7164
|
+
const from = records.seen.length;
|
|
7165
|
+
const clock = await new Promise((resolve) => {
|
|
7166
|
+
const begin = Date.now();
|
|
7167
|
+
const look = () => {
|
|
7168
|
+
const line = records.seen.slice(from).find((record) => record.ev === "clock");
|
|
7169
|
+
if (line !== void 0) resolve(line);
|
|
7170
|
+
else if (Date.now() - begin > 5e3) resolve(null);
|
|
7171
|
+
else setTimeout(look, 50);
|
|
7172
|
+
};
|
|
7173
|
+
look();
|
|
7174
|
+
});
|
|
7175
|
+
return clock === null ? null : Number(clock["lost"] ?? 0);
|
|
7176
|
+
},
|
|
7177
|
+
async manifest(sysctls) {
|
|
7178
|
+
const config = await output(admin, ["zcat", "/proc/config.gz"]).catch(() => "");
|
|
7179
|
+
const inspect = async (container) => runtime.container.inspect(runtime.container.getById(container.getId()));
|
|
7180
|
+
const settings = {
|
|
7181
|
+
settings: Object.entries(kafka.settings).map(([property, value]) => `${property}=${value}`).join("; "),
|
|
7182
|
+
heap: kafka.heap,
|
|
7183
|
+
"min.insync.replicas": String(kafka.minInsync)
|
|
7184
|
+
};
|
|
7185
|
+
const list = async (names) => {
|
|
7186
|
+
const out = [];
|
|
7187
|
+
for (const name of names) {
|
|
7188
|
+
const version = lastLine(await output(box(name), ["/opt/kafka/bin/kafka-topics.sh", "--version"]).catch(() => ""));
|
|
7189
|
+
out.push({
|
|
7190
|
+
name,
|
|
7191
|
+
ip: parts[name].ip,
|
|
7192
|
+
id: (await inspect(box(name))).Image,
|
|
7193
|
+
settings: {
|
|
7194
|
+
version,
|
|
7195
|
+
...settings
|
|
7196
|
+
}
|
|
7197
|
+
});
|
|
7198
|
+
}
|
|
7199
|
+
return out;
|
|
7200
|
+
};
|
|
7201
|
+
const brokers = await list(kafka.brokers);
|
|
7202
|
+
return {
|
|
7203
|
+
kernel: await output(holder, ["uname", "-r"]),
|
|
7204
|
+
architecture: await output(holder, ["uname", "-m"]),
|
|
7205
|
+
hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
|
|
7206
|
+
boot_id: await output(holder, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
7207
|
+
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
7208
|
+
target: {
|
|
7209
|
+
image: kafka.image,
|
|
7210
|
+
id: brokers[0].id,
|
|
7211
|
+
settings: brokers[0].settings,
|
|
7212
|
+
sysctls: {}
|
|
7213
|
+
},
|
|
7214
|
+
harness: {
|
|
7215
|
+
image,
|
|
7216
|
+
id: (await inspect(holder)).Image,
|
|
7217
|
+
packages: (await output(holder, [
|
|
7218
|
+
"apk",
|
|
7219
|
+
"info",
|
|
7220
|
+
"-v"
|
|
7221
|
+
])).split("\n").sort()
|
|
7222
|
+
},
|
|
7223
|
+
client: {
|
|
7224
|
+
kind: "image",
|
|
7225
|
+
image: clientImage
|
|
7226
|
+
},
|
|
7227
|
+
netns: {
|
|
7228
|
+
client: clientNetns,
|
|
7229
|
+
target: members[kafka.brokers[0]].netns
|
|
7230
|
+
},
|
|
7231
|
+
sysctls,
|
|
7232
|
+
group: {
|
|
7233
|
+
members: brokers,
|
|
7234
|
+
sentinels: await list([kafka.controller]),
|
|
7235
|
+
observer: { image: clientImage }
|
|
7236
|
+
}
|
|
7237
|
+
};
|
|
7238
|
+
},
|
|
7239
|
+
close
|
|
7240
|
+
};
|
|
7241
|
+
} catch (error) {
|
|
7242
|
+
await close();
|
|
7243
|
+
throw error;
|
|
7244
|
+
}
|
|
7245
|
+
};
|
|
7246
|
+
/** This count is the number of acknowledged writes that a run waits for before its fault: the stream is steady then. */
|
|
7247
|
+
const STEADY = 20;
|
|
7248
|
+
/**
|
|
7249
|
+
* This time is how long a run continues after its lift. The record then shows the step-down of the
|
|
7250
|
+
* leader and the writes after it. It has no source. It is above the largest step-down of the
|
|
7251
|
+
* law, which is approximately 2 s after the lift.
|
|
7252
|
+
*/
|
|
7253
|
+
const KAFKA_TAIL_MS = 1e4;
|
|
7254
|
+
/** This function gives the arguments of the client of a Kafka cell: the topic, the writers, the stream of writes and the settings. */
|
|
7255
|
+
const kafkaArgs = (cell, topic) => [
|
|
7256
|
+
"--topic",
|
|
7257
|
+
topic,
|
|
7258
|
+
"--writers",
|
|
7259
|
+
String(cell.kafka.writers),
|
|
7260
|
+
"--interval-ms",
|
|
7261
|
+
String(cell.probe.interval === void 0 ? 50 : valueIn(cell.probe.interval, "ms")),
|
|
7262
|
+
"--max-s",
|
|
7263
|
+
String(Math.ceil(deadlineOf(cell) / 1e3 + (cell.hold === void 0 ? 0 : valueIn(cell.hold, "s")) + 120)),
|
|
7264
|
+
...Object.entries(cell.driverOptions ?? {}).flatMap(([name, value]) => ["--set", `${name}=${String(value)}`])
|
|
7265
|
+
];
|
|
7266
|
+
/** This function waits until a condition on the lines of the client holds, or for `within` ms. It gives the first line that matches. */
|
|
7267
|
+
const waitFor = (client, match, within) => new Promise((resolve) => {
|
|
7268
|
+
const started = Date.now();
|
|
7269
|
+
const look = () => {
|
|
7270
|
+
const found = client.events.find(match);
|
|
7271
|
+
if (found !== void 0 || Date.now() - started > within) resolve(found);
|
|
7272
|
+
else setTimeout(look, 20);
|
|
7273
|
+
};
|
|
7274
|
+
look();
|
|
7275
|
+
});
|
|
7276
|
+
/** This function gives the count of lost records in the latest clock line of the prober. */
|
|
7277
|
+
const droppedSoFar$1 = (records) => {
|
|
7278
|
+
for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
|
|
7279
|
+
return 0;
|
|
7280
|
+
};
|
|
7281
|
+
/**
|
|
7282
|
+
* This function does one run of a Kafka cell:
|
|
7283
|
+
* - Each broker is up, the topic of the run exists, and its partition is whole. The client starts,
|
|
7284
|
+
* and its stream of writes gets steady.
|
|
7285
|
+
* - The fault cuts the leader from the other brokers and the controller, and it lifts at its instant
|
|
7286
|
+
* in the VM. The client keeps its route to the leader, thus only the group knows of the cut.
|
|
7287
|
+
* - The run waits for the lift and for the tail after it.
|
|
7288
|
+
* - Each fault goes, the client stops, and the partition converges again. The admin then reads
|
|
7289
|
+
* the whole partition, thus the record tells which writes the group kept.
|
|
7290
|
+
*/
|
|
7291
|
+
const kafkaOnce = async (lab, cell, environment, index) => {
|
|
7292
|
+
if (cell.fault !== "partition") throw new Error(`a Kafka cell has no fault ${cell.fault}`);
|
|
7293
|
+
if (cell.hold === void 0) throw new Error("a Kafka cell needs a hold: its law reads it");
|
|
7294
|
+
const place = cell.faultAt ?? { role: "primary" };
|
|
7295
|
+
if (place.role !== "primary") throw new Error(`a Kafka cell has no fault on the ${place.role}`);
|
|
7296
|
+
const cuts = place.cuts ?? "group";
|
|
7297
|
+
const topic = topicOf(environment, index);
|
|
7298
|
+
await lab.brokersUp();
|
|
7299
|
+
await lab.create(topic);
|
|
7300
|
+
const leader = await lab.converge(topic);
|
|
7301
|
+
await lab.forget();
|
|
7302
|
+
const from = lab.kernel.length;
|
|
7303
|
+
const lostBefore = droppedSoFar$1(lab.kernel);
|
|
7304
|
+
const client = await lab.client(kafkaArgs(cell, topic));
|
|
7305
|
+
let fault = null;
|
|
7306
|
+
let heal = null;
|
|
7307
|
+
const undo = [];
|
|
7308
|
+
try {
|
|
7309
|
+
await client.until(["ready"], 6e4);
|
|
7310
|
+
if (await waitFor(client, (event) => event.ev === "ack" && Number(event["n"]) >= STEADY, 3e4) === void 0) throw new Error(`the client did not write ${STEADY} times in 30 s`);
|
|
7311
|
+
fault = await lab.fault("blackhole", leader, cuts);
|
|
7312
|
+
undo.push(() => lab.lift("blackhole", leader, cuts));
|
|
7313
|
+
heal = await lab.lift("blackhole", leader, cuts, fault.t0 + valueIn(cell.hold, "s") * 1e9);
|
|
7314
|
+
undo.length = 0;
|
|
7315
|
+
await sleep$1(KAFKA_TAIL_MS);
|
|
7316
|
+
} finally {
|
|
7317
|
+
for (const step of undo.reverse()) await step().catch(() => void 0);
|
|
7318
|
+
await client.stop();
|
|
7319
|
+
}
|
|
7320
|
+
const settled = await lab.settle();
|
|
7321
|
+
const lost = settled === null ? null : settled - lostBefore;
|
|
7322
|
+
const after = await lab.converge(topic).catch(() => null);
|
|
7323
|
+
const holds = after === null ? null : await lab.holds(topic);
|
|
7324
|
+
return {
|
|
7325
|
+
environment,
|
|
7326
|
+
index,
|
|
7327
|
+
events: [...client.events],
|
|
7328
|
+
fault,
|
|
7329
|
+
...heal === null ? {} : { heal },
|
|
7330
|
+
kernel: lab.kernel.slice(from),
|
|
7331
|
+
netns: lab.netns.client,
|
|
7332
|
+
targetNetns: lab.members[leader].netns,
|
|
7333
|
+
lost,
|
|
7334
|
+
group: {
|
|
7335
|
+
members: lab.members,
|
|
7336
|
+
sentinels: { [lab.kafka.controller]: lab.controller },
|
|
7337
|
+
primary: leader,
|
|
7338
|
+
at: leader,
|
|
7339
|
+
timeline: [],
|
|
7340
|
+
after,
|
|
7341
|
+
holds
|
|
7342
|
+
}
|
|
7343
|
+
};
|
|
7344
|
+
};
|
|
7345
|
+
//#endregion
|
|
7346
|
+
//#region src/run.ts
|
|
7347
|
+
/**
|
|
7348
|
+
* This function measures and judges the raw records of a cell, with the code of the harness at
|
|
7349
|
+
* this time. `runCell` uses it during a cell. A holder of the raw records of a cell can use it
|
|
7350
|
+
* again later. Thus the records get a new analysis after a change to observe.ts or to the verdict.
|
|
7351
|
+
*/
|
|
7352
|
+
const judge = (cell, runs) => {
|
|
7353
|
+
const measured = runs.filter((run) => run.warmup !== true).map((run) => measure(cell, run));
|
|
7354
|
+
return {
|
|
5238
7355
|
measured,
|
|
5239
7356
|
verdict: verdictOf(cell.prediction, measured, noiseOf(cell))
|
|
5240
7357
|
};
|
|
@@ -5295,6 +7412,7 @@ const probeArgs = (cell, lab) => {
|
|
|
5295
7412
|
if (probe.driver?.name === "pg" || probe.driver?.name === "hikari" || probe.driver?.name === "amqp") {
|
|
5296
7413
|
if (probe.driver.name !== "amqp" && probe.driver.query !== void 0) args.push("--query", probe.driver.query);
|
|
5297
7414
|
if (probe.driver.name !== "amqp" && probe.driver.inflight !== void 0) args.push("--inflight-query", probe.driver.inflight);
|
|
7415
|
+
if (probe.driver.name === "hikari" && probe.driver.burst !== void 0) args.push("--burst", String(probe.driver.burst.count), "--burst-gap-ms", String(Math.round(valueIn(probe.driver.burst.gap, "ms"))));
|
|
5298
7416
|
if (cell.before === true) args.push("--before");
|
|
5299
7417
|
if (cell.driverOptions !== void 0) args.push("--options", JSON.stringify(cell.driverOptions));
|
|
5300
7418
|
args.push("--max-s", String(Math.ceil(longest * 2 + 10)));
|
|
@@ -5367,6 +7485,8 @@ const requestSent = (probe, within) => new Promise((resolve, reject) => {
|
|
|
5367
7485
|
});
|
|
5368
7486
|
/** This time is how long a run of a driver cell continues after its outcome, so that the record shows what came next. */
|
|
5369
7487
|
const TAIL_MS = 1e3;
|
|
7488
|
+
/** This time lets the last lines of a route monitor reach the harness after a stall ends. */
|
|
7489
|
+
const MONITOR_MS = 200;
|
|
5370
7490
|
/** This function waits for the outcome of a run of a driver cell, or for `within` ms. The outcome is the end of its band and of each later band. */
|
|
5371
7491
|
const driverOutcome = async (cell, current, within) => {
|
|
5372
7492
|
const started = Date.now();
|
|
@@ -5454,6 +7574,19 @@ const dials = (probe, count, within) => new Promise((resolve) => {
|
|
|
5454
7574
|
};
|
|
5455
7575
|
look();
|
|
5456
7576
|
});
|
|
7577
|
+
/** This time is the most that a run waits for the lines of the target after the end of its client. */
|
|
7578
|
+
const LINES_MS = 5e3;
|
|
7579
|
+
/**
|
|
7580
|
+
* This function waits for the lines of a run's counts. The lines of the target come on their own
|
|
7581
|
+
* stream, thus they can come after the last line of the client. A line above a count comes in the
|
|
7582
|
+
* pause after the wait, thus it is in the run that wrote it.
|
|
7583
|
+
*/
|
|
7584
|
+
const linesCounted = async (lab, from, counts) => {
|
|
7585
|
+
const started = Date.now();
|
|
7586
|
+
const short = () => counts.some((each) => countOf(lab.server.slice(from), each.match) < each.count);
|
|
7587
|
+
while (short() && Date.now() - started < LINES_MS) await sleep(20);
|
|
7588
|
+
await sleep(250);
|
|
7589
|
+
};
|
|
5457
7590
|
/** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
|
|
5458
7591
|
const netemSpec = (cell, fault) => {
|
|
5459
7592
|
const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
|
|
@@ -5474,6 +7607,9 @@ const netemSpec = (cell, fault) => {
|
|
|
5474
7607
|
*/
|
|
5475
7608
|
const once = async (lab, cell, environment, index, dry = false) => {
|
|
5476
7609
|
if (cell.before === true) return beforeOnce(lab, cell, environment, index);
|
|
7610
|
+
await lab.fabric?.ready();
|
|
7611
|
+
const sessions = await lab.fabric?.sessions();
|
|
7612
|
+
const routesFrom = lab.fabric?.routes.length ?? 0;
|
|
5477
7613
|
await lab.forget();
|
|
5478
7614
|
const from = lab.kernel.length;
|
|
5479
7615
|
const middleboxFrom = lab.middlebox.length;
|
|
@@ -5493,6 +7629,10 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5493
7629
|
throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
|
|
5494
7630
|
});
|
|
5495
7631
|
const placed = lab.topology === null ? {} : { topology: lab.topology };
|
|
7632
|
+
const fabricOf = () => {
|
|
7633
|
+
if (lab.fabric === void 0) throw new Error(`${cell.name} silences a router of a lab with no fabric`);
|
|
7634
|
+
return lab.fabric;
|
|
7635
|
+
};
|
|
5496
7636
|
/** This function gives the run as the records show it so far: the waits read it. */
|
|
5497
7637
|
const partial = () => ({
|
|
5498
7638
|
environment,
|
|
@@ -5566,6 +7706,7 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5566
7706
|
case "server-close":
|
|
5567
7707
|
await lab.serverClose();
|
|
5568
7708
|
return null;
|
|
7709
|
+
case "silence": return fabricOf().silence();
|
|
5569
7710
|
case "hop-event": {
|
|
5570
7711
|
if (cell.event === void 0) throw new Error(`${cell.name} names no event`);
|
|
5571
7712
|
const stamp = await lab.event(cell.event);
|
|
@@ -5623,6 +7764,9 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5623
7764
|
case "toxic":
|
|
5624
7765
|
await lab.toxic(null);
|
|
5625
7766
|
return;
|
|
7767
|
+
case "silence":
|
|
7768
|
+
await fabricOf().clear();
|
|
7769
|
+
return;
|
|
5626
7770
|
default: return heal === null ? lab.lift("blackhole", faultAt) : void 0;
|
|
5627
7771
|
}
|
|
5628
7772
|
};
|
|
@@ -5643,6 +7787,7 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5643
7787
|
const gap = cell.probe.http?.gap === void 0 ? 0 : valueIn(cell.probe.http.gap, "s");
|
|
5644
7788
|
const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
|
|
5645
7789
|
await probe.until(HTTP_DONE, (gap + window) * 2e3 + 1e4).catch(() => void 0);
|
|
7790
|
+
if (prediction.detects === null && prediction.counts !== void 0) await linesCounted(lab, serverFrom, prediction.counts);
|
|
5646
7791
|
} else if (cell.fault === "stall") {
|
|
5647
7792
|
if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
|
|
5648
7793
|
const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
|
|
@@ -5654,6 +7799,21 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5654
7799
|
"eof",
|
|
5655
7800
|
"drop"
|
|
5656
7801
|
], window * 1e3).catch(() => void 0);
|
|
7802
|
+
} else if (cell.fault === "silence") {
|
|
7803
|
+
const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
|
|
7804
|
+
await requestSent(probe, 5e3);
|
|
7805
|
+
await probe.until([
|
|
7806
|
+
"data",
|
|
7807
|
+
"error",
|
|
7808
|
+
"eof",
|
|
7809
|
+
"drop"
|
|
7810
|
+
], window * 1e3).catch(() => void 0);
|
|
7811
|
+
await sleep(MONITOR_MS);
|
|
7812
|
+
const moved = laterMove(fabricOf().routes.slice(routesFrom), fault?.t0 ?? 0);
|
|
7813
|
+
heal = moved === null ? null : {
|
|
7814
|
+
t0: moved,
|
|
7815
|
+
t1: moved
|
|
7816
|
+
};
|
|
5657
7817
|
} else if (prediction.detects === null) {
|
|
5658
7818
|
const window = valueIn(prediction.window, "s") * 1e3;
|
|
5659
7819
|
await probe.until([
|
|
@@ -5695,14 +7855,21 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5695
7855
|
index,
|
|
5696
7856
|
events: [...probe.events],
|
|
5697
7857
|
fault,
|
|
5698
|
-
...cell.fault === "stall" || thawing !== null ? { heal } : {},
|
|
7858
|
+
...cell.fault === "stall" || cell.fault === "silence" || thawing !== null ? { heal } : {},
|
|
5699
7859
|
kernel: lab.kernel.slice(from),
|
|
5700
7860
|
netns: lab.netns.client,
|
|
5701
7861
|
targetNetns: lab.netns.target,
|
|
5702
7862
|
lost,
|
|
5703
7863
|
...placed,
|
|
5704
7864
|
...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) },
|
|
5705
|
-
...cell.target.lines === true ? { server: lab.server.slice(serverFrom) } : {}
|
|
7865
|
+
...cell.target.lines === true ? { server: lab.server.slice(serverFrom) } : {},
|
|
7866
|
+
...lab.fabric === void 0 || sessions === void 0 ? {} : { fabric: {
|
|
7867
|
+
routes: lab.fabric.routes.slice(routesFrom),
|
|
7868
|
+
established: {
|
|
7869
|
+
before: sessions,
|
|
7870
|
+
after: await lab.fabric.sessions()
|
|
7871
|
+
}
|
|
7872
|
+
} }
|
|
5706
7873
|
};
|
|
5707
7874
|
};
|
|
5708
7875
|
/** This function divides a count of runs across environments, as equally as possible. */
|
|
@@ -5714,6 +7881,17 @@ const spread = (runs, environments) => Array.from({ length: environments }, (_,
|
|
|
5714
7881
|
const endsEnvironment = (cell) => cell.fresh === true || cell.fault === "fin" && (cell.observer === void 0 || cell.observer === "client");
|
|
5715
7882
|
/** This function says one line for a run. */
|
|
5716
7883
|
const line = (cell, run, read) => `${cell.name}: env ${run.environment} ${run.warmup === true ? "warm-up" : `run ${run.index}`} ${read.outcome.kind}${read.elapsed === null ? "" : ` ${read.elapsed.value} s`}${read.void === null ? "" : ` (void: ${read.void})`}`;
|
|
7884
|
+
/** This function adds to a run the share of the CPU that the host took while the run operated. */
|
|
7885
|
+
const withSteal = async (work) => {
|
|
7886
|
+
const before = readCpu();
|
|
7887
|
+
const run = await work();
|
|
7888
|
+
const after = readCpu();
|
|
7889
|
+
const steal = before === null || after === null ? null : stealShare(before, after);
|
|
7890
|
+
return steal === null ? run : {
|
|
7891
|
+
...run,
|
|
7892
|
+
steal
|
|
7893
|
+
};
|
|
7894
|
+
};
|
|
5717
7895
|
/**
|
|
5718
7896
|
* This function does the part of the runs of a cell in one environment, and records its manifest.
|
|
5719
7897
|
* A warm-up goes first, and the verdict does not count it. On a loaded machine, a new environment is
|
|
@@ -5724,6 +7902,8 @@ const inEnvironment = async (cell, environment, count, say) => {
|
|
|
5724
7902
|
if (cell.group !== void 0) return inGroupEnvironment(cell, environment, count, say);
|
|
5725
7903
|
if (cell.replicaSet !== void 0) return inReplicaSetEnvironment(cell, environment, count, say);
|
|
5726
7904
|
if (cell.cluster !== void 0) return inClusterEnvironment(cell, environment, count, say);
|
|
7905
|
+
if (cell.kafka !== void 0) return inKafkaEnvironment(cell, environment, count, say);
|
|
7906
|
+
if (cell.pair !== void 0) return inPairEnvironment(cell, environment, count, say);
|
|
5727
7907
|
const lab = await openLab(cell.target, cell.client ?? cell.probe.driver?.name ?? "tcp", cell.topology);
|
|
5728
7908
|
try {
|
|
5729
7909
|
const settings = await lab.configure(cell.targetSettings ?? {});
|
|
@@ -5743,13 +7923,13 @@ const inEnvironment = async (cell, environment, count, say) => {
|
|
|
5743
7923
|
jitter: jitterOf(cell, run)
|
|
5744
7924
|
});
|
|
5745
7925
|
const warmup = {
|
|
5746
|
-
...timed(await once(lab, cell, environment, -1, endsEnvironment(cell))),
|
|
7926
|
+
...timed(await withSteal(() => once(lab, cell, environment, -1, endsEnvironment(cell)))),
|
|
5747
7927
|
warmup: true
|
|
5748
7928
|
};
|
|
5749
7929
|
say(line(cell, warmup, measure(cell, warmup)));
|
|
5750
7930
|
const runs = [];
|
|
5751
7931
|
for (let index = 0; index < count; index += 1) {
|
|
5752
|
-
const run = timed(await once(lab, cell, environment, index));
|
|
7932
|
+
const run = timed(await withSteal(() => once(lab, cell, environment, index)));
|
|
5753
7933
|
runs.push(run);
|
|
5754
7934
|
say(line(cell, run, measure(cell, run)));
|
|
5755
7935
|
}
|
|
@@ -5796,17 +7976,48 @@ const inGroupEnvironment = async (cell, environment, count, say) => {
|
|
|
5796
7976
|
* is a whole run with its fault, as for a group under Sentinels. The set converges before each run.
|
|
5797
7977
|
*/
|
|
5798
7978
|
const inReplicaSetEnvironment = async (cell, environment, count, say) => {
|
|
5799
|
-
const lab = await openReplicaSetLab(cell.replicaSet);
|
|
7979
|
+
const lab = await openReplicaSetLab(cell.replicaSet, cell.drill !== void 0);
|
|
7980
|
+
const once = cell.drill === void 0 ? replicaSetOnce : drillOnce;
|
|
5800
7981
|
try {
|
|
5801
7982
|
const manifest = await lab.manifest(await lab.sysctl(cell.sysctls));
|
|
5802
7983
|
const warmup = {
|
|
5803
|
-
...await
|
|
7984
|
+
...await once(lab, cell, environment, -1),
|
|
5804
7985
|
warmup: true
|
|
5805
7986
|
};
|
|
5806
7987
|
say(line(cell, warmup, measure(cell, warmup)));
|
|
5807
7988
|
const runs = [];
|
|
5808
7989
|
for (let index = 0; index < count; index += 1) {
|
|
5809
|
-
const run = await
|
|
7990
|
+
const run = await once(lab, cell, environment, index);
|
|
7991
|
+
runs.push(run);
|
|
7992
|
+
say(line(cell, run, measure(cell, run)));
|
|
7993
|
+
}
|
|
7994
|
+
return {
|
|
7995
|
+
manifest,
|
|
7996
|
+
runs,
|
|
7997
|
+
warmup
|
|
7998
|
+
};
|
|
7999
|
+
} finally {
|
|
8000
|
+
await lab.close();
|
|
8001
|
+
}
|
|
8002
|
+
};
|
|
8003
|
+
/**
|
|
8004
|
+
* This function does the part of the runs of a pair cell in one environment. The warm-up is a dry
|
|
8005
|
+
* run. The run promotes the standby, thus a pair cell has an environment for each run.
|
|
8006
|
+
*/
|
|
8007
|
+
const inPairEnvironment = async (cell, environment, count, say) => {
|
|
8008
|
+
const lab = await openLab(cell.target, cell.client ?? cell.probe.driver?.name ?? "tcp", cell.topology);
|
|
8009
|
+
try {
|
|
8010
|
+
await lab.warm();
|
|
8011
|
+
const manifest = await lab.manifest(await lab.sysctl(cell.sysctls));
|
|
8012
|
+
const standby = await openStandby(lab, cell);
|
|
8013
|
+
const warmup = {
|
|
8014
|
+
...await pairOnce(lab, standby, cell, environment, -1, true),
|
|
8015
|
+
warmup: true
|
|
8016
|
+
};
|
|
8017
|
+
say(line(cell, warmup, measure(cell, warmup)));
|
|
8018
|
+
const runs = [];
|
|
8019
|
+
for (let index = 0; index < count; index += 1) {
|
|
8020
|
+
const run = await pairOnce(lab, standby, cell, environment, index);
|
|
5810
8021
|
runs.push(run);
|
|
5811
8022
|
say(line(cell, run, measure(cell, run)));
|
|
5812
8023
|
}
|
|
@@ -5848,6 +8059,34 @@ const inClusterEnvironment = async (cell, environment, count, say) => {
|
|
|
5848
8059
|
}
|
|
5849
8060
|
};
|
|
5850
8061
|
/**
|
|
8062
|
+
* This function does the part of the runs of a Kafka cell in one environment. The warm-up is a whole
|
|
8063
|
+
* run with its fault, as for a cluster. Each run makes its own topic.
|
|
8064
|
+
*/
|
|
8065
|
+
const inKafkaEnvironment = async (cell, environment, count, say) => {
|
|
8066
|
+
const lab = await openKafkaLab(cell.kafka);
|
|
8067
|
+
try {
|
|
8068
|
+
const manifest = await lab.manifest(await lab.sysctl(cell.sysctls));
|
|
8069
|
+
const warmup = {
|
|
8070
|
+
...await kafkaOnce(lab, cell, environment, -1),
|
|
8071
|
+
warmup: true
|
|
8072
|
+
};
|
|
8073
|
+
say(line(cell, warmup, measure(cell, warmup)));
|
|
8074
|
+
const runs = [];
|
|
8075
|
+
for (let index = 0; index < count; index += 1) {
|
|
8076
|
+
const run = await kafkaOnce(lab, cell, environment, index);
|
|
8077
|
+
runs.push(run);
|
|
8078
|
+
say(line(cell, run, measure(cell, run)));
|
|
8079
|
+
}
|
|
8080
|
+
return {
|
|
8081
|
+
manifest,
|
|
8082
|
+
runs,
|
|
8083
|
+
warmup
|
|
8084
|
+
};
|
|
8085
|
+
} finally {
|
|
8086
|
+
await lab.close();
|
|
8087
|
+
}
|
|
8088
|
+
};
|
|
8089
|
+
/**
|
|
5851
8090
|
* This function does the runs of a cell. They go across new environments, and each environment records
|
|
5852
8091
|
* its manifest. Then the verdict judges the runs. A median across the edge of the band extends
|
|
5853
8092
|
* the cell from 11 runs to 22, and then to 44, each extension in new environments. With a
|
|
@@ -5901,6 +8140,6 @@ const runCell = async (cell, say = () => {}) => {
|
|
|
5901
8140
|
}
|
|
5902
8141
|
};
|
|
5903
8142
|
//#endregion
|
|
5904
|
-
export { AWS_FROM, CLIENT_NAMES, CLUSTER_STAGES, CONVERGE_MS, DROP_HANDLING, GIVES_AWAY, INJECT, KEPT, LINGER_MS, LISTING, MEMBER_MARKS, MONITOR_DOWN, NODE_MARKS, NOISE, PINNED, PUMP, PUMP_SLACK, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, REPOSITORY, SHIPPED_CLIENTS, SHIPPED_NAMES, STAGES, STATUS_SCRIPT, STEPS, STEP_DOWN, amqpClientImage, callOf, clockOffsetOf, closeOrExit, cloudOf, clusterArgs, clusterConf, clusterCutOf, clusterHostsOf, clusterOnce, clusterStagesOf, clusterViewOf, comparable, crossingsOf, cutOf, deadlineOf, delayVoids, deviceOf, dropFinding, dropOf, endpointAfter, errorsOf, exitOf, fieldOf, fieldsOf, firstUnanswered, goHttpImage, groupOnce, groupVerdictOf, grpcClientImage, harnessImage, hikariClientImage, hzOf, imageOf, imageProblems, inContext, infoOf, initiateScript, isHopPlace, jitterOf, jitterOfLines, judge, lagsOf, latestEndOf, leftAfter, logNameOf, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, monotonicOf, nodeClientImage, nodeCommand, nodeHttpImage, nodeLineOf, nodeNameOf, nodesOnMonotonic, noiseOf, onMonotonic, openClusterLab, openGroupLab, openLab, openReplicaSetLab, pairingOf, pairingsOf, peersOf, pgClientImage, pingsOf, plainOf, planOf, programLines, programOf, quantileOf, readHost, reconnectFinding, reconnectOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, restamped, routerImage, routesOf, runCell, sentinelConfig, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, statusViewOf, subjectClient, subjectProblems, subscriptionsOf, substrateKey, substrateOf, substrateOn, targetImageOf, targetSourceOf, templated, toolJsonOf, toxicBody, unlockedManifests, unpinnedBases, unpoolable, verdictOf, viewOf, writesOf };
|
|
8143
|
+
export { AWS_FROM, CLIENT_NAMES, CLUSTER_STAGES, CONVERGE_MS, DAEMONS, DEBUG, DIRTY, DROP_HANDLING, FABRIC_CLIENT, FABRIC_DEVICE, FABRIC_TARGET, FRR_IMAGE, GIVES_AWAY, HASH_SCRIPT, INJECT, INSERT, KAFKA_TAIL_MS, KEPT, LINGER_MS, LISTING, LOCK_NAMES, LOSS_MARGIN, MEMBER_MARKS, MONITOR_DOWN, NODE_MARKS, NOISE, PINNED, PUMP, PUMP_SLACK, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, REPOSITORY, RESTART, ROUTERS, SEED_DOCUMENT, SHIPPED_CLIENTS, SHIPPED_NAMES, STAGES, STANDBY_READY, STATUS_SCRIPT, STEPS, STEP_DOWN, amqpClientImage, basesOf, bootstrapOf, brokerEnv, buildOf, buildRecordOf, callOf, cancelsOf, cargoVersions, clockOffsetOf, closeOrExit, cloudOf, clusterArgs, clusterConf, clusterCutOf, clusterHostsOf, clusterOnce, clusterStagesOf, clusterViewOf, commitProblems, comparable, controllerEnv, crossingsOf, cutOf, deadlineOf, delayVoids, deviceOf, drillOnce, drillSpan, dropFinding, dropOf, endpointAfter, envOf, errorsOf, exitOf, fieldOf, fieldsOf, firstUnanswered, frrConf, goHttpImage, goVersions, groupOnce, groupVerdictOf, grpcClientImage, harnessImage, harnessRecord, harnessVersion, hashOf, highestHeld, hikariClientImage, hzOf, imageOf, imageProblems, inContext, infoOf, initiateScript, isHopPlace, jarVersions, jitterOf, jitterOfLines, judge, kafkaArgs, kafkaClientImage, kafkaCutOf, kafkaOnce, kafkaViewOf, lagsOf, laterMove, latestEndOf, leafLacks, leftAfter, locksOf, logNameOf, manifestProblems, matches, measure, measureDriver, measureGroup, measurePorts, measureQueue, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, monotonicAt, monotonicOf, nodeClientImage, nodeCommand, nodeHttpImage, nodeIdOf, nodeLineOf, nodeNameOf, nodesOnMonotonic, noiseOf, npmVersions, numbersOf, onMonotonic, openClusterLab, openFabric, openGroupLab, openKafkaLab, openLab, openReplicaSetLab, openStandby, pairArgs, pairOnce, pairingOf, pairingsOf, peersOf, pgClientImage, pingsOf, plainOf, planOf, programLines, programOf, quantileOf, readCpu, readHost, readOplog, reconnectFinding, reconnectOf, recordProblems, recoveryFindings, recoveryOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, restamped, routeLine, routerImage, routesOf, runCell, seedScript, sentinelConfig, sessionsOf, shippedClientRecord, shippedSource, sliceQuery, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, standaloneCommand, standbyCommand, statusViewOf, stealShare, stopScript, subjectClient, subjectProblems, subscriptionsOf, substrateHere, substrateKey, substrateOf, substrateOn, targetImageOf, targetSourceOf, templated, toolJsonOf, topicOf, topologyOf, toxicBody, unlockedManifests, unpinnedBases, unpoolable, verdictOf, versionsOf, viewOf, writesOf };
|
|
5905
8144
|
|
|
5906
8145
|
//# sourceMappingURL=index.js.map
|