@mark1russell7/service 0.4.1 → 0.5.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 +268 -18
- package/build/builder-B_q-JeW8.d.ts +197 -0
- package/build/http.d.ts +277 -0
- package/build/http.js +789 -0
- package/build/http.js.map +1 -0
- package/build/index.d.ts +15 -5
- package/build/index.js +745 -159
- package/build/index.js.map +1 -1
- package/build/{builder-DnfUDW9u.d.ts → lifecycle-DIj4YGmO.d.ts} +238 -181
- package/build/redis.d.ts +2 -1
- package/build/testing.d.ts +92 -4
- package/build/testing.js +302 -8
- package/build/testing.js.map +1 -1
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A service that you declare as data before it starts. You compose it with plain calls: its dependencies, its checks, its policies and its shutdown. `describe()` gives the whole graph as data, and it starts nothing. `lint()` then applies the timer-ordering law to that graph. `start()` gives the graph to the health runtime, and `run()` starts the whole lifecycle.
|
|
4
4
|
|
|
5
|
-
This release has the declaration, its export, the law, the health runtime and the
|
|
5
|
+
This release has the declaration, its export, the law, the health runtime, the lifecycle and the edge of a service in Node (`@mark1russell7/service/http`). The wrappers of the clients come in a later release.
|
|
6
6
|
|
|
7
7
|
## What the package does
|
|
8
8
|
|
|
9
9
|
- **The builder.** `service(name)` gives a builder. You add each part with a call: `svc.dependency(…)`, `svc.server(…)`, `svc.controller("checkout").requires(…).route(…)`, `svc.check(check, run)`, the policies, the couplings and the shutdown phases.
|
|
10
|
-
- **No defaults.** The runtime has no defaults of its own. Thus `describe()` refuses a service that does not declare its readiness, its liveness, its startup budget, its shutdown phases or its cooperative window. The error tells which. `start()` also refuses a check without its function.
|
|
10
|
+
- **No defaults.** The runtime has no defaults of its own. Thus `describe()` refuses a service that does not declare its readiness, its liveness, its startup budget, its shutdown phases or its cooperative window. It also refuses a service that does not declare what a repeat signal does. The error tells which. `start()` also refuses a check without its function.
|
|
11
11
|
- **One timing shape.** Each policy has a `Timing`: a debounce of polls, or a window of time. Each converts to the other (`asWindow`, `asDebounce`). `failSpan` and `recoverSpan` give the earliest and the latest time to detect a change. At 3 polls of 5 s ± 20 %, a latch fails in 8–18 s on an error, and in 12–22 s on a 4 s timeout.
|
|
12
12
|
- **Checks.** A check names the step of the connection that it examines (`dial`, `round-trip`, `checkout` and others). It also tells if it uses the connection of the application, and which policy it controls. The policy has no default.
|
|
13
13
|
- **Idempotency for each operation.** One dependency has the two kinds of operation. A Redis GET is safe to send again. An INCR that goes again after a timeout counts two times. Thus each operation of a dependency, and each route of a controller, declares if it can go again: `true`, `false`, or keyed.
|
|
@@ -36,17 +36,29 @@ The format of a description is `mark1russell7.service/1`.
|
|
|
36
36
|
|
|
37
37
|
`await svc.run()` starts the whole life of the service, and gives a `Lifecycle` when the startup ends. It refuses a declaration that it cannot start: a transport without its implementation, or a stage with work and no declared phase.
|
|
38
38
|
|
|
39
|
-
- **The parts.** `svc.server(name, knobs, impl)`, `svc.consumer(…)` and `svc.worker(…)` give a transport its implementation. Each transport has one contract: `start`, `stopIntake`, `drain(deadline, signal)`, `inFlight` and `close`. `svc.dependency(name, spec, handle)` gives a dependency a handle that connects and closes. `svc.healthServer(server)` gives the server of the probes.
|
|
39
|
+
- **The parts.** `svc.server(name, knobs, impl)`, `svc.consumer(…)` and `svc.worker(…)` give a transport its implementation. Each transport has one contract: `start(signal, life)`, `stopIntake`, `drain(deadline, signal)`, `inFlight` and `close`. `svc.dependency(name, spec, handle)` gives a dependency a handle that connects and closes. `svc.healthServer(server)` gives the server of the probes.
|
|
40
40
|
- **The startup.** The health server starts first, thus the probes answer while the dependencies connect. Then each dependency connects, in its declared sequence. Then each transport starts, and the startup gate opens last, after one warm-up sweep of each check. A part that does not start fails the startup. Then only the parts that started stop, and the exit code is 1.
|
|
41
41
|
- **The startup budget.** When the gate is not open at the end of its budget, the gate says so. The runtime does not stop itself, because the startup probe of the platform acts on it.
|
|
42
|
-
- **The signals.** The runtime owns the handlers of SIGINT, SIGTERM and SIGQUIT. The first signal starts the shutdown
|
|
42
|
+
- **The signals.** The runtime owns the handlers of SIGINT, SIGTERM and SIGQUIT. The first signal starts the shutdown. A signal during the startup gets its shutdown after the startup ends.
|
|
43
|
+
- **A repeat signal.** Each declaration tells what a signal during the shutdown does (`svc.onRepeatSignal`). With `"exit"`, the signal ends the process at once, with the code 1. With `"ignore"`, the signal has no effect, as in 0.4. A second `shutdown()` and a second process trigger do not end the process.
|
|
44
|
+
- **A declaration without the policy.** `describe()` refuses a declaration that does not declare `onRepeatSignal`. A description from another source can lack it, for example the JSON of 0.4. Then `lint()` gives a `depends` finding of the rule `declaration`, and `unbound` names the field.
|
|
45
|
+
- **Process triggers.** `svc.trigger("stdin-close")` starts the shutdown when the standard input ends, as when the parent of a stdio child stops. `"ipc-close"` starts it when the IPC channel to the parent closes. Each trigger is opt-in. The process must read its input, as a stdio transport does, or Node does not see the end.
|
|
43
46
|
- **The shutdown.** Readiness fails first. Then the declared phases occur in their sequence, and each phase races its cap. `svc.onPhase(name, work)` adds the work of an owner to a phase.
|
|
44
|
-
- **The stages.** A declared phase with the name of a stage gets the work of that stage. The stages are `fail readiness`, `stop intake`, `drain`, `close transports`, `stop the poller`, `tear down dependencies
|
|
45
|
-
- **The cooperative abort.** During the drain, the requests and the transports get a signal that aborts at the cap less the cooperative window.
|
|
46
|
-
- **Requests.** `life.request(controller, route, work)` does one request on a declared route. While the service cannot take new work, it refuses the request with a retryable 503. That occurs before the start, after the intake stops, or while the controller is not ready.
|
|
47
|
-
- **
|
|
47
|
+
- **The stages.** A declared phase with the name of a stage gets the work of that stage. The stages are `fail readiness`, `stop intake`, `drain`, `close transports`, `stop the poller`, `tear down dependencies`, `close the health server` and `flush the output`. The phase `fail readiness` holds for its cap while the endpoints update. The signal of the service aborts after the poller stops. `describe()` refuses the stages out of their sequence.
|
|
48
|
+
- **The cooperative abort.** During the drain, the requests and the transports get a signal that aborts at the cap less the cooperative window. With the floor `half`, that time is half the cap at the least, and a window without a bound gives half the cap. With `svc.cooperativeFloor("none")`, a window of the whole cap aborts the work at the start of the drain.
|
|
49
|
+
- **Requests.** `life.request(controller, route, work, { detail })` does one request on a declared route or route pattern. While the service cannot take new work, it refuses the request with a retryable 503. That occurs before the start, after the intake stops, or while the controller is not ready. A route that no route and no pattern declares throws, because it has no idempotency.
|
|
50
|
+
- **Each request is a stream.** The request gives a `RequestStream`: each item of the work, and then one end. A work that gives an async iterable is a stream, also through a promise. Each other value is a stream of one item. `stream.collect()` reads the items and the end.
|
|
51
|
+
- **The reader controls the work.** The stream asks the work for an item only when its reader asks for the next event. The request stays in flight until the iterator of the work ends. Thus the drain waits for an open stream. When the reader stops before the end, the work gets its abort and the `return()` of its iterator.
|
|
52
|
+
- **The end.** The end is `done`, `failed`, `refused` or `cut`, with the count of the delivered items. Before the first item, `refused` and `cut` have a status. After the first item, the end is in-band: the transport sent its answer, thus it writes the end as the last line of the stream.
|
|
53
|
+
- **Requests while the transports start.** Each transport gets the lifecycle at its start, and `svc.lifecycle` gives it from the start of `run()`. Thus a transport tracks its requests from its start. A request before the end of the startup gets a retryable 503, and `keepAlive()` is false.
|
|
54
|
+
- **Cut requests.** When the drain cuts a request, an idempotent route answers 503, retryable. Each other route answers 500, because the work can have had its effect. A keyed route is retryable only while the server keeps its key. After the first item, a cut has no status, and `retryable` tells the caller if it can send the request again.
|
|
55
|
+
- **The cooperative abort of a stream.** At the cooperative abort, the work gets its abort, and its iterator gets `return()`. A stream that ends after the abort ends as a cut. At the deadline of the drain, each request that is left gets a cut at once.
|
|
56
|
+
- **Route patterns.** `controller.pattern(pattern, operations, operationOf)` matches the routes that the exact routes do not name, in the sequence of the patterns. In a pattern, `*` matches each text, and `:name` matches one part without `/`. The declaration names each operation that a request of the pattern can be, with its idempotency.
|
|
57
|
+
- **The idempotency of each request.** The function `operationOf` gets the route, the parts of the pattern and the `detail` of the request. It gives the name of the operation, thus the retry rule and the answer of a cut. A pattern with one operation needs no function. `describe()` exports each pattern and its operations as data, and it checks them as it checks a route.
|
|
48
58
|
- **Keep-alive.** `life.keepAlive()` is false while the service is not ready. Thus each response then closes its connection.
|
|
49
59
|
- **The exit.** Only the lifecycle ends the process, through its `ProcessPort`. The exit code is 0 after a clean shutdown. It is 1 after an error: a startup that failed, a phase whose work did not end in its cap, or a work that threw. A phase over its cap does not stop the sequence.
|
|
60
|
+
- **Exit codes.** `life.shutdown(cause, { code })` gives the exit code of the work, a whole number from 0 to 255. The first request with a code sets it. `svc.exitCode(trigger, code)` gives the code of a shutdown that a trigger starts, for example 130 for SIGINT. A code that is not 0 stays after an error. A code of 0, or no code, becomes 1 after an error.
|
|
61
|
+
- **The output.** The stage `flush the output` comes last. The owners of the phase write their last output first, for example a log or a trace exporter. Then the `ProcessPort` writes the buffered output of stdout and stderr, in the cap of the stage. Without this phase, the process exits without a wait, as in 0.4.
|
|
50
62
|
- **The watchdog.** `svc.watchdog(spec)` declares a check on the event loop. A blocked loop delays each timer, thus a tick that comes late by more than the threshold fails the check. A liveness window then reads the wedge of the process.
|
|
51
63
|
|
|
52
64
|
## The machines
|
|
@@ -55,6 +67,115 @@ The machines of the runtime are `mark1russell7.machine/1` documents, which `@mar
|
|
|
55
67
|
|
|
56
68
|
The tests replay the real runtime against each document. They also compare each timer of the documents, from the algebra, with the time that the runtime keeps.
|
|
57
69
|
|
|
70
|
+
## Composition
|
|
71
|
+
|
|
72
|
+
A process has one service, and one entry that uses `run()`. A component gives parts to that service, and it does not start them. For example, a component is the procedures host of another repository, or one of the HTTP hosts of a process.
|
|
73
|
+
|
|
74
|
+
- **The parts.** A component gives a `TransportImpl` for each source of work, a `DependencyHandle` for each dependency that it owns, and each check with its `CheckFn`. It can also give work for a declared phase (`onPhase`).
|
|
75
|
+
- **The entry.** The entry declares one service: the parts of each component, the policies, the startup budget, the phases and the cooperative window. Only the entry uses `run()`. Thus the process has one set of signal handlers, one staged shutdown and one exit.
|
|
76
|
+
- **What a component does not do.** A component does not use `start()` or `run()`. It installs no signal handler, and it does not end the process. A service has one health server, thus the entry chooses it.
|
|
77
|
+
- **Names.** A service refuses a second part of one kind with the same name. Thus the entry gives each component a prefix, for example `app.http` and `ingest.http`.
|
|
78
|
+
- **The sequence.** The lifecycle connects the dependencies of all components in their declared sequence, and then it starts each transport. Each stage of the shutdown acts on the transports of all components together. The dependencies close in the reverse sequence.
|
|
79
|
+
- **Requests.** The transports start before the startup ends, but `run()` gives the `Lifecycle` only at that end. Thus each transport gets the lifecycle at its start (`start(signal, life)`), and its request tracking operates at once.
|
|
80
|
+
- **The builder form.** A component can also take the builder of the entry and declare its parts on it. The Redis client does this: `attach(svc, options)` declares its dependency, its handle and its checks.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import type { Extent } from "@mark1russell7/quantity";
|
|
84
|
+
import { service } from "@mark1russell7/service";
|
|
85
|
+
import type { Check, CheckFn, Dependency, DependencyHandle, ServiceBuilder, TransportImpl } from "@mark1russell7/service";
|
|
86
|
+
|
|
87
|
+
// the parts of a component, as data: the component declares nothing, and it does not run
|
|
88
|
+
interface Parts {
|
|
89
|
+
readonly servers: ReadonlyArray<{ name: string; knobs: Record<string, Extent>; impl: TransportImpl }>;
|
|
90
|
+
readonly dependencies: ReadonlyArray<{ name: string; spec: Omit<Dependency, "name">; handle: DependencyHandle }>;
|
|
91
|
+
readonly checks: ReadonlyArray<{ check: Check; run: CheckFn }>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const declare = (svc: ServiceBuilder, parts: Parts): void => {
|
|
95
|
+
for (const { name, spec, handle } of parts.dependencies) svc.dependency(name, spec, handle);
|
|
96
|
+
for (const { name, knobs, impl } of parts.servers) svc.server(name, knobs, impl);
|
|
97
|
+
for (const { check, run } of parts.checks) svc.check(check, run);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const s = (value: number) => ({ value, unit: "s" });
|
|
101
|
+
const svc = service("app");
|
|
102
|
+
// three hosts in one process: each component gives its parts under its own prefix
|
|
103
|
+
for (const [prefix, port] of [["app", 4820], ["ingest", 4830], ["remote", 4840]] as const)
|
|
104
|
+
declare(svc, createHost({ prefix, port }));
|
|
105
|
+
svc
|
|
106
|
+
.healthServer(probes) // the entry chooses the one health server of the process
|
|
107
|
+
.readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
|
|
108
|
+
.liveness({ type: "window", failAfter: s(45) })
|
|
109
|
+
.startup(s(60))
|
|
110
|
+
.phase("fail readiness", s(2))
|
|
111
|
+
.phase("stop intake", s(5))
|
|
112
|
+
.phase("drain", s(15))
|
|
113
|
+
.phase("close transports", s(2))
|
|
114
|
+
.phase("stop the poller", s(1))
|
|
115
|
+
.phase("tear down dependencies", s(2))
|
|
116
|
+
.phase("close the health server", s(1))
|
|
117
|
+
.cooperativeWindow(s(5))
|
|
118
|
+
.onRepeatSignal("ignore");
|
|
119
|
+
|
|
120
|
+
const life = await svc.run(); // only the entry calls run(), and the process exits after the shutdown
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The requirements that the lifecycle follows are in [the requirements of S6](https://github.com/mark1russell7/systems/blob/main/docs/S6.md).
|
|
124
|
+
|
|
125
|
+
## Profiles
|
|
126
|
+
|
|
127
|
+
A profile is a part of a declaration as data: the phases, the cooperative window, the policies and the rules of the shutdown. `svc.profile(profile)` declares each field of a profile, as the method of that field does. The runtime still has no defaults, because the caller gives the profile. The caller changes a field with a spread, and `describe()` shows the values.
|
|
128
|
+
|
|
129
|
+
| Profile | For | What it declares |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `localDev` | A service on the computer of a developer | No readiness hold, caps that sum to 10 s, a flush of the output, and an exit on a second signal |
|
|
132
|
+
| `daemon` | A background host on the computer of a developer | The caps of `localDev`, no effect for a repeat signal, and no process trigger |
|
|
133
|
+
| `kubernetes({ grace, preStop })` | A pod | Caps from the grace less the preStop sleep, and no effect for a repeat signal |
|
|
134
|
+
| `ephemeral` | A command that runs one time, or a stdio child | No gating checks, an abort at the start of the drain, a flush of the output, and the exit codes 130, 143 and 131 |
|
|
135
|
+
|
|
136
|
+
- **Kubernetes.** The budget is the grace less the preStop sleep: the time from SIGTERM to SIGKILL. The stages get 2, 5, 15, 2, 1, 2 and 1 thirtieths of the budget, rounded down to the millisecond. The cooperative window is 5 thirtieths. Thus 2 thirtieths stay as a margin, and O10 and O16 hold.
|
|
137
|
+
- **The defaults of Kubernetes.** At a grace of 30 s and no preStop hook, the caps are 2, 5, 15, 2, 1, 2 and 1 s, with a window of 5 s. These are the caps of jarvis and of the kind lab.
|
|
138
|
+
- **Local development.** No load balancer routes to a computer of a developer, thus the profile has no readiness hold. A second Ctrl-C exits at once.
|
|
139
|
+
- **A background host.** A supervisor or a command stops the host with a signal. A repeat signal has no effect, thus the host ends its drain. The host has no parent on stdin, thus the profile declares no process trigger.
|
|
140
|
+
- **The repeat signal.** Each profile declares `onRepeatSignal`: `"exit"` for `localDev` and `ephemeral`, and `"ignore"` for `daemon` and `kubernetes`.
|
|
141
|
+
- **The policies.** `localDev` and `kubernetes` do not declare readiness, liveness or the startup budget, because they depend on the checks of the service.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { kubernetes, service } from "@mark1russell7/service";
|
|
145
|
+
|
|
146
|
+
const s = (value: number) => ({ value, unit: "s" });
|
|
147
|
+
const svc = service("app")
|
|
148
|
+
.profile({ ...kubernetes({ grace: s(60), preStop: s(5) }), onRepeatSignal: "exit" })
|
|
149
|
+
.readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
|
|
150
|
+
.liveness({ type: "window", failAfter: s(45) })
|
|
151
|
+
.startup(s(60));
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### A small process
|
|
155
|
+
|
|
156
|
+
A command that runs one time and a stdio child also use `run()`. Then the process has one shutdown and one exit, with the code of its work. The minimal declaration has no probes, no health server and no checks.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { ephemeral, service } from "@mark1russell7/service";
|
|
160
|
+
|
|
161
|
+
// a stdio child: the end of stdin starts the staged shutdown
|
|
162
|
+
const mcp = service("mcp").profile({ ...ephemeral, triggers: ["stdin-close"] }).server("stdio", {}, stdio);
|
|
163
|
+
mcp.controller("tools").route("tools/call", { idempotent: false });
|
|
164
|
+
await mcp.run();
|
|
165
|
+
|
|
166
|
+
// a command: it runs one time, and the process exits with its code
|
|
167
|
+
const tool = service("tool").profile(ephemeral);
|
|
168
|
+
tool.controller("command").route("RUN", { idempotent: false });
|
|
169
|
+
const life = await tool.run();
|
|
170
|
+
const { items, end } = await life.request("command", "RUN", (signal) => main(signal)).collect();
|
|
171
|
+
await life.shutdown("the command ended", { code: end.kind === "done" ? items[0] : 1 });
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- **The declaration.** The profile `ephemeral` gives the policies, the startup budget, the phases and the rules of the shutdown. The recipe passes `describe()` and `lint()`, and a test holds it to them.
|
|
175
|
+
- **Ctrl-C.** The floor of the cooperative abort is `none`, and the window is the whole drain. Thus a first Ctrl-C stops the work at once, and the process exits 130. A second Ctrl-C exits 1 at once.
|
|
176
|
+
- **The output.** The stage `flush the output` waits for stdout and stderr. Thus the last lines of a command are not cut.
|
|
177
|
+
- **The dependencies.** The phase `tear down dependencies` caps the close of the dependencies at 2 s.
|
|
178
|
+
|
|
58
179
|
## The timers
|
|
59
180
|
|
|
60
181
|
The clock, the heartbeat and the backoff have their own entries. Each entry imports no `node:` module, no zod and no other package. Thus a client in a browser can use them. Each entry is less than 1 kB of gzip. The root of the package also gives each function.
|
|
@@ -94,15 +215,108 @@ socket.addEventListener("message", (event) => {
|
|
|
94
215
|
const delay = backoffDelay({ base: 1000, factor: 1.5, cap: 30_000, jitter: { kind: "subtract", fraction: 1 } }, attempt);
|
|
95
216
|
```
|
|
96
217
|
|
|
218
|
+
## The HTTP edge
|
|
219
|
+
|
|
220
|
+
`@mark1russell7/service/http` is the edge of a service in Node. Of the modules of Node, it imports only `node:http` and `node:net`. The root of the package does not import it. It is not a framework: it has no routing language, no body parser and no middleware.
|
|
221
|
+
|
|
222
|
+
| Export | Function |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `httpTransport(server, knobs, options)` | A server of `node:http` as a transport of the lifecycle, with a list of mounts |
|
|
225
|
+
| `probeServer({ port, host, server_retry_after })` | A health server on a port of its own |
|
|
226
|
+
| `probeHandler(health, { prefix })` | The probes as a request handler on the port of the application |
|
|
227
|
+
| `handle(controller, route, work, options)` | A request handler that does each request of a route through `life.request` |
|
|
228
|
+
| `writeOutcome(res, stream, options)` | The stream of a request as a response of `node:http`, unary or a stream |
|
|
229
|
+
| `ndjson`, `sse` | The formats of a stream: NDJSON, and server-sent events |
|
|
230
|
+
| `outcomeResponse`, `endRecord`, `probeResponse`, `pathOf` | The same answers as data, for any HTTP framework or none |
|
|
231
|
+
|
|
232
|
+
- **The transport.** `httpTransport(server, knobs, options)` makes a server of `node:http` a transport of the lifecycle. Give `transport.knobs` to `svc.server(name, knobs, impl)`. Thus the declaration and the law read the same knobs.
|
|
233
|
+
- **The knobs.** The transport has no defaults. `server_idle_sweep_interval` is the interval of the sweep of idle connections. `server_retry_after` goes in the `Retry-After` of each 503 of the transport.
|
|
234
|
+
- **The idle close.** `server_idle_close` is the real idle close of the server, which O01 compares with the idle time of a client. Node closes an idle connection at `keepAliveTimeout` plus `keepAliveTimeoutBuffer`. Thus the transport sets `keepAliveTimeout` to `server_idle_close` less the buffer of the server.
|
|
235
|
+
- **The hint.** The buffer stays, thus the hint of each response (`Keep-Alive: timeout=…`) is less than the real close. A Node without a buffer gives 0. The transport refuses a `server_idle_close` at or below the buffer. A time without a bound stops the timer.
|
|
236
|
+
- **The three times.** `transport.knobs` adds `server_idle_close_hint` and `server_idle_close_buffer` to the knobs of the caller. Thus the declaration shows the hint, the buffer and the real close.
|
|
237
|
+
- **The count.** The count of the work in flight comes from events. Each response counts until its `close` event, and each upgraded connection counts until it closes. The transport polls nothing.
|
|
238
|
+
- **The stop of the intake.** `stopIntake()` closes the listener, thus each new connection gets a refusal. It closes the idle connections at once, and again at each `server_idle_sweep_interval`. Each later response gets `Connection: close`, and each later request gets a retryable 503.
|
|
239
|
+
- **The drain and the close.** `drain(deadline, signal)` ends when no work is in flight, or at the deadline. It gives the cooperative abort to the handlers. `close()` destroys each connection, the upgraded connections too. A second close gives the same promise.
|
|
240
|
+
- **The lifecycle.** The transport gets the lifecycle at its start (`start(signal, life)`), before the warm-up. Each handler gets it in its context (`context.life`), with `server_retry_after` (`context.retryAfter`).
|
|
241
|
+
- **Keep-alive.** The transport reads `life.keepAlive()` when the headers of each response go, or the option `keepAlive` in its place. While it gives false, each response gets `Connection: close`. That occurs during the warm-up too. An upgrade does not get it.
|
|
242
|
+
- **The clock.** Give the transport the clock of the lifecycle (`options.clock`). The deadline of the drain and the sweep use it.
|
|
243
|
+
- **The mounts.** `transport.mount(handler)` adds a request handler at the end of the list. The first handler that gives true answers the request. A handler that gives false lets the next handler try, and a request that no handler takes gets 404.
|
|
244
|
+
- **The upgrades.** `transport.upgrade(path, handler)` gives an upgrade to its handler, by its path or by a function. An upgrade that no handler takes gets 404. Thus several faces and the routes of the application share one port.
|
|
245
|
+
- **One owner of the listener.** The transport refuses a server that has a request or upgrade listener of its own. Mount that listener as a handler: `transport.mount((req, res) => (app(req, res), true))`.
|
|
246
|
+
- **The signals of a handler.** `context.intake` aborts when the intake stops, and `context.signal` aborts at the cooperative abort or at the close. A long stream ends in its own protocol at them.
|
|
247
|
+
- **A mount through the drain.** A mount with `{ until: "close transports" }` answers until the close. While it is mounted, the listener stays open through the drain, and each other request gets a retryable 503.
|
|
248
|
+
- **The probes on their own port.** `probeServer({ port, host, server_retry_after })` is a health server. It starts first and closes last, thus its probes answer through the whole shutdown. It has no defaults, thus `server_retry_after` is necessary, and each 503 of a probe gets its `Retry-After`.
|
|
249
|
+
- **The probes on the port of the application.** `probeHandler(health, { prefix })` answers the same probes. Its mount answers until `close transports`, thus it answers through the drain. After the close of the transports, only a probe server answers.
|
|
250
|
+
- **The probe paths.** The paths are `/startup`, `/readiness`, `/readiness/<controller>`, `/liveness` and `/report`, after the prefix. Each answer comes from the cache, as `application/health+json` with 200 or 503.
|
|
251
|
+
- **A route through the request tracking.** `handle(controller, route, work, options)` gives a request handler. Each request that it takes goes through `life.request`, thus the service admits it, counts it and can cut it.
|
|
252
|
+
- **The route of a handler.** A route name takes a request whose method and path are that name, for example `POST /orders`. A function gives the name of a route or of a pattern (`controller.pattern`), or null for a request of another handler. `options.detail` gives the detail of a request for the function of a pattern.
|
|
253
|
+
- **The work.** The work gets the request and a signal. The signal aborts when the shutdown cuts the request, at the cooperative abort, or when the client goes away.
|
|
254
|
+
- **A unary response.** Without `options.stream`, the response waits for the end of the work. A value gets 200, and no value gets 204. A work that gives more than one value gets 500.
|
|
255
|
+
- **A stream response.** With `options.stream` (`ndjson` or `sse`), the status 200 goes with the first item. Then each item goes as it comes, and the next item waits while the client reads slowly.
|
|
256
|
+
- **The end of a stream.** An end after the first item goes in the body, as the last record (`endRecord`), with no status of its own. The cooperative abort and a cut end a stream in this way. When the client goes away, the work stops through `return()`.
|
|
257
|
+
- **An end before the first byte.** A refusal gets 503: the service did not start, it stopped its intake, or a controller is not ready. A cut gets 503 for an idempotent route (A12), and 500 for each other route. A failure of the route gets 500 with no detail, and not 503.
|
|
258
|
+
- **Each 503 gets `Retry-After`.** Each 503 gets the `Retry-After` of `server_retry_after` (RFC 9110), and a 500 gets none. The transport applies the rule when the headers of each response go, whatever wrote them. Thus the rule applies to a probe, to a refusal of the transport and to a handler that writes its own status.
|
|
259
|
+
- **The rule for a handler.** A 503 without `Retry-After` gets it, and a 503 with its own `Retry-After` keeps it. A 500 loses each `Retry-After`, from `setHeader` and from `writeHead`. The other statuses keep their headers.
|
|
260
|
+
- **Another framework.** `writeOutcome(res, stream, options)` writes the stream of any `life.request` to a response of `node:http`. `outcomeResponse(items, end, options)` gives a unary response as data.
|
|
261
|
+
- **Tracing in an ESM service.** An ESM service that uses OpenTelemetry starts with a preload, for example `node --import ./instrumentation.js app.js`. The preload must register `@opentelemetry/instrumentation/hook.mjs` before the application loads. Without that hook, the instrumentation does not see the ESM imports, and the spans of express are lost. The telemetry check of Graph found this when its server moved to ESM.
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
// instrumentation.js, the preload: node --import ./instrumentation.js app.js
|
|
265
|
+
import { register } from "node:module";
|
|
266
|
+
register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);
|
|
267
|
+
// then start the SDK of OpenTelemetry, before the application loads
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { createServer } from "node:http";
|
|
272
|
+
import { service } from "@mark1russell7/service";
|
|
273
|
+
import { handle, httpTransport, ndjson, probeServer } from "@mark1russell7/service/http";
|
|
274
|
+
|
|
275
|
+
const s = (value: number) => ({ value, unit: "s" });
|
|
276
|
+
const http = httpTransport(
|
|
277
|
+
createServer(),
|
|
278
|
+
{ server_idle_close: s(65), server_idle_sweep_interval: s(1), server_retry_after: s(1) },
|
|
279
|
+
{ listen: { port: 8080 } },
|
|
280
|
+
);
|
|
281
|
+
// a unary route: one status and one value
|
|
282
|
+
http.mount(handle("orders", "POST /orders", (req, signal) => createOrder(req, signal)));
|
|
283
|
+
// a stream: each row as it comes, and the end as the last line
|
|
284
|
+
http.mount(handle("orders", "GET /orders/export", (_req, signal) => exportRows(signal), { stream: ndjson }));
|
|
285
|
+
// a WebSocket server in its noServer mode shares the port
|
|
286
|
+
http.upgrade("/ws", (req, socket, head) => wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)));
|
|
287
|
+
|
|
288
|
+
const app = service("app")
|
|
289
|
+
.dependency("db", { system: null, knobs: {}, operations: {} }, { connect: () => db.connect(), close: () => db.end() })
|
|
290
|
+
.server("http", http.knobs, http)
|
|
291
|
+
.healthServer(probeServer({ port: 9090, server_retry_after: s(1) }))
|
|
292
|
+
.readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
|
|
293
|
+
.liveness({ type: "window", failAfter: s(45) })
|
|
294
|
+
.startup(s(60))
|
|
295
|
+
.phase("fail readiness", s(2))
|
|
296
|
+
.phase("stop intake", s(5))
|
|
297
|
+
.phase("drain", s(15))
|
|
298
|
+
.phase("close transports", s(2))
|
|
299
|
+
.phase("stop the poller", s(1))
|
|
300
|
+
.phase("tear down dependencies", s(2))
|
|
301
|
+
.phase("close the health server", s(1))
|
|
302
|
+
.cooperativeWindow(s(5))
|
|
303
|
+
.onRepeatSignal("ignore");
|
|
304
|
+
app.controller("orders").requires("db").route("POST /orders", { idempotent: false }).route("GET /orders/export", { idempotent: true });
|
|
305
|
+
|
|
306
|
+
const life = await app.run(); // the process exits after SIGTERM, with 0 or 1
|
|
307
|
+
```
|
|
308
|
+
|
|
97
309
|
## The testing subpath
|
|
98
310
|
|
|
99
311
|
`@mark1russell7/service/testing` has the seams that a test of a service needs:
|
|
100
312
|
|
|
101
313
|
- `fakeClock()` gives a clock that moves only on `advance(ms)`. Pass it to `start({ clock })`, and the timing of the runtime is exact and repeatable. `block(ms)` moves the clock and starts no timer, as a blocked event loop does.
|
|
102
|
-
- `fakeProcess()` gives a process port that takes signals from a test
|
|
314
|
+
- `fakeProcess({ flush, clock })` gives a process port that takes signals and process triggers from a test (`signal(name)`, `trigger(name)`). It keeps each exit code in place of an exit. `flush` is its flush of the output, and `log` gives the time of each flush and each exit.
|
|
103
315
|
- `seeded(seed)` gives a seeded random number generator for the jitter (`start({ random })`).
|
|
104
316
|
- `scriptedCheck(behavior)` gives a check whose behavior a test sets: `"ok"`, `"error"`, `"indeterminate"`, `"throw"`, `"timeout"` or `"hang"`.
|
|
105
317
|
- `quietServer()` starts a TCP echo server that can go quiet. Its connections stay open while it is quiet, as with a frozen peer.
|
|
318
|
+
- `detachedLifecycle()` gives a lifecycle with no service, for the start of a transport in a test.
|
|
319
|
+
- `transportConformance(make, options)` generates the conformance test of a transport. `make` gives a subject: the transport, a way to offer new work and a way to hold work in flight. The cases check that `stopIntake` refuses new work, and that the count is correct and comes from events. They also check that `drain` holds to its deadline and gives the cooperative abort, and that `close` is idempotent and bounded. The fake clock makes the cases fast, and the real clock measures them.
|
|
106
320
|
- `contractCases(declare)` generates the contract test of a declaration. The cases are the service at rest, each dependency unavailable, each dependency degraded by 100 ms, each recovery, and a negative control. A dependency that no check exercises is a finding, and so is a check whose timeout a latency of 100 ms fails.
|
|
107
321
|
|
|
108
322
|
## The Redis client
|
|
@@ -124,12 +338,12 @@ const delay = backoffDelay({ base: 1000, factor: 1.5, cap: 30_000, jitter: { kin
|
|
|
124
338
|
## Installation
|
|
125
339
|
|
|
126
340
|
```sh
|
|
127
|
-
pnpm add @mark1russell7/service
|
|
341
|
+
pnpm add @mark1russell7/service
|
|
128
342
|
```
|
|
129
343
|
|
|
130
344
|
For the Redis client, the application also installs `ioredis@6`.
|
|
131
345
|
|
|
132
|
-
The package uses [`@mark1russell7/quantity`](https://www.npmjs.com/package/@mark1russell7/quantity) for each amount
|
|
346
|
+
The package uses [`@mark1russell7/quantity`](https://www.npmjs.com/package/@mark1russell7/quantity) for each amount. No entry of the service loads `zod` at run time, thus `zod` is an optional peer dependency. An application that validates amounts with the schemas of `quantity` installs `zod`.
|
|
133
347
|
|
|
134
348
|
## Example
|
|
135
349
|
|
|
@@ -158,7 +372,8 @@ const svc = service("app")
|
|
|
158
372
|
.couple({ from: "liveness", to: "readiness", effect: "fails" })
|
|
159
373
|
.phase("fail readiness", s(2))
|
|
160
374
|
.phase("drain", s(15))
|
|
161
|
-
.cooperativeWindow(s(5))
|
|
375
|
+
.cooperativeWindow(s(5))
|
|
376
|
+
.onRepeatSignal("ignore");
|
|
162
377
|
|
|
163
378
|
const rules: Rule[] = []; // the rules of the law, as data
|
|
164
379
|
const scenario = {
|
|
@@ -171,13 +386,18 @@ const health = await svc.start();
|
|
|
171
386
|
const answer = health.probe("readiness"); // { status: 200, contentType, body }
|
|
172
387
|
```
|
|
173
388
|
|
|
174
|
-
A service that starts its whole lifecycle declares its parts and each stage of its shutdown
|
|
389
|
+
A service that starts its whole lifecycle declares its parts and each stage of its shutdown. [The HTTP edge](#the-http-edge) has a whole example, with the mounts and the probes.
|
|
175
390
|
|
|
176
391
|
```ts
|
|
392
|
+
const http = httpTransport(
|
|
393
|
+
createServer(),
|
|
394
|
+
{ server_idle_close: s(65), server_idle_sweep_interval: s(1), server_retry_after: s(1) },
|
|
395
|
+
{ listen: { port: 8080 } },
|
|
396
|
+
);
|
|
177
397
|
const app = service("app")
|
|
178
398
|
.dependency("db", { system: null, knobs: {}, operations: {} }, { connect: () => db.connect(), close: () => db.end() })
|
|
179
|
-
.server("http",
|
|
180
|
-
.healthServer(probeServer)
|
|
399
|
+
.server("http", http.knobs, http)
|
|
400
|
+
.healthServer(probeServer({ port: 9090, server_retry_after: s(1) }))
|
|
181
401
|
.check(roundTrip, roundTripFn)
|
|
182
402
|
.readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
|
|
183
403
|
.liveness({ type: "window", failAfter: s(45) })
|
|
@@ -189,11 +409,32 @@ const app = service("app")
|
|
|
189
409
|
.phase("stop the poller", s(1))
|
|
190
410
|
.phase("tear down dependencies", s(2))
|
|
191
411
|
.phase("close the health server", s(1))
|
|
192
|
-
.cooperativeWindow(s(5))
|
|
412
|
+
.cooperativeWindow(s(5))
|
|
413
|
+
.onRepeatSignal("ignore");
|
|
193
414
|
app.controller("orders").requires("db").route("POST /orders", { idempotent: false });
|
|
194
415
|
|
|
195
416
|
const life = await app.run(); // the process exits after SIGTERM, with 0 or 1
|
|
196
|
-
const
|
|
417
|
+
const { items, end } = await life.request("orders", "POST /orders", (signal) => createOrder(signal)).collect();
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A registry that changes while the service runs declares a pattern, and each request a stream:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
app
|
|
424
|
+
.controller("procedures")
|
|
425
|
+
.requires("db")
|
|
426
|
+
.pattern(
|
|
427
|
+
"POST /rpc/:procedure",
|
|
428
|
+
{ read: { idempotent: true }, write: { idempotent: false } },
|
|
429
|
+
({ detail }) => ((detail as Procedure).readOnly ? "read" : "write"),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const stream = life.request("procedures", "POST /rpc/logs.tail", (signal) => tail(signal), { detail: procedure });
|
|
433
|
+
for await (const event of stream) {
|
|
434
|
+
if (event.kind === "item") write(JSON.stringify(event.value));
|
|
435
|
+
else if (event.delivered > 0) write(JSON.stringify({ end: event.kind, delivered: event.delivered })); // in-band
|
|
436
|
+
else respond(event); // a status: 503, 500 or the error of the route
|
|
437
|
+
}
|
|
197
438
|
```
|
|
198
439
|
|
|
199
440
|
## Tests
|
|
@@ -213,7 +454,16 @@ const outcome = await life.request("orders", "POST /orders", (signal) => createO
|
|
|
213
454
|
- **The Redis client.** These tests use a fake ioredis and a fake clock. They cover the declaration, the three outcomes, and the commands that the client sends again or not. They also cover the join, the backoff, the watchdog, the close, the checks and the manifestations. Five scenarios replay against the document of the client.
|
|
214
455
|
- **F6 for Redis.** The fault matrix crosses each manifestation (a refusal, a frozen process, a black hole, a death without a close, and an app stall), each gating posture, two timings and two jitters. That is 60 cases on the fake. Each case holds the trip and the release of its policy to `failSpan` and `recoverSpan`. Five more cases shut the service down during each manifestation, and each one exits 0.
|
|
215
456
|
- **The timers.** These tests use a fake clock, and they cover the hooks of the heartbeat and its two kinds of ping. A property test holds the death of a silent peer to `detectionTime` in 300 seeded cases. Another one compares the heartbeat with a WebSocket heartbeat that sends one ping at a time, on 300 schedules of pongs. The backoff gives the delays of three earlier backoffs, number for number, and each delay is in its band.
|
|
216
|
-
- **The entries.** The timer entries reach no other package, no `node:` module and no zod.
|
|
457
|
+
- **The entries.** The timer entries reach no other package, no `node:` module and no zod. No entry of the service reaches zod, also through `quantity` and the replay of the algebra. The root reaches no `node:` module. The HTTP edge reaches only `node:http`, `node:net` and `quantity`.
|
|
458
|
+
- **The requests.** A unary work is a stream of one item, and a stream gives each item when its reader asks. The drain waits for an open stream. The cooperative abort ends a stream through `return()`, and a cut or a failure after the first item ends in-band. A reader that stops early stops the work. The function of a route pattern decides the answer of each cut request.
|
|
459
|
+
- **The options of the runner.** These tests cover the requests of a transport while the startup runs, the policy of a repeat signal, the profiles and the process triggers. They also cover the exit codes, a first Ctrl-C that aborts at once, and the flush of the output.
|
|
460
|
+
- **The composition.** Two components, each with a transport, a dependency and a check, share one service and one `run()`. The parts start and stop in their sequence, and the process exits one time.
|
|
461
|
+
- **The process port.** The port of Node, on a fake host: the signals, the end of stdin, the close of the IPC channel, the flush and the exit.
|
|
462
|
+
- **The HTTP edge.** The transport, the mounts, the upgrades, the probes and `handle` run on fake servers and the fake clock, with no socket. A service under `run()` drains a held request, and its probes answer through the drain. A request during the warm-up gets 503, and a stream ends in-band after its first item.
|
|
463
|
+
- **The headers of the outcomes.** Each path of the request tracker gets its status. Each 503 gets `Retry-After`: the refusals, the retryable cut and the cut of a unary stream. No 500 gets it.
|
|
464
|
+
- **The headers on the port.** The transport adds `Retry-After` to each 503 of a probe and of a handler that writes its own status, in each form of `writeHead`. It takes `Retry-After` from each 500. The probe server gives its 503s the time of its knob.
|
|
465
|
+
- **The conformance of a transport.** A reference transport passes each case, and each of nine mutations of it fails the case of its part. The HTTP transport passes each case on a fake server, for its requests and its upgrades.
|
|
466
|
+
- **On real sockets.** `HTTP_IT=1` starts the tests of the HTTP edge on the loopback. They cover the conformance on the fake clock and on the real clock, keep-alive and the stop of the intake. They also cover the upgrades, the probe server and a service after SIGTERM. They measure time, thus only CI starts them (`http.yml`), on Node 22.19, 24 and 26. CI keeps the raw records of each job as an artifact.
|
|
217
467
|
- **Against a real Redis.** `REDIS_IT=1` starts the tests of the client against Redis 7.4. `REDIS_F6=1` starts the F6 matrix in the VM of Docker. The injector of `@mark1russell7/harness` makes the network faults, and an agent in the namespaces of Redis makes the process faults. All of them use one monotonic clock.
|
|
218
468
|
|
|
219
469
|
`pnpm --filter @mark1russell7/service build` writes the package for npm into `build/`.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { n as Clock } from "./clock-D-38uH_z.js";
|
|
2
|
+
import { B as Gate, D as Outcome, E as CheckFn, F as CooperativeFloor, H as RepeatSignal, I as Coupling, L as Dependency, N as Check, R as ExitCodes, T as StartOptions, U as ServiceDescription, V as Phase, Y as Timing, _t as Operation, d as TransportImpl, dt as ProcessTrigger, k as Trace, n as HealthServer, o as PhaseWork, ot as ExitTrigger, q as Poll, r as Lifecycle, s as RunOptions, t as DependencyHandle, tt as OperationOf, x as Health } from "./lifecycle-DIj4YGmO.js";
|
|
3
|
+
import { Extent, Quantity } from "@mark1russell7/quantity";
|
|
4
|
+
//#region src/profiles.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A profile is a part of a declaration as data: policies, phases and the rules of the shutdown.
|
|
7
|
+
* `svc.profile(profile)` declares each field, as its method does. The runtime has no defaults, and
|
|
8
|
+
* a profile is not a default, because the caller gives it. The caller can change each field with a
|
|
9
|
+
* spread. `describe()` then shows the values of the profile.
|
|
10
|
+
*
|
|
11
|
+
* svc.profile({ ...kubernetes({ grace: s(60), preStop: s(5) }), onRepeatSignal: "exit" });
|
|
12
|
+
*/
|
|
13
|
+
interface Profile {
|
|
14
|
+
readonly readiness?: Timing | undefined;
|
|
15
|
+
readonly liveness?: Timing | undefined;
|
|
16
|
+
readonly startup?: Quantity | undefined;
|
|
17
|
+
readonly couplings?: readonly Coupling[] | undefined;
|
|
18
|
+
/** These are the phases of the shutdown, in their sequence. */
|
|
19
|
+
readonly phases?: readonly Phase[] | undefined;
|
|
20
|
+
readonly cooperativeWindow?: Extent | undefined;
|
|
21
|
+
readonly cooperativeFloor?: CooperativeFloor | undefined;
|
|
22
|
+
readonly onRepeatSignal?: RepeatSignal | undefined;
|
|
23
|
+
readonly triggers?: readonly ProcessTrigger[] | undefined;
|
|
24
|
+
readonly exitCodes?: ExitCodes | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* This profile is for a service on the computer of a developer. No load balancer routes to it,
|
|
28
|
+
* thus it has no readiness hold. Its caps sum to 10 s, and a second signal exits at once. It
|
|
29
|
+
* declares no checks, thus the caller declares readiness, liveness and the startup budget.
|
|
30
|
+
*/
|
|
31
|
+
declare const localDev: Profile;
|
|
32
|
+
/**
|
|
33
|
+
* This profile is for a background host on the computer of a developer, under a supervisor or a
|
|
34
|
+
* command that stops it. It has the caps of `localDev`. A repeat signal has no effect, thus the
|
|
35
|
+
* host ends its drain. It has no process trigger: a background host has no parent on stdin.
|
|
36
|
+
*/
|
|
37
|
+
declare const daemon: Profile;
|
|
38
|
+
/**
|
|
39
|
+
* This profile is for a small process: a command that runs one time, or a child process on stdio.
|
|
40
|
+
* It has no checks and no probes, and its policies gate nothing. The cooperative abort occurs at the
|
|
41
|
+
* start of the drain, thus a first Ctrl-C stops the work at once. A second signal exits at once.
|
|
42
|
+
* The exit codes tell the shell how the process stopped: 130 for SIGINT, 143 for SIGTERM and 131
|
|
43
|
+
* for SIGQUIT. A stdio child also declares `triggers: ["stdin-close"]`.
|
|
44
|
+
*/
|
|
45
|
+
declare const ephemeral: Profile;
|
|
46
|
+
/** These are the settings of a pod that the profile `kubernetes` derives its caps from. */
|
|
47
|
+
interface PodTiming {
|
|
48
|
+
/** This time is `terminationGracePeriodSeconds`: from the deletion of the pod to SIGKILL. */
|
|
49
|
+
readonly grace: Quantity;
|
|
50
|
+
/** This time is the preStop sleep, or 0 for no preStop hook. SIGTERM comes after it. */
|
|
51
|
+
readonly preStop: Quantity;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* This function gives the profile of a pod in Kubernetes. The budget is the grace less the preStop
|
|
55
|
+
* sleep: the time from SIGTERM to SIGKILL. Each stage gets a part of the budget, rounded down to
|
|
56
|
+
* the millisecond.
|
|
57
|
+
* - The parts in thirtieths are 2, 5, 15, 2, 1, 2 and 1, in the sequence of the stages. Thus 2
|
|
58
|
+
* thirtieths of the budget stay as a margin before SIGKILL.
|
|
59
|
+
* - The cooperative window is 5 thirtieths of the budget.
|
|
60
|
+
* - The kubelet sends SIGTERM one time, thus a repeat signal has no effect.
|
|
61
|
+
*
|
|
62
|
+
* At the defaults of Kubernetes (a grace of 30 s and no preStop hook), the caps are 2, 5, 15, 2, 1,
|
|
63
|
+
* 2 and 1 s. Thus the sum of the caps and the preStop sleep fits in the grace (O10). The window is
|
|
64
|
+
* shorter than the drain, which is shorter than the budget (O16).
|
|
65
|
+
*/
|
|
66
|
+
declare const kubernetes: (pod: PodTiming) => Profile;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/watchdog.d.ts
|
|
69
|
+
/**
|
|
70
|
+
* A watchdog spec declares a watchdog of the event loop. The service declares it as a check whose
|
|
71
|
+
* function the watchdog gives: the process's own wedge, which a liveness window reads.
|
|
72
|
+
*/
|
|
73
|
+
interface WatchdogSpec {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
/** This time is the interval of the tick of the watchdog. */
|
|
76
|
+
readonly tick: Quantity;
|
|
77
|
+
/** This time is the most that a tick can be late before the loop counts as blocked. */
|
|
78
|
+
readonly threshold: Quantity;
|
|
79
|
+
/** This gate is the policy that the check controls, or null for a check that only reports. */
|
|
80
|
+
readonly gates: Gate | null;
|
|
81
|
+
readonly poll: Poll;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A watchdog measures how late its own tick comes. A blocked event loop delays each timer, thus a
|
|
85
|
+
* tick that comes later than the threshold shows the block. Its states are `watching`, `blocked`
|
|
86
|
+
* and `stopped`.
|
|
87
|
+
*/
|
|
88
|
+
interface Watchdog {
|
|
89
|
+
/** This function is the check: it fails when a tick since the last poll came late, or the loop is still blocked. */
|
|
90
|
+
readonly check: () => Outcome;
|
|
91
|
+
/** This time is the most that a tick was late, in milliseconds, since the start. */
|
|
92
|
+
readonly worst: number;
|
|
93
|
+
readonly blocked: boolean;
|
|
94
|
+
stop(): void;
|
|
95
|
+
}
|
|
96
|
+
/** This function starts a watchdog on a clock. */
|
|
97
|
+
declare const watchdog: (clock: Clock, spec: WatchdogSpec, trace?: Trace) => Watchdog;
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/builder.d.ts
|
|
100
|
+
/**
|
|
101
|
+
* A service is composed with plain method uses before it starts. The builder is imperative, and
|
|
102
|
+
* it holds static knowledge. The service does not use an `init` function that gives registrations.
|
|
103
|
+
*
|
|
104
|
+
* const svc = service("app");
|
|
105
|
+
* svc.dependency("weather", {
|
|
106
|
+
* system: null,
|
|
107
|
+
* knobs: { request_timeout },
|
|
108
|
+
* operations: { "GET /v1/forecast": { idempotent: true } },
|
|
109
|
+
* });
|
|
110
|
+
* svc.controller("forecast").requires("weather").route("GET /api/forecast", { idempotent: true });
|
|
111
|
+
* svc.check({ name: "weather.round-trip", dependency: "weather", … }, roundTrip);
|
|
112
|
+
* svc.describe(); // the whole graph as data, with nothing started
|
|
113
|
+
* const health = await svc.start();
|
|
114
|
+
*
|
|
115
|
+
* The graph has one source, thus its parts cannot disagree. A dependency or a check can join
|
|
116
|
+
* after the start. It starts tripped, and the export is partial. The runtime has no defaults of
|
|
117
|
+
* its own. Thus `describe()` refuses a service that does not declare a policy, its startup budget
|
|
118
|
+
* or its shutdown, and it tells which. The shutdown includes what a repeat signal does.
|
|
119
|
+
*
|
|
120
|
+
* `start()` starts only the health runtime. `run()` starts the whole lifecycle: the startup in its
|
|
121
|
+
* sequence, the exit signals, the capped phases of the shutdown, and the exit of the process.
|
|
122
|
+
*/
|
|
123
|
+
interface ControllerBuilder {
|
|
124
|
+
/** This method declares the dependencies that its routes need. The controller is ready only while they are ready. */
|
|
125
|
+
requires(...dependencies: string[]): ControllerBuilder;
|
|
126
|
+
/** This method declares a route that the controller serves, and if its callers can send it again. */
|
|
127
|
+
route(route: string, operation: Operation): ControllerBuilder;
|
|
128
|
+
/**
|
|
129
|
+
* This method declares a route pattern, the operations that its requests can be, and the function
|
|
130
|
+
* that picks the operation of each request. A pattern with one operation needs no function.
|
|
131
|
+
*/
|
|
132
|
+
pattern(pattern: string, operations: Readonly<Record<string, Operation>>, operationOf?: OperationOf): ControllerBuilder;
|
|
133
|
+
}
|
|
134
|
+
/** A service builder composes a service, gives it as data, and starts it. */
|
|
135
|
+
interface ServiceBuilder {
|
|
136
|
+
readonly name: string;
|
|
137
|
+
/** This method declares a dependency, and the handle that connects to it and closes it, if it has one. */
|
|
138
|
+
dependency(name: string, spec: Omit<Dependency, "name">, handle?: DependencyHandle): ServiceBuilder;
|
|
139
|
+
/** This method declares a server, and its implementation. `run()` needs the implementation. */
|
|
140
|
+
server(name: string, knobs: Readonly<Record<string, Extent>>, impl?: TransportImpl): ServiceBuilder;
|
|
141
|
+
consumer(name: string, knobs: Readonly<Record<string, Extent>>, impl?: TransportImpl): ServiceBuilder;
|
|
142
|
+
worker(name: string, knobs: Readonly<Record<string, Extent>>, impl?: TransportImpl): ServiceBuilder;
|
|
143
|
+
/** This method gives the server of the probes. It starts first and closes last. */
|
|
144
|
+
healthServer(server: HealthServer): ServiceBuilder;
|
|
145
|
+
/** This method adds work to a declared phase of the shutdown: the owner of the work registers it. */
|
|
146
|
+
onPhase(phase: string, work: PhaseWork): ServiceBuilder;
|
|
147
|
+
/** This method declares a watchdog of the event loop, as a check on the state of the process. */
|
|
148
|
+
watchdog(spec: WatchdogSpec): ServiceBuilder;
|
|
149
|
+
controller(name: string): ControllerBuilder;
|
|
150
|
+
/** This method declares a check, and the function that measures its step. `start()` needs the function. */
|
|
151
|
+
check(check: Check, run?: CheckFn): ServiceBuilder;
|
|
152
|
+
readiness(timing: Timing): ServiceBuilder;
|
|
153
|
+
liveness(timing: Timing): ServiceBuilder;
|
|
154
|
+
startup(budget: Quantity): ServiceBuilder;
|
|
155
|
+
couple(coupling: Coupling): ServiceBuilder;
|
|
156
|
+
phase(name: string, cap: Quantity): ServiceBuilder;
|
|
157
|
+
cooperativeWindow(window: Extent): ServiceBuilder;
|
|
158
|
+
/** This method declares the earliest time of the cooperative abort: half the cap of the drain, or no floor. */
|
|
159
|
+
cooperativeFloor(floor: CooperativeFloor): ServiceBuilder;
|
|
160
|
+
/** This method declares what an exit signal does while the shutdown runs: nothing, or an exit at once. */
|
|
161
|
+
onRepeatSignal(policy: RepeatSignal): ServiceBuilder;
|
|
162
|
+
/** This method declares process triggers that start the shutdown, in addition to the exit signals. */
|
|
163
|
+
trigger(...triggers: ProcessTrigger[]): ServiceBuilder;
|
|
164
|
+
/** This method declares the exit code of a clean shutdown that a trigger starts, in place of 0. */
|
|
165
|
+
exitCode(trigger: ExitTrigger, code: number): ServiceBuilder;
|
|
166
|
+
/** This method declares each field of a profile, as the method of that field does. */
|
|
167
|
+
profile(profile: Profile): ServiceBuilder;
|
|
168
|
+
/** This method gives the service as data, and it starts nothing. */
|
|
169
|
+
describe(): ServiceDescription;
|
|
170
|
+
/**
|
|
171
|
+
* This method closes the composition, starts the health runtime, and gives it after one warm-up
|
|
172
|
+
* sweep of each check. A part that joins after it starts tripped, and it makes the export
|
|
173
|
+
* partial. A second use gives the same runtime.
|
|
174
|
+
*/
|
|
175
|
+
start(options?: StartOptions): Promise<Health>;
|
|
176
|
+
/**
|
|
177
|
+
* This method starts the whole lifecycle and gives it when its startup ends: the service operates,
|
|
178
|
+
* or its startup failed and it shuts down. It refuses a declaration that it cannot start: a transport
|
|
179
|
+
* without its implementation, or a stage with work and no declared phase.
|
|
180
|
+
*/
|
|
181
|
+
run(options?: RunOptions): Promise<Lifecycle>;
|
|
182
|
+
/**
|
|
183
|
+
* This value is the health runtime from the time that `start()` begins, or null before. Thus the
|
|
184
|
+
* probes can answer during the warm-up sweep: the startup probe fails until the sweep ends.
|
|
185
|
+
*/
|
|
186
|
+
readonly health: Health | null;
|
|
187
|
+
/**
|
|
188
|
+
* This value is the lifecycle from the time that `run()` begins, or null before. Its request
|
|
189
|
+
* tracking operates while the transports start, before `run()` gives it.
|
|
190
|
+
*/
|
|
191
|
+
readonly lifecycle: Lifecycle | null;
|
|
192
|
+
}
|
|
193
|
+
/** This function starts the composition of a service. */
|
|
194
|
+
declare const service: (name: string) => ServiceBuilder;
|
|
195
|
+
//#endregion
|
|
196
|
+
export { WatchdogSpec as a, Profile as c, kubernetes as d, localDev as f, Watchdog as i, daemon as l, ServiceBuilder as n, watchdog as o, service as r, PodTiming as s, ControllerBuilder as t, ephemeral as u };
|
|
197
|
+
//# sourceMappingURL=builder-B_q-JeW8.d.ts.map
|