@greendrake/service-state 0.4.0 → 0.6.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 +17 -3
- package/package.json +4 -4
- package/src/server.ts +121 -25
package/README.md
CHANGED
|
@@ -44,15 +44,29 @@ const unit = (name: string) => ({
|
|
|
44
44
|
check: () => systemd.isActive(name).then(on => (on ? 'ON' : 'OFF'))
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
// Each machine is named for the wire; the registry — any EventSink
|
|
48
|
-
// @greendrake/rpc-server's ConnRegistry is
|
|
49
|
-
|
|
47
|
+
// Each machine is named for the wire; the registry — any EventSink that is an
|
|
48
|
+
// Audience too, and @greendrake/rpc-server's ConnRegistry is both — is where
|
|
49
|
+
// its pushes go and who is there to hear them; and the last argument is how
|
|
50
|
+
// old a reading may get before the machine takes another, in milliseconds.
|
|
51
|
+
const machines = await Promise.all([createServiceStateMachine('web', unit('web.service'), registry, 30_000), createServiceStateMachine('worker', unit('worker.service'), registry, 30_000)])
|
|
50
52
|
|
|
51
53
|
const methods = { ...serviceStateMethods(machines), ...whateverElse }
|
|
54
|
+
|
|
55
|
+
// On shutdown, stop the re-reads and the listening for watchers.
|
|
56
|
+
machines.forEach(machine => machine.close())
|
|
52
57
|
```
|
|
53
58
|
|
|
54
59
|
The name is what a call's `{ service }` reaches the machine by and what its every status carries. The factory reads the service before returning, so the machine never reports a state it has not verified — one that assumed `OFF` at boot would have every dashboard showing `OFF` for a service that is up.
|
|
55
60
|
|
|
61
|
+
Nor does it keep reporting one it verified long ago, to anybody who needs to know. Nothing tells the machine when a service is changed by other hands — a unit stopped from a shell, a schedule that turns it off overnight — so it reads the service again when that is needed, and only then:
|
|
62
|
+
|
|
63
|
+
- **Asked.** `status` answers from the latest reading while it is younger than `maxAgeMs`; one that old is taken again first, and the answer is what it found. A dashboard opened after a quiet spell is shown what the service is, not what it was.
|
|
64
|
+
- **Watched.** While the sink's audience has anybody in it — a socket open to be pushed to — the machine re-reads whenever its latest reading turns `maxAgeMs` old, and pushes what changed, so a dashboard left open follows the service. The first to arrive has a stale reading taken again at once; the last to leave stops the re-reads.
|
|
65
|
+
|
|
66
|
+
With nobody asking and nobody watching, the service is left alone. A transition that lands counts as a reading, and so does a `refresh`; one asked while a read is under way joins it, unless a transition has begun since that read went out — which makes it a reading of the past, so a fresh one goes out instead. A transition under way and `ERROR` are the machine's own word rather than a reading, and are not read past — `ERROR` until somebody asks for a `refresh`, because reading past it would take the why away before anyone saw it. A transition begun while a read is out goes ahead, and the read's answer, which describes the service before it, is dropped. `close()` stops the re-reads and the listening for watchers, for a process shutting down.
|
|
67
|
+
|
|
68
|
+
`check` must settle promptly: a `status` or a `refresh` can wait on it, and the next re-read is timed from when it settles, so a check that never settles leaves the state as it last stood.
|
|
69
|
+
|
|
56
70
|
`start` and `stop` **return as soon as the transition has begun**, with the in-flight status. Where it ends up is the machine's next state, pushed to everyone — not the caller's answer, because everyone watching needs it equally and one of them happening to have asked changes nothing. A hook that rejects lands the machine in `ERROR` carrying its message.
|
|
57
71
|
|
|
58
72
|
One transition at a time, and each only from the state it is defined for. Anything else — `start` while already `ON`, `refresh` mid-transition — is refused with `SERVICE_BUSY`. A client showing the state never triggers it, because the state it is showing offers no such button.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greendrake/service-state",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A backend service as a state machine (OFF/STARTING/ON/STOPPING/ERROR): the wire contract both halves share, and the server-side machine behind it",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"prettier": "@greendrake/dev-config/prettier",
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@greendrake/rpc-server": "0.
|
|
41
|
+
"@greendrake/rpc-server": "0.6.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@greendrake/rpc-server": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@greendrake/dev-config": "0.
|
|
50
|
-
"@greendrake/rpc-server": "0.
|
|
49
|
+
"@greendrake/dev-config": "0.6.0",
|
|
50
|
+
"@greendrake/rpc-server": "0.6.0",
|
|
51
51
|
"@types/bun": "^1.3.5",
|
|
52
52
|
"typescript": "6.0.3"
|
|
53
53
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { ApiError } from '@greendrake/rpc-server'
|
|
2
|
-
import type { AuthRequirement, EventSink, MethodDef } from '@greendrake/rpc-server'
|
|
2
|
+
import type { Audience, AuthRequirement, EventSink, MethodDef } from '@greendrake/rpc-server'
|
|
3
3
|
import { SERVICE_STATE_EVENT, TRANSITIONS } from './main'
|
|
4
4
|
import type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, ServiceStatus } from './main'
|
|
5
5
|
|
|
6
6
|
// What a concrete service plugs in. `start` and `stop` resolve once the
|
|
7
7
|
// service is up or down; rejecting puts the machine in ERROR with the
|
|
8
8
|
// rejection's message. `check` reports what the service is actually doing
|
|
9
|
-
// right now, read from the service rather than remembered
|
|
9
|
+
// right now, read from the service rather than remembered, and must settle
|
|
10
|
+
// promptly: a status or a refresh can wait on it, and the next re-read is
|
|
11
|
+
// timed from when it settles, so a check that never does leaves the state as
|
|
12
|
+
// it last stood.
|
|
10
13
|
export interface ServiceImplementation {
|
|
11
14
|
start(): Promise<void>
|
|
12
15
|
stop(): Promise<void>
|
|
@@ -17,15 +20,22 @@ export interface ServiceStateMachine {
|
|
|
17
20
|
// What the service is called on the wire: what a call names to reach it,
|
|
18
21
|
// and what each of its pushes carries.
|
|
19
22
|
readonly name: string
|
|
20
|
-
|
|
23
|
+
// What the service is: its last reading, or what its last transition left
|
|
24
|
+
// it as — read again first when that is `maxAgeMs` old (see the factory).
|
|
25
|
+
status(): Promise<ServiceStatus>
|
|
21
26
|
// Return as soon as the transition has begun, with the in-flight status.
|
|
22
27
|
// Where it ends up is the machine's next state, pushed to everyone — not
|
|
23
28
|
// this caller's answer, because everyone watching needs it equally and one
|
|
24
29
|
// of them happening to have asked changes nothing.
|
|
25
30
|
start(): ServiceStatus
|
|
26
31
|
stop(): ServiceStatus
|
|
27
|
-
// Re-read the service and adopt what it reports
|
|
32
|
+
// Re-read the service and adopt what it reports, joining a read already
|
|
33
|
+
// under way — one begun since the last transition — rather than asking
|
|
34
|
+
// the service again.
|
|
28
35
|
refresh(): Promise<ServiceStatus>
|
|
36
|
+
// Stop re-reading the service and listening for watchers, for a process
|
|
37
|
+
// shutting down.
|
|
38
|
+
close(): void
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
// The command a state is in no position to take. One code covers both cases —
|
|
@@ -41,15 +51,41 @@ const NOT_FOUND = 'SERVICE_NOT_FOUND'
|
|
|
41
51
|
// The service-state machine over a concrete implementation. Reads the service
|
|
42
52
|
// before returning, so it never reports a state it has not verified — a
|
|
43
53
|
// machine that assumed OFF at boot would have the dashboard show OFF for a
|
|
44
|
-
// service that is up.
|
|
45
|
-
|
|
54
|
+
// service that is up. Nor does it later, when anybody needs to know: nothing
|
|
55
|
+
// tells the machine when a service is changed by other hands — a unit stopped
|
|
56
|
+
// from a shell, a schedule that turns it off overnight — so a reading
|
|
57
|
+
// `maxAgeMs` old is taken again before a status is answered from it, and
|
|
58
|
+
// while the sink has an audience — a socket open to be pushed to — the
|
|
59
|
+
// machine re-reads on that cadence by itself and pushes what changed, so a
|
|
60
|
+
// dashboard left open follows the service. With nobody asking and nobody
|
|
61
|
+
// watching, the service is left alone. A transition that lands counts as a
|
|
62
|
+
// reading. A transition under way and ERROR are the machine's own word rather
|
|
63
|
+
// than a reading, and are not read past — ERROR until somebody asks for a
|
|
64
|
+
// refresh, because reading past it would take the why away before anyone saw
|
|
65
|
+
// it.
|
|
66
|
+
export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink & Audience, maxAgeMs: number): Promise<ServiceStateMachine> => {
|
|
46
67
|
let current: ServiceStatus = {
|
|
47
68
|
service: name,
|
|
48
69
|
state: 'OFF',
|
|
49
70
|
error: null,
|
|
50
71
|
changed_at: Date.now()
|
|
51
72
|
}
|
|
52
|
-
|
|
73
|
+
// When the service was last read, or a transition last landed — which is
|
|
74
|
+
// what `maxAgeMs` is counted from. The boot read below is the first.
|
|
75
|
+
let readAt = Date.now()
|
|
76
|
+
let transitioning = false
|
|
77
|
+
// Counts the transitions begun. A read that began before the latest one
|
|
78
|
+
// reports what the service was before it, so its answer is dropped rather
|
|
79
|
+
// than adopted over the transition's.
|
|
80
|
+
let transitions = 0
|
|
81
|
+
// The read under way and the transition count it began under. A refresh
|
|
82
|
+
// or re-read asked while it is out joins it rather than asking the service
|
|
83
|
+
// again — unless a transition has begun since, which makes it a reading of
|
|
84
|
+
// the past: then a fresh read goes out, and the old one is dropped when it
|
|
85
|
+
// settles.
|
|
86
|
+
let reading: { promise: Promise<ServiceStatus>; under: number } | undefined
|
|
87
|
+
let nextRead: ReturnType<typeof setTimeout> | undefined
|
|
88
|
+
let closed = false
|
|
53
89
|
|
|
54
90
|
const set = (state: ServiceState, error: string | null): ServiceStatus => {
|
|
55
91
|
current = {
|
|
@@ -66,44 +102,104 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
|
|
|
66
102
|
// a poll against a steady service is silent.
|
|
67
103
|
const adopt = (state: ServiceState, error: string | null): ServiceStatus => (state === current.state && error === current.error ? current : set(state, error))
|
|
68
104
|
|
|
105
|
+
// The next re-read, for when the latest reading turns `maxAgeMs` old —
|
|
106
|
+
// straight away if it already has. Only while somebody is watching; none
|
|
107
|
+
// while a transition is under way, whose landing is the next reading; and
|
|
108
|
+
// none in ERROR, which stands until somebody refreshes.
|
|
109
|
+
const schedule = (): void => {
|
|
110
|
+
clearTimeout(nextRead)
|
|
111
|
+
if (!closed && sink.size > 0 && !transitioning && current.state !== 'ERROR') {
|
|
112
|
+
nextRead = setTimeout(() => void read(), Math.max(0, readAt + maxAgeMs - Date.now()))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The first watcher starts the re-reads and the last one to leave stops
|
|
117
|
+
// them.
|
|
118
|
+
const stopListening = sink.onPresence(present => {
|
|
119
|
+
if (present) {
|
|
120
|
+
schedule()
|
|
121
|
+
} else {
|
|
122
|
+
clearTimeout(nextRead)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// A transition landing or a read settling: the service as it now stands,
|
|
127
|
+
// which is a reading — and a push, wherever it moved. A transition lands
|
|
128
|
+
// away from the state it passed through, so it always pushes.
|
|
129
|
+
const reached = (state: ServiceState, error: string | null): ServiceStatus => {
|
|
130
|
+
readAt = Date.now()
|
|
131
|
+
const status = adopt(state, error)
|
|
132
|
+
schedule()
|
|
133
|
+
return status
|
|
134
|
+
}
|
|
135
|
+
|
|
69
136
|
const run = (command: ServiceCommand): ServiceStatus => {
|
|
70
137
|
const { from, during, to } = TRANSITIONS[command]
|
|
71
|
-
if (
|
|
138
|
+
if (transitioning || current.state !== from) {
|
|
72
139
|
throw new ApiError(BUSY)
|
|
73
140
|
}
|
|
74
|
-
|
|
141
|
+
transitioning = true
|
|
142
|
+
transitions += 1
|
|
143
|
+
clearTimeout(nextRead)
|
|
75
144
|
const answer = set(during, null)
|
|
76
|
-
|
|
145
|
+
// Over the moment it lands, because its landing is a reading and a
|
|
146
|
+
// reading schedules the next only with no transition under way.
|
|
147
|
+
const landed = (state: ServiceState, error: string | null): void => {
|
|
148
|
+
transitioning = false
|
|
149
|
+
reached(state, error)
|
|
150
|
+
}
|
|
151
|
+
void service[command]().then(
|
|
152
|
+
() => landed(to, null),
|
|
153
|
+
(e: Error) => landed('ERROR', e.message)
|
|
154
|
+
)
|
|
155
|
+
return answer
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const read = (): Promise<ServiceStatus> => {
|
|
159
|
+
if (reading?.under === transitions) {
|
|
160
|
+
return reading.promise
|
|
161
|
+
}
|
|
162
|
+
clearTimeout(nextRead)
|
|
163
|
+
const under = transitions
|
|
164
|
+
// A transition begun since the read went out has the last word — its
|
|
165
|
+
// own landing schedules the next read — and the read's answer, from
|
|
166
|
+
// before it, is dropped.
|
|
167
|
+
const land = (state: ServiceState, error: string | null): ServiceStatus => (under === transitions ? reached(state, error) : current)
|
|
168
|
+
const promise = service
|
|
169
|
+
.check()
|
|
77
170
|
.then(
|
|
78
|
-
|
|
79
|
-
(e: Error) =>
|
|
171
|
+
state => land(state, null),
|
|
172
|
+
(e: Error) => land('ERROR', e.message)
|
|
80
173
|
)
|
|
81
174
|
.finally(() => {
|
|
82
|
-
|
|
175
|
+
if (reading?.promise === promise) {
|
|
176
|
+
reading = undefined
|
|
177
|
+
}
|
|
83
178
|
})
|
|
84
|
-
|
|
179
|
+
reading = { promise, under }
|
|
180
|
+
return promise
|
|
85
181
|
}
|
|
86
182
|
|
|
87
183
|
const refresh = async (): Promise<ServiceStatus> => {
|
|
88
|
-
if (
|
|
184
|
+
if (transitioning) {
|
|
89
185
|
throw new ApiError(BUSY)
|
|
90
186
|
}
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
return adopt(await service.check(), null)
|
|
94
|
-
} catch (e) {
|
|
95
|
-
return adopt('ERROR', (e as Error).message)
|
|
96
|
-
} finally {
|
|
97
|
-
inFlight = false
|
|
98
|
-
}
|
|
187
|
+
return read()
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
const machine: ServiceStateMachine = {
|
|
102
191
|
name,
|
|
103
|
-
|
|
192
|
+
// Answered from the latest reading while it is fresh. A transition under
|
|
193
|
+
// way and ERROR are answered as they stand, being the machine's own word.
|
|
194
|
+
status: () => (transitioning || current.state === 'ERROR' || Date.now() - readAt < maxAgeMs ? Promise.resolve(current) : read()),
|
|
104
195
|
start: () => run('start'),
|
|
105
196
|
stop: () => run('stop'),
|
|
106
|
-
refresh
|
|
197
|
+
refresh,
|
|
198
|
+
close: () => {
|
|
199
|
+
closed = true
|
|
200
|
+
clearTimeout(nextRead)
|
|
201
|
+
stopListening()
|
|
202
|
+
}
|
|
107
203
|
}
|
|
108
204
|
await machine.refresh()
|
|
109
205
|
return machine
|