@mark1russell7/service 0.1.0 → 0.3.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/build/redis.js ADDED
@@ -0,0 +1,853 @@
1
+ import { n as mayReissue, t as realClock } from "./clock-DlJFFZlX.js";
2
+ import { quantity, valueIn } from "@mark1russell7/quantity";
3
+ import { connect } from "node:net";
4
+ //#region src/redis/connection.ts
5
+ /**
6
+ * This factory makes an ioredis client. It loads ioredis only when a client connects, thus a
7
+ * service that does not use Redis does not need ioredis.
8
+ */
9
+ const ioredis = async (options) => {
10
+ return new (await (import("ioredis"))).default(options);
11
+ };
12
+ //#endregion
13
+ //#region src/redis/meets.ts
14
+ const never = {
15
+ value: "infinite",
16
+ unit: "s"
17
+ };
18
+ const s = (value) => quantity(Math.round(value * 1e6) / 1e6, "s");
19
+ const seconds = (amount) => amount.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(amount, "s");
20
+ const plus = (amount, more) => amount.value === "infinite" ? never : s(seconds(amount) + more);
21
+ /**
22
+ * This function gives the manifestations that a Redis client can meet, and the detectors of each, from
23
+ * its knobs. A reset or a FIN arrives on the next segment. A black hole and a host that is gone give
24
+ * no answer at all. A frozen process and a stalled server keep a kernel that acknowledges each
25
+ * segment. Thus TCP keepalive does not fire on them, and only the client's own timers find them.
26
+ */
27
+ const meetings = (name, knobs, heartbeat) => {
28
+ const keepalive = plus(knobs.keepalive_idle, 10);
29
+ const watchdog = (situation) => ({
30
+ by: "heartbeat_detection_time",
31
+ within: heartbeat,
32
+ situation,
33
+ effect: `the watchdog of ${name} closes the connection, and the client connects again`
34
+ });
35
+ const writeoff = {
36
+ by: "driver_writeoff_time",
37
+ within: knobs.driver_writeoff_time,
38
+ situation: "in flight",
39
+ effect: "ioredis destroys the socket of a command without data, and the client connects again"
40
+ };
41
+ const command = {
42
+ by: "request_timeout",
43
+ within: knobs.request_timeout,
44
+ situation: "in flight",
45
+ effect: "the command fails as indeterminate, and the state of the connection does not change"
46
+ };
47
+ const giveUp = {
48
+ by: "retrans_giveup_time",
49
+ within: knobs.retrans_giveup_time,
50
+ situation: "in flight",
51
+ effect: "the kernel gives up the retransmissions and closes the socket"
52
+ };
53
+ const silent = (manifestation, what) => ({
54
+ manifestation,
55
+ what,
56
+ detectors: [
57
+ command,
58
+ writeoff,
59
+ watchdog("in flight"),
60
+ giveUp,
61
+ watchdog("idle"),
62
+ {
63
+ by: "keepalive_idle",
64
+ within: keepalive,
65
+ situation: "idle",
66
+ effect: "TCP keepalive gives up, and the socket closes"
67
+ }
68
+ ]
69
+ });
70
+ const acknowledged = (manifestation, what) => ({
71
+ manifestation,
72
+ what,
73
+ detectors: [
74
+ command,
75
+ writeoff,
76
+ watchdog("in flight"),
77
+ watchdog("idle"),
78
+ {
79
+ by: "keepalive_idle",
80
+ within: never,
81
+ situation: "idle",
82
+ effect: "the kernel of the server answers each probe"
83
+ }
84
+ ]
85
+ });
86
+ const immediate = (situation, effect) => ({
87
+ by: "the next segment",
88
+ within: s(0),
89
+ situation,
90
+ effect
91
+ });
92
+ return [
93
+ {
94
+ manifestation: "rst",
95
+ what: "the server answers each segment with a reset, and refuses each new connection",
96
+ detectors: [
97
+ immediate("in flight", "the command fails, and the client connects again, and each attempt is refused"),
98
+ watchdog("idle"),
99
+ {
100
+ by: "keepalive_idle",
101
+ within: keepalive.value === "infinite" ? never : knobs.keepalive_idle,
102
+ situation: "idle",
103
+ effect: "the first probe gets a reset"
104
+ }
105
+ ]
106
+ },
107
+ {
108
+ manifestation: "fin",
109
+ what: "the process of the server ends, and its kernel closes the connection",
110
+ detectors: [immediate("in flight", "the close arrives, and the command fails"), immediate("idle", "the close arrives")]
111
+ },
112
+ silent("blackhole", "the network drops each segment in the two directions"),
113
+ silent("freeze-host", "the host of the server stops, without a close: the death without a close"),
114
+ acknowledged("freeze-process", "the process of the server stops, and its kernel stays up"),
115
+ acknowledged("app-stall", "the server stops its replies for a time, for example a long command, and its kernel stays up")
116
+ ];
117
+ };
118
+ /** This function gives the first detector of a manifestation in a situation, or null when no detector has an end (C3). */
119
+ const firstDetector = (meeting, situation) => {
120
+ return meeting.detectors.filter((detector) => detector.situation === situation && detector.within.value !== "infinite").sort((a, b) => seconds(a.within) - seconds(b.within))[0] ?? null;
121
+ };
122
+ /** This function gives each manifestation and situation that no detector of the client finds: the standing rule, as a query (C3, E9). */
123
+ const unwatched = (meets) => meets.flatMap((meeting) => ["in flight", "idle"].flatMap((situation) => firstDetector(meeting, situation) === null ? [{
124
+ manifestation: meeting.manifestation,
125
+ situation
126
+ }] : []));
127
+ //#endregion
128
+ //#region src/redis/operations.ts
129
+ /**
130
+ * These are the Redis commands whose idempotency the client declares when the declaration does not
131
+ * say. A second issue of an idempotent command has the same effect. Thus the client can send it
132
+ * again after a failure that left its result unknown. The client does not send again a command
133
+ * that is not in this table: a declaration that uses it says its idempotency itself.
134
+ */
135
+ const SAME_EFFECT = [
136
+ "PING",
137
+ "ECHO",
138
+ "GET",
139
+ "MGET",
140
+ "STRLEN",
141
+ "GETRANGE",
142
+ "EXISTS",
143
+ "TYPE",
144
+ "TTL",
145
+ "PTTL",
146
+ "EXPIRETIME",
147
+ "HGET",
148
+ "HMGET",
149
+ "HGETALL",
150
+ "HKEYS",
151
+ "HVALS",
152
+ "HLEN",
153
+ "HEXISTS",
154
+ "SMEMBERS",
155
+ "SISMEMBER",
156
+ "SMISMEMBER",
157
+ "SCARD",
158
+ "ZRANGE",
159
+ "ZRANGEBYSCORE",
160
+ "ZSCORE",
161
+ "ZMSCORE",
162
+ "ZRANK",
163
+ "ZCARD",
164
+ "ZCOUNT",
165
+ "LRANGE",
166
+ "LINDEX",
167
+ "LLEN",
168
+ "XRANGE",
169
+ "XREVRANGE",
170
+ "XLEN",
171
+ "SCAN",
172
+ "SSCAN",
173
+ "HSCAN",
174
+ "ZSCAN",
175
+ "DBSIZE",
176
+ "INFO",
177
+ "CLIENT ID",
178
+ "SET",
179
+ "MSET",
180
+ "SETEX",
181
+ "PSETEX",
182
+ "DEL",
183
+ "UNLINK",
184
+ "EXPIRE",
185
+ "PEXPIRE",
186
+ "EXPIREAT",
187
+ "PEXPIREAT",
188
+ "PERSIST",
189
+ "HSET",
190
+ "HDEL",
191
+ "SADD",
192
+ "SREM",
193
+ "ZADD",
194
+ "ZREM"
195
+ ];
196
+ /**
197
+ * These commands change a state relative to the state before them. A second issue changes it again:
198
+ * an INCR that the client sends again after a timeout counts two times.
199
+ */
200
+ const CHANGE = [
201
+ "INCR",
202
+ "INCRBY",
203
+ "INCRBYFLOAT",
204
+ "DECR",
205
+ "DECRBY",
206
+ "HINCRBY",
207
+ "HINCRBYFLOAT",
208
+ "ZINCRBY",
209
+ "APPEND",
210
+ "LPUSH",
211
+ "RPUSH",
212
+ "LPOP",
213
+ "RPOP",
214
+ "BLPOP",
215
+ "BRPOP",
216
+ "LMOVE",
217
+ "BLMOVE",
218
+ "SPOP",
219
+ "XADD",
220
+ "XACK",
221
+ "PUBLISH",
222
+ "EVAL",
223
+ "EVALSHA",
224
+ "FCALL",
225
+ "MULTI",
226
+ "EXEC",
227
+ "GETDEL",
228
+ "GETSET",
229
+ "SETNX"
230
+ ];
231
+ /** These are the operations of the client: the idempotency of each command that the table names. */
232
+ const REDIS_OPERATIONS = Object.fromEntries([...SAME_EFFECT.map((command) => [command, { idempotent: true }]), ...CHANGE.map((command) => [command, { idempotent: false }])]);
233
+ /** This function gives the name of a command as the table writes it: upper case, and CLIENT ID with its subcommand. */
234
+ const commandName = (command, args) => {
235
+ const name = command.toUpperCase();
236
+ if ((name === "CLIENT" || name === "CONFIG" || name === "DEBUG") && typeof args[0] === "string") return `${name} ${args[0].toUpperCase()}`;
237
+ return name;
238
+ };
239
+ //#endregion
240
+ //#region src/redis/client.ts
241
+ const ms = (amount) => amount.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(amount, "ms");
242
+ const finite = (amount) => {
243
+ const value = ms(amount);
244
+ return Number.isFinite(value) ? value : void 0;
245
+ };
246
+ const message = (error) => error instanceof Error ? error.message : String(error);
247
+ /**
248
+ * This function gives the time that the watchdog takes to find a silent server, at the most. A PING
249
+ * starts each interval, from the start of the one before. Each one fails at its timeout, and the
250
+ * last of `misses` failures in sequence closes the connection: `misses` intervals plus a timeout.
251
+ */
252
+ const heartbeatDetection = (interval, timeout, misses) => interval.value === "infinite" ? {
253
+ value: "infinite",
254
+ unit: "s"
255
+ } : quantity((misses * ms(interval) + ms(timeout)) / 1e3, "s");
256
+ /** This function gives the problems of a spec: the knobs that the client cannot use. */
257
+ const specProblems = (name, spec) => {
258
+ const problems = [];
259
+ const { knobs } = spec;
260
+ if (!Number.isInteger(spec.heartbeatMisses) || spec.heartbeatMisses < 1) problems.push(`${name}'s watchdog closes the connection after ${spec.heartbeatMisses} misses, not a whole number above zero`);
261
+ if (knobs.max_connection_age.value !== "infinite") problems.push(`${name} cannot replace its connection at an age: max_connection_age must be "infinite"`);
262
+ for (const [knob, amount] of Object.entries(knobs)) if (amount.value !== "infinite" && !(ms(amount) >= 0)) problems.push(`${name}'s ${knob} is not a time of zero or more`);
263
+ if (ms(knobs.request_timeout) <= 0) problems.push(`${name}'s request_timeout is not above zero: each command must have a bound`);
264
+ if (ms(knobs.heartbeat_timeout) <= 0) problems.push(`${name}'s heartbeat_timeout is not above zero`);
265
+ if (ms(knobs.retry_cap) < ms(knobs.retry_base)) problems.push(`${name}'s retry_cap is below its retry_base`);
266
+ return problems;
267
+ };
268
+ /** This dial function opens a TCP connection, sends an inline PING, and waits for +PONG. */
269
+ const tcpDial = (host, port, signal) => new Promise((resolve, reject) => {
270
+ if (signal.aborted) {
271
+ reject(/* @__PURE__ */ new Error("the dial was aborted"));
272
+ return;
273
+ }
274
+ const socket = connect({
275
+ host,
276
+ port
277
+ });
278
+ let reply = "";
279
+ const end = (error) => {
280
+ signal.removeEventListener("abort", onAbort);
281
+ socket.destroy();
282
+ if (error === void 0) resolve();
283
+ else reject(error);
284
+ };
285
+ const onAbort = () => end(/* @__PURE__ */ new Error("the dial was aborted"));
286
+ signal.addEventListener("abort", onAbort, { once: true });
287
+ socket.once("connect", () => socket.write("PING\r\n"));
288
+ socket.on("data", (chunk) => {
289
+ reply += chunk.toString();
290
+ if (reply.includes("\r\n")) end(reply.startsWith("+PONG") ? void 0 : /* @__PURE__ */ new Error(`the reply was ${reply.trim()}`));
291
+ });
292
+ socket.once("error", (error) => end(error));
293
+ socket.once("close", () => end(/* @__PURE__ */ new Error("the server closed the dial")));
294
+ });
295
+ /**
296
+ * This function makes a Redis client. It is a dependency of a service, with ioredis under it:
297
+ * - It sets each timer of ioredis from the declaration, and it does not let ioredis send a command
298
+ * again (R5 §8).
299
+ * - It tells the outcome of a command in three values, and it settles each command in flight as
300
+ * indeterminate when its connection closes (C8, C10).
301
+ * - It records the server's id of each connection at `ready` (D1).
302
+ * - A watchdog sends a PING in the band of the connection, and it connects again after the misses
303
+ * of the declaration. It also starts a client that stopped (principle 19).
304
+ */
305
+ const redisClient = (name, spec, options = {}) => {
306
+ const problems = specProblems(name, spec);
307
+ if (problems.length > 0) throw new Error(problems.join("; "));
308
+ const { knobs } = spec;
309
+ const clock = options.clock ?? realClock;
310
+ const random = options.random ?? Math.random;
311
+ const create = options.create ?? ioredis;
312
+ const dial = options.dial ?? tcpDial;
313
+ const turn = options.turn ?? (() => new Promise((resolve) => setImmediate(resolve)));
314
+ const operations = {
315
+ ...REDIS_OPERATIONS,
316
+ ...spec.operations
317
+ };
318
+ const requestTimeout = ms(knobs.request_timeout);
319
+ const detection = heartbeatDetection(knobs.heartbeat_interval, knobs.heartbeat_timeout, spec.heartbeatMisses);
320
+ let connection = null;
321
+ let status = "none";
322
+ let closing = false;
323
+ const joins = [];
324
+ const inFlight = /* @__PURE__ */ new Set();
325
+ const readyWaiters = /* @__PURE__ */ new Set();
326
+ const listeners = [];
327
+ const record = (from, to, on, detail) => options.record?.({
328
+ at: clock.now(),
329
+ machine: "client",
330
+ instance: name,
331
+ from,
332
+ to,
333
+ on,
334
+ ...detail === void 0 ? {} : { detail }
335
+ });
336
+ let retrySince = null;
337
+ const retryStrategy = (times) => {
338
+ const now = clock.now();
339
+ retrySince ??= now;
340
+ if (now - retrySince > ms(knobs.client_retry_window)) {
341
+ record(status, status, "retry.window-ended");
342
+ return null;
343
+ }
344
+ const delay = Math.min(ms(knobs.retry_base) * 2 ** (times - 1), ms(knobs.retry_cap));
345
+ return Math.round(delay + random() * ms(knobs.retry_base));
346
+ };
347
+ const connectionOptions = {
348
+ host: spec.host,
349
+ port: spec.port,
350
+ ...spec.db === void 0 ? {} : { db: spec.db },
351
+ ...spec.username === void 0 ? {} : { username: spec.username },
352
+ ...spec.password === void 0 ? {} : { password: spec.password },
353
+ lazyConnect: true,
354
+ connectTimeout: ms(knobs.connect_bound),
355
+ commandTimeout: requestTimeout,
356
+ socketTimeout: finite(knobs.driver_writeoff_time),
357
+ keepAlive: finite(knobs.keepalive_idle) ?? false,
358
+ enableOfflineQueue: false,
359
+ autoResendUnfulfilledCommands: false,
360
+ maxRetriesPerRequest: null,
361
+ enableReadyCheck: true,
362
+ connectionName: name,
363
+ retryStrategy
364
+ };
365
+ /** This function gives the outcome of a failed command: a reply of the server, a command that did not leave the client, or neither. */
366
+ const failed = (error) => {
367
+ const text = message(error);
368
+ if (error instanceof Error && error.name === "ReplyError") return {
369
+ outcome: "error",
370
+ error: text,
371
+ reply: true
372
+ };
373
+ if (/isn't writeable|enableOfflineQueue/i.test(text)) return {
374
+ outcome: "error",
375
+ error: text,
376
+ reply: false
377
+ };
378
+ return {
379
+ outcome: "indeterminate",
380
+ error: text
381
+ };
382
+ };
383
+ const onReady = async () => {
384
+ retrySince = null;
385
+ const c = connection;
386
+ if (c === null) return;
387
+ const generation = joins.length + 1;
388
+ const at = clock.now();
389
+ const socket = c.stream;
390
+ let clientId = null;
391
+ try {
392
+ const reply = await c.call("CLIENT", "ID");
393
+ clientId = typeof reply === "number" ? reply : Number(reply);
394
+ if (!Number.isFinite(clientId)) clientId = null;
395
+ } catch {
396
+ clientId = null;
397
+ }
398
+ if (connection !== c || c.status !== "ready") return;
399
+ joins.push({
400
+ generation,
401
+ at,
402
+ clientId,
403
+ localAddress: socket?.localAddress ?? null,
404
+ localPort: socket?.localPort ?? null,
405
+ remoteAddress: socket?.remoteAddress ?? null,
406
+ remotePort: socket?.remotePort ?? null
407
+ });
408
+ record("ready", "ready", "join", `generation ${generation}, client id ${clientId ?? "unknown"}`);
409
+ for (const wake of [...readyWaiters]) wake();
410
+ };
411
+ const onStatus = (to) => () => {
412
+ const from = status;
413
+ status = to;
414
+ record(from, to, `status.${to}`);
415
+ if (to === "close" || to === "end") for (const settle of [...inFlight]) settle({
416
+ outcome: "indeterminate",
417
+ error: `the connection closed with the command in flight (${from})`
418
+ });
419
+ if (to === "ready") onReady();
420
+ };
421
+ const watch = (c) => {
422
+ for (const state of [
423
+ "connecting",
424
+ "connect",
425
+ "ready",
426
+ "close",
427
+ "reconnecting",
428
+ "end"
429
+ ]) {
430
+ const listener = onStatus(state);
431
+ c.on(state, listener);
432
+ listeners.push([state, listener]);
433
+ }
434
+ const onError = (error) => record(status, status, "error", message(error));
435
+ c.on("error", onError);
436
+ listeners.push(["error", onError]);
437
+ };
438
+ /** This function waits until the connection is ready, for `within` milliseconds at most. It gives true when it is ready. */
439
+ const waitReady = (within, signal) => new Promise((resolve) => {
440
+ if (connection?.status === "ready") {
441
+ resolve(true);
442
+ return;
443
+ }
444
+ let cancel = () => {};
445
+ const done = (value) => {
446
+ readyWaiters.delete(wake);
447
+ cancel();
448
+ signal?.removeEventListener("abort", aborted);
449
+ resolve(value);
450
+ };
451
+ const wake = () => done(true);
452
+ const aborted = () => done(false);
453
+ readyWaiters.add(wake);
454
+ cancel = clock.after(within, () => done(connection?.status === "ready"));
455
+ signal?.addEventListener("abort", aborted, { once: true });
456
+ });
457
+ const attempt = (command, args, signal) => {
458
+ const c = connection;
459
+ if (c === null) return Promise.resolve({
460
+ outcome: "error",
461
+ error: `${name} has not connected`,
462
+ reply: false
463
+ });
464
+ if (c.status !== "ready") return Promise.resolve({
465
+ outcome: "error",
466
+ error: `${name} is ${c.status}, not ready: the command did not leave the client`,
467
+ reply: false
468
+ });
469
+ if (signal?.aborted) return Promise.resolve({
470
+ outcome: "error",
471
+ error: "the caller aborted the command before it left the client",
472
+ reply: false
473
+ });
474
+ return new Promise((resolve) => {
475
+ let settled = false;
476
+ const settle = (value) => {
477
+ if (settled) return;
478
+ settled = true;
479
+ inFlight.delete(settle);
480
+ signal?.removeEventListener("abort", aborted);
481
+ resolve(value);
482
+ };
483
+ const aborted = () => settle({
484
+ outcome: "indeterminate",
485
+ error: "the caller abandoned the command after it was sent"
486
+ });
487
+ inFlight.add(settle);
488
+ signal?.addEventListener("abort", aborted, { once: true });
489
+ c.call(command, ...args).then((value) => settle({
490
+ outcome: "ok",
491
+ value
492
+ }), (error) => settle(failed(error)));
493
+ });
494
+ };
495
+ const send = async (command, args = [], sendOptions = {}) => {
496
+ const { signal } = sendOptions;
497
+ const operation = sendOptions.reissue === false ? void 0 : operations[commandName(command, args)];
498
+ const started = clock.now();
499
+ const first = await attempt(command, args, signal);
500
+ if (first.outcome === "ok") return {
501
+ outcome: "ok",
502
+ value: first.value,
503
+ attempts: 1
504
+ };
505
+ const again = !(first.outcome === "error" && first.reply) && operation !== void 0 && signal?.aborted !== true && mayReissue(operation, quantity((clock.now() - started) / 1e3, "s"));
506
+ const left = requestTimeout - (clock.now() - started);
507
+ if (!again || left <= 0 || !await waitReady(left, signal)) return {
508
+ ...first,
509
+ attempts: 1
510
+ };
511
+ record(status, status, "command.reissue", commandName(command, args));
512
+ const bound = new AbortController();
513
+ const end = clock.after(Math.max(0, requestTimeout - (clock.now() - started)), () => bound.abort());
514
+ const forward = () => bound.abort();
515
+ signal?.addEventListener("abort", forward, { once: true });
516
+ const second = await attempt(command, args, bound.signal);
517
+ end();
518
+ signal?.removeEventListener("abort", forward);
519
+ return second.outcome === "ok" ? {
520
+ outcome: "ok",
521
+ value: second.value,
522
+ attempts: 2
523
+ } : {
524
+ ...second,
525
+ attempts: 2
526
+ };
527
+ };
528
+ let misses = 0;
529
+ let nextPing = null;
530
+ const heartbeatInterval = ms(knobs.heartbeat_interval);
531
+ const ping = () => {
532
+ nextPing = clock.after(heartbeatInterval, ping);
533
+ const c = connection;
534
+ if (c === null || closing) return;
535
+ if (c.status === "end") {
536
+ record(status, status, "watchdog.revive");
537
+ retrySince = null;
538
+ c.connect().catch(() => {});
539
+ return;
540
+ }
541
+ if (c.status !== "ready") return;
542
+ const controller = new AbortController();
543
+ const timer = clock.after(ms(knobs.heartbeat_timeout), () => controller.abort());
544
+ attempt("PING", [], controller.signal).then(async (result) => {
545
+ timer();
546
+ const answered = result.outcome === "ok" || result.outcome === "error" && result.reply;
547
+ await turn();
548
+ if (answered) {
549
+ misses = 0;
550
+ return;
551
+ }
552
+ if (connection?.status !== "ready" || closing) return;
553
+ misses += 1;
554
+ record(status, status, "watchdog.miss", `${misses} of ${spec.heartbeatMisses}`);
555
+ if (misses >= spec.heartbeatMisses) {
556
+ misses = 0;
557
+ record(status, status, "watchdog.reconnect");
558
+ connection.disconnect(true);
559
+ }
560
+ });
561
+ };
562
+ const handle = {
563
+ async connect(signal) {
564
+ closing = false;
565
+ const c = await create(connectionOptions);
566
+ connection = c;
567
+ watch(c);
568
+ if (signal.aborted) throw new Error(`${name}: the service stopped before the connect`);
569
+ await c.connect().catch((error) => record(status, status, "connect.failed", message(error)));
570
+ if (Number.isFinite(heartbeatInterval)) nextPing = clock.after(heartbeatInterval, ping);
571
+ },
572
+ async close() {
573
+ closing = true;
574
+ nextPing?.();
575
+ nextPing = null;
576
+ const c = connection;
577
+ if (c === null) return;
578
+ const quit = c.quit().then(() => "quit", () => "quit");
579
+ let cancel = () => {};
580
+ const bound = new Promise((resolve) => {
581
+ cancel = clock.after(ms(knobs.close_bound), () => resolve("bound"));
582
+ });
583
+ const startedClose = clock.now();
584
+ const first = await Promise.race([quit, bound]);
585
+ cancel();
586
+ if (first === "bound") record(status, status, "close.bound");
587
+ if (c.status !== "end") c.disconnect(false);
588
+ const ended = (within) => c.status === "end" ? Promise.resolve() : new Promise((resolve) => {
589
+ let stop = () => {};
590
+ const end = () => {
591
+ c.off("end", end);
592
+ stop();
593
+ resolve();
594
+ };
595
+ c.on("end", end);
596
+ stop = clock.after(Math.max(0, within), end);
597
+ });
598
+ await ended(ms(knobs.close_bound) - (clock.now() - startedClose));
599
+ if (c.status !== "end") {
600
+ record(status, status, "close.destroy");
601
+ c.stream?.destroy?.();
602
+ await ended(0);
603
+ }
604
+ for (const [event, listener] of listeners) c.off(event, listener);
605
+ listeners.length = 0;
606
+ for (const settle of [...inFlight]) settle({
607
+ outcome: "indeterminate",
608
+ error: "the client closed with the command in flight"
609
+ });
610
+ status = "end";
611
+ }
612
+ };
613
+ const declaration = {
614
+ system: spec.system,
615
+ knobs: {
616
+ connect_bound: knobs.connect_bound,
617
+ request_timeout: knobs.request_timeout,
618
+ driver_writeoff_time: knobs.driver_writeoff_time,
619
+ keepalive_idle: knobs.keepalive_idle,
620
+ heartbeat_interval: knobs.heartbeat_interval,
621
+ heartbeat_timeout: knobs.heartbeat_timeout,
622
+ heartbeat_detection_time: detection,
623
+ client_retry_window: knobs.client_retry_window,
624
+ retry_base: knobs.retry_base,
625
+ retry_cap: knobs.retry_cap,
626
+ close_bound: knobs.close_bound,
627
+ max_connection_age: knobs.max_connection_age,
628
+ retrans_giveup_time: knobs.retrans_giveup_time
629
+ },
630
+ operations
631
+ };
632
+ const checks = (checkOptions) => {
633
+ const made = [];
634
+ const declare = (step, ownConnection, run) => {
635
+ if (!(step in checkOptions.steps)) return;
636
+ made.push([{
637
+ name: `${name}.${step}`,
638
+ dependency: name,
639
+ step,
640
+ ownConnection,
641
+ gates: checkOptions.steps[step] ?? null,
642
+ poll: checkOptions.poll
643
+ }, run]);
644
+ };
645
+ declare("local-state", true, () => connection?.status === "ready" ? "ok" : "error");
646
+ declare("round-trip", true, async (signal) => {
647
+ return (await send("PING", [], {
648
+ signal,
649
+ reissue: false
650
+ })).outcome === "ok" ? "ok" : "error";
651
+ });
652
+ declare("dial", false, async (signal) => {
653
+ await dial(spec.host, spec.port, signal);
654
+ return "ok";
655
+ });
656
+ return made;
657
+ };
658
+ return {
659
+ name,
660
+ declaration,
661
+ handle,
662
+ meets: meetings(name, knobs, detection),
663
+ get status() {
664
+ return connection === null ? "none" : connection.status;
665
+ },
666
+ joins,
667
+ send,
668
+ checks,
669
+ attach(svc, checkOptions) {
670
+ svc.dependency(name, declaration, handle);
671
+ for (const [check, run] of checks(checkOptions)) svc.check(check, run);
672
+ return svc;
673
+ },
674
+ problems(description) {
675
+ const found = [];
676
+ const teardown = description.shutdown.phases.find((phase) => phase.name === "tear down dependencies");
677
+ if (teardown === void 0) found.push(`${name} needs the phase "tear down dependencies" to close`);
678
+ else if (ms(knobs.close_bound) >= valueIn(teardown.cap, "ms")) found.push(`${name}'s close_bound of ${ms(knobs.close_bound)} ms does not fit in the cap of "tear down dependencies"`);
679
+ for (const check of description.checks) {
680
+ if (check.dependency !== name) continue;
681
+ const slowest = valueIn(check.poll.timeout, "ms");
682
+ if (check.step === "round-trip" && requestTimeout > slowest + valueIn(check.poll.interval, "ms") * (1 + check.poll.jitter)) found.push(`${name}'s request_timeout outlives one jittered poll of ${check.name} (B7, O13)`);
683
+ }
684
+ return found;
685
+ }
686
+ };
687
+ };
688
+ //#endregion
689
+ //#region src/redis/machine.ts
690
+ const t = (id, from, on, to, extra = {}) => ({
691
+ id,
692
+ from,
693
+ on,
694
+ to,
695
+ ...extra
696
+ });
697
+ const knob = (key) => ({
698
+ key,
699
+ scope: "client",
700
+ unit: "s",
701
+ default: null
702
+ });
703
+ /** The states where the connection exists or tries to exist, thus where it can close. */
704
+ const LIVE = [
705
+ "connecting",
706
+ "connect",
707
+ "ready",
708
+ "reconnecting"
709
+ ];
710
+ const ANY = [
711
+ "none",
712
+ "connecting",
713
+ "connect",
714
+ "ready",
715
+ "close",
716
+ "reconnecting",
717
+ "end"
718
+ ];
719
+ /**
720
+ * The Redis client as a `mark1russell7.machine/1` document. Its states are the states of ioredis.
721
+ * The wrapper adds its own events: the join at each `ready`, and the misses of its watchdog. It also
722
+ * adds a command that it sends again, and the bound of its close. A test records the transitions of the
723
+ * client and replays them against this document.
724
+ *
725
+ * The state `ready` hides states that the client cannot see (D4). In a black hole, ioredis stays
726
+ * `ready` while the kernel retransmits, until `driver_writeoff_time` or the watchdog ends that
727
+ * hidden interval. A timer of this document bounds each hidden interval.
728
+ */
729
+ const REDIS_CLIENT_MACHINE = {
730
+ format: "mark1russell7.machine/1",
731
+ id: "service.machine.redis-client",
732
+ title: "The Redis client of the service, over ioredis 6",
733
+ layer: "driver",
734
+ version: "0.2.0",
735
+ sources: [{
736
+ ref: "packages/service/src/redis (the client implements this document)",
737
+ rung: "implemented"
738
+ }],
739
+ states: {
740
+ none: {
741
+ initial: true,
742
+ note: "the client exists, and its connection does not"
743
+ },
744
+ connecting: { note: "a TCP connection attempt" },
745
+ connect: { note: "the handshake: HELLO, then the ready check (INFO)" },
746
+ ready: { note: "it can hide a black hole or a frozen server, until a timer of the client ends it" },
747
+ close: { note: "the socket closed. Each command in flight settles as indeterminate" },
748
+ reconnecting: { note: "the delay of the retry strategy, which doubles to its cap" },
749
+ end: { note: "the retry window ended, or the service closed the client. The watchdog starts it again" }
750
+ },
751
+ events: {
752
+ "status.connecting": { kind: "local" },
753
+ "status.connect": { kind: "rx" },
754
+ "status.ready": { kind: "rx" },
755
+ "status.close": { kind: "rx" },
756
+ "status.reconnecting": { kind: "timer" },
757
+ "status.end": { kind: "local" },
758
+ join: {
759
+ kind: "rx",
760
+ note: "the reply of CLIENT ID at ready (D1)"
761
+ },
762
+ error: { kind: "rx" },
763
+ "connect.failed": { kind: "local" },
764
+ "command.written": { kind: "tx" },
765
+ "command.reissue": { kind: "local" },
766
+ data: { kind: "rx" },
767
+ heartbeat: { kind: "timer" },
768
+ "watchdog.miss": { kind: "local" },
769
+ "watchdog.reconnect": { kind: "local" },
770
+ "watchdog.revive": { kind: "local" },
771
+ "retry.window-ended": { kind: "local" },
772
+ "close.request": { kind: "local" },
773
+ "close.bound": { kind: "timer" },
774
+ "close.destroy": {
775
+ kind: "local",
776
+ note: "the bound destroys a socket that a silent server does not close"
777
+ }
778
+ },
779
+ knobs: {
780
+ connect_bound: knob("connectTimeout"),
781
+ driver_writeoff_time: knob("socketTimeout"),
782
+ heartbeat_interval: knob("watchdog.interval"),
783
+ close_bound: knob("close.bound")
784
+ },
785
+ timers: {
786
+ connect_timeout: {
787
+ duration: {
788
+ knob: "connect_bound",
789
+ unit: "s"
790
+ },
791
+ arms: ["enter:connecting"],
792
+ cancels: ["status.connect", "status.close"],
793
+ fires: "status.close",
794
+ note: "ioredis destroys the attempt"
795
+ },
796
+ writeoff: {
797
+ duration: {
798
+ knob: "driver_writeoff_time",
799
+ unit: "s"
800
+ },
801
+ arms: ["command.written"],
802
+ cancels: ["data", "status.close"],
803
+ fires: "status.close",
804
+ note: "ioredis destroys a socket that gives no data while a command waits: it ends the hidden interval of a black hole"
805
+ },
806
+ heartbeat: {
807
+ duration: {
808
+ knob: "heartbeat_interval",
809
+ unit: "s"
810
+ },
811
+ arms: ["enter:ready", "heartbeat"],
812
+ cancels: ["status.close", "status.end"],
813
+ fires: "heartbeat",
814
+ note: "a PING on the connection of the application"
815
+ },
816
+ close: {
817
+ duration: {
818
+ knob: "close_bound",
819
+ unit: "s"
820
+ },
821
+ arms: ["close.request"],
822
+ cancels: ["status.end"],
823
+ fires: "close.bound"
824
+ }
825
+ },
826
+ transitions: [
827
+ t("connect", [
828
+ "none",
829
+ "reconnecting",
830
+ "end",
831
+ "close"
832
+ ], "status.connecting", "connecting"),
833
+ t("tcp", "connecting", "status.connect", "connect"),
834
+ t("ready", "connect", "status.ready", "ready"),
835
+ t("close", [...LIVE], "status.close", "close"),
836
+ t("retry", "close", "status.reconnecting", "reconnecting"),
837
+ t("end", [...LIVE, "close"], "status.end", "end"),
838
+ t("join", "ready", "join", "ready"),
839
+ t("miss", "ready", "watchdog.miss", "ready"),
840
+ t("watchdog-reconnect", "ready", "watchdog.reconnect", "ready"),
841
+ t("revive", "end", "watchdog.revive", "end"),
842
+ t("window-ended", "close", "retry.window-ended", "close"),
843
+ ...ANY.map((state) => t(`error-${state}`, state, "error", state)),
844
+ ...ANY.map((state) => t(`connect-failed-${state}`, state, "connect.failed", state)),
845
+ ...ANY.map((state) => t(`reissue-${state}`, state, "command.reissue", state)),
846
+ ...ANY.map((state) => t(`close-bound-${state}`, state, "close.bound", state, { timer: "close" })),
847
+ ...ANY.map((state) => t(`close-destroy-${state}`, state, "close.destroy", state))
848
+ ]
849
+ };
850
+ //#endregion
851
+ export { REDIS_CLIENT_MACHINE, REDIS_OPERATIONS, commandName, firstDetector, heartbeatDetection, ioredis, meetings, redisClient, specProblems, tcpDial, unwatched };
852
+
853
+ //# sourceMappingURL=redis.js.map