@greendrake/service-state 0.4.0 → 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 +11 -2
- package/package.json +4 -4
- package/src/server.ts +87 -18
package/README.md
CHANGED
|
@@ -45,14 +45,23 @@ const unit = (name: string) => ({
|
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
// Each machine is named for the wire; the registry — any EventSink, and
|
|
48
|
-
// @greendrake/rpc-server's ConnRegistry is one — is where its pushes go
|
|
49
|
-
|
|
48
|
+
// @greendrake/rpc-server's ConnRegistry is one — is where its pushes go; and
|
|
49
|
+
// the last argument is how old a reading may get before the machine takes
|
|
50
|
+
// 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.
|
|
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. 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 whenever its last reading is `maxAgeMs` old, and pushes what changed: a dashboard left open follows the service, and one opened after a quiet spell is shown what the service is, not what it was. `status` answers from the latest reading without waiting on the service. 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, for a process shutting down.
|
|
62
|
+
|
|
63
|
+
`check` must settle promptly: a `refresh` waits 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.
|
|
64
|
+
|
|
56
65
|
`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
66
|
|
|
58
67
|
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.5.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.5.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.5.0",
|
|
50
|
+
"@greendrake/rpc-server": "0.5.0",
|
|
51
51
|
"@types/bun": "^1.3.5",
|
|
52
52
|
"typescript": "6.0.3"
|
|
53
53
|
}
|
package/src/server.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, Ser
|
|
|
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 refresh waits on it, and the next re-read is timed from when it
|
|
11
|
+
// settles, so a check that never does leaves the state as it last stood.
|
|
10
12
|
export interface ServiceImplementation {
|
|
11
13
|
start(): Promise<void>
|
|
12
14
|
stop(): Promise<void>
|
|
@@ -17,6 +19,8 @@ export interface ServiceStateMachine {
|
|
|
17
19
|
// What the service is called on the wire: what a call names to reach it,
|
|
18
20
|
// and what each of its pushes carries.
|
|
19
21
|
readonly name: string
|
|
22
|
+
// What the service was last read as, or what its last transition left it
|
|
23
|
+
// as — kept current by the machine's own re-reads (see the factory).
|
|
20
24
|
status(): ServiceStatus
|
|
21
25
|
// Return as soon as the transition has begun, with the in-flight status.
|
|
22
26
|
// Where it ends up is the machine's next state, pushed to everyone — not
|
|
@@ -24,8 +28,12 @@ export interface ServiceStateMachine {
|
|
|
24
28
|
// of them happening to have asked changes nothing.
|
|
25
29
|
start(): ServiceStatus
|
|
26
30
|
stop(): ServiceStatus
|
|
27
|
-
// Re-read the service and adopt what it reports
|
|
31
|
+
// Re-read the service and adopt what it reports, joining a read already
|
|
32
|
+
// under way — one begun since the last transition — rather than asking
|
|
33
|
+
// the service again.
|
|
28
34
|
refresh(): Promise<ServiceStatus>
|
|
35
|
+
// Stop re-reading the service, for a process shutting down.
|
|
36
|
+
close(): void
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
// The command a state is in no position to take. One code covers both cases —
|
|
@@ -41,15 +49,35 @@ const NOT_FOUND = 'SERVICE_NOT_FOUND'
|
|
|
41
49
|
// The service-state machine over a concrete implementation. Reads the service
|
|
42
50
|
// before returning, so it never reports a state it has not verified — a
|
|
43
51
|
// machine that assumed OFF at boot would have the dashboard show OFF for a
|
|
44
|
-
// service that is up.
|
|
45
|
-
|
|
52
|
+
// service that is up. Nor does it later: nothing tells the machine when a
|
|
53
|
+
// service is changed by other hands — a unit stopped from a shell, a schedule
|
|
54
|
+
// that turns it off overnight — so it reads the service again whenever its
|
|
55
|
+
// last reading is `maxAgeMs` old, and pushes what changed to everyone
|
|
56
|
+
// watching, open dashboards included. A transition that lands counts as a
|
|
57
|
+
// reading. A transition under way and ERROR are the machine's own word rather
|
|
58
|
+
// than a reading, and are not read past — ERROR until somebody asks for a
|
|
59
|
+
// refresh, because reading past it would take the why away before anyone saw
|
|
60
|
+
// it.
|
|
61
|
+
export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink, maxAgeMs: number): Promise<ServiceStateMachine> => {
|
|
46
62
|
let current: ServiceStatus = {
|
|
47
63
|
service: name,
|
|
48
64
|
state: 'OFF',
|
|
49
65
|
error: null,
|
|
50
66
|
changed_at: Date.now()
|
|
51
67
|
}
|
|
52
|
-
let
|
|
68
|
+
let transitioning = false
|
|
69
|
+
// Counts the transitions begun. A read that began before the latest one
|
|
70
|
+
// reports what the service was before it, so its answer is dropped rather
|
|
71
|
+
// than adopted over the transition's.
|
|
72
|
+
let transitions = 0
|
|
73
|
+
// The read under way and the transition count it began under. A refresh
|
|
74
|
+
// or re-read asked while it is out joins it rather than asking the service
|
|
75
|
+
// again — unless a transition has begun since, which makes it a reading of
|
|
76
|
+
// the past: then a fresh read goes out, and the old one is dropped when it
|
|
77
|
+
// settles.
|
|
78
|
+
let reading: { promise: Promise<ServiceStatus>; under: number } | undefined
|
|
79
|
+
let nextRead: ReturnType<typeof setTimeout> | undefined
|
|
80
|
+
let closed = false
|
|
53
81
|
|
|
54
82
|
const set = (state: ServiceState, error: string | null): ServiceStatus => {
|
|
55
83
|
current = {
|
|
@@ -66,12 +94,23 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
|
|
|
66
94
|
// a poll against a steady service is silent.
|
|
67
95
|
const adopt = (state: ServiceState, error: string | null): ServiceStatus => (state === current.state && error === current.error ? current : set(state, error))
|
|
68
96
|
|
|
97
|
+
// The next re-read, `maxAgeMs` after the reading just taken. None in ERROR,
|
|
98
|
+
// which stands until somebody refreshes.
|
|
99
|
+
const schedule = (): void => {
|
|
100
|
+
clearTimeout(nextRead)
|
|
101
|
+
if (!closed && current.state !== 'ERROR') {
|
|
102
|
+
nextRead = setTimeout(() => void read(), maxAgeMs)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
const run = (command: ServiceCommand): ServiceStatus => {
|
|
70
107
|
const { from, during, to } = TRANSITIONS[command]
|
|
71
|
-
if (
|
|
108
|
+
if (transitioning || current.state !== from) {
|
|
72
109
|
throw new ApiError(BUSY)
|
|
73
110
|
}
|
|
74
|
-
|
|
111
|
+
transitioning = true
|
|
112
|
+
transitions += 1
|
|
113
|
+
clearTimeout(nextRead)
|
|
75
114
|
const answer = set(during, null)
|
|
76
115
|
void service[command]()
|
|
77
116
|
.then(
|
|
@@ -79,23 +118,49 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
|
|
|
79
118
|
(e: Error) => set('ERROR', e.message)
|
|
80
119
|
)
|
|
81
120
|
.finally(() => {
|
|
82
|
-
|
|
121
|
+
transitioning = false
|
|
122
|
+
schedule()
|
|
83
123
|
})
|
|
84
124
|
return answer
|
|
85
125
|
}
|
|
86
126
|
|
|
127
|
+
const read = (): Promise<ServiceStatus> => {
|
|
128
|
+
if (reading?.under === transitions) {
|
|
129
|
+
return reading.promise
|
|
130
|
+
}
|
|
131
|
+
clearTimeout(nextRead)
|
|
132
|
+
const under = transitions
|
|
133
|
+
// A transition begun since the read went out has the last word — its
|
|
134
|
+
// own landing schedules the next read — and the read's answer, from
|
|
135
|
+
// before it, is dropped.
|
|
136
|
+
const land = (state: ServiceState, error: string | null): ServiceStatus => {
|
|
137
|
+
if (under !== transitions) {
|
|
138
|
+
return current
|
|
139
|
+
}
|
|
140
|
+
const status = adopt(state, error)
|
|
141
|
+
schedule()
|
|
142
|
+
return status
|
|
143
|
+
}
|
|
144
|
+
const promise = service
|
|
145
|
+
.check()
|
|
146
|
+
.then(
|
|
147
|
+
state => land(state, null),
|
|
148
|
+
(e: Error) => land('ERROR', e.message)
|
|
149
|
+
)
|
|
150
|
+
.finally(() => {
|
|
151
|
+
if (reading?.promise === promise) {
|
|
152
|
+
reading = undefined
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
reading = { promise, under }
|
|
156
|
+
return promise
|
|
157
|
+
}
|
|
158
|
+
|
|
87
159
|
const refresh = async (): Promise<ServiceStatus> => {
|
|
88
|
-
if (
|
|
160
|
+
if (transitioning) {
|
|
89
161
|
throw new ApiError(BUSY)
|
|
90
162
|
}
|
|
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
|
-
}
|
|
163
|
+
return read()
|
|
99
164
|
}
|
|
100
165
|
|
|
101
166
|
const machine: ServiceStateMachine = {
|
|
@@ -103,7 +168,11 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
|
|
|
103
168
|
status: () => current,
|
|
104
169
|
start: () => run('start'),
|
|
105
170
|
stop: () => run('stop'),
|
|
106
|
-
refresh
|
|
171
|
+
refresh,
|
|
172
|
+
close: () => {
|
|
173
|
+
closed = true
|
|
174
|
+
clearTimeout(nextRead)
|
|
175
|
+
}
|
|
107
176
|
}
|
|
108
177
|
await machine.refresh()
|
|
109
178
|
return machine
|