@mark1russell7/service 0.1.0 → 0.2.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 +120 -15
- package/build/builder-BFxOuULH.d.ts +607 -0
- package/build/clock-DlJFFZlX.js +39 -0
- package/build/clock-DlJFFZlX.js.map +1 -0
- package/build/index.d.ts +47 -228
- package/build/index.js +1536 -88
- package/build/index.js.map +1 -1
- package/build/redis.d.ts +294 -0
- package/build/redis.js +853 -0
- package/build/redis.js.map +1 -0
- package/build/testing.d.ts +102 -0
- package/build/testing.js +349 -0
- package/build/testing.js.map +1 -0
- package/build/timing-DxZ4nkQg.js +79 -0
- package/build/timing-DxZ4nkQg.js.map +1 -0
- package/package.json +20 -2
package/build/index.js
CHANGED
|
@@ -1,109 +1,969 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { i as recoverSpan, n as asWindow, r as failSpan, t as asDebounce } from "./timing-DxZ4nkQg.js";
|
|
2
|
+
import { n as mayReissue, r as operationProblems, t as realClock } from "./clock-DlJFFZlX.js";
|
|
3
|
+
import { convertExact, convertible, quantity, rationals, valueIn } from "@mark1russell7/quantity";
|
|
4
|
+
import { arr, fromStates } from "@mark1russell7/algebra";
|
|
5
|
+
//#region src/description.ts
|
|
6
|
+
/**
|
|
7
|
+
* This is the format of a service as data: what `describe()` exports, and nothing starts. The
|
|
8
|
+
* timer algebra and the ordering law read it. Each timer has a unit. Each knob comes from the
|
|
9
|
+
* declaration, because the runtime has no defaults. A bound that nobody gives is "no bound", not
|
|
10
|
+
* zero.
|
|
11
|
+
*/
|
|
12
|
+
const FORMAT = "mark1russell7.service/1";
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/latch.ts
|
|
15
|
+
const ms = (amount) => valueIn(amount, "ms");
|
|
16
|
+
/**
|
|
17
|
+
* This function makes a latch. `tripped` is its state at the start: a readiness latch starts
|
|
18
|
+
* tripped, and a liveness latch starts released. The latch starts `changed` each time it trips or
|
|
19
|
+
* releases, and it gives each transition to `trace`. A debounce has the states `released` and
|
|
20
|
+
* `tripped`. A window also has the states `failing` and `recovering`, while one of its timers runs.
|
|
21
|
+
*/
|
|
22
|
+
const latch = (timing, clock, tripped, changed, trace) => {
|
|
23
|
+
let state = tripped;
|
|
24
|
+
let since = clock.now();
|
|
25
|
+
let streak = 0;
|
|
26
|
+
let fails = 0;
|
|
27
|
+
let passes = 0;
|
|
28
|
+
let failingSince = null;
|
|
29
|
+
let passingSince = null;
|
|
30
|
+
let failTimer = null;
|
|
31
|
+
let passTimer = null;
|
|
32
|
+
/** This function gives the name of the state, as the machine documents name it. */
|
|
33
|
+
const named = () => state ? passingSince === null ? "tripped" : "recovering" : failingSince === null ? "released" : "failing";
|
|
34
|
+
/** This function does one step of the machine, and gives its transition to the trace. */
|
|
35
|
+
const step = (on, change) => {
|
|
36
|
+
const from = named();
|
|
37
|
+
change();
|
|
38
|
+
trace?.(from, named(), on);
|
|
39
|
+
};
|
|
40
|
+
const set = (next) => {
|
|
41
|
+
if (next === state) return;
|
|
42
|
+
state = next;
|
|
43
|
+
since = clock.now();
|
|
44
|
+
changed();
|
|
45
|
+
};
|
|
46
|
+
const stopFail = () => {
|
|
47
|
+
failTimer?.();
|
|
48
|
+
failTimer = null;
|
|
49
|
+
failingSince = null;
|
|
50
|
+
};
|
|
51
|
+
const stopPass = () => {
|
|
52
|
+
passTimer?.();
|
|
53
|
+
passTimer = null;
|
|
54
|
+
passingSince = null;
|
|
55
|
+
};
|
|
56
|
+
const debounce = (result) => {
|
|
57
|
+
if (timing.type !== "debounce") return;
|
|
58
|
+
if (result.outcome === "ok") {
|
|
59
|
+
passes += 1;
|
|
60
|
+
fails = 0;
|
|
61
|
+
if (state && passes >= timing.recoverAfter) set(false);
|
|
62
|
+
} else {
|
|
63
|
+
fails += 1;
|
|
64
|
+
passes = 0;
|
|
65
|
+
if (!state && fails >= timing.failAfter) set(true);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const windowed = (result) => {
|
|
69
|
+
if (timing.type !== "window") return;
|
|
70
|
+
if (result.outcome === "ok") {
|
|
71
|
+
stopFail();
|
|
72
|
+
if (!state) return;
|
|
73
|
+
if (timing.recoverAfter === void 0) {
|
|
74
|
+
set(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (passingSince !== null) return;
|
|
78
|
+
const wait = ms(timing.recoverAfter);
|
|
79
|
+
if (wait <= 0) {
|
|
80
|
+
stopPass();
|
|
81
|
+
set(false);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
passingSince = result.at;
|
|
85
|
+
passTimer = clock.after(wait, () => step("window.recovered", () => {
|
|
86
|
+
passTimer = null;
|
|
87
|
+
passingSince = null;
|
|
88
|
+
set(false);
|
|
89
|
+
}));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
stopPass();
|
|
93
|
+
if (state || failingSince !== null) return;
|
|
94
|
+
const wait = ms(timing.failAfter);
|
|
95
|
+
if (wait <= 0) {
|
|
96
|
+
stopFail();
|
|
97
|
+
set(true);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
failingSince = result.at;
|
|
101
|
+
failTimer = clock.after(wait, () => step("window.failed", () => {
|
|
102
|
+
failTimer = null;
|
|
103
|
+
failingSince = null;
|
|
104
|
+
set(true);
|
|
105
|
+
}));
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
get tripped() {
|
|
109
|
+
return state;
|
|
110
|
+
},
|
|
111
|
+
get since() {
|
|
112
|
+
return since;
|
|
113
|
+
},
|
|
114
|
+
get streak() {
|
|
115
|
+
return streak;
|
|
116
|
+
},
|
|
117
|
+
record(result) {
|
|
118
|
+
if (result.outcome === "indeterminate") return;
|
|
119
|
+
const pass = result.outcome === "ok";
|
|
120
|
+
step(pass ? "poll.ok" : "poll.fail", () => {
|
|
121
|
+
streak = pass ? Math.max(0, streak) + 1 : Math.min(0, streak) - 1;
|
|
122
|
+
debounce(result);
|
|
123
|
+
windowed(result);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
stop() {
|
|
127
|
+
stopFail();
|
|
128
|
+
stopPass();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
12
131
|
};
|
|
13
|
-
|
|
14
|
-
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/poller.ts
|
|
134
|
+
const message$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
15
135
|
/**
|
|
16
|
-
* This function
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
136
|
+
* This function makes a poller. The loop is on a fixed delay from the start of each poll. Thus the
|
|
137
|
+
* next poll starts one interval after the current poll starts, with the jitter (±20 % is 0.2).
|
|
138
|
+
* The timing algebra of the declaration uses this model.
|
|
139
|
+
*
|
|
140
|
+
* Each poll makes a new measurement. While a use of the check is pending, the poller does not
|
|
141
|
+
* start the check again. Such a poll fails at once with the same cause, and the sequence of
|
|
142
|
+
* failures continues.
|
|
21
143
|
*/
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
144
|
+
const poller = (options) => {
|
|
145
|
+
const { clock, poll, run } = options;
|
|
146
|
+
const interval = valueIn(poll.interval, "ms");
|
|
147
|
+
const timeout = valueIn(poll.timeout, "ms");
|
|
148
|
+
let last = null;
|
|
149
|
+
let next = null;
|
|
150
|
+
let stopped = false;
|
|
151
|
+
let looping = false;
|
|
152
|
+
let pending = null;
|
|
153
|
+
const active = /* @__PURE__ */ new Set();
|
|
154
|
+
/** This function gives the name of the state, as the machine document of the poller names it. */
|
|
155
|
+
const named = () => stopped ? "stopped" : pending !== null ? pending.late ? "hung" : "calling" : looping ? "waiting" : "idle";
|
|
156
|
+
const step = (on, change) => {
|
|
157
|
+
const from = named();
|
|
158
|
+
change();
|
|
159
|
+
options.trace?.(from, named(), on);
|
|
160
|
+
};
|
|
161
|
+
const result = (outcome, started, fields = {}) => ({
|
|
162
|
+
outcome,
|
|
163
|
+
at: clock.now(),
|
|
164
|
+
took: clock.now() - started,
|
|
165
|
+
cause: fields.cause ?? null,
|
|
166
|
+
timedOut: fields.timedOut ?? false,
|
|
167
|
+
thrown: fields.thrown ?? false,
|
|
168
|
+
forced: fields.forced ?? false
|
|
169
|
+
});
|
|
170
|
+
const once = () => {
|
|
171
|
+
const started = clock.now();
|
|
172
|
+
if (options.forced()) return Promise.resolve(result("error", started, {
|
|
173
|
+
cause: "the force-fail list names it",
|
|
174
|
+
forced: true
|
|
175
|
+
}));
|
|
176
|
+
if (pending !== null) {
|
|
177
|
+
const cause = `its call of ${Math.round(started - pending.since)} ms ago is still pending`;
|
|
178
|
+
return Promise.resolve(result("error", started, { cause }));
|
|
179
|
+
}
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
let done = false;
|
|
183
|
+
const finish = (value) => {
|
|
184
|
+
if (done) return;
|
|
185
|
+
done = true;
|
|
186
|
+
active.delete(abandon);
|
|
187
|
+
cancel();
|
|
188
|
+
resolve(value);
|
|
189
|
+
};
|
|
190
|
+
const abandon = () => {
|
|
191
|
+
controller.abort();
|
|
192
|
+
finish(result("error", started, { cause: "the runtime stopped" }));
|
|
193
|
+
};
|
|
194
|
+
active.add(abandon);
|
|
195
|
+
const call = {
|
|
196
|
+
since: started,
|
|
197
|
+
late: false
|
|
198
|
+
};
|
|
199
|
+
pending = call;
|
|
200
|
+
const cancel = clock.after(timeout, () => {
|
|
201
|
+
controller.abort();
|
|
202
|
+
step("timeout", () => {
|
|
203
|
+
if (pending === call) call.late = true;
|
|
204
|
+
});
|
|
205
|
+
finish(result("error", started, {
|
|
206
|
+
cause: `no answer in ${timeout} ms`,
|
|
207
|
+
timedOut: true
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
const settled = () => {
|
|
211
|
+
if (pending !== call) return;
|
|
212
|
+
step(call.late ? "late-answer" : "answer", () => {
|
|
213
|
+
pending = null;
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
let answer;
|
|
217
|
+
try {
|
|
218
|
+
answer = Promise.resolve(run(controller.signal));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
answer = Promise.reject(error);
|
|
221
|
+
}
|
|
222
|
+
answer.then((value) => {
|
|
223
|
+
settled();
|
|
224
|
+
const outcome = value ?? "ok";
|
|
225
|
+
finish(result(outcome, started, outcome === "ok" ? {} : { cause: `the check gave ${outcome}` }));
|
|
226
|
+
}, (error) => {
|
|
227
|
+
settled();
|
|
228
|
+
finish(result("error", started, {
|
|
229
|
+
cause: message$1(error),
|
|
230
|
+
thrown: true
|
|
231
|
+
}));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
const handle = (value) => {
|
|
236
|
+
last = value;
|
|
237
|
+
if (!stopped) options.record(value);
|
|
238
|
+
return value;
|
|
239
|
+
};
|
|
240
|
+
const jittered = () => interval * (1 + poll.jitter * (2 * options.random() - 1));
|
|
241
|
+
const tick = () => {
|
|
242
|
+
next = null;
|
|
243
|
+
if (stopped) return;
|
|
244
|
+
let polled;
|
|
245
|
+
step("tick", () => {
|
|
246
|
+
next = clock.after(jittered(), tick);
|
|
247
|
+
polled = once();
|
|
248
|
+
});
|
|
249
|
+
polled.then(handle);
|
|
250
|
+
};
|
|
34
251
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
252
|
+
get last() {
|
|
253
|
+
return last;
|
|
254
|
+
},
|
|
255
|
+
pollNow: () => {
|
|
256
|
+
let polled;
|
|
257
|
+
step("sweep", () => {
|
|
258
|
+
polled = once();
|
|
259
|
+
});
|
|
260
|
+
return polled.then(handle);
|
|
261
|
+
},
|
|
262
|
+
start(delay) {
|
|
263
|
+
if (stopped || next !== null) return;
|
|
264
|
+
step("start", () => {
|
|
265
|
+
looping = true;
|
|
266
|
+
next = clock.after(Math.max(0, delay), tick);
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
jittered,
|
|
270
|
+
stop() {
|
|
271
|
+
if (stopped) return;
|
|
272
|
+
step("stop", () => {
|
|
273
|
+
stopped = true;
|
|
274
|
+
next?.();
|
|
275
|
+
next = null;
|
|
276
|
+
});
|
|
277
|
+
for (const abandon of [...active]) abandon();
|
|
278
|
+
}
|
|
37
279
|
};
|
|
38
280
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/health.ts
|
|
283
|
+
/**
|
|
284
|
+
* This function makes the runtime of a declaration. The service has no defaults of its own: each
|
|
285
|
+
* timing, poll and coupling comes from the description.
|
|
286
|
+
*/
|
|
287
|
+
const healthRuntime = (description, runs, options = {}) => {
|
|
288
|
+
const clock = options.clock ?? realClock;
|
|
289
|
+
const random = options.random ?? Math.random;
|
|
290
|
+
const forced = new Set(options.forceFail ?? []);
|
|
291
|
+
const startedAt = clock.now();
|
|
292
|
+
const budget = valueIn(description.policies.startup, "ms");
|
|
293
|
+
const entries = /* @__PURE__ */ new Map();
|
|
294
|
+
const dependencies = new Set(description.dependencies.map((dependency) => dependency.name));
|
|
295
|
+
const lateDependencies = /* @__PURE__ */ new Set();
|
|
296
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
297
|
+
let started = false;
|
|
298
|
+
let draining = false;
|
|
299
|
+
let stopped = false;
|
|
300
|
+
let gate = "closed";
|
|
301
|
+
const tracer = (machine, instance) => (from, to, on) => options.record?.({
|
|
302
|
+
at: clock.now(),
|
|
303
|
+
machine,
|
|
304
|
+
instance,
|
|
305
|
+
from,
|
|
306
|
+
to,
|
|
307
|
+
on
|
|
308
|
+
});
|
|
309
|
+
const gateTrace = tracer("gate", description.name);
|
|
310
|
+
const moveGate = (to, on, detail) => {
|
|
311
|
+
const from = gate;
|
|
312
|
+
gate = to;
|
|
313
|
+
options.record?.({
|
|
314
|
+
at: clock.now(),
|
|
315
|
+
machine: "gate",
|
|
316
|
+
instance: description.name,
|
|
317
|
+
from,
|
|
318
|
+
to,
|
|
319
|
+
on,
|
|
320
|
+
...detail === void 0 ? {} : { detail }
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
let budgetTimer = clock.after(budget, () => {
|
|
324
|
+
budgetTimer = null;
|
|
325
|
+
if (gate !== "open" && gate !== "failed") gateTrace(gate, gate, "startup.over-budget");
|
|
326
|
+
});
|
|
327
|
+
const changed = () => {
|
|
328
|
+
if (listeners.size === 0) return;
|
|
329
|
+
const now = verdict();
|
|
330
|
+
for (const listener of listeners) listener(now);
|
|
331
|
+
};
|
|
332
|
+
const add = (check, given, late) => {
|
|
333
|
+
const run = options.wrapCheck?.(check, given) ?? given;
|
|
334
|
+
const timing = check.gates === null ? null : description.policies[check.gates];
|
|
335
|
+
const made = timing === null ? null : latch(timing, clock, check.gates === "readiness", changed, tracer(timing.type === "window" ? "window" : "latch", check.name));
|
|
336
|
+
const entry = {
|
|
337
|
+
check,
|
|
338
|
+
latch: made,
|
|
339
|
+
poller: poller({
|
|
340
|
+
name: check.name,
|
|
341
|
+
poll: check.poll,
|
|
342
|
+
run,
|
|
343
|
+
clock,
|
|
344
|
+
random,
|
|
345
|
+
forced: () => forced.has(check.name) || check.dependency !== null && forced.has(check.dependency),
|
|
346
|
+
record: (result) => {
|
|
347
|
+
made?.record(result);
|
|
348
|
+
if (result.outcome === "ok" && check.dependency !== null) {
|
|
349
|
+
if (lateDependencies.delete(check.dependency)) changed();
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
trace: tracer("poller", check.name)
|
|
353
|
+
})
|
|
47
354
|
};
|
|
355
|
+
entries.set(check.name, entry);
|
|
356
|
+
if (late && !stopped) {
|
|
357
|
+
entry.poller.pollNow();
|
|
358
|
+
entry.poller.start(entry.poller.jittered());
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
for (const check of description.checks) {
|
|
362
|
+
const run = runs.get(check.name);
|
|
363
|
+
if (run === void 0) throw new Error(`check ${check.name} has no function: the runtime has no defaults`);
|
|
364
|
+
add(check, run, false);
|
|
48
365
|
}
|
|
49
|
-
const
|
|
50
|
-
const
|
|
366
|
+
const latchesOf = (gateOf, keep = () => true) => [...entries.values()].flatMap((entry) => entry.check.gates === gateOf && entry.latch !== null && keep(entry.check) ? [entry.latch] : []);
|
|
367
|
+
const released = (latches) => latches.every((each) => !each.tripped);
|
|
368
|
+
/** This function tells if a policy fails through a declared coupling from another policy. */
|
|
369
|
+
const coupledOff = (to, base) => description.couplings.some((coupling) => coupling.to === to && !base[coupling.from]);
|
|
370
|
+
const verdict = () => {
|
|
371
|
+
const base = {
|
|
372
|
+
readiness: released(latchesOf("readiness")) && lateDependencies.size === 0,
|
|
373
|
+
liveness: released(latchesOf("liveness"))
|
|
374
|
+
};
|
|
375
|
+
const alive = base.liveness && !coupledOff("liveness", base);
|
|
376
|
+
const open = started && !draining && !coupledOff("readiness", base);
|
|
377
|
+
const ready = open && base.readiness;
|
|
378
|
+
const dependencyReady = (name) => !lateDependencies.has(name) && released(latchesOf("readiness", (check) => check.dependency === name));
|
|
379
|
+
const own = released(latchesOf("readiness", (check) => check.dependency === null));
|
|
380
|
+
const dependenciesOut = {};
|
|
381
|
+
for (const name of dependencies) dependenciesOut[name] = dependencyReady(name);
|
|
382
|
+
const controllers = {};
|
|
383
|
+
for (const controller of description.controllers) controllers[controller.name] = open && own && controller.requires.every(dependencyReady);
|
|
384
|
+
const checks = {};
|
|
385
|
+
for (const [name, entry] of entries) checks[name] = {
|
|
386
|
+
gates: entry.check.gates,
|
|
387
|
+
dependency: entry.check.dependency,
|
|
388
|
+
tripped: entry.latch?.tripped ?? null,
|
|
389
|
+
streak: entry.latch?.streak ?? 0,
|
|
390
|
+
since: entry.latch?.since ?? null,
|
|
391
|
+
last: entry.poller.last
|
|
392
|
+
};
|
|
393
|
+
const elapsed = clock.now() - startedAt;
|
|
394
|
+
return {
|
|
395
|
+
started,
|
|
396
|
+
draining,
|
|
397
|
+
ready,
|
|
398
|
+
alive,
|
|
399
|
+
controllers,
|
|
400
|
+
dependencies: dependenciesOut,
|
|
401
|
+
checks,
|
|
402
|
+
startup: {
|
|
403
|
+
budget,
|
|
404
|
+
elapsed,
|
|
405
|
+
overBudget: !started && elapsed > budget
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
const answer = (kind, pass, controller) => ({
|
|
410
|
+
status: pass || kind === "report" ? 200 : 503,
|
|
411
|
+
contentType: "application/health+json",
|
|
412
|
+
body: {
|
|
413
|
+
status: pass ? "pass" : "fail",
|
|
414
|
+
service: description.name,
|
|
415
|
+
probe: kind,
|
|
416
|
+
...controller === void 0 ? {} : { controller },
|
|
417
|
+
...kind === "report" ? { verdict: verdict() } : {}
|
|
418
|
+
}
|
|
419
|
+
});
|
|
51
420
|
return {
|
|
52
|
-
|
|
53
|
-
|
|
421
|
+
verdict,
|
|
422
|
+
probe(kind, controller) {
|
|
423
|
+
const now = verdict();
|
|
424
|
+
if (kind === "startup") return answer(kind, now.started);
|
|
425
|
+
if (kind === "liveness") return answer(kind, now.alive);
|
|
426
|
+
if (kind === "report") return answer(kind, now.ready && now.alive);
|
|
427
|
+
if (controller === void 0) return answer(kind, now.ready);
|
|
428
|
+
const known = now.controllers[controller];
|
|
429
|
+
if (known === void 0) throw new Error(`${description.name} has no controller ${controller}`);
|
|
430
|
+
return answer(kind, known, controller);
|
|
431
|
+
},
|
|
432
|
+
subscribe(listener) {
|
|
433
|
+
listeners.add(listener);
|
|
434
|
+
return () => listeners.delete(listener);
|
|
435
|
+
},
|
|
436
|
+
forceFail(name, on) {
|
|
437
|
+
if (on) forced.add(name);
|
|
438
|
+
else forced.delete(name);
|
|
439
|
+
},
|
|
440
|
+
drain() {
|
|
441
|
+
if (draining) return;
|
|
442
|
+
draining = true;
|
|
443
|
+
changed();
|
|
444
|
+
},
|
|
445
|
+
stop() {
|
|
446
|
+
stopped = true;
|
|
447
|
+
budgetTimer?.();
|
|
448
|
+
budgetTimer = null;
|
|
449
|
+
for (const entry of entries.values()) {
|
|
450
|
+
entry.poller.stop();
|
|
451
|
+
entry.latch?.stop();
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
beginStartup() {
|
|
455
|
+
if (gate === "closed") moveGate("starting", "startup.begin");
|
|
456
|
+
},
|
|
457
|
+
failStartup(cause) {
|
|
458
|
+
if (gate === "open" || gate === "failed") return;
|
|
459
|
+
moveGate("failed", "startup.fail", cause);
|
|
460
|
+
budgetTimer?.();
|
|
461
|
+
budgetTimer = null;
|
|
462
|
+
},
|
|
463
|
+
async warmUp() {
|
|
464
|
+
if (started || stopped || gate === "failed" || gate === "sweeping") return;
|
|
465
|
+
moveGate("sweeping", "mark-ready");
|
|
466
|
+
const sweep = clock.now();
|
|
467
|
+
await Promise.all([...entries.values()].map((entry) => entry.poller.pollNow()));
|
|
468
|
+
if (stopped || gate !== "sweeping") return;
|
|
469
|
+
for (const entry of entries.values()) entry.poller.start(entry.poller.jittered() - (clock.now() - sweep));
|
|
470
|
+
started = true;
|
|
471
|
+
budgetTimer?.();
|
|
472
|
+
budgetTimer = null;
|
|
473
|
+
moveGate("open", "sweep.done");
|
|
474
|
+
changed();
|
|
475
|
+
},
|
|
476
|
+
addCheck(check, run) {
|
|
477
|
+
if (entries.has(check.name)) throw new Error(`${description.name} already has a check named ${check.name}`);
|
|
478
|
+
add(check, run, true);
|
|
479
|
+
changed();
|
|
480
|
+
},
|
|
481
|
+
addDependency(name) {
|
|
482
|
+
if (dependencies.has(name)) return;
|
|
483
|
+
dependencies.add(name);
|
|
484
|
+
lateDependencies.add(name);
|
|
485
|
+
changed();
|
|
486
|
+
}
|
|
54
487
|
};
|
|
55
488
|
};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/process.ts
|
|
491
|
+
/** These are the signals that the runtime handles. */
|
|
492
|
+
const EXIT_SIGNALS = [
|
|
493
|
+
"SIGINT",
|
|
494
|
+
"SIGTERM",
|
|
495
|
+
"SIGQUIT"
|
|
496
|
+
];
|
|
497
|
+
/** This port is the process of Node. */
|
|
498
|
+
const nodeProcess = {
|
|
499
|
+
on(signal, handler) {
|
|
500
|
+
const host = globalThis.process;
|
|
501
|
+
if (host === void 0) throw new Error("no process: give the runtime a process port");
|
|
502
|
+
host.on(signal, handler);
|
|
503
|
+
return () => {
|
|
504
|
+
host.off(signal, handler);
|
|
505
|
+
};
|
|
506
|
+
},
|
|
507
|
+
exit(code) {
|
|
508
|
+
const host = globalThis.process;
|
|
509
|
+
if (host === void 0) throw new Error("no process: give the runtime a process port");
|
|
510
|
+
host.exit(code);
|
|
511
|
+
}
|
|
61
512
|
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/requests.ts
|
|
515
|
+
/**
|
|
516
|
+
* This function makes a tracker. `refuses` gives the reason that the service refuses a new request
|
|
517
|
+
* for a controller at this time, or null when it admits it.
|
|
518
|
+
*/
|
|
519
|
+
const tracker = (controllers, clock, refuses) => {
|
|
520
|
+
const byName = new Map(controllers.map((controller) => [controller.name, controller]));
|
|
521
|
+
const flights = /* @__PURE__ */ new Set();
|
|
522
|
+
const idle = /* @__PURE__ */ new Set();
|
|
523
|
+
let intake = true;
|
|
524
|
+
const settle = (flight) => {
|
|
525
|
+
flights.delete(flight);
|
|
526
|
+
if (flights.size === 0) for (const done of [...idle]) done();
|
|
527
|
+
};
|
|
528
|
+
/** This function answers a request that the shutdown stopped, by the idempotency of its route. */
|
|
529
|
+
const cut = (flight, cause) => {
|
|
530
|
+
const operation = byName.get(flight.controller).routes[flight.route];
|
|
531
|
+
const since = quantity(Math.max(0, clock.now() - flight.started) / 1e3, "s");
|
|
532
|
+
const retryable = mayReissue(operation, since);
|
|
533
|
+
return {
|
|
534
|
+
kind: "cut",
|
|
535
|
+
status: retryable ? 503 : 500,
|
|
536
|
+
retryable,
|
|
537
|
+
cause
|
|
538
|
+
};
|
|
539
|
+
};
|
|
67
540
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
541
|
+
request(controller, route, work) {
|
|
542
|
+
const held = byName.get(controller);
|
|
543
|
+
if (held === void 0) throw new Error(`there is no controller ${controller}`);
|
|
544
|
+
if (held.routes[route] === void 0) throw new Error(`controller ${controller} does not declare the route ${route}`);
|
|
545
|
+
const refusal = intake ? refuses(controller) : "the service stopped its intake";
|
|
546
|
+
if (refusal !== null) return Promise.resolve({
|
|
547
|
+
kind: "refused",
|
|
548
|
+
status: 503,
|
|
549
|
+
retryable: true,
|
|
550
|
+
cause: refusal
|
|
551
|
+
});
|
|
552
|
+
return new Promise((resolve) => {
|
|
553
|
+
let answered = false;
|
|
554
|
+
const flight = {
|
|
555
|
+
controller,
|
|
556
|
+
route,
|
|
557
|
+
started: clock.now(),
|
|
558
|
+
abort: new AbortController(),
|
|
559
|
+
answer: (outcome) => {
|
|
560
|
+
if (answered) return;
|
|
561
|
+
answered = true;
|
|
562
|
+
settle(flight);
|
|
563
|
+
resolve(outcome);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
flights.add(flight);
|
|
567
|
+
let promise;
|
|
568
|
+
try {
|
|
569
|
+
promise = Promise.resolve(work(flight.abort.signal));
|
|
570
|
+
} catch (error) {
|
|
571
|
+
promise = Promise.reject(error);
|
|
572
|
+
}
|
|
573
|
+
promise.then((value) => {
|
|
574
|
+
if (answered) return;
|
|
575
|
+
answered = true;
|
|
576
|
+
settle(flight);
|
|
577
|
+
resolve({
|
|
578
|
+
kind: "done",
|
|
579
|
+
value
|
|
580
|
+
});
|
|
581
|
+
}, (error) => {
|
|
582
|
+
if (answered) return;
|
|
583
|
+
const outcome = flight.abort.signal.aborted ? cut(flight, "the shutdown aborted it, and it failed") : {
|
|
584
|
+
kind: "failed",
|
|
585
|
+
error
|
|
586
|
+
};
|
|
587
|
+
answered = true;
|
|
588
|
+
settle(flight);
|
|
589
|
+
resolve(outcome);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
inFlight(controller) {
|
|
594
|
+
let count = 0;
|
|
595
|
+
for (const flight of flights) if (controller === void 0 || flight.controller === controller) count += 1;
|
|
596
|
+
return count;
|
|
597
|
+
},
|
|
598
|
+
stopIntake() {
|
|
599
|
+
intake = false;
|
|
600
|
+
},
|
|
601
|
+
cooperativeAbort() {
|
|
602
|
+
for (const flight of flights) flight.abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
|
|
603
|
+
},
|
|
604
|
+
drain(deadline) {
|
|
605
|
+
if (flights.size === 0) return Promise.resolve();
|
|
606
|
+
return new Promise((resolve) => {
|
|
607
|
+
let finished = false;
|
|
608
|
+
const done = () => {
|
|
609
|
+
if (finished) return;
|
|
610
|
+
finished = true;
|
|
611
|
+
idle.delete(done);
|
|
612
|
+
cancel();
|
|
613
|
+
resolve();
|
|
614
|
+
};
|
|
615
|
+
idle.add(done);
|
|
616
|
+
const cancel = clock.after(Math.max(0, deadline - clock.now()), () => {
|
|
617
|
+
for (const flight of [...flights]) {
|
|
618
|
+
flight.abort.abort(/* @__PURE__ */ new Error("the drain reached its deadline"));
|
|
619
|
+
flight.answer(cut(flight, "the drain reached its deadline"));
|
|
620
|
+
}
|
|
621
|
+
done();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
}
|
|
72
625
|
};
|
|
73
626
|
};
|
|
74
627
|
//#endregion
|
|
75
|
-
//#region src/
|
|
628
|
+
//#region src/lifecycle.ts
|
|
76
629
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
630
|
+
* These are the stages of the shutdown, in their sequence. A declared phase with one of these
|
|
631
|
+
* names gets the work of the runtime for its stage. A phase with another name gets only the work of
|
|
632
|
+
* its owners.
|
|
633
|
+
* - `fail readiness`: readiness fails at the start of the shutdown. This phase then holds for its
|
|
634
|
+
* cap, while the endpoints of the platform remove the service.
|
|
635
|
+
* - `stop intake`: each transport stops its intake, and new requests get a retryable 503.
|
|
636
|
+
* - `drain`: the transports and the requests in flight end, until the cap. The cooperative abort
|
|
637
|
+
* comes before the end of the cap, by the cooperative window.
|
|
638
|
+
* - `close transports`: each transport closes.
|
|
639
|
+
* - `stop the poller`: each check and watchdog stops. Then the signal of the service aborts.
|
|
640
|
+
* - `tear down dependencies`: each dependency closes, in the reverse sequence of its connection.
|
|
641
|
+
* - `close the health server`: the probes stop last.
|
|
81
642
|
*/
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
643
|
+
const STAGES = [
|
|
644
|
+
"fail readiness",
|
|
645
|
+
"stop intake",
|
|
646
|
+
"drain",
|
|
647
|
+
"close transports",
|
|
648
|
+
"stop the poller",
|
|
649
|
+
"tear down dependencies",
|
|
650
|
+
"close the health server"
|
|
651
|
+
];
|
|
652
|
+
/** This function gives the place of a stage in the sequence, or -1 for a phase of another name. */
|
|
653
|
+
const stageRank = (name) => STAGES.indexOf(name);
|
|
654
|
+
/** The signal of the service aborts after the poller stops, and before the dependencies close. */
|
|
655
|
+
const ABORT_AFTER = stageRank("stop the poller");
|
|
85
656
|
/**
|
|
86
|
-
* This function
|
|
87
|
-
*
|
|
88
|
-
*
|
|
657
|
+
* This function gives the time of the cooperative abort, in milliseconds after the drain starts. It
|
|
658
|
+
* is the cap less the cooperative window, and not less than half the cap. A window without a bound
|
|
659
|
+
* gives half the cap.
|
|
89
660
|
*/
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
return compare(since, idempotent.window) < 0;
|
|
661
|
+
const cooperativeAbortAt = (cap, window) => {
|
|
662
|
+
const before = window.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(window, "ms");
|
|
663
|
+
return Math.max(cap - before, cap / 2);
|
|
94
664
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
665
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
666
|
+
/**
|
|
667
|
+
* This function gives the problems of a declaration that the lifecycle cannot start: a stage with
|
|
668
|
+
* work and no declared phase. The runtime has no defaults, thus a transport without a drain phase
|
|
669
|
+
* has no time to drain.
|
|
670
|
+
*/
|
|
671
|
+
const lifecycleProblems = (description, components) => {
|
|
672
|
+
const declared = new Set(description.shutdown.phases.map((phase) => phase.name));
|
|
673
|
+
const needs = (stage, why) => declared.has(stage) ? [] : [`${description.name} has ${why}, thus it declares the phase "${stage}"`];
|
|
99
674
|
const problems = [];
|
|
100
|
-
if (
|
|
101
|
-
if (
|
|
675
|
+
for (const transport of description.transports) if (!components.transports.has(transport.name)) problems.push(`transport ${transport.name} has no implementation: the runtime has no defaults`);
|
|
676
|
+
if (components.transports.size > 0 || description.controllers.length > 0) {
|
|
677
|
+
problems.push(...needs("stop intake", "transports or controllers"));
|
|
678
|
+
problems.push(...needs("drain", "transports or controllers"));
|
|
679
|
+
}
|
|
680
|
+
if (components.transports.size > 0) problems.push(...needs("close transports", "transports"));
|
|
681
|
+
if (description.checks.length > 0) problems.push(...needs("stop the poller", "checks"));
|
|
682
|
+
if (components.dependencies.size > 0) problems.push(...needs("tear down dependencies", "dependency handles"));
|
|
683
|
+
if (components.healthServer !== null) problems.push(...needs("close the health server", "a health server"));
|
|
102
684
|
return problems;
|
|
103
685
|
};
|
|
686
|
+
/**
|
|
687
|
+
* This function starts the lifecycle of a service. The startup has a sequence: the health server
|
|
688
|
+
* first, then each dependency, then each transport, and the startup gate last. A signal during the
|
|
689
|
+
* startup gets its shutdown after the startup ends. The shutdown does the declared phases in their
|
|
690
|
+
* sequence, each one capped. Only the lifecycle ends the process: 0 after a clean shutdown, and 1
|
|
691
|
+
* after an error.
|
|
692
|
+
*/
|
|
693
|
+
const lifecycle = (description, runtime, components, options = {}) => {
|
|
694
|
+
const clock = options.clock ?? realClock;
|
|
695
|
+
const port = options.process ?? nodeProcess;
|
|
696
|
+
const record = (machine, from, to, on, detail) => options.record?.({
|
|
697
|
+
at: clock.now(),
|
|
698
|
+
machine,
|
|
699
|
+
instance: description.name,
|
|
700
|
+
from,
|
|
701
|
+
to,
|
|
702
|
+
on,
|
|
703
|
+
...detail === void 0 ? {} : { detail }
|
|
704
|
+
});
|
|
705
|
+
const service = new AbortController();
|
|
706
|
+
const errors = [];
|
|
707
|
+
let state = "starting";
|
|
708
|
+
let exitCode = null;
|
|
709
|
+
let intakeStopped = false;
|
|
710
|
+
let shutdownStarted = false;
|
|
711
|
+
let failedStartup = false;
|
|
712
|
+
let exitState = "running";
|
|
713
|
+
const moveExit = (to, on, detail) => {
|
|
714
|
+
record("exit", exitState, to, on, detail);
|
|
715
|
+
exitState = to;
|
|
716
|
+
};
|
|
717
|
+
const fail = (cause) => {
|
|
718
|
+
errors.push(cause);
|
|
719
|
+
record("exit", exitState, exitState, "error", cause);
|
|
720
|
+
};
|
|
721
|
+
const requests = tracker(description.controllers, clock, (controller) => {
|
|
722
|
+
if (state === "starting") return "the service has not started";
|
|
723
|
+
if (state === "exited") return "the service has exited";
|
|
724
|
+
if (state === "draining") return null;
|
|
725
|
+
return runtime.verdict().controllers[controller] === true ? null : `controller ${controller} is not ready`;
|
|
726
|
+
});
|
|
727
|
+
let finish = () => {};
|
|
728
|
+
const done = new Promise((resolve) => {
|
|
729
|
+
finish = resolve;
|
|
730
|
+
});
|
|
731
|
+
let healthListening = false;
|
|
732
|
+
const connected = [];
|
|
733
|
+
const startedTransports = [];
|
|
734
|
+
const offs = EXIT_SIGNALS.map((signal) => port.on(signal, () => {
|
|
735
|
+
shutdown(signal);
|
|
736
|
+
}));
|
|
737
|
+
const startup = (async () => {
|
|
738
|
+
runtime.beginStartup();
|
|
739
|
+
const started = (what) => record("gate", "starting", "starting", "component.started", what);
|
|
740
|
+
try {
|
|
741
|
+
if (components.healthServer !== null) {
|
|
742
|
+
await components.healthServer.listen(runtime);
|
|
743
|
+
healthListening = true;
|
|
744
|
+
started("the health server");
|
|
745
|
+
}
|
|
746
|
+
for (const dependency of description.dependencies) {
|
|
747
|
+
const handle = components.dependencies.get(dependency.name);
|
|
748
|
+
if (handle === void 0) continue;
|
|
749
|
+
await handle.connect(service.signal);
|
|
750
|
+
connected.push([dependency.name, handle]);
|
|
751
|
+
started(`dependency ${dependency.name}`);
|
|
752
|
+
}
|
|
753
|
+
for (const transport of description.transports) {
|
|
754
|
+
const impl = components.transports.get(transport.name);
|
|
755
|
+
await impl.start(service.signal);
|
|
756
|
+
startedTransports.push([transport.name, impl]);
|
|
757
|
+
started(`${transport.kind} ${transport.name}`);
|
|
758
|
+
}
|
|
759
|
+
await runtime.warmUp();
|
|
760
|
+
if (state === "starting") state = "running";
|
|
761
|
+
} catch (error) {
|
|
762
|
+
const cause = `the startup failed: ${message(error)}`;
|
|
763
|
+
failedStartup = true;
|
|
764
|
+
runtime.failStartup(cause);
|
|
765
|
+
fail(cause);
|
|
766
|
+
if (exitState === "running") moveExit("stopping", "startup.fail", cause);
|
|
767
|
+
else record("exit", exitState, exitState, "startup.fail", cause);
|
|
768
|
+
}
|
|
769
|
+
})();
|
|
770
|
+
startup.then(() => {
|
|
771
|
+
if (failedStartup && !shutdownStarted) shutdown("the startup failed");
|
|
772
|
+
});
|
|
773
|
+
/** This function does the work of one phase, until its cap. It gives true when the work ended in time. */
|
|
774
|
+
const runPhase = async (phase, hold, work) => {
|
|
775
|
+
const cap = valueIn(phase.cap, "ms");
|
|
776
|
+
const deadline = clock.now() + cap;
|
|
777
|
+
const capped = new AbortController();
|
|
778
|
+
const context = {
|
|
779
|
+
deadline,
|
|
780
|
+
signal: capped.signal,
|
|
781
|
+
clock
|
|
782
|
+
};
|
|
783
|
+
let pending = work.length;
|
|
784
|
+
const all = Promise.all(work.map(async (each) => {
|
|
785
|
+
try {
|
|
786
|
+
await each(context);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
fail(`the phase ${phase.name} failed: ${message(error)}`);
|
|
789
|
+
} finally {
|
|
790
|
+
pending -= 1;
|
|
791
|
+
}
|
|
792
|
+
}));
|
|
793
|
+
let cancelCap = () => {};
|
|
794
|
+
const end = new Promise((resolve) => {
|
|
795
|
+
cancelCap = clock.after(cap, () => resolve("cap"));
|
|
796
|
+
});
|
|
797
|
+
if (await Promise.race([all.then(() => "work"), end]) === "work" && hold) await end;
|
|
798
|
+
cancelCap();
|
|
799
|
+
capped.abort(/* @__PURE__ */ new Error(`the phase ${phase.name} reached its cap`));
|
|
800
|
+
return pending === 0;
|
|
801
|
+
};
|
|
802
|
+
async function shutdown(cause = "the service asked for it") {
|
|
803
|
+
if (shutdownStarted || exitState === "exited") {
|
|
804
|
+
if (exitState === "stopping") record("exit", exitState, exitState, "exit.repeat", cause);
|
|
805
|
+
return done;
|
|
806
|
+
}
|
|
807
|
+
shutdownStarted = true;
|
|
808
|
+
if (exitState === "running") moveExit("stopping", "exit.request", cause);
|
|
809
|
+
await startup;
|
|
810
|
+
state = "draining";
|
|
811
|
+
runtime.drain();
|
|
812
|
+
let drainState = "running";
|
|
813
|
+
const moveDrain = (to, on, detail) => {
|
|
814
|
+
record("drain", drainState, to, on, detail);
|
|
815
|
+
drainState = to;
|
|
816
|
+
};
|
|
817
|
+
const abortService = () => {
|
|
818
|
+
if (service.signal.aborted) return;
|
|
819
|
+
service.abort(/* @__PURE__ */ new Error("the service stopped"));
|
|
820
|
+
record("drain", drainState, drainState, "signal.abort");
|
|
821
|
+
};
|
|
822
|
+
const phases = description.shutdown.phases;
|
|
823
|
+
let on = "exit.request";
|
|
824
|
+
for (const phase of phases) {
|
|
825
|
+
const rank = stageRank(phase.name);
|
|
826
|
+
moveDrain(phase.name, on);
|
|
827
|
+
if (rank > ABORT_AFTER) abortService();
|
|
828
|
+
const work = [];
|
|
829
|
+
if (phase.name === "stop intake") {
|
|
830
|
+
work.push(() => {
|
|
831
|
+
intakeStopped = true;
|
|
832
|
+
requests.stopIntake();
|
|
833
|
+
});
|
|
834
|
+
for (const [, impl] of startedTransports) work.push(() => impl.stopIntake());
|
|
835
|
+
}
|
|
836
|
+
let cancelAbort = () => {};
|
|
837
|
+
if (phase.name === "drain") {
|
|
838
|
+
const cap = valueIn(phase.cap, "ms");
|
|
839
|
+
const cooperative = new AbortController();
|
|
840
|
+
const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow);
|
|
841
|
+
cancelAbort = clock.after(fireAt, () => {
|
|
842
|
+
cooperative.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
|
|
843
|
+
requests.cooperativeAbort();
|
|
844
|
+
record("drain", drainState, drainState, "drain.cooperative-abort");
|
|
845
|
+
});
|
|
846
|
+
work.push(({ deadline }) => requests.drain(deadline));
|
|
847
|
+
for (const [, impl] of startedTransports) work.push(({ deadline }) => impl.drain(deadline, cooperative.signal));
|
|
848
|
+
}
|
|
849
|
+
if (phase.name === "close transports") for (const [, impl] of startedTransports) work.push(() => impl.close());
|
|
850
|
+
if (phase.name === "stop the poller") work.push(() => {
|
|
851
|
+
runtime.stop();
|
|
852
|
+
components.stopPollers();
|
|
853
|
+
});
|
|
854
|
+
if (phase.name === "tear down dependencies") work.push(async () => {
|
|
855
|
+
for (const [, handle] of [...connected].reverse()) await handle.close();
|
|
856
|
+
});
|
|
857
|
+
if (phase.name === "close the health server" && healthListening && components.healthServer !== null) {
|
|
858
|
+
const server = components.healthServer;
|
|
859
|
+
work.push(() => server.close());
|
|
860
|
+
}
|
|
861
|
+
for (const owned of components.phaseWork.get(phase.name) ?? []) work.push(owned);
|
|
862
|
+
const inTime = await runPhase(phase, phase.name === "fail readiness", work);
|
|
863
|
+
cancelAbort();
|
|
864
|
+
if (!inTime) fail(`the phase ${phase.name} did not end in its cap of ${valueIn(phase.cap, "s")} s`);
|
|
865
|
+
on = inTime ? "phase.done" : "phase.cap";
|
|
866
|
+
}
|
|
867
|
+
moveDrain("done", on);
|
|
868
|
+
abortService();
|
|
869
|
+
runtime.stop();
|
|
870
|
+
components.stopPollers();
|
|
871
|
+
if (!intakeStopped) requests.stopIntake();
|
|
872
|
+
for (const off of offs) off();
|
|
873
|
+
state = "exited";
|
|
874
|
+
exitCode = errors.length > 0 ? 1 : 0;
|
|
875
|
+
moveExit("exited", "shutdown.done", `exit ${exitCode}`);
|
|
876
|
+
finish(exitCode);
|
|
877
|
+
port.exit(exitCode);
|
|
878
|
+
return exitCode;
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
health: runtime,
|
|
882
|
+
get state() {
|
|
883
|
+
return state;
|
|
884
|
+
},
|
|
885
|
+
get exitCode() {
|
|
886
|
+
return exitCode;
|
|
887
|
+
},
|
|
888
|
+
get errors() {
|
|
889
|
+
return [...errors];
|
|
890
|
+
},
|
|
891
|
+
started: startup,
|
|
892
|
+
done,
|
|
893
|
+
signal: service.signal,
|
|
894
|
+
shutdown,
|
|
895
|
+
request: (controller, route, work) => requests.request(controller, route, work),
|
|
896
|
+
inFlight: (controller) => requests.inFlight(controller),
|
|
897
|
+
keepAlive: () => state === "running" && runtime.verdict().ready
|
|
898
|
+
};
|
|
899
|
+
};
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/watchdog.ts
|
|
902
|
+
/** This function starts a watchdog on a clock. */
|
|
903
|
+
const watchdog = (clock, spec, trace) => {
|
|
904
|
+
const tick = valueIn(spec.tick, "ms");
|
|
905
|
+
const threshold = valueIn(spec.threshold, "ms");
|
|
906
|
+
if (!(tick > 0)) throw new RangeError(`watchdog ${spec.name} ticks every ${tick} ms, not above zero`);
|
|
907
|
+
let blocked = false;
|
|
908
|
+
let stopped = false;
|
|
909
|
+
let worst = 0;
|
|
910
|
+
let sincePoll = 0;
|
|
911
|
+
let next = null;
|
|
912
|
+
const named = () => stopped ? "stopped" : blocked ? "blocked" : "watching";
|
|
913
|
+
const arm = () => {
|
|
914
|
+
const due = clock.now() + tick;
|
|
915
|
+
next = clock.after(tick, () => {
|
|
916
|
+
next = null;
|
|
917
|
+
if (stopped) return;
|
|
918
|
+
const late = Math.max(0, clock.now() - due);
|
|
919
|
+
worst = Math.max(worst, late);
|
|
920
|
+
sincePoll = Math.max(sincePoll, late);
|
|
921
|
+
const from = named();
|
|
922
|
+
blocked = late > threshold;
|
|
923
|
+
trace?.(from, named(), "tick");
|
|
924
|
+
arm();
|
|
925
|
+
});
|
|
926
|
+
};
|
|
927
|
+
arm();
|
|
928
|
+
return {
|
|
929
|
+
check: () => {
|
|
930
|
+
const failed = blocked || sincePoll > threshold;
|
|
931
|
+
sincePoll = 0;
|
|
932
|
+
return failed ? "error" : "ok";
|
|
933
|
+
},
|
|
934
|
+
get worst() {
|
|
935
|
+
return worst;
|
|
936
|
+
},
|
|
937
|
+
get blocked() {
|
|
938
|
+
return blocked;
|
|
939
|
+
},
|
|
940
|
+
stop() {
|
|
941
|
+
if (stopped) return;
|
|
942
|
+
const from = named();
|
|
943
|
+
stopped = true;
|
|
944
|
+
next?.();
|
|
945
|
+
next = null;
|
|
946
|
+
trace?.(from, "stopped", "stop");
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
};
|
|
104
950
|
//#endregion
|
|
105
951
|
//#region src/builder.ts
|
|
106
952
|
const isTime = (amount) => convertible(amount.unit, "s");
|
|
953
|
+
const above = (amount) => amount.value === "infinite" || isTime(amount) && valueIn(amount, "s") > 0;
|
|
954
|
+
const whole = (count) => Number.isInteger(count) && count >= 1;
|
|
955
|
+
/** This function gives the problems of a timing: a zero interval or count fails decoding. */
|
|
956
|
+
const timingProblems = (policy, timing) => {
|
|
957
|
+
if (timing.type === "debounce") {
|
|
958
|
+
const problems = [];
|
|
959
|
+
if (!above(timing.interval)) problems.push(`the ${policy} debounce polls at an interval that is not above zero`);
|
|
960
|
+
if (!whole(timing.failAfter)) problems.push(`the ${policy} debounce fails after ${timing.failAfter} polls, not a whole number above zero`);
|
|
961
|
+
if (!whole(timing.recoverAfter)) problems.push(`the ${policy} debounce recovers after ${timing.recoverAfter} polls, not a whole number above zero`);
|
|
962
|
+
return problems;
|
|
963
|
+
}
|
|
964
|
+
const negative = (amount) => amount !== void 0 && (!isTime(amount) || valueIn(amount, "s") < 0);
|
|
965
|
+
return negative(timing.failAfter) || negative(timing.recoverAfter) ? [`the ${policy} window is not a time of zero or more`] : [];
|
|
966
|
+
};
|
|
107
967
|
/** This function starts the composition of a service. */
|
|
108
968
|
const service = (name) => {
|
|
109
969
|
if (name.trim().length === 0) throw new Error("a service needs a name");
|
|
@@ -111,6 +971,7 @@ const service = (name) => {
|
|
|
111
971
|
const dependencies = [];
|
|
112
972
|
const controllers = [];
|
|
113
973
|
const checks = [];
|
|
974
|
+
const runs = /* @__PURE__ */ new Map();
|
|
114
975
|
const couplings = [];
|
|
115
976
|
const phases = [];
|
|
116
977
|
let readiness;
|
|
@@ -119,6 +980,14 @@ const service = (name) => {
|
|
|
119
980
|
let cooperative;
|
|
120
981
|
let started = false;
|
|
121
982
|
let partial = false;
|
|
983
|
+
let runtime;
|
|
984
|
+
let live;
|
|
985
|
+
let running;
|
|
986
|
+
const transportImpls = /* @__PURE__ */ new Map();
|
|
987
|
+
const handles = /* @__PURE__ */ new Map();
|
|
988
|
+
const phaseWork = /* @__PURE__ */ new Map();
|
|
989
|
+
const watchdogs = [];
|
|
990
|
+
let probes = null;
|
|
122
991
|
/** This function stops a change to the shape of the service after the start. */
|
|
123
992
|
const composing = (what) => {
|
|
124
993
|
if (started) throw new Error(`${name} has started: ${what} is declared before svc.start()`);
|
|
@@ -126,7 +995,7 @@ const service = (name) => {
|
|
|
126
995
|
const once = (list, entry, what) => {
|
|
127
996
|
if (list.some((held) => held.name === entry)) throw new Error(`${name} already has a ${what} named ${entry}`);
|
|
128
997
|
};
|
|
129
|
-
const transport = (kind) => (entry, knobs) => {
|
|
998
|
+
const transport = (kind) => (entry, knobs, impl) => {
|
|
130
999
|
composing(`a ${kind}`);
|
|
131
1000
|
once(transports, entry, "transport");
|
|
132
1001
|
transports.push({
|
|
@@ -134,19 +1003,67 @@ const service = (name) => {
|
|
|
134
1003
|
kind,
|
|
135
1004
|
knobs: { ...knobs }
|
|
136
1005
|
});
|
|
1006
|
+
if (impl !== void 0) transportImpls.set(entry, impl);
|
|
137
1007
|
return builder;
|
|
138
1008
|
};
|
|
1009
|
+
/** This function makes the runtime of the declaration: its checks, and a watchdog for each spec. */
|
|
1010
|
+
const makeRuntime = (options) => {
|
|
1011
|
+
const description = builder.describe();
|
|
1012
|
+
const clock = options.clock ?? realClock;
|
|
1013
|
+
const dogs = [];
|
|
1014
|
+
const all = new Map(runs);
|
|
1015
|
+
for (const spec of watchdogs) {
|
|
1016
|
+
const dog = watchdog(clock, spec, (from, to, on) => options.record?.({
|
|
1017
|
+
at: clock.now(),
|
|
1018
|
+
machine: "watchdog",
|
|
1019
|
+
instance: spec.name,
|
|
1020
|
+
from,
|
|
1021
|
+
to,
|
|
1022
|
+
on
|
|
1023
|
+
}));
|
|
1024
|
+
dogs.push(dog);
|
|
1025
|
+
all.set(spec.name, () => dog.check());
|
|
1026
|
+
}
|
|
1027
|
+
const stopPollers = () => {
|
|
1028
|
+
for (const dog of dogs) dog.stop();
|
|
1029
|
+
};
|
|
1030
|
+
let made;
|
|
1031
|
+
try {
|
|
1032
|
+
made = healthRuntime(description, all, options);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
stopPollers();
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
runtime: {
|
|
1039
|
+
...made,
|
|
1040
|
+
stop: () => {
|
|
1041
|
+
made.stop();
|
|
1042
|
+
stopPollers();
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
description,
|
|
1046
|
+
stopPollers
|
|
1047
|
+
};
|
|
1048
|
+
};
|
|
139
1049
|
const builder = {
|
|
140
1050
|
name,
|
|
141
|
-
|
|
1051
|
+
get health() {
|
|
1052
|
+
return live ?? null;
|
|
1053
|
+
},
|
|
1054
|
+
dependency(entry, spec, handle) {
|
|
142
1055
|
once(dependencies, entry, "dependency");
|
|
1056
|
+
if (handle !== void 0 && !started) handles.set(entry, handle);
|
|
143
1057
|
dependencies.push({
|
|
144
1058
|
name: entry,
|
|
145
1059
|
...spec,
|
|
146
1060
|
knobs: { ...spec.knobs },
|
|
147
1061
|
operations: { ...spec.operations }
|
|
148
1062
|
});
|
|
149
|
-
if (started)
|
|
1063
|
+
if (started) {
|
|
1064
|
+
partial = true;
|
|
1065
|
+
live?.addDependency(entry);
|
|
1066
|
+
}
|
|
150
1067
|
return builder;
|
|
151
1068
|
},
|
|
152
1069
|
server: transport("server"),
|
|
@@ -176,10 +1093,43 @@ const service = (name) => {
|
|
|
176
1093
|
};
|
|
177
1094
|
return handle;
|
|
178
1095
|
},
|
|
179
|
-
check(check) {
|
|
1096
|
+
check(check, run) {
|
|
180
1097
|
once(checks, check.name, "check");
|
|
1098
|
+
if (started && run === void 0) throw new Error(`check ${check.name} joins after the start, thus it needs its function`);
|
|
181
1099
|
checks.push({ ...check });
|
|
182
|
-
if (
|
|
1100
|
+
if (run !== void 0) runs.set(check.name, run);
|
|
1101
|
+
if (started) {
|
|
1102
|
+
partial = true;
|
|
1103
|
+
live?.addCheck({
|
|
1104
|
+
...check,
|
|
1105
|
+
poll: { ...check.poll }
|
|
1106
|
+
}, run);
|
|
1107
|
+
}
|
|
1108
|
+
return builder;
|
|
1109
|
+
},
|
|
1110
|
+
healthServer(server) {
|
|
1111
|
+
composing("the health server");
|
|
1112
|
+
if (probes !== null) throw new Error(`${name} already has a health server`);
|
|
1113
|
+
probes = server;
|
|
1114
|
+
return builder;
|
|
1115
|
+
},
|
|
1116
|
+
onPhase(phase, work) {
|
|
1117
|
+
composing(`work for the phase ${phase}`);
|
|
1118
|
+
phaseWork.set(phase, [...phaseWork.get(phase) ?? [], work]);
|
|
1119
|
+
return builder;
|
|
1120
|
+
},
|
|
1121
|
+
watchdog(spec) {
|
|
1122
|
+
composing(`the watchdog ${spec.name}`);
|
|
1123
|
+
once(checks, spec.name, "check");
|
|
1124
|
+
checks.push({
|
|
1125
|
+
name: spec.name,
|
|
1126
|
+
dependency: null,
|
|
1127
|
+
step: "local-state",
|
|
1128
|
+
ownConnection: true,
|
|
1129
|
+
gates: spec.gates,
|
|
1130
|
+
poll: { ...spec.poll }
|
|
1131
|
+
});
|
|
1132
|
+
watchdogs.push(spec);
|
|
183
1133
|
return builder;
|
|
184
1134
|
},
|
|
185
1135
|
readiness(timing) {
|
|
@@ -226,9 +1176,18 @@ const service = (name) => {
|
|
|
226
1176
|
if (missing.length > 0) throw new Error(`${name} does not declare ${missing.join("; ")}: the runtime has no defaults`);
|
|
227
1177
|
const known = new Set(dependencies.map((entry) => entry.name));
|
|
228
1178
|
const problems = [];
|
|
1179
|
+
const policies = {
|
|
1180
|
+
readiness,
|
|
1181
|
+
liveness
|
|
1182
|
+
};
|
|
1183
|
+
for (const gate of ["readiness", "liveness"]) problems.push(...timingProblems(gate, policies[gate]));
|
|
229
1184
|
for (const check of checks) {
|
|
230
1185
|
if (check.dependency !== null && !known.has(check.dependency)) problems.push(`check ${check.name} exercises ${check.dependency}, which is not a dependency`);
|
|
231
1186
|
if (!isTime(check.poll.interval) || !isTime(check.poll.timeout)) problems.push(`check ${check.name} polls in a unit that is not a time`);
|
|
1187
|
+
else if (!above(check.poll.interval) || !above(check.poll.timeout)) problems.push(`check ${check.name} polls with an interval or a timeout that is not above zero`);
|
|
1188
|
+
if (!(check.poll.jitter >= 0 && check.poll.jitter < 1)) problems.push(`check ${check.name} has a jitter of ${check.poll.jitter}, not a fraction below 1`);
|
|
1189
|
+
const timing = check.gates === null ? null : policies[check.gates];
|
|
1190
|
+
if (timing?.type === "debounce" && isTime(check.poll.interval) && above(timing.interval) && valueIn(check.poll.interval, "s") !== valueIn(timing.interval, "s")) problems.push(`check ${check.name} polls every ${valueIn(check.poll.interval, "s")} s, but the ${check.gates} debounce counts polls every ${valueIn(timing.interval, "s")} s`);
|
|
232
1191
|
}
|
|
233
1192
|
for (const controller of controllers) {
|
|
234
1193
|
for (const needed of controller.requires) if (!known.has(needed)) problems.push(`controller ${controller.name} requires ${needed}, which is not a dependency`);
|
|
@@ -236,6 +1195,8 @@ const service = (name) => {
|
|
|
236
1195
|
}
|
|
237
1196
|
for (const dependency of dependencies) for (const [name, operation] of Object.entries(dependency.operations)) problems.push(...operationProblems(`dependency ${dependency.name}`, name, operation));
|
|
238
1197
|
for (const phase of phases) if (!isTime(phase.cap)) problems.push(`phase ${phase.name}'s cap is not a time`);
|
|
1198
|
+
const ranked = phases.map((phase) => phase.name).filter((each) => stageRank(each) >= 0);
|
|
1199
|
+
for (let at = 1; at < ranked.length; at += 1) if (stageRank(ranked[at]) < stageRank(ranked[at - 1])) problems.push(`the phase "${ranked[at]}" comes after "${ranked[at - 1]}", but the stages go ${STAGES.join(", ")}`);
|
|
239
1200
|
if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
|
|
240
1201
|
const controllersOut = controllers.map((entry) => ({
|
|
241
1202
|
name: entry.name,
|
|
@@ -272,10 +1233,47 @@ const service = (name) => {
|
|
|
272
1233
|
}
|
|
273
1234
|
};
|
|
274
1235
|
},
|
|
275
|
-
start() {
|
|
276
|
-
|
|
1236
|
+
start(options = {}) {
|
|
1237
|
+
if (runtime !== void 0) return runtime;
|
|
1238
|
+
if (running !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} runs its lifecycle: use its health`));
|
|
1239
|
+
let made;
|
|
1240
|
+
try {
|
|
1241
|
+
made = makeRuntime(options).runtime;
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
return Promise.reject(error);
|
|
1244
|
+
}
|
|
277
1245
|
started = true;
|
|
278
|
-
|
|
1246
|
+
live = made;
|
|
1247
|
+
runtime = made.warmUp().then(() => made);
|
|
1248
|
+
return runtime;
|
|
1249
|
+
},
|
|
1250
|
+
run(options = {}) {
|
|
1251
|
+
if (running !== void 0) return running;
|
|
1252
|
+
if (runtime !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} started its health only: run() starts the whole lifecycle`));
|
|
1253
|
+
let life;
|
|
1254
|
+
try {
|
|
1255
|
+
const description = builder.describe();
|
|
1256
|
+
const components = {
|
|
1257
|
+
transports: transportImpls,
|
|
1258
|
+
dependencies: handles,
|
|
1259
|
+
healthServer: probes,
|
|
1260
|
+
phaseWork,
|
|
1261
|
+
stopPollers: () => {}
|
|
1262
|
+
};
|
|
1263
|
+
const problems = lifecycleProblems(description, components);
|
|
1264
|
+
if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
|
|
1265
|
+
const made = makeRuntime(options);
|
|
1266
|
+
started = true;
|
|
1267
|
+
live = made.runtime;
|
|
1268
|
+
life = lifecycle(made.description, made.runtime, {
|
|
1269
|
+
...components,
|
|
1270
|
+
stopPollers: made.stopPollers
|
|
1271
|
+
}, options);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
return Promise.reject(error);
|
|
1274
|
+
}
|
|
1275
|
+
running = life.started.then(() => life);
|
|
1276
|
+
return running;
|
|
279
1277
|
}
|
|
280
1278
|
};
|
|
281
1279
|
return builder;
|
|
@@ -448,6 +1446,456 @@ const lint = (svc, scenario, rules) => {
|
|
|
448
1446
|
return findings;
|
|
449
1447
|
};
|
|
450
1448
|
//#endregion
|
|
451
|
-
|
|
1449
|
+
//#region src/machines.ts
|
|
1450
|
+
/**
|
|
1451
|
+
* The machines of the runtime as `mark1russell7.machine/1` documents. The documents are the
|
|
1452
|
+
* specification, and TypeScript implements them. A test records the transitions of the runtime
|
|
1453
|
+
* and replays them against each document, thus the code and the documents cannot disagree.
|
|
1454
|
+
*/
|
|
1455
|
+
const SOURCE = [{
|
|
1456
|
+
ref: "packages/service/src (the runtime implements this document)",
|
|
1457
|
+
rung: "implemented"
|
|
1458
|
+
}];
|
|
1459
|
+
const base = (id, title) => ({
|
|
1460
|
+
format: "mark1russell7.machine/1",
|
|
1461
|
+
id: `service.machine.${id}`,
|
|
1462
|
+
title,
|
|
1463
|
+
layer: "self",
|
|
1464
|
+
version: "0.2.0",
|
|
1465
|
+
sources: SOURCE
|
|
1466
|
+
});
|
|
1467
|
+
const t = (id, from, on, to, extra = {}) => ({
|
|
1468
|
+
id,
|
|
1469
|
+
from,
|
|
1470
|
+
on,
|
|
1471
|
+
to,
|
|
1472
|
+
...extra
|
|
1473
|
+
});
|
|
1474
|
+
/**
|
|
1475
|
+
* The startup gate opens one time. The lifecycle moves it from `closed` to `starting` while its
|
|
1476
|
+
* components start. Then one warm-up sweep of each check opens it. A component that does not start
|
|
1477
|
+
* fails it for good.
|
|
1478
|
+
*/
|
|
1479
|
+
const GATE_MACHINE = {
|
|
1480
|
+
...base("gate", "The startup gate"),
|
|
1481
|
+
states: {
|
|
1482
|
+
closed: { initial: true },
|
|
1483
|
+
starting: { note: "the health server, then the dependencies, then the transports" },
|
|
1484
|
+
sweeping: { note: "one bounded warm-up sweep of each check" },
|
|
1485
|
+
open: { final: true },
|
|
1486
|
+
failed: { final: true }
|
|
1487
|
+
},
|
|
1488
|
+
events: {
|
|
1489
|
+
"startup.begin": { kind: "local" },
|
|
1490
|
+
"component.started": { kind: "local" },
|
|
1491
|
+
"mark-ready": { kind: "local" },
|
|
1492
|
+
"sweep.done": { kind: "local" },
|
|
1493
|
+
"startup.fail": { kind: "local" },
|
|
1494
|
+
"startup.over-budget": { kind: "timer" }
|
|
1495
|
+
},
|
|
1496
|
+
knobs: { startup: {
|
|
1497
|
+
key: "policies.startup",
|
|
1498
|
+
scope: "service",
|
|
1499
|
+
unit: "s",
|
|
1500
|
+
default: null
|
|
1501
|
+
} },
|
|
1502
|
+
timers: { startup_budget: {
|
|
1503
|
+
duration: {
|
|
1504
|
+
knob: "startup",
|
|
1505
|
+
unit: "s"
|
|
1506
|
+
},
|
|
1507
|
+
arms: ["enter:closed"],
|
|
1508
|
+
cancels: ["sweep.done", "startup.fail"],
|
|
1509
|
+
fires: "startup.over-budget",
|
|
1510
|
+
note: "the gate says that it is over its budget, and the startup probe of the platform acts on it"
|
|
1511
|
+
} },
|
|
1512
|
+
transitions: [
|
|
1513
|
+
t("begin", "closed", "startup.begin", "starting"),
|
|
1514
|
+
t("component", "starting", "component.started", "starting"),
|
|
1515
|
+
t("mark-ready", ["closed", "starting"], "mark-ready", "sweeping"),
|
|
1516
|
+
t("open", "sweeping", "sweep.done", "open"),
|
|
1517
|
+
t("fail", [
|
|
1518
|
+
"closed",
|
|
1519
|
+
"starting",
|
|
1520
|
+
"sweeping"
|
|
1521
|
+
], "startup.fail", "failed"),
|
|
1522
|
+
t("over-budget-closed", "closed", "startup.over-budget", "closed", { timer: "startup_budget" }),
|
|
1523
|
+
t("over-budget-starting", "starting", "startup.over-budget", "starting", { timer: "startup_budget" }),
|
|
1524
|
+
t("over-budget-sweeping", "sweeping", "startup.over-budget", "sweeping", { timer: "startup_budget" })
|
|
1525
|
+
]
|
|
1526
|
+
};
|
|
1527
|
+
/** A debounce latch counts the polls of its check in sequence. A readiness latch starts tripped. */
|
|
1528
|
+
const LATCH_MACHINE = {
|
|
1529
|
+
...base("latch", "The latch of a policy, as a debounce"),
|
|
1530
|
+
states: {
|
|
1531
|
+
tripped: {
|
|
1532
|
+
initial: true,
|
|
1533
|
+
note: "a readiness latch starts here; a liveness latch starts released"
|
|
1534
|
+
},
|
|
1535
|
+
released: {}
|
|
1536
|
+
},
|
|
1537
|
+
events: {
|
|
1538
|
+
"poll.ok": { kind: "local" },
|
|
1539
|
+
"poll.fail": { kind: "local" }
|
|
1540
|
+
},
|
|
1541
|
+
knobs: {
|
|
1542
|
+
fail_after: {
|
|
1543
|
+
key: "policies.<gate>.failAfter",
|
|
1544
|
+
scope: "check",
|
|
1545
|
+
unit: "count",
|
|
1546
|
+
default: null
|
|
1547
|
+
},
|
|
1548
|
+
recover_after: {
|
|
1549
|
+
key: "policies.<gate>.recoverAfter",
|
|
1550
|
+
scope: "check",
|
|
1551
|
+
unit: "count",
|
|
1552
|
+
default: null
|
|
1553
|
+
}
|
|
1554
|
+
},
|
|
1555
|
+
timers: {},
|
|
1556
|
+
transitions: [
|
|
1557
|
+
t("pass", "released", "poll.ok", "released"),
|
|
1558
|
+
t("fail-count", "released", "poll.fail", "released", { guard: "failures in sequence < fail_after" }),
|
|
1559
|
+
t("trip", "released", "poll.fail", "tripped", { guard: "failures in sequence >= fail_after" }),
|
|
1560
|
+
t("fail", "tripped", "poll.fail", "tripped"),
|
|
1561
|
+
t("pass-count", "tripped", "poll.ok", "tripped", { guard: "passes in sequence < recover_after" }),
|
|
1562
|
+
t("release", "tripped", "poll.ok", "released", { guard: "passes in sequence >= recover_after" })
|
|
1563
|
+
]
|
|
1564
|
+
};
|
|
1565
|
+
/**
|
|
1566
|
+
* A window latch measures time on the monotonic clock from the first failure of a sequence, and
|
|
1567
|
+
* from the first pass after a trip.
|
|
1568
|
+
*/
|
|
1569
|
+
const WINDOW_MACHINE = {
|
|
1570
|
+
...base("window", "The latch of a policy, as a window"),
|
|
1571
|
+
states: {
|
|
1572
|
+
released: {
|
|
1573
|
+
initial: true,
|
|
1574
|
+
note: "a liveness latch starts here; a readiness latch starts tripped"
|
|
1575
|
+
},
|
|
1576
|
+
failing: { note: "the fail window runs" },
|
|
1577
|
+
tripped: {},
|
|
1578
|
+
recovering: { note: "the recover window runs" }
|
|
1579
|
+
},
|
|
1580
|
+
events: {
|
|
1581
|
+
"poll.ok": { kind: "local" },
|
|
1582
|
+
"poll.fail": { kind: "local" },
|
|
1583
|
+
"window.failed": { kind: "timer" },
|
|
1584
|
+
"window.recovered": { kind: "timer" }
|
|
1585
|
+
},
|
|
1586
|
+
knobs: {
|
|
1587
|
+
fail_after: {
|
|
1588
|
+
key: "policies.<gate>.failAfter",
|
|
1589
|
+
scope: "check",
|
|
1590
|
+
unit: "s",
|
|
1591
|
+
default: null
|
|
1592
|
+
},
|
|
1593
|
+
recover_after: {
|
|
1594
|
+
key: "policies.<gate>.recoverAfter",
|
|
1595
|
+
scope: "check",
|
|
1596
|
+
unit: "s",
|
|
1597
|
+
default: null
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
timers: {
|
|
1601
|
+
fail_window: {
|
|
1602
|
+
duration: {
|
|
1603
|
+
knob: "fail_after",
|
|
1604
|
+
unit: "s"
|
|
1605
|
+
},
|
|
1606
|
+
arms: ["enter:failing"],
|
|
1607
|
+
cancels: ["poll.ok"],
|
|
1608
|
+
fires: "window.failed"
|
|
1609
|
+
},
|
|
1610
|
+
recover_window: {
|
|
1611
|
+
duration: {
|
|
1612
|
+
knob: "recover_after",
|
|
1613
|
+
unit: "s"
|
|
1614
|
+
},
|
|
1615
|
+
arms: ["enter:recovering"],
|
|
1616
|
+
cancels: ["poll.fail"],
|
|
1617
|
+
fires: "window.recovered"
|
|
1618
|
+
}
|
|
1619
|
+
},
|
|
1620
|
+
transitions: [
|
|
1621
|
+
t("pass", "released", "poll.ok", "released"),
|
|
1622
|
+
t("start-failing", "released", "poll.fail", "failing", { guard: "fail_after > 0" }),
|
|
1623
|
+
t("trip-at-once", "released", "poll.fail", "tripped", { guard: "fail_after = 0" }),
|
|
1624
|
+
t("still-failing", "failing", "poll.fail", "failing"),
|
|
1625
|
+
t("stop-failing", "failing", "poll.ok", "released"),
|
|
1626
|
+
t("trip", "failing", "window.failed", "tripped", { timer: "fail_window" }),
|
|
1627
|
+
t("fail", "tripped", "poll.fail", "tripped"),
|
|
1628
|
+
t("release-at-once", "tripped", "poll.ok", "released", { guard: "recover_after is absent or 0" }),
|
|
1629
|
+
t("start-recovering", "tripped", "poll.ok", "recovering", { guard: "recover_after > 0" }),
|
|
1630
|
+
t("still-recovering", "recovering", "poll.ok", "recovering"),
|
|
1631
|
+
t("stop-recovering", "recovering", "poll.fail", "tripped"),
|
|
1632
|
+
t("release", "recovering", "window.recovered", "released", { timer: "recover_window" })
|
|
1633
|
+
]
|
|
1634
|
+
};
|
|
1635
|
+
/**
|
|
1636
|
+
* A poller starts one check on a jittered loop with a fixed delay. A call that outlives its timeout
|
|
1637
|
+
* stays pending (`hung`), and the poller does not start the check again until the call settles.
|
|
1638
|
+
*/
|
|
1639
|
+
const POLLER_MACHINE = {
|
|
1640
|
+
...base("poller", "The poller of a check"),
|
|
1641
|
+
states: {
|
|
1642
|
+
idle: {
|
|
1643
|
+
initial: true,
|
|
1644
|
+
note: "before the loop starts"
|
|
1645
|
+
},
|
|
1646
|
+
waiting: { note: "the loop timer runs, and no call is pending" },
|
|
1647
|
+
calling: { note: "a call runs, in its timeout" },
|
|
1648
|
+
hung: { note: "a call outlived its timeout, and it is still pending" },
|
|
1649
|
+
stopped: { final: true }
|
|
1650
|
+
},
|
|
1651
|
+
events: {
|
|
1652
|
+
start: { kind: "local" },
|
|
1653
|
+
sweep: { kind: "local" },
|
|
1654
|
+
tick: { kind: "timer" },
|
|
1655
|
+
answer: { kind: "local" },
|
|
1656
|
+
timeout: { kind: "timer" },
|
|
1657
|
+
"late-answer": { kind: "local" },
|
|
1658
|
+
stop: { kind: "local" }
|
|
1659
|
+
},
|
|
1660
|
+
knobs: {
|
|
1661
|
+
interval: {
|
|
1662
|
+
key: "checks.<name>.poll.interval",
|
|
1663
|
+
scope: "check",
|
|
1664
|
+
unit: "s",
|
|
1665
|
+
default: null
|
|
1666
|
+
},
|
|
1667
|
+
timeout: {
|
|
1668
|
+
key: "checks.<name>.poll.timeout",
|
|
1669
|
+
scope: "check",
|
|
1670
|
+
unit: "s",
|
|
1671
|
+
default: null
|
|
1672
|
+
}
|
|
1673
|
+
},
|
|
1674
|
+
timers: {
|
|
1675
|
+
loop: {
|
|
1676
|
+
duration: {
|
|
1677
|
+
knob: "interval",
|
|
1678
|
+
unit: "s"
|
|
1679
|
+
},
|
|
1680
|
+
arms: ["start", "tick"],
|
|
1681
|
+
cancels: ["stop"],
|
|
1682
|
+
fires: "tick",
|
|
1683
|
+
note: "the interval, with its jitter: a fixed delay from the start of each poll"
|
|
1684
|
+
},
|
|
1685
|
+
poll_timeout: {
|
|
1686
|
+
duration: {
|
|
1687
|
+
knob: "timeout",
|
|
1688
|
+
unit: "s"
|
|
1689
|
+
},
|
|
1690
|
+
arms: ["enter:calling"],
|
|
1691
|
+
cancels: ["answer", "stop"],
|
|
1692
|
+
fires: "timeout"
|
|
1693
|
+
}
|
|
1694
|
+
},
|
|
1695
|
+
transitions: [
|
|
1696
|
+
t("sweep", "idle", "sweep", "calling"),
|
|
1697
|
+
t("start", "idle", "start", "waiting"),
|
|
1698
|
+
t("start-while-calling", "calling", "start", "calling", { note: "a late check polls at once, then starts its loop" }),
|
|
1699
|
+
t("start-while-hung", "hung", "start", "hung"),
|
|
1700
|
+
t("tick", "waiting", "tick", "calling", { guard: "the force-fail list does not name the check" }),
|
|
1701
|
+
t("tick-forced", "waiting", "tick", "waiting", { guard: "the force-fail list names the check" }),
|
|
1702
|
+
t("tick-pending", "calling", "tick", "calling", { note: "the poll fails at once: its call is pending" }),
|
|
1703
|
+
t("tick-hung", "hung", "tick", "hung", { note: "the poll fails at once: its call is pending" }),
|
|
1704
|
+
t("answer-loop", "calling", "answer", "waiting", { guard: "the loop runs" }),
|
|
1705
|
+
t("answer-sweep", "calling", "answer", "idle", { guard: "the loop has not started" }),
|
|
1706
|
+
t("timeout", "calling", "timeout", "hung", { timer: "poll_timeout" }),
|
|
1707
|
+
t("late-answer-loop", "hung", "late-answer", "waiting", { guard: "the loop runs" }),
|
|
1708
|
+
t("late-answer-sweep", "hung", "late-answer", "idle", { guard: "the loop has not started" }),
|
|
1709
|
+
t("stop", [
|
|
1710
|
+
"idle",
|
|
1711
|
+
"waiting",
|
|
1712
|
+
"calling",
|
|
1713
|
+
"hung"
|
|
1714
|
+
], "stop", "stopped"),
|
|
1715
|
+
t("settle-after-stop", "stopped", [
|
|
1716
|
+
"answer",
|
|
1717
|
+
"late-answer",
|
|
1718
|
+
"timeout"
|
|
1719
|
+
], "stopped")
|
|
1720
|
+
]
|
|
1721
|
+
};
|
|
1722
|
+
/**
|
|
1723
|
+
* The exit manager is the only part that ends the process. The first exit request starts the
|
|
1724
|
+
* shutdown, and a later one has no effect. An error makes the exit code 1.
|
|
1725
|
+
*/
|
|
1726
|
+
const EXIT_MACHINE = {
|
|
1727
|
+
...base("exit", "The exit manager"),
|
|
1728
|
+
states: {
|
|
1729
|
+
running: { initial: true },
|
|
1730
|
+
stopping: {},
|
|
1731
|
+
exited: { final: true }
|
|
1732
|
+
},
|
|
1733
|
+
events: {
|
|
1734
|
+
"exit.request": {
|
|
1735
|
+
kind: "app",
|
|
1736
|
+
note: "SIGINT, SIGTERM, SIGQUIT or shutdown()"
|
|
1737
|
+
},
|
|
1738
|
+
"exit.repeat": { kind: "app" },
|
|
1739
|
+
"startup.fail": { kind: "local" },
|
|
1740
|
+
error: { kind: "local" },
|
|
1741
|
+
"shutdown.done": { kind: "local" }
|
|
1742
|
+
},
|
|
1743
|
+
knobs: {},
|
|
1744
|
+
timers: {},
|
|
1745
|
+
transitions: [
|
|
1746
|
+
t("request", "running", "exit.request", "stopping"),
|
|
1747
|
+
t("startup-fail", "running", "startup.fail", "stopping"),
|
|
1748
|
+
t("error-running", "running", "error", "running"),
|
|
1749
|
+
t("repeat", "stopping", "exit.repeat", "stopping"),
|
|
1750
|
+
t("startup-fail-stopping", "stopping", "startup.fail", "stopping"),
|
|
1751
|
+
t("error", "stopping", "error", "stopping"),
|
|
1752
|
+
t("exit", "stopping", "shutdown.done", "exited", { note: "the exit code is 1 after an error, else 0" })
|
|
1753
|
+
]
|
|
1754
|
+
};
|
|
1755
|
+
/** A watchdog measures how late its own tick comes, and a late tick shows a blocked event loop. */
|
|
1756
|
+
const WATCHDOG_MACHINE = {
|
|
1757
|
+
...base("watchdog", "The watchdog of the event loop"),
|
|
1758
|
+
states: {
|
|
1759
|
+
watching: { initial: true },
|
|
1760
|
+
blocked: {},
|
|
1761
|
+
stopped: { final: true }
|
|
1762
|
+
},
|
|
1763
|
+
events: {
|
|
1764
|
+
tick: { kind: "timer" },
|
|
1765
|
+
stop: { kind: "local" }
|
|
1766
|
+
},
|
|
1767
|
+
knobs: {
|
|
1768
|
+
tick: {
|
|
1769
|
+
key: "watchdog.tick",
|
|
1770
|
+
scope: "process",
|
|
1771
|
+
unit: "s",
|
|
1772
|
+
default: null
|
|
1773
|
+
},
|
|
1774
|
+
threshold: {
|
|
1775
|
+
key: "watchdog.threshold",
|
|
1776
|
+
scope: "process",
|
|
1777
|
+
unit: "s",
|
|
1778
|
+
default: null
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
timers: { tick: {
|
|
1782
|
+
duration: {
|
|
1783
|
+
knob: "tick",
|
|
1784
|
+
unit: "s"
|
|
1785
|
+
},
|
|
1786
|
+
arms: ["enter:watching", "tick"],
|
|
1787
|
+
cancels: ["stop"],
|
|
1788
|
+
fires: "tick"
|
|
1789
|
+
} },
|
|
1790
|
+
transitions: [
|
|
1791
|
+
t("on-time", "watching", "tick", "watching", { guard: "lateness <= threshold" }),
|
|
1792
|
+
t("block", "watching", "tick", "blocked", { guard: "lateness > threshold" }),
|
|
1793
|
+
t("still-blocked", "blocked", "tick", "blocked", { guard: "lateness > threshold" }),
|
|
1794
|
+
t("unblock", "blocked", "tick", "watching", { guard: "lateness <= threshold" }),
|
|
1795
|
+
t("stop", ["watching", "blocked"], "stop", "stopped")
|
|
1796
|
+
]
|
|
1797
|
+
};
|
|
1798
|
+
/** This function gives the name of a knob for the cap of a phase: `drain` gives `drain_cap`. */
|
|
1799
|
+
const capKnob = (phase) => `${phase.replace(/[^A-Za-z0-9]+/g, "_")}_cap`;
|
|
1800
|
+
/**
|
|
1801
|
+
* This function gives the drain of a declaration as a machine. It has one state for each declared
|
|
1802
|
+
* phase, in its sequence, and each state has a timer at its cap. The drain also has the cooperative abort: the
|
|
1803
|
+
* cap of the drain less the cooperative window, and half that cap at the least.
|
|
1804
|
+
*/
|
|
1805
|
+
const drainMachine = (description) => {
|
|
1806
|
+
const phases = description.shutdown.phases;
|
|
1807
|
+
const states = { running: { initial: true } };
|
|
1808
|
+
for (const phase of phases) states[phase.name] = {};
|
|
1809
|
+
states["done"] = { final: true };
|
|
1810
|
+
const knobs = Object.fromEntries([...phases.map((phase) => [capKnob(phase.name), {
|
|
1811
|
+
key: `shutdown.phases.${phase.name}.cap`,
|
|
1812
|
+
scope: "service",
|
|
1813
|
+
unit: "s",
|
|
1814
|
+
default: null
|
|
1815
|
+
}]), ["cooperative_window", {
|
|
1816
|
+
key: "shutdown.cooperativeWindow",
|
|
1817
|
+
scope: "service",
|
|
1818
|
+
unit: "s",
|
|
1819
|
+
default: null
|
|
1820
|
+
}]]);
|
|
1821
|
+
const timers = {};
|
|
1822
|
+
for (const phase of phases) timers[`${capKnob(phase.name)}_timer`] = {
|
|
1823
|
+
duration: {
|
|
1824
|
+
knob: capKnob(phase.name),
|
|
1825
|
+
unit: "s"
|
|
1826
|
+
},
|
|
1827
|
+
arms: [`enter:${phase.name}`],
|
|
1828
|
+
cancels: ["phase.done"],
|
|
1829
|
+
fires: "phase.cap"
|
|
1830
|
+
};
|
|
1831
|
+
if (phases.some((phase) => phase.name === "drain")) timers["cooperative_abort"] = {
|
|
1832
|
+
duration: {
|
|
1833
|
+
expr: "max(drain_cap - cooperative_window, drain_cap / 2)",
|
|
1834
|
+
unit: "s"
|
|
1835
|
+
},
|
|
1836
|
+
arms: ["enter:drain"],
|
|
1837
|
+
cancels: ["phase.done", "phase.cap"],
|
|
1838
|
+
fires: "drain.cooperative-abort"
|
|
1839
|
+
};
|
|
1840
|
+
const transitions = [];
|
|
1841
|
+
const order = [
|
|
1842
|
+
"running",
|
|
1843
|
+
...phases.map((phase) => phase.name),
|
|
1844
|
+
"done"
|
|
1845
|
+
];
|
|
1846
|
+
for (let at = 0; at + 1 < order.length; at += 1) {
|
|
1847
|
+
const from = order[at];
|
|
1848
|
+
const to = order[at + 1];
|
|
1849
|
+
transitions.push(at === 0 ? t(`begin`, from, "exit.request", to) : t(`after-${from}`, from, ["phase.done", "phase.cap"], to, {
|
|
1850
|
+
timer: `${capKnob(from)}_timer`,
|
|
1851
|
+
note: "phase.cap: the work of the phase did not end in its cap, and the exit code is 1"
|
|
1852
|
+
}));
|
|
1853
|
+
}
|
|
1854
|
+
if (phases.some((phase) => phase.name === "drain")) transitions.push(t("cooperative-abort", "drain", "drain.cooperative-abort", "drain", { timer: "cooperative_abort" }));
|
|
1855
|
+
for (const state of [...phases.map((phase) => phase.name), "done"]) transitions.push(t(`signal-abort-${state}`, state, "signal.abort", state));
|
|
1856
|
+
return {
|
|
1857
|
+
...base("drain", `The drain of ${description.name}`),
|
|
1858
|
+
states,
|
|
1859
|
+
events: {
|
|
1860
|
+
"exit.request": { kind: "app" },
|
|
1861
|
+
"phase.done": { kind: "local" },
|
|
1862
|
+
"phase.cap": { kind: "timer" },
|
|
1863
|
+
"drain.cooperative-abort": { kind: "timer" },
|
|
1864
|
+
"signal.abort": { kind: "local" }
|
|
1865
|
+
},
|
|
1866
|
+
knobs,
|
|
1867
|
+
timers,
|
|
1868
|
+
transitions
|
|
1869
|
+
};
|
|
1870
|
+
};
|
|
1871
|
+
/** These are the static machines of the runtime, by the name that their events carry. */
|
|
1872
|
+
const MACHINES = {
|
|
1873
|
+
gate: GATE_MACHINE,
|
|
1874
|
+
latch: LATCH_MACHINE,
|
|
1875
|
+
window: WINDOW_MACHINE,
|
|
1876
|
+
poller: POLLER_MACHINE,
|
|
1877
|
+
exit: EXIT_MACHINE,
|
|
1878
|
+
watchdog: WATCHDOG_MACHINE
|
|
1879
|
+
};
|
|
1880
|
+
/**
|
|
1881
|
+
* This function replays the transitions of one machine of the runtime against its document. Each
|
|
1882
|
+
* instance starts in the state that its first transition leaves. Each transition must leave the
|
|
1883
|
+
* state where the replay is. The document must have a transition from that state, on that event,
|
|
1884
|
+
* to that state. The function gives each disagreement.
|
|
1885
|
+
*/
|
|
1886
|
+
const replay = (doc, events) => {
|
|
1887
|
+
const problems = [];
|
|
1888
|
+
const at = /* @__PURE__ */ new Map();
|
|
1889
|
+
for (const event of events) {
|
|
1890
|
+
const where = at.get(event.instance) ?? event.from;
|
|
1891
|
+
if (doc.states[where] === void 0) problems.push(`${event.instance}: the state ${where} is not in ${doc.id}`);
|
|
1892
|
+
if (event.from !== where) problems.push(`${event.instance}: the runtime left ${event.from} on ${event.on}, but the replay is in ${where}`);
|
|
1893
|
+
if (!doc.transitions.some((each) => fromStates(doc, each.from).includes(event.from) && arr(each.on).includes(event.on) && each.to === event.to)) problems.push(`${event.instance}: ${doc.id} has no transition from ${event.from} on ${event.on} to ${event.to}`);
|
|
1894
|
+
at.set(event.instance, event.to);
|
|
1895
|
+
}
|
|
1896
|
+
return problems;
|
|
1897
|
+
};
|
|
1898
|
+
//#endregion
|
|
1899
|
+
export { EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, MACHINES, POLLER_MACHINE, SERVICE_RULES, STAGES, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, capKnob, cooperativeAbortAt, drainMachine, evaluate, failSpan, inequalities, lint, mayReissue, nodeProcess, realClock, recoverSpan, replay, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog };
|
|
452
1900
|
|
|
453
1901
|
//# sourceMappingURL=index.js.map
|