@mark1russell7/harness 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -4
- package/build/index.d.ts +537 -23
- package/build/index.js +1497 -94
- package/build/index.js.map +1 -1
- package/image/go-http/Dockerfile +14 -0
- package/image/go-http/go.mod +3 -0
- package/image/go-http/main.go +324 -0
- package/image/mongo/Dockerfile +11 -0
- package/image/mongo/client-mongo.mjs +273 -0
- package/image/mongo/npm-lock.json +163 -0
- package/image/mongo/package.json +8 -0
- package/image/mongo/stepdown-mongo.mjs +37 -0
- package/image/node-http/Dockerfile +12 -0
- package/image/node-http/client-agent.mjs +163 -0
- package/image/node-http/client-undici.mjs +141 -0
- package/image/node-http/lines.mjs +59 -0
- package/image/node-http/npm-lock.json +22 -0
- package/image/node-http/package.json +8 -0
- package/package.json +16 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The Go HTTP client of the harness: the http.Transport of Go 1.27.2 at its defaults, with the
|
|
2
|
+
# settings of a cell over them. It runs as a container in the client's network namespace, as the
|
|
3
|
+
# harness's own TCP client does, and prints the same JSON lines. The program uses the standard
|
|
4
|
+
# library only, thus the digest of the builder pins all of its code.
|
|
5
|
+
# lab.ts stages the build context: this folder, under harness/image/go-http.
|
|
6
|
+
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
7
|
+
WORKDIR /src
|
|
8
|
+
COPY harness/image/go-http/go.mod harness/image/go-http/main.go ./
|
|
9
|
+
# the format and the vet of the source come first, as the cargo test of the harness image does
|
|
10
|
+
RUN test -z "$(gofmt -l .)" || { gofmt -d .; exit 1; }
|
|
11
|
+
RUN CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go build -trimpath -o /client-go .
|
|
12
|
+
|
|
13
|
+
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
|
14
|
+
COPY --from=build /client-go /opt/harness/client-go
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Command client-go is the Go client of the HTTP cells: the http.Transport of Go 1.27.2 at its
|
|
2
|
+
// defaults, with each setting that a cell gives over them. Each event is a JSON line with `ev` and
|
|
3
|
+
// `t`, the CLOCK_MONOTONIC time in nanoseconds, as the TCP client of the harness writes them.
|
|
4
|
+
//
|
|
5
|
+
// client-go HOST PORT --mode idle|inflight|gap [--gap-ms N] [--method GET|POST] [--hold-ms N]
|
|
6
|
+
// [--bytes N] [--go PATH] [--max-s N] [--linger-ms N]
|
|
7
|
+
// [--idle-conn-timeout-ms N] [--max-idle-per-host N] [--dial-timeout-ms N]
|
|
8
|
+
// [--keepalive-ms N] [--keepalive-interval-ms N] [--keepalive-count N] [--timeout-ms N]
|
|
9
|
+
//
|
|
10
|
+
// The first request is GET /, and the end of its reply is the ready line. Then the client holds
|
|
11
|
+
// the connection idle (idle), sends the second request when the file --go appears (inflight), or
|
|
12
|
+
// sends it after the gap (gap).
|
|
13
|
+
//
|
|
14
|
+
// A wrapper on each connection writes what the read loop of the transport reads: an end of file,
|
|
15
|
+
// or an error. The trace of the transport writes which connection each request goes on, whether
|
|
16
|
+
// the transport reused it, and each new try of a request on a new connection.
|
|
17
|
+
package main
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"context"
|
|
21
|
+
"encoding/json"
|
|
22
|
+
"errors"
|
|
23
|
+
"flag"
|
|
24
|
+
"fmt"
|
|
25
|
+
"io"
|
|
26
|
+
"net"
|
|
27
|
+
"net/http"
|
|
28
|
+
"net/http/httptrace"
|
|
29
|
+
"os"
|
|
30
|
+
"runtime"
|
|
31
|
+
"strings"
|
|
32
|
+
"sync"
|
|
33
|
+
"syscall"
|
|
34
|
+
"time"
|
|
35
|
+
"unsafe"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
var out sync.Mutex
|
|
39
|
+
|
|
40
|
+
// now gives the time on CLOCK_MONOTONIC in nanoseconds: the clock of the prober.
|
|
41
|
+
func now() int64 {
|
|
42
|
+
var ts syscall.Timespec
|
|
43
|
+
syscall.Syscall(syscall.SYS_CLOCK_GETTIME, 1, uintptr(unsafe.Pointer(&ts)), 0)
|
|
44
|
+
return ts.Nano()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// emitAt writes one line with the time t.
|
|
48
|
+
func emitAt(ev string, t int64, fields map[string]any) {
|
|
49
|
+
line := map[string]any{"ev": ev, "t": t}
|
|
50
|
+
for key, value := range fields {
|
|
51
|
+
line[key] = value
|
|
52
|
+
}
|
|
53
|
+
bytes, _ := json.Marshal(line)
|
|
54
|
+
out.Lock()
|
|
55
|
+
os.Stdout.Write(append(bytes, '\n'))
|
|
56
|
+
out.Unlock()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// emit writes one line with the time now.
|
|
60
|
+
func emit(ev string, fields map[string]any) { emitAt(ev, now(), fields) }
|
|
61
|
+
|
|
62
|
+
var names = map[syscall.Errno]string{
|
|
63
|
+
syscall.ECONNRESET: "ECONNRESET",
|
|
64
|
+
syscall.ECONNREFUSED: "ECONNREFUSED",
|
|
65
|
+
syscall.ETIMEDOUT: "ETIMEDOUT",
|
|
66
|
+
syscall.EHOSTUNREACH: "EHOSTUNREACH",
|
|
67
|
+
syscall.ENETUNREACH: "ENETUNREACH",
|
|
68
|
+
syscall.EPIPE: "EPIPE",
|
|
69
|
+
syscall.ECONNABORTED: "ECONNABORTED",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// errnoOf gives the name of the error of a socket, EOF for an end of file, or TIMEOUT for a
|
|
73
|
+
// deadline of Go.
|
|
74
|
+
func errnoOf(err error) string {
|
|
75
|
+
var errno syscall.Errno
|
|
76
|
+
if errors.As(err, &errno) {
|
|
77
|
+
if name, ok := names[errno]; ok {
|
|
78
|
+
return name
|
|
79
|
+
}
|
|
80
|
+
return fmt.Sprintf("E%d", int(errno))
|
|
81
|
+
}
|
|
82
|
+
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
83
|
+
return "EOF"
|
|
84
|
+
}
|
|
85
|
+
var timeout interface{ Timeout() bool }
|
|
86
|
+
if errors.As(err, &timeout) && timeout.Timeout() {
|
|
87
|
+
return "TIMEOUT"
|
|
88
|
+
}
|
|
89
|
+
return "ERROR"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// traced is a connection that writes what its reads end with, and its close.
|
|
93
|
+
type traced struct {
|
|
94
|
+
net.Conn
|
|
95
|
+
local string
|
|
96
|
+
mu sync.Mutex
|
|
97
|
+
closed bool
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (c *traced) Read(p []byte) (int, error) {
|
|
101
|
+
n, err := c.Conn.Read(p)
|
|
102
|
+
if err != nil {
|
|
103
|
+
c.mu.Lock()
|
|
104
|
+
closed := c.closed
|
|
105
|
+
c.mu.Unlock()
|
|
106
|
+
// a read after the close of the client itself is not an event of the peer
|
|
107
|
+
if !closed {
|
|
108
|
+
if errors.Is(err, io.EOF) {
|
|
109
|
+
emit("eof", map[string]any{"local": c.local})
|
|
110
|
+
} else {
|
|
111
|
+
emit("conn-error", map[string]any{"local": c.local, "errno": errnoOf(err), "message": err.Error()})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return n, err
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (c *traced) Close() error {
|
|
119
|
+
c.mu.Lock()
|
|
120
|
+
first := !c.closed
|
|
121
|
+
c.closed = true
|
|
122
|
+
c.mu.Unlock()
|
|
123
|
+
if first {
|
|
124
|
+
emit("closed", map[string]any{"local": c.local})
|
|
125
|
+
}
|
|
126
|
+
return c.Conn.Close()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// dial gives the DialContext of the transport: the dialer, and a wrapper on each connection.
|
|
130
|
+
func dial(d *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
131
|
+
var mu sync.Mutex
|
|
132
|
+
count := 0
|
|
133
|
+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
134
|
+
start := now()
|
|
135
|
+
conn, err := d.DialContext(ctx, network, addr)
|
|
136
|
+
if err != nil {
|
|
137
|
+
emit("dial-error", map[string]any{"errno": errnoOf(err), "message": err.Error(), "ns": now() - start})
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
mu.Lock()
|
|
141
|
+
count++
|
|
142
|
+
n := count
|
|
143
|
+
mu.Unlock()
|
|
144
|
+
local := conn.LocalAddr().String()
|
|
145
|
+
emit("conn", map[string]any{"n": n, "local": local})
|
|
146
|
+
if n == 1 {
|
|
147
|
+
emit("connected", map[string]any{"local": local})
|
|
148
|
+
}
|
|
149
|
+
return &traced{Conn: conn, local: local}, nil
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// request sends request i and waits for its outcome. It tells whether a reply came.
|
|
154
|
+
func request(client *http.Client, base string, i int, method, path string) bool {
|
|
155
|
+
attempts := 0
|
|
156
|
+
trace := &httptrace.ClientTrace{
|
|
157
|
+
GotConn: func(info httptrace.GotConnInfo) {
|
|
158
|
+
attempts++
|
|
159
|
+
ev := "request"
|
|
160
|
+
if attempts > 1 {
|
|
161
|
+
ev = "retry"
|
|
162
|
+
}
|
|
163
|
+
emit(ev, map[string]any{
|
|
164
|
+
"i": i,
|
|
165
|
+
"local": info.Conn.LocalAddr().String(),
|
|
166
|
+
"reused": info.Reused,
|
|
167
|
+
"idle_ns": info.IdleTime.Nanoseconds(),
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
WroteRequest: func(info httptrace.WroteRequestInfo) {
|
|
171
|
+
fields := map[string]any{"i": i}
|
|
172
|
+
if info.Err != nil {
|
|
173
|
+
fields["errno"] = errnoOf(info.Err)
|
|
174
|
+
}
|
|
175
|
+
emit("wrote", fields)
|
|
176
|
+
},
|
|
177
|
+
GotFirstResponseByte: func() { emit("first-byte", map[string]any{"i": i}) },
|
|
178
|
+
}
|
|
179
|
+
var body io.Reader
|
|
180
|
+
if method == "POST" {
|
|
181
|
+
body = strings.NewReader("x")
|
|
182
|
+
}
|
|
183
|
+
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), method, base+path, body)
|
|
184
|
+
if err != nil {
|
|
185
|
+
emit("fail", map[string]any{"why": err.Error()})
|
|
186
|
+
os.Exit(1)
|
|
187
|
+
}
|
|
188
|
+
// the stamp comes before the call: the transport writes the request after it
|
|
189
|
+
at := now()
|
|
190
|
+
emitAt("sent", at, map[string]any{"i": i})
|
|
191
|
+
resp, err := client.Do(req)
|
|
192
|
+
if err != nil {
|
|
193
|
+
emit("error", map[string]any{"i": i, "errno": errnoOf(err), "message": err.Error()})
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
emit("response", map[string]any{"i": i, "status": resp.StatusCode})
|
|
197
|
+
n, err := io.Copy(io.Discard, resp.Body)
|
|
198
|
+
resp.Body.Close()
|
|
199
|
+
if err != nil {
|
|
200
|
+
emit("error", map[string]any{"i": i, "errno": errnoOf(err), "message": err.Error()})
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
if i == 1 {
|
|
204
|
+
emit("ready", map[string]any{"reply": resp.Status})
|
|
205
|
+
} else {
|
|
206
|
+
emit("data", map[string]any{"i": i, "n": n})
|
|
207
|
+
emit("replied", map[string]any{"i": i, "n": map[string]any{"value": n, "unit": "B"}})
|
|
208
|
+
}
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func milliseconds(ms float64) time.Duration { return time.Duration(ms * float64(time.Millisecond)) }
|
|
213
|
+
|
|
214
|
+
func main() {
|
|
215
|
+
usage := "client-go HOST PORT --mode idle|inflight|gap"
|
|
216
|
+
if len(os.Args) < 3 {
|
|
217
|
+
emit("fail", map[string]any{"why": usage})
|
|
218
|
+
os.Exit(1)
|
|
219
|
+
}
|
|
220
|
+
host, port := os.Args[1], os.Args[2]
|
|
221
|
+
flags := flag.NewFlagSet("client-go", flag.ExitOnError)
|
|
222
|
+
mode := flags.String("mode", "", "idle, inflight or gap")
|
|
223
|
+
gapMs := flags.Float64("gap-ms", 0, "the gap after the first reply, in gap mode")
|
|
224
|
+
method := flags.String("method", "GET", "the method of the second request")
|
|
225
|
+
holdMs := flags.Int("hold-ms", 0, "the second request asks the server to hold its reply")
|
|
226
|
+
size := flags.Int("bytes", 0, "the second request asks for a body of this length")
|
|
227
|
+
goPath := flags.String("go", "/tmp/go", "the file that starts the second request, in inflight mode")
|
|
228
|
+
maxS := flags.Float64("max-s", 60, "the deadline of the client")
|
|
229
|
+
lingerMs := flags.Float64("linger-ms", 500, "the wait after the second request, before the exit")
|
|
230
|
+
idleMs := flags.Float64("idle-conn-timeout-ms", 0, "Transport.IdleConnTimeout")
|
|
231
|
+
maxIdle := flags.Int("max-idle-per-host", 0, "Transport.MaxIdleConnsPerHost")
|
|
232
|
+
dialMs := flags.Float64("dial-timeout-ms", 0, "Dialer.Timeout")
|
|
233
|
+
keepAliveMs := flags.Float64("keepalive-ms", 0, "Dialer.KeepAlive, negative for off")
|
|
234
|
+
intervalMs := flags.Float64("keepalive-interval-ms", 0, "KeepAliveConfig.Interval")
|
|
235
|
+
probes := flags.Int("keepalive-count", 0, "KeepAliveConfig.Count")
|
|
236
|
+
timeoutMs := flags.Float64("timeout-ms", 0, "Client.Timeout")
|
|
237
|
+
flags.Parse(os.Args[3:])
|
|
238
|
+
given := map[string]bool{}
|
|
239
|
+
flags.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
|
240
|
+
if *mode != "idle" && *mode != "inflight" && *mode != "gap" {
|
|
241
|
+
emit("fail", map[string]any{"why": usage})
|
|
242
|
+
os.Exit(1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// the dialer of http.DefaultTransport (src/net/http/transport.go:47-53), with the settings of the cell
|
|
246
|
+
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
|
247
|
+
if given["dial-timeout-ms"] {
|
|
248
|
+
dialer.Timeout = milliseconds(*dialMs)
|
|
249
|
+
}
|
|
250
|
+
if given["keepalive-ms"] {
|
|
251
|
+
dialer.KeepAlive = milliseconds(*keepAliveMs)
|
|
252
|
+
}
|
|
253
|
+
if given["keepalive-interval-ms"] || given["keepalive-count"] {
|
|
254
|
+
dialer.KeepAliveConfig = net.KeepAliveConfig{
|
|
255
|
+
Enable: dialer.KeepAlive >= 0,
|
|
256
|
+
Idle: dialer.KeepAlive,
|
|
257
|
+
Interval: milliseconds(*intervalMs),
|
|
258
|
+
Count: *probes,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
262
|
+
transport.DialContext = dial(dialer)
|
|
263
|
+
if given["idle-conn-timeout-ms"] {
|
|
264
|
+
transport.IdleConnTimeout = milliseconds(*idleMs)
|
|
265
|
+
}
|
|
266
|
+
if given["max-idle-per-host"] {
|
|
267
|
+
transport.MaxIdleConnsPerHost = *maxIdle
|
|
268
|
+
}
|
|
269
|
+
client := &http.Client{Transport: transport}
|
|
270
|
+
if given["timeout-ms"] {
|
|
271
|
+
client.Timeout = milliseconds(*timeoutMs)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
bootID, _ := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
|
275
|
+
settings := map[string]any{}
|
|
276
|
+
settings["IdleConnTimeout_ms"] = transport.IdleConnTimeout.Milliseconds()
|
|
277
|
+
settings["MaxIdleConnsPerHost"] = transport.MaxIdleConnsPerHost
|
|
278
|
+
settings["DialTimeout_ms"] = dialer.Timeout.Milliseconds()
|
|
279
|
+
settings["KeepAlive_ms"] = dialer.KeepAlive.Milliseconds()
|
|
280
|
+
settings["KeepAliveInterval_ms"] = dialer.KeepAliveConfig.Interval.Milliseconds()
|
|
281
|
+
settings["KeepAliveCount"] = dialer.KeepAliveConfig.Count
|
|
282
|
+
settings["ClientTimeout_ms"] = client.Timeout.Milliseconds()
|
|
283
|
+
start := map[string]any{}
|
|
284
|
+
start["boot_id"] = strings.TrimSpace(string(bootID))
|
|
285
|
+
start["mode"] = *mode
|
|
286
|
+
start["client"] = "go-nethttp"
|
|
287
|
+
start["go"] = runtime.Version()
|
|
288
|
+
start["settings"] = settings
|
|
289
|
+
emit("start", start)
|
|
290
|
+
go func() {
|
|
291
|
+
time.Sleep(time.Duration(*maxS * float64(time.Second)))
|
|
292
|
+
emit("deadline", nil)
|
|
293
|
+
os.Exit(0)
|
|
294
|
+
}()
|
|
295
|
+
|
|
296
|
+
base := "http://" + net.JoinHostPort(host, port)
|
|
297
|
+
second := "/"
|
|
298
|
+
if *holdMs > 0 {
|
|
299
|
+
second = fmt.Sprintf("/hold?ms=%d", *holdMs)
|
|
300
|
+
} else if *size > 0 {
|
|
301
|
+
second = fmt.Sprintf("/big?bytes=%d", *size)
|
|
302
|
+
}
|
|
303
|
+
if !request(client, base, 1, "GET", "/") {
|
|
304
|
+
os.Exit(1)
|
|
305
|
+
}
|
|
306
|
+
switch *mode {
|
|
307
|
+
case "idle":
|
|
308
|
+
// the connection stays in the pool until it ends, or until the deadline
|
|
309
|
+
select {}
|
|
310
|
+
case "inflight":
|
|
311
|
+
for {
|
|
312
|
+
if _, err := os.Stat(*goPath); err == nil {
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
time.Sleep(2 * time.Millisecond)
|
|
316
|
+
}
|
|
317
|
+
case "gap":
|
|
318
|
+
time.Sleep(milliseconds(*gapMs))
|
|
319
|
+
}
|
|
320
|
+
request(client, base, 2, *method, second)
|
|
321
|
+
emit("done", map[string]any{"i": 2})
|
|
322
|
+
time.Sleep(milliseconds(*lingerMs))
|
|
323
|
+
os.Exit(0)
|
|
324
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# The harness's MongoDB client: the driver mongodb 7.7.0 on Node 24.21.0. It runs as a container
|
|
2
|
+
# in the client's network namespace, as the harness's own TCP client does, and prints the same JSON
|
|
3
|
+
# lines, with the driver's own events beside them. The same image sends a planned step-down.
|
|
4
|
+
# lab.ts stages the build context: this folder, under harness/image/mongo. The lockfile is
|
|
5
|
+
# npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
|
|
6
|
+
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
|
7
|
+
WORKDIR /opt/harness/mongo
|
|
8
|
+
COPY harness/image/mongo/package.json ./
|
|
9
|
+
COPY harness/image/mongo/npm-lock.json ./package-lock.json
|
|
10
|
+
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
|
|
11
|
+
COPY harness/image/mongo/client-mongo.mjs harness/image/mongo/stepdown-mongo.mjs ./
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// client-mongo HOST PORT --mode idle|inflight|stream [--hosts H:P,H:P] [--set NAME] [--op write|read-long]
|
|
2
|
+
// [--interval-ms N] [--max-s N] [--go PATH] [--w majority|1] [--before] [--options JSON]
|
|
3
|
+
//
|
|
4
|
+
// The client of a MongoDB cell: the driver mongodb 7.7.0 on Node 24.21.0, against a replica set.
|
|
5
|
+
// Each event is a JSON line with `ev` and `t`, the time on CLOCK_MONOTONIC in nanoseconds
|
|
6
|
+
// (process.hrtime, which libuv reads from it). The prober stamps the records of the kernel on the
|
|
7
|
+
// same clock, and the injector stamps each fault on it.
|
|
8
|
+
//
|
|
9
|
+
// The lines:
|
|
10
|
+
// - start: the version of the driver and of Node, the mode and the options.
|
|
11
|
+
// - clocks: the wall clock in ms beside the monotonic clock, each second. The log of mongod is on
|
|
12
|
+
// the wall clock, and these lines put it on the monotonic clock.
|
|
13
|
+
// - socket, socket-end, socket-error: each socket of the driver by its role (monitor, rtt, pool),
|
|
14
|
+
// with its local address. The role comes from the stack of the call that makes the socket.
|
|
15
|
+
// - connected: the first socket of the pool. The TCP client of the harness names its own socket so.
|
|
16
|
+
// - sdam: each heartbeat, each change of the description of a server, and each change of the topology.
|
|
17
|
+
// - cmap: each event of a connection pool, and each clear with its interruption of connections in use.
|
|
18
|
+
// - command: each try of an operation, with its server. A retry is a second try with the same number.
|
|
19
|
+
// - connecting: the start of the selection of the first server.
|
|
20
|
+
// - ready: the reply to the first write once the monitor streams (idle, inflight), or the
|
|
21
|
+
// connection of the client (stream).
|
|
22
|
+
// - sent, ack, fail: each operation by its sequence number, with its server or its error and labels.
|
|
23
|
+
// - data, error: the end of the operation in flight, as the TCP client of the harness tells it.
|
|
24
|
+
// In idle mode, an error of the socket of the first pool connection is an error line too.
|
|
25
|
+
// - done, deadline: the end of the client.
|
|
26
|
+
//
|
|
27
|
+
// The modes: idle sends one write, then holds the connections until --max-s. inflight sends one
|
|
28
|
+
// write, waits for the file --go, sends one more operation into the fault, and holds the
|
|
29
|
+
// connections after its end. stream sends a write each --interval-ms after the reply before, until
|
|
30
|
+
// --max-s or SIGTERM. --before starts the client into a fault that holds already: its first
|
|
31
|
+
// selection meets it.
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import net from "node:net";
|
|
36
|
+
|
|
37
|
+
const now = () => process.hrtime.bigint();
|
|
38
|
+
const emit = (ev, fields = {}, at = now()) => process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
|
|
39
|
+
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const flag = (name) => {
|
|
42
|
+
const at = argv.indexOf(name);
|
|
43
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
44
|
+
};
|
|
45
|
+
const [host, port] = argv;
|
|
46
|
+
const mode = flag("--mode");
|
|
47
|
+
if (host === undefined || port === undefined || !["idle", "inflight", "stream"].includes(mode)) {
|
|
48
|
+
emit("fail", { why: "client-mongo HOST PORT --mode idle|inflight|stream" });
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const first = `${host}:${port}`;
|
|
52
|
+
const hosts = [first, ...(flag("--hosts") ?? "").split(",").filter((each) => each !== "" && each !== first)];
|
|
53
|
+
const set = flag("--set") ?? "rs0";
|
|
54
|
+
const op = flag("--op") ?? "write";
|
|
55
|
+
const interval = Number(flag("--interval-ms") ?? 50);
|
|
56
|
+
const max = Number(flag("--max-s") ?? 120) * 1000;
|
|
57
|
+
const go = flag("--go") ?? "/tmp/go";
|
|
58
|
+
const w = flag("--w") === "1" ? 1 : "majority";
|
|
59
|
+
const before = argv.includes("--before");
|
|
60
|
+
const options = JSON.parse(flag("--options") ?? "{}");
|
|
61
|
+
|
|
62
|
+
// ---- the two clocks: the wall clock of a log line of mongod goes onto the monotonic clock by them
|
|
63
|
+
const clocks = () => {
|
|
64
|
+
const t = now();
|
|
65
|
+
emit("clocks", { real: Date.now() }, t);
|
|
66
|
+
};
|
|
67
|
+
clocks();
|
|
68
|
+
setInterval(clocks, 1000).unref();
|
|
69
|
+
|
|
70
|
+
// ---- each socket of the driver, by its role: the stack of the call that makes it names the caller
|
|
71
|
+
const roleOf = (stack) => {
|
|
72
|
+
if (/measureRoundTripTime/.test(stack)) return "rtt";
|
|
73
|
+
if (/sdam[\\/]monitor\.js/.test(stack)) return "monitor";
|
|
74
|
+
if (/cmap[\\/]connection_pool\.js/.test(stack)) return "pool";
|
|
75
|
+
return "other";
|
|
76
|
+
};
|
|
77
|
+
let connected = null;
|
|
78
|
+
const createConnection = net.createConnection;
|
|
79
|
+
Error.stackTraceLimit = 50;
|
|
80
|
+
net.createConnection = function (...args) {
|
|
81
|
+
const role = roleOf(new Error().stack ?? "");
|
|
82
|
+
const socket = createConnection.apply(this, args);
|
|
83
|
+
const server = `${args[0]?.host}:${args[0]?.port}`;
|
|
84
|
+
socket.once("connect", () => {
|
|
85
|
+
const local = `${socket.localAddress}:${socket.localPort}`;
|
|
86
|
+
emit("socket", { role, server, local });
|
|
87
|
+
if (role === "pool" && connected === null) {
|
|
88
|
+
connected = local;
|
|
89
|
+
emit("connected", { local });
|
|
90
|
+
}
|
|
91
|
+
socket.once("close", (hadError) => emit("socket-end", { role, server, local, error: hadError }));
|
|
92
|
+
});
|
|
93
|
+
socket.once("error", (error) => {
|
|
94
|
+
const local = socket.localPort === undefined ? null : `${socket.localAddress}:${socket.localPort}`;
|
|
95
|
+
const errno = error.code ?? error.errno ?? null;
|
|
96
|
+
emit("socket-error", { role, server, local, errno, message: error.message });
|
|
97
|
+
// the socket of the client in idle mode: its error is the detection, as for the TCP client
|
|
98
|
+
if (mode === "idle" && local !== null && local === connected) emit("error", { errno });
|
|
99
|
+
});
|
|
100
|
+
return socket;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// the driver loads after the patch, thus each of its sockets goes through it
|
|
104
|
+
const { MongoClient } = await import("mongodb");
|
|
105
|
+
const version = JSON.parse(readFileSync(new URL("./node_modules/mongodb/package.json", import.meta.url), "utf8")).version;
|
|
106
|
+
|
|
107
|
+
const bootId = () => {
|
|
108
|
+
try {
|
|
109
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
emit("start", { boot_id: bootId(), client: "mongodb", driver: version, node: process.versions.node, mode, op, w, hosts, options });
|
|
115
|
+
|
|
116
|
+
const uri = `mongodb://${hosts.join(",")}/?replicaSet=${set}`;
|
|
117
|
+
const client = new MongoClient(uri, { ...options, writeConcern: { w }, monitorCommands: true });
|
|
118
|
+
|
|
119
|
+
// ---- the driver's own events, each stamped as the driver emits it
|
|
120
|
+
const failure = (error) =>
|
|
121
|
+
error === undefined || error === null
|
|
122
|
+
? null
|
|
123
|
+
: { name: error.name, code: error.code ?? null, labels: [...(error.errorLabelSet ?? [])], message: error.message };
|
|
124
|
+
const describe = (d) =>
|
|
125
|
+
d === undefined ? null : { type: d.type, setName: d.setName ?? null, electionId: d.electionId?.toHexString?.() ?? null, error: failure(d.error) };
|
|
126
|
+
/** This promise resolves at the first awaited hello of a monitor: the stream of the monitor has begun. */
|
|
127
|
+
let streamed = () => undefined;
|
|
128
|
+
const streaming = new Promise((resolve) => {
|
|
129
|
+
streamed = resolve;
|
|
130
|
+
});
|
|
131
|
+
client.on("serverHeartbeatStarted", (e) => {
|
|
132
|
+
emit("sdam", { event: "heartbeat-started", address: e.connectionId, awaited: e.awaited });
|
|
133
|
+
if (e.awaited) streamed();
|
|
134
|
+
});
|
|
135
|
+
client.on("serverHeartbeatSucceeded", (e) =>
|
|
136
|
+
emit("sdam", { event: "heartbeat-succeeded", address: e.connectionId, awaited: e.awaited, duration: e.duration, primary: e.reply?.isWritablePrimary ?? null }),
|
|
137
|
+
);
|
|
138
|
+
client.on("serverHeartbeatFailed", (e) =>
|
|
139
|
+
emit("sdam", { event: "heartbeat-failed", address: e.connectionId, awaited: e.awaited, duration: e.duration, failure: failure(e.failure) }),
|
|
140
|
+
);
|
|
141
|
+
client.on("serverDescriptionChanged", (e) =>
|
|
142
|
+
emit("sdam", { event: "server-changed", address: e.address, previous: describe(e.previousDescription), next: describe(e.newDescription) }),
|
|
143
|
+
);
|
|
144
|
+
client.on("topologyDescriptionChanged", (e) => {
|
|
145
|
+
const primary = [...e.newDescription.servers.values()].find((d) => d.type === "RSPrimary")?.address ?? null;
|
|
146
|
+
emit("sdam", { event: "topology-changed", type: e.newDescription.type, primary });
|
|
147
|
+
});
|
|
148
|
+
client.on("serverOpening", (e) => emit("sdam", { event: "server-opening", address: e.address }));
|
|
149
|
+
client.on("serverClosed", (e) => emit("sdam", { event: "server-closed", address: e.address }));
|
|
150
|
+
for (const [event, name] of [
|
|
151
|
+
["connectionPoolCreated", "pool-created"],
|
|
152
|
+
["connectionPoolReady", "pool-ready"],
|
|
153
|
+
["connectionPoolClosed", "pool-closed"],
|
|
154
|
+
["connectionCreated", "created"],
|
|
155
|
+
["connectionReady", "connection-ready"],
|
|
156
|
+
["connectionCheckOutStarted", "checkout-started"],
|
|
157
|
+
["connectionCheckedOut", "checked-out"],
|
|
158
|
+
["connectionCheckedIn", "checked-in"],
|
|
159
|
+
]) {
|
|
160
|
+
client.on(event, (e) => emit("cmap", { event: name, address: e.address, connectionId: e.connectionId ?? null }));
|
|
161
|
+
}
|
|
162
|
+
client.on("connectionPoolCleared", (e) => emit("cmap", { event: "pool-cleared", address: e.address, interruptInUseConnections: e.interruptInUseConnections ?? false }));
|
|
163
|
+
client.on("connectionClosed", (e) => emit("cmap", { event: "closed", address: e.address, connectionId: e.connectionId, reason: e.reason, failure: failure(e.error) }));
|
|
164
|
+
client.on("connectionCheckOutFailed", (e) => emit("cmap", { event: "checkout-failed", address: e.address, reason: e.reason, failure: failure(e.error) }));
|
|
165
|
+
|
|
166
|
+
/** This function gives the sequence number of an operation from its comment. */
|
|
167
|
+
const numberOf = (command) => {
|
|
168
|
+
const match = /^[wr]:(\d+)$/.exec(String(command?.comment ?? ""));
|
|
169
|
+
return match === null ? null : Number(match[1]);
|
|
170
|
+
};
|
|
171
|
+
const started = new Map();
|
|
172
|
+
/** This map holds the server of the last try of each operation that succeeded, by its sequence number. */
|
|
173
|
+
const lastServer = new Map();
|
|
174
|
+
client.on("commandStarted", (e) => {
|
|
175
|
+
const n = numberOf(e.command);
|
|
176
|
+
if (n === null) return;
|
|
177
|
+
started.set(e.requestId, n);
|
|
178
|
+
emit("command", { event: "started", name: e.commandName, n, address: e.address, connectionId: e.connectionId, requestId: e.requestId });
|
|
179
|
+
});
|
|
180
|
+
client.on("commandSucceeded", (e) => {
|
|
181
|
+
if (!started.has(e.requestId)) return;
|
|
182
|
+
const n = started.get(e.requestId);
|
|
183
|
+
started.delete(e.requestId);
|
|
184
|
+
lastServer.set(n, e.address);
|
|
185
|
+
emit("command", { event: "succeeded", name: e.commandName, n, address: e.address, requestId: e.requestId, duration: e.duration });
|
|
186
|
+
});
|
|
187
|
+
client.on("commandFailed", (e) => {
|
|
188
|
+
if (!started.has(e.requestId)) return;
|
|
189
|
+
const n = started.get(e.requestId);
|
|
190
|
+
started.delete(e.requestId);
|
|
191
|
+
emit("command", { event: "failed", name: e.commandName, n, address: e.address, requestId: e.requestId, duration: e.duration, failure: failure(e.failure) });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---- the end of the client
|
|
195
|
+
let stopped = false;
|
|
196
|
+
const stop = (code = 0) => {
|
|
197
|
+
if (stopped) return;
|
|
198
|
+
stopped = true;
|
|
199
|
+
emit("done");
|
|
200
|
+
client.close().finally(() => setTimeout(() => process.exit(code), 50));
|
|
201
|
+
};
|
|
202
|
+
process.on("SIGTERM", () => stop(0));
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
emit("deadline");
|
|
205
|
+
stop(0);
|
|
206
|
+
}, max);
|
|
207
|
+
|
|
208
|
+
const collection = client.db("cells").collection("w");
|
|
209
|
+
// the writes of one client: each run starts its numbers at 1, and the field `run` keeps them apart
|
|
210
|
+
const run = randomUUID();
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* This function sends operation n: a write of its sequence number, or the read of the long
|
|
214
|
+
* document. Its stamp is before the call. It gives null for a reply, or the error.
|
|
215
|
+
*/
|
|
216
|
+
const operate = async (n) => {
|
|
217
|
+
const at = now();
|
|
218
|
+
const kind = op === "read-long" && mode === "inflight" && n > 1 ? "read" : "write";
|
|
219
|
+
const reply = kind === "read" ? collection.findOne({ _id: "long" }, { comment: `r:${n}` }) : collection.insertOne({ n, run }, { comment: `w:${n}` });
|
|
220
|
+
emit("sent", { n, kind }, at);
|
|
221
|
+
try {
|
|
222
|
+
const answer = await reply;
|
|
223
|
+
emit("ack", { n, kind, at: lastServer.get(n) ?? null, bytes: kind === "read" ? JSON.stringify(answer ?? {}).length : 0 });
|
|
224
|
+
return null;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
emit("fail", { n, kind, ...failure(error) });
|
|
227
|
+
return error;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
emit("connecting", { hosts });
|
|
232
|
+
try {
|
|
233
|
+
await client.connect();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
emit("fail", { why: "connect", ...failure(error) });
|
|
236
|
+
emit("error", { errno: error.name, message: error.message });
|
|
237
|
+
stop(before ? 0 : 1);
|
|
238
|
+
}
|
|
239
|
+
if (stopped) await new Promise(() => undefined);
|
|
240
|
+
|
|
241
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
242
|
+
|
|
243
|
+
if (mode === "stream") {
|
|
244
|
+
emit("ready", { at: first });
|
|
245
|
+
for (let n = 1; !stopped; n += 1) {
|
|
246
|
+
await operate(n);
|
|
247
|
+
await sleep(interval);
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
// the first write makes the connection of the pool. Its reply is the last data before the fault
|
|
251
|
+
const failed = await operate(1);
|
|
252
|
+
if (failed !== null) {
|
|
253
|
+
emit("error", { errno: failed.name, message: failed.message });
|
|
254
|
+
stop(1);
|
|
255
|
+
} else {
|
|
256
|
+
// the monitor sends its first awaited hello minHeartbeatFrequencyMS after its first check. A
|
|
257
|
+
// fault before that hello meets the start of the stream, not the stream that a cell measures
|
|
258
|
+
const awaited = await Promise.race([streaming.then(() => true), sleep(5000).then(() => false)]);
|
|
259
|
+
emit("ready", { reply: "ack", streaming: awaited });
|
|
260
|
+
if (mode === "inflight") {
|
|
261
|
+
await new Promise((resolve) => {
|
|
262
|
+
const wait = setInterval(() => {
|
|
263
|
+
if (!existsSync(go)) return;
|
|
264
|
+
clearInterval(wait);
|
|
265
|
+
resolve();
|
|
266
|
+
}, 2);
|
|
267
|
+
});
|
|
268
|
+
const error = await operate(2);
|
|
269
|
+
if (error === null) emit("data", { n: 1 });
|
|
270
|
+
else emit("error", { errno: error.name, message: error.message });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|