@greendrake/service-state 0.5.0 → 0.7.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 +12 -7
  2. package/package.json +4 -4
  3. package/src/server.ts +62 -35
package/README.md CHANGED
@@ -44,23 +44,28 @@ 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; and
49
- // the last argument is how old a reading may get before the machine takes
50
- // another, in milliseconds.
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
51
  const machines = await Promise.all([createServiceStateMachine('web', unit('web.service'), registry, 30_000), createServiceStateMachine('worker', unit('worker.service'), registry, 30_000)])
52
52
 
53
53
  const methods = { ...serviceStateMethods(machines), ...whateverElse }
54
54
 
55
- // On shutdown, stop the re-reads.
55
+ // On shutdown, stop the re-reads and the listening for watchers.
56
56
  machines.forEach(machine => machine.close())
57
57
  ```
58
58
 
59
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
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.
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
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.
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.
64
69
 
65
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.
66
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/service-state",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.5.0"
41
+ "@greendrake/rpc-server": "0.7.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.5.0",
50
- "@greendrake/rpc-server": "0.5.0",
49
+ "@greendrake/dev-config": "0.7.0",
50
+ "@greendrake/rpc-server": "0.7.0",
51
51
  "@types/bun": "^1.3.5",
52
52
  "typescript": "6.0.3"
53
53
  }
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
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
 
@@ -7,8 +7,9 @@ import type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, Ser
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
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
+ // 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.
12
13
  export interface ServiceImplementation {
13
14
  start(): Promise<void>
14
15
  stop(): Promise<void>
@@ -19,9 +20,9 @@ export interface ServiceStateMachine {
19
20
  // What the service is called on the wire: what a call names to reach it,
20
21
  // and what each of its pushes carries.
21
22
  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).
24
- 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>
25
26
  // Return as soon as the transition has begun, with the in-flight status.
26
27
  // Where it ends up is the machine's next state, pushed to everyone — not
27
28
  // this caller's answer, because everyone watching needs it equally and one
@@ -32,7 +33,8 @@ export interface ServiceStateMachine {
32
33
  // under way — one begun since the last transition — rather than asking
33
34
  // the service again.
34
35
  refresh(): Promise<ServiceStatus>
35
- // Stop re-reading the service, for a process shutting down.
36
+ // Stop re-reading the service and listening for watchers, for a process
37
+ // shutting down.
36
38
  close(): void
37
39
  }
38
40
 
@@ -49,22 +51,28 @@ const NOT_FOUND = 'SERVICE_NOT_FOUND'
49
51
  // The service-state machine over a concrete implementation. Reads the service
50
52
  // before returning, so it never reports a state it has not verified — a
51
53
  // machine that assumed OFF at boot would have the dashboard show OFF for a
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
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
57
62
  // reading. A transition under way and ERROR are the machine's own word rather
58
63
  // than a reading, and are not read past — ERROR until somebody asks for a
59
64
  // refresh, because reading past it would take the why away before anyone saw
60
65
  // it.
61
- export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink, maxAgeMs: number): Promise<ServiceStateMachine> => {
66
+ export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink & Audience, maxAgeMs: number): Promise<ServiceStateMachine> => {
62
67
  let current: ServiceStatus = {
63
68
  service: name,
64
69
  state: 'OFF',
65
70
  error: null,
66
71
  changed_at: Date.now()
67
72
  }
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()
68
76
  let transitioning = false
69
77
  // Counts the transitions begun. A read that began before the latest one
70
78
  // reports what the service was before it, so its answer is dropped rather
@@ -94,15 +102,37 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
94
102
  // a poll against a steady service is silent.
95
103
  const adopt = (state: ServiceState, error: string | null): ServiceStatus => (state === current.state && error === current.error ? current : set(state, error))
96
104
 
97
- // The next re-read, `maxAgeMs` after the reading just taken. None in ERROR,
98
- // which stands until somebody refreshes.
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.
99
109
  const schedule = (): void => {
100
110
  clearTimeout(nextRead)
101
- if (!closed && current.state !== 'ERROR') {
102
- nextRead = setTimeout(() => void read(), maxAgeMs)
111
+ if (!closed && sink.size > 0 && !transitioning && current.state !== 'ERROR') {
112
+ nextRead = setTimeout(() => void read(), Math.max(0, readAt + maxAgeMs - Date.now()))
103
113
  }
104
114
  }
105
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
+
106
136
  const run = (command: ServiceCommand): ServiceStatus => {
107
137
  const { from, during, to } = TRANSITIONS[command]
108
138
  if (transitioning || current.state !== from) {
@@ -112,15 +142,16 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
112
142
  transitions += 1
113
143
  clearTimeout(nextRead)
114
144
  const answer = set(during, null)
115
- void service[command]()
116
- .then(
117
- () => set(to, null),
118
- (e: Error) => set('ERROR', e.message)
119
- )
120
- .finally(() => {
121
- transitioning = false
122
- schedule()
123
- })
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
+ )
124
155
  return answer
125
156
  }
126
157
 
@@ -133,14 +164,7 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
133
164
  // A transition begun since the read went out has the last word — its
134
165
  // own landing schedules the next read — and the read's answer, from
135
166
  // 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
- }
167
+ const land = (state: ServiceState, error: string | null): ServiceStatus => (under === transitions ? reached(state, error) : current)
144
168
  const promise = service
145
169
  .check()
146
170
  .then(
@@ -165,13 +189,16 @@ export const createServiceStateMachine = async (name: string, service: ServiceIm
165
189
 
166
190
  const machine: ServiceStateMachine = {
167
191
  name,
168
- 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()),
169
195
  start: () => run('start'),
170
196
  stop: () => run('stop'),
171
197
  refresh,
172
198
  close: () => {
173
199
  closed = true
174
200
  clearTimeout(nextRead)
201
+ stopListening()
175
202
  }
176
203
  }
177
204
  await machine.refresh()