@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.
Files changed (3) hide show
  1. package/README.md +17 -3
  2. package/package.json +4 -4
  3. 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, and
48
- // @greendrake/rpc-server's ConnRegistry is one — is where its pushes go.
49
- const machines = await Promise.all([createServiceStateMachine('web', unit('web.service'), registry), createServiceStateMachine('worker', unit('worker.service'), registry)])
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.4.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.4.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.4.0",
50
- "@greendrake/rpc-server": "0.4.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
- status(): ServiceStatus
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
- export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink): Promise<ServiceStateMachine> => {
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
- let inFlight = false
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 (inFlight || current.state !== from) {
138
+ if (transitioning || current.state !== from) {
72
139
  throw new ApiError(BUSY)
73
140
  }
74
- inFlight = true
141
+ transitioning = true
142
+ transitions += 1
143
+ clearTimeout(nextRead)
75
144
  const answer = set(during, null)
76
- void service[command]()
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
- () => set(to, null),
79
- (e: Error) => set('ERROR', e.message)
171
+ state => land(state, null),
172
+ (e: Error) => land('ERROR', e.message)
80
173
  )
81
174
  .finally(() => {
82
- inFlight = false
175
+ if (reading?.promise === promise) {
176
+ reading = undefined
177
+ }
83
178
  })
84
- return answer
179
+ reading = { promise, under }
180
+ return promise
85
181
  }
86
182
 
87
183
  const refresh = async (): Promise<ServiceStatus> => {
88
- if (inFlight) {
184
+ if (transitioning) {
89
185
  throw new ApiError(BUSY)
90
186
  }
91
- inFlight = true
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
- status: () => current,
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