@greendrake/service-state 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @greendrake/service-state
2
2
 
3
- A backend service an operator can turn on and off, as a state machine both halves of a dashboard share. The root entry is the contract — the states, the status, the method and push names, the transition table — and is pure types and data with no runtime dependency, so the frontend importing it pulls in nothing. `./server` is the machine behind it.
3
+ Backend services an operator can turn on and off, each a state machine both halves of a dashboard share, any number of them over one socket. The root entry is the contract — the states, the status, the method and push names, the transition table — and is pure types and data with no runtime dependency, so the frontend importing it pulls in nothing. `./server` is the machine behind it.
4
4
 
5
5
  ```
6
6
  OFF --start--> STARTING --> ON ON --stop--> STOPPING --> OFF
@@ -24,37 +24,46 @@ The `./server` entry needs `@greendrake/rpc-server`, which is an optional peer d
24
24
  import { SERVICE_STATE_EVENT, type ServiceStateMethods, type ServiceStatePushes, type ServiceStatus } from '@greendrake/service-state'
25
25
 
26
26
  interface ServiceStatus {
27
+ service: string // which service this is the status of
27
28
  state: 'OFF' | 'STARTING' | 'ON' | 'STOPPING' | 'ERROR'
28
29
  error: string | null // what went wrong, in ERROR; null everywhere else
29
30
  changed_at: number // epoch ms
30
31
  }
31
32
  ```
32
33
 
33
- `ServiceStateMethods` is the four methods (`service.status`, `service.start`, `service.stop`, `service.refresh`), each answering a `ServiceStatus` — hand it to an `ApiClient`. `ServiceStatePushes` types the `service.state` event, which carries the same status to **every** connected socket: a service has one state, and everyone watching it is watching the same thing.
34
-
35
- `@greendrake/dash`'s `ServiceStateControl` renders all of this; a dashboard that wants the control needs no code of its own beyond wiring it to its client and transport.
34
+ `ServiceStateMethods` is the four methods (`service.status`, `service.start`, `service.stop`, `service.refresh`), each taking a `ServiceRef` — `{ service }`, the name of the service it is about — and answering its `ServiceStatus`; hand it to a typed client. `ServiceStatePushes` types the `service.state` event, which carries a status to **every** connected socket: a service has one state, and everyone watching it is watching the same thing. The status names its service, so a socket watching several tells their pushes apart by it.
36
35
 
37
36
  ## The machine
38
37
 
39
38
  ```ts
40
39
  import { createServiceStateMachine, serviceStateMethods } from '@greendrake/service-state/server'
41
40
 
42
- const machine = await createServiceStateMachine(
43
- {
44
- start: () => systemd.start('thing.service'),
45
- stop: () => systemd.stop('thing.service'),
46
- check: () => systemd.isActive('thing.service').then(on => (on ? 'ON' : 'OFF'))
47
- },
48
- registry // any EventSink — @greendrake/rpc-server's ConnRegistry is one
49
- )
41
+ const unit = (name: string) => ({
42
+ start: () => systemd.start(name),
43
+ stop: () => systemd.stop(name),
44
+ check: () => systemd.isActive(name).then(on => (on ? 'ON' : 'OFF'))
45
+ })
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; 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)])
52
+
53
+ const methods = { ...serviceStateMethods(machines), ...whateverElse }
50
54
 
51
- const methods = { ...serviceStateMethods(machine), ...whateverElse }
55
+ // On shutdown, stop the re-reads.
56
+ machines.forEach(machine => machine.close())
52
57
  ```
53
58
 
54
- 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.
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.
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.
55
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.
59
68
 
60
- `serviceStateMethods(machine, auth = 'admin')` returns the four `MethodDef`s. Admin-only by default: turning a service off is not a read.
69
+ `serviceStateMethods(machines, auth = 'admin')` returns the four `MethodDef`s over every machine given, so one dispatch table — and one socket — serves them all. A call's argument is checked for its shape before any machine is reached (`INVALID_ARGUMENT` on `service`); one naming a service the table does not serve is refused with `SERVICE_NOT_FOUND` — a `_NOT_FOUND` code, which clients following `@greendrake/rpc-server`'s convention treat as not found. Two machines under one name are refused as the table is built. Admin-only by default: turning a service off is not a read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/service-state",
3
- "version": "0.3.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.3.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.3.0",
50
- "@greendrake/rpc-server": "0.3.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/main.ts CHANGED
@@ -7,8 +7,17 @@ export const SERVICE_STATES = ['OFF', 'STARTING', 'ON', 'STOPPING', 'ERROR'] as
7
7
 
8
8
  export type ServiceState = (typeof SERVICE_STATES)[number]
9
9
 
10
+ // Which service a call is about. Every method takes it, so one socket serves
11
+ // any number of services side by side.
12
+ export interface ServiceRef {
13
+ service: string
14
+ }
15
+
10
16
  // Wire shape: snake_case, as the envelope's own fields are.
11
17
  export interface ServiceStatus {
18
+ // The service this is the status of — what a push is told apart by, since
19
+ // every service's pushes arrive on the same socket.
20
+ service: string
12
21
  state: ServiceState
13
22
  // What went wrong, in ERROR. Null in every other state — a stale message
14
23
  // outliving the failure it describes is worse than none.
@@ -39,14 +48,15 @@ export type ServiceCommand = keyof typeof TRANSITIONS
39
48
  export const SERVICE_STATE_EVENT = 'service.state'
40
49
 
41
50
  // The methods a service-state backend serves. A dashboard hands this to its
42
- // ApiClient; the server derives its definitions from it.
51
+ // typed client; the server derives its definitions from it.
43
52
  export interface ServiceStateMethods {
44
- 'service.status': () => ServiceStatus
45
- 'service.start': () => ServiceStatus
46
- 'service.stop': () => ServiceStatus
47
- 'service.refresh': () => ServiceStatus
53
+ 'service.status': (ref: ServiceRef) => ServiceStatus
54
+ 'service.start': (ref: ServiceRef) => ServiceStatus
55
+ 'service.stop': (ref: ServiceRef) => ServiceStatus
56
+ 'service.refresh': (ref: ServiceRef) => ServiceStatus
48
57
  }
49
58
 
50
59
  // Every state change reaches every connected socket: a service has one state,
51
- // and everyone watching it is watching the same thing.
60
+ // and everyone watching it is watching the same thing. The status names its
61
+ // service, so a socket watching several follows each by its name.
52
62
  export type ServiceStatePushes = { [SERVICE_STATE_EVENT]: ServiceStatus }
package/src/server.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { ApiError } from '@greendrake/rpc-server'
2
2
  import type { AuthRequirement, EventSink, MethodDef } from '@greendrake/rpc-server'
3
3
  import { SERVICE_STATE_EVENT, TRANSITIONS } from './main'
4
- import type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatus } from './main'
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 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>
@@ -14,6 +16,11 @@ export interface ServiceImplementation {
14
16
  }
15
17
 
16
18
  export interface ServiceStateMachine {
19
+ // What the service is called on the wire: what a call names to reach it,
20
+ // and what each of its pushes carries.
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).
17
24
  status(): ServiceStatus
18
25
  // Return as soon as the transition has begun, with the in-flight status.
19
26
  // Where it ends up is the machine's next state, pushed to everyone — not
@@ -21,8 +28,12 @@ export interface ServiceStateMachine {
21
28
  // of them happening to have asked changes nothing.
22
29
  start(): ServiceStatus
23
30
  stop(): ServiceStatus
24
- // 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.
25
34
  refresh(): Promise<ServiceStatus>
35
+ // Stop re-reading the service, for a process shutting down.
36
+ close(): void
26
37
  }
27
38
 
28
39
  // The command a state is in no position to take. One code covers both cases —
@@ -31,20 +42,46 @@ export interface ServiceStateMachine {
31
42
  // what a client that ignored the state gets told.
32
43
  const BUSY = 'SERVICE_BUSY'
33
44
 
45
+ // A call naming a service the table does not serve. A `_NOT_FOUND` code, so
46
+ // clients following rpc-server's convention treat it as not found.
47
+ const NOT_FOUND = 'SERVICE_NOT_FOUND'
48
+
34
49
  // The service-state machine over a concrete implementation. Reads the service
35
50
  // before returning, so it never reports a state it has not verified — a
36
51
  // machine that assumed OFF at boot would have the dashboard show OFF for a
37
- // service that is up.
38
- export const createServiceStateMachine = async (service: ServiceImplementation, sink: EventSink): Promise<ServiceStateMachine> => {
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> => {
39
62
  let current: ServiceStatus = {
63
+ service: name,
40
64
  state: 'OFF',
41
65
  error: null,
42
66
  changed_at: Date.now()
43
67
  }
44
- let inFlight = false
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
45
81
 
46
82
  const set = (state: ServiceState, error: string | null): ServiceStatus => {
47
83
  current = {
84
+ service: name,
48
85
  state,
49
86
  error,
50
87
  changed_at: Date.now()
@@ -57,12 +94,23 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
57
94
  // a poll against a steady service is silent.
58
95
  const adopt = (state: ServiceState, error: string | null): ServiceStatus => (state === current.state && error === current.error ? current : set(state, error))
59
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
+
60
106
  const run = (command: ServiceCommand): ServiceStatus => {
61
107
  const { from, during, to } = TRANSITIONS[command]
62
- if (inFlight || current.state !== from) {
108
+ if (transitioning || current.state !== from) {
63
109
  throw new ApiError(BUSY)
64
110
  }
65
- inFlight = true
111
+ transitioning = true
112
+ transitions += 1
113
+ clearTimeout(nextRead)
66
114
  const answer = set(during, null)
67
115
  void service[command]()
68
116
  .then(
@@ -70,30 +118,61 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
70
118
  (e: Error) => set('ERROR', e.message)
71
119
  )
72
120
  .finally(() => {
73
- inFlight = false
121
+ transitioning = false
122
+ schedule()
74
123
  })
75
124
  return answer
76
125
  }
77
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
+
78
159
  const refresh = async (): Promise<ServiceStatus> => {
79
- if (inFlight) {
160
+ if (transitioning) {
80
161
  throw new ApiError(BUSY)
81
162
  }
82
- inFlight = true
83
- try {
84
- return adopt(await service.check(), null)
85
- } catch (e) {
86
- return adopt('ERROR', (e as Error).message)
87
- } finally {
88
- inFlight = false
89
- }
163
+ return read()
90
164
  }
91
165
 
92
166
  const machine: ServiceStateMachine = {
167
+ name,
93
168
  status: () => current,
94
169
  start: () => run('start'),
95
170
  stop: () => run('stop'),
96
- refresh
171
+ refresh,
172
+ close: () => {
173
+ closed = true
174
+ clearTimeout(nextRead)
175
+ }
97
176
  }
98
177
  await machine.refresh()
99
178
  return machine
@@ -101,16 +180,48 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
101
180
 
102
181
  // Derived from the client-facing contract rather than restated beside it: a
103
182
  // method added there has to be defined here before this compiles.
104
- export type ServiceStateMethodDefs = { [K in keyof ServiceStateMethods]: MethodDef<void, ReturnType<ServiceStateMethods[K]>> }
183
+ export type ServiceStateMethodDefs = { [K in keyof ServiceStateMethods]: MethodDef<Parameters<ServiceStateMethods[K]>[0], ReturnType<ServiceStateMethods[K]>> }
184
+
185
+ // The argument every method takes, checked for its shape. Whether it names a
186
+ // service the table serves is the table's to say, as SERVICE_NOT_FOUND. Written
187
+ // against Standard Schema directly, so this package carries no validator.
188
+ const SERVICE_REF: NonNullable<MethodDef<ServiceRef>['args']> = {
189
+ '~standard': {
190
+ version: 1,
191
+ vendor: 'greendrake',
192
+ validate: value => (typeof value === 'object' && value !== null && 'service' in value && typeof value.service === 'string' ? { value: { service: value.service } } : { issues: [{ message: 'Expected the name of a service', path: ['service'] }] })
193
+ }
194
+ }
105
195
 
106
- // The four methods, ready to merge into a dispatch table. Admin-only by
107
- // default: turning a service off is not a read.
108
- export const serviceStateMethods = (machine: ServiceStateMachine, auth: AuthRequirement = 'admin'): ServiceStateMethodDefs => ({
109
- 'service.status': { auth, handler: () => machine.status() },
110
- 'service.start': { auth, handler: () => machine.start() },
111
- 'service.stop': { auth, handler: () => machine.stop() },
112
- 'service.refresh': { auth, handler: () => machine.refresh() }
113
- })
196
+ // The four methods over every machine given, ready to merge into a dispatch
197
+ // table: one socket serves them all, each call naming its service. Admin-only
198
+ // by default: turning a service off is not a read.
199
+ export const serviceStateMethods = (machines: readonly ServiceStateMachine[], auth: AuthRequirement = 'admin'): ServiceStateMethodDefs => {
200
+ const byName = new Map(machines.map(machine => [machine.name, machine]))
201
+ if (byName.size !== machines.length) {
202
+ throw new Error(`serviceStateMethods: two machines share a name among ${machines.map(machine => machine.name).join(', ')}`)
203
+ }
204
+ // Each method is the same three steps — the argument checked, the service
205
+ // it names found, one thing asked of that service's machine — and differs
206
+ // only in what is asked.
207
+ const asking = (ask: (machine: ServiceStateMachine) => ServiceStatus | Promise<ServiceStatus>): MethodDef<ServiceRef, ServiceStatus> => ({
208
+ auth,
209
+ args: SERVICE_REF,
210
+ handler: (_ctx, { service }) => {
211
+ const machine = byName.get(service)
212
+ if (!machine) {
213
+ throw new ApiError(NOT_FOUND)
214
+ }
215
+ return ask(machine)
216
+ }
217
+ })
218
+ return {
219
+ 'service.status': asking(machine => machine.status()),
220
+ 'service.start': asking(machine => machine.start()),
221
+ 'service.stop': asking(machine => machine.stop()),
222
+ 'service.refresh': asking(machine => machine.refresh())
223
+ }
224
+ }
114
225
 
115
226
  export { SERVICE_STATE_EVENT, SERVICE_STATES, TRANSITIONS } from './main'
116
- export type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatePushes, ServiceStatus } from './main'
227
+ export type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, ServiceStatePushes, ServiceStatus } from './main'