@toa.io/boot 1.0.0-alpha.32 → 1.0.0-alpha.320

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 (60) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/package.json +11 -8
  3. package/src/atom.js +41 -0
  4. package/src/atomicity.js +11 -0
  5. package/src/bindings/broadcast.js +3 -6
  6. package/src/bindings/constants.js +1 -3
  7. package/src/bindings/consume.js +18 -6
  8. package/src/bindings/emit.js +3 -6
  9. package/src/bindings/factory.js +5 -9
  10. package/src/bindings/inbound.js +11 -0
  11. package/src/bindings/index.js +7 -13
  12. package/src/bindings/outbound.js +10 -0
  13. package/src/bindings/produce.js +56 -17
  14. package/src/bindings/receive.js +3 -6
  15. package/src/bridge.js +22 -16
  16. package/src/call.js +28 -9
  17. package/src/cascade.js +16 -10
  18. package/src/component.js +98 -32
  19. package/src/composition.js +106 -15
  20. package/src/context.js +7 -8
  21. package/src/contract.js +11 -12
  22. package/src/emission.js +21 -14
  23. package/src/env.js +19 -12
  24. package/src/events.js +27 -0
  25. package/src/extensions/aspects.js +4 -8
  26. package/src/extensions/component.js +4 -8
  27. package/src/extensions/context.js +4 -8
  28. package/src/extensions/destinations.js +27 -0
  29. package/src/extensions/emitter.js +7 -9
  30. package/src/extensions/index.js +10 -19
  31. package/src/extensions/instances.js +2 -4
  32. package/src/extensions/load.js +7 -7
  33. package/src/extensions/manage.js +15 -0
  34. package/src/extensions/receiver.js +5 -9
  35. package/src/extensions/resolve.js +62 -13
  36. package/src/extensions/storage.js +6 -9
  37. package/src/extensions/tenants.js +5 -9
  38. package/src/guards.js +20 -0
  39. package/src/host.js +29 -0
  40. package/src/inbox.js +12 -0
  41. package/src/index.js +28 -34
  42. package/src/manifest.js +78 -36
  43. package/src/manifest.test.js +85 -8
  44. package/src/map.js +108 -0
  45. package/src/operation.js +26 -14
  46. package/src/outbox.js +22 -0
  47. package/src/rc.js +5 -0
  48. package/src/receivers.js +53 -23
  49. package/src/remote.js +41 -22
  50. package/src/rendering.js +50 -0
  51. package/src/span.js +19 -0
  52. package/src/storage.js +50 -16
  53. package/src/storage.test.js +57 -0
  54. package/src/workload.js +281 -0
  55. package/src/workload.test.js +321 -0
  56. package/types/bindings.d.ts +6 -2
  57. package/types/index.d.ts +62 -10
  58. package/types/manifest.d.ts +5 -4
  59. package/src/discovery.js +0 -43
  60. package/src/exposition.js +0 -17
@@ -0,0 +1,57 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { Locator } from '@toa.io/core'
5
+
6
+ import { storage } from './storage.js'
7
+
8
+ const manifest = (entity) => ({
9
+ locator: new Locator('one', 'test'),
10
+ path: import.meta.dirname,
11
+ entity
12
+ })
13
+
14
+ const schema = { properties: {} }
15
+
16
+ it('should not create a storage for a component that stores nothing', async () => {
17
+ assert.equal(await storage(manifest(undefined), false), undefined)
18
+ })
19
+
20
+ it('should refuse migrations a storage does not apply', async () => {
21
+ const entity = {
22
+ schema,
23
+ storage: '@toa.io/storages.null',
24
+ migrations: [{ id: '0001', steps: [] }]
25
+ }
26
+
27
+ await assert.rejects(
28
+ storage(manifest(entity), false),
29
+ /declares migrations, which storage '@toa.io\/storages.null' does not apply/
30
+ )
31
+ })
32
+
33
+ it('should accept inherited migrations a storage does not apply', async () => {
34
+ const entity = {
35
+ schema,
36
+ storage: '@toa.io/storages.null',
37
+ migrations: [{ id: 'system:0001', steps: [], prototype: 'system' }]
38
+ }
39
+
40
+ assert.notEqual(await storage(manifest(entity), false), undefined)
41
+ })
42
+
43
+ it('should accept a storage with no migrations', async () => {
44
+ const entity = { schema, storage: '@toa.io/storages.null' }
45
+
46
+ assert.notEqual(await storage(manifest(entity), false), undefined)
47
+ })
48
+
49
+ it('should accept migrations a storage applies', async () => {
50
+ const entity = {
51
+ schema,
52
+ storage: '@toa.io/storages.mongodb',
53
+ migrations: [{ id: '0001', steps: [] }]
54
+ }
55
+
56
+ assert.notEqual(await storage(manifest(entity), false), undefined)
57
+ })
@@ -0,0 +1,281 @@
1
+ import { console } from 'openspan'
2
+ import { Connector, Gate, deliveries, halting } from '@toa.io/core'
3
+ import { PREDEFINED } from '@toa.io/definitions'
4
+
5
+ import { resolve } from './extensions/resolve.js'
6
+ import { host } from './host.js'
7
+
8
+ /**
9
+ * A process, as a connector.
10
+ *
11
+ * What it holds is in two parts. Its *residents* are what the extensions keep in every process
12
+ * whatever it runs — the readiness probe is the one there is — and they connect before
13
+ * everything else and go last. The rest is what the command built.
14
+ *
15
+ * Which is what a process had no name for before: `toa compose`, `toa serve` and `toa mono`
16
+ * each assembled a root out of a bare `Connector` and their own arithmetic, and an extension
17
+ * with something to put in a process had nowhere to put it.
18
+ */
19
+ export class Workload extends Connector {
20
+ /** @type {(workload: Workload) => Promise<import('@toa.io/core').Connector>} */
21
+ #build
22
+
23
+ /** @type {import('@toa.io/core/types').extensions.Resident[]} */
24
+ #residents = []
25
+
26
+ /** @type {import('@toa.io/core').Gate[]} */
27
+ #gates = []
28
+
29
+ /** @type {NodeJS.Timeout | null} */
30
+ #timer = null
31
+
32
+ #halting = false
33
+
34
+ #quiesced = false
35
+
36
+ /**
37
+ * @param {(workload: Workload) => Promise<import('@toa.io/core').Connector>} build
38
+ * what the command runs, called once
39
+ */
40
+ constructor(build) {
41
+ super()
42
+
43
+ this.#build = build
44
+ }
45
+
46
+ /**
47
+ * A part of this tree a halt takes down and builds again. Handed to extensions as
48
+ * `Host.gate`, and used by the handlers for what the command runs.
49
+ *
50
+ * @param {() => Promise<import('@toa.io/core').Connector>} build
51
+ * @returns {import('@toa.io/core').Gate}
52
+ */
53
+ gate(build) {
54
+ const gate = new Gate(build)
55
+
56
+ this.#gates.push(gate)
57
+
58
+ return gate
59
+ }
60
+
61
+ /**
62
+ * Whether what a halt takes down is up. False from the moment a halt begins until the rebuild
63
+ * has landed, so whoever watches one watches the thing itself rather than a clock beside it.
64
+ */
65
+ running() {
66
+ return !this.#halting && this.#gates.every((gate) => gate.holding())
67
+ }
68
+
69
+ /**
70
+ * Whether this process is quiet: it does nothing of its own accord, and holds everything it
71
+ * had open. What a halt does before it decides whether to go down.
72
+ */
73
+ quiescent() {
74
+ return this.#quiesced
75
+ }
76
+
77
+ /**
78
+ * Stops what this process does of its own accord and holds open everything it has: the
79
+ * gateway answers `503`, the clocks stop, and each component gets its `pause`. Nothing
80
+ * closes, so nothing has to be built again to undo it.
81
+ *
82
+ * Awaited, unlike `stop`: whoever asks for this goes on to watch what the deployment does
83
+ * next, and that is only worth watching once this process has gone quiet.
84
+ */
85
+ async quiesce() {
86
+ if (this.#halting || this.#quiesced || !this.connected) return
87
+
88
+ this.#quiesced = true
89
+
90
+ await this.halt()
91
+ }
92
+
93
+ /** Undoes a quiesce. Nothing was taken down, so nothing is built again. */
94
+ async cancel() {
95
+ if (this.#halting || !this.#quiesced) return
96
+
97
+ this.#quiesced = false
98
+
99
+ await this.restore()
100
+ }
101
+
102
+ /**
103
+ * Stops this process for `seconds`, then builds it again.
104
+ *
105
+ * Returns before anything closes, on the next tick rather than in the caller's own: whoever
106
+ * asks for this is a consumer callback more often than not, and a connection waits for the
107
+ * message being handled before it closes, so a teardown awaited inside that callback would be
108
+ * waiting on the callback that is waiting on it.
109
+ *
110
+ * @param {number} seconds
111
+ */
112
+ stop(seconds) {
113
+ if (this.#halting || !this.connected) return
114
+
115
+ this.#halting = true
116
+
117
+ setImmediate(() => void this.#cycle(seconds))
118
+ }
119
+
120
+ async open() {
121
+ /*
122
+ * Connected here rather than declared as dependencies, because what they are is read from
123
+ * the extensions and a connector's dependencies are walked before `open` runs. The probe
124
+ * goes up before the build so that it answers while the build is still happening.
125
+ */
126
+ this.#residents = await residents(this)
127
+
128
+ for (const resident of this.#residents) {
129
+ this.depends(resident)
130
+
131
+ await resident.connect()
132
+ }
133
+
134
+ const root = await this.#build(this)
135
+
136
+ this.depends(root)
137
+
138
+ await root.connect()
139
+
140
+ for (const resident of this.#residents) await resident.complete?.()
141
+ }
142
+
143
+ async close() {
144
+ if (this.#timer !== null) {
145
+ clearTimeout(this.#timer)
146
+ this.#timer = null
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @param {number} seconds
152
+ */
153
+ async #cycle(seconds) {
154
+ /*
155
+ * The window is measured from here rather than from where the teardown ends.
156
+ *
157
+ * A teardown takes as long as what it drains, so a process that started counting afterwards
158
+ * would be down for a window beginning where everyone else's ended. Counting from the signal
159
+ * means every process comes back at one instant however long its own teardown took, and one
160
+ * whose teardown outran the window comes back at once with nothing left to wait for.
161
+ */
162
+ const resumesAt = Date.now() + seconds * 1000
163
+
164
+ halting.begin()
165
+
166
+ console.warn('Halting', { seconds })
167
+
168
+ this.#quiesced = true
169
+
170
+ /*
171
+ * Quiet first, then down. A source stopped before the drain begins is work that never
172
+ * starts, so what the teardown waits for is only what was already in hand — and a component
173
+ * gets its `pause` while it is still whole, which is the one moment it can release what the
174
+ * runtime cannot see.
175
+ */
176
+ await this.halt()
177
+
178
+ /*
179
+ * Whoever called this stop saw the deployment still, and this process was not: what it is
180
+ * handling has written no edge yet. Nothing is abandoned — the teardown waits for every
181
+ * one of these — but it is the one thing that makes a halt take longer than it says, so
182
+ * it is said out loud.
183
+ */
184
+ const inflight = deliveries.inflight()
185
+
186
+ if (inflight > 0) console.warn('Halting with deliveries in flight', { inflight })
187
+
188
+ await this.#down(seconds)
189
+
190
+ const remaining = resumesAt - Date.now()
191
+
192
+ if (remaining <= 0) {
193
+ // said out loud: for as long as it took, this process was neither halted nor working
194
+ console.warn('Halted late, the teardown outran the window', {
195
+ seconds,
196
+ over: Math.round(-remaining / 1000)
197
+ })
198
+
199
+ return await this.#resume()
200
+ }
201
+
202
+ console.warn('Halted', { seconds })
203
+
204
+ /*
205
+ * Referenced, unlike every other timer the runtime keeps: a halted process holds nothing
206
+ * at all — no connection, no listening socket that is not unreferenced — so this is the
207
+ * only thing left to keep it alive. Unreferenced, the process would exit here rather than
208
+ * come back, and whatever restarts it would bring up a process that never heard of the
209
+ * halt. `close` clears it, so it holds nothing open against a shutdown.
210
+ */
211
+ this.#timer = setTimeout(() => void this.#resume(), remaining)
212
+ }
213
+
214
+ /**
215
+ * In reverse: a gate an extension made is below the one that holds what the command runs.
216
+ *
217
+ * @param {number} [seconds] how long they stay down, where they are coming back
218
+ */
219
+ async #down(seconds) {
220
+ for (const gate of [...this.#gates].reverse()) await gate.down(seconds)
221
+ }
222
+
223
+ /**
224
+ * Building again is building, and nothing about it is special: the same function of the same
225
+ * arguments, against the same infrastructure, as at boot. So it is tried once, and a failure
226
+ * ends the process the way a boot failure ends it. Anything else would be a second, weaker
227
+ * path to being up, one that could paper over a rebuild that does not work, which is precisely
228
+ * what there is to find out.
229
+ */
230
+ async #resume() {
231
+ this.#timer = null
232
+
233
+ try {
234
+ for (const gate of this.#gates) await gate.up()
235
+ } catch (error) {
236
+ console.error('Resume failed', { message: error?.message })
237
+
238
+ await this.disconnect().catch((failure) => {
239
+ console.error('Shutdown after a failed resume failed', { message: failure?.message })
240
+ })
241
+
242
+ process.exit(1)
243
+ }
244
+
245
+ await this.restore()
246
+
247
+ this.#quiesced = false
248
+ this.#halting = false
249
+
250
+ halting.end()
251
+
252
+ console.info('Resumed')
253
+ }
254
+ }
255
+
256
+ /**
257
+ * What the extensions this process has loaded keep in it.
258
+ *
259
+ * The predefined ones are loaded here rather than left to whichever component happens to
260
+ * reference them: what a resident answers for is the process, and a process that runs no
261
+ * component of its own must have one all the same.
262
+ *
263
+ * @returns {Promise<import('@toa.io/core/types').extensions.Resident[]>}
264
+ */
265
+ async function residents(workload) {
266
+ const { instances } = await import('./extensions/instances.js')
267
+
268
+ await Promise.all(Object.keys(PREDEFINED).map(async (name) => await resolve(name)))
269
+
270
+ const residents = []
271
+
272
+ for (const factory of Object.values(instances)) {
273
+ if (factory.resident === undefined) continue
274
+
275
+ const resident = await factory.resident(host(workload))
276
+
277
+ if (resident !== null && resident !== undefined) residents.push(resident)
278
+ }
279
+
280
+ return residents
281
+ }
@@ -0,0 +1,321 @@
1
+ import { describe, it, before, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Connector } from '@toa.io/core'
4
+ import { environment, timeout } from '@toa.io/generic'
5
+
6
+ /*
7
+ * The probe binds a port and the halt listener reaches a broker, and this suite is about
8
+ * neither: what it exercises is the workload's own lifecycle, so the extensions that would
9
+ * contribute a resident are off. `TOA_INTROSPECTION` is unset, so introspection's is `null`.
10
+ */
11
+ environment.set('TOA_TELEMETRY_READY', 'false')
12
+
13
+ let Workload
14
+
15
+ before(async () => {
16
+ ;({ Workload } = await import('./workload.js'))
17
+ })
18
+
19
+ let sequence
20
+
21
+ beforeEach(() => {
22
+ sequence = []
23
+ })
24
+
25
+ class TestConnector extends Connector {
26
+ #label
27
+
28
+ constructor(label) {
29
+ super()
30
+ this.#label = label
31
+ }
32
+
33
+ async open() {
34
+ sequence.push(`+${this.#label}`)
35
+ }
36
+
37
+ async close() {
38
+ sequence.push(`-${this.#label}`)
39
+ }
40
+
41
+ async dispose() {
42
+ sequence.push(`*${this.#label}`)
43
+ }
44
+ }
45
+
46
+ /** One that has something to stop doing, which most connectors have not. */
47
+ class QuietConnector extends Connector {
48
+ #label
49
+
50
+ constructor(label) {
51
+ super()
52
+ this.#label = label
53
+ }
54
+
55
+ async pause() {
56
+ sequence.push(`~${this.#label}`)
57
+ }
58
+
59
+ async unpause() {
60
+ sequence.push(`^${this.#label}`)
61
+ }
62
+ }
63
+
64
+ /** Waits for a condition the halt reaches on its own timers. */
65
+ async function until(condition, limit = 2000) {
66
+ const deadline = Date.now() + limit
67
+
68
+ while (Date.now() < deadline) {
69
+ if (condition()) return
70
+
71
+ await timeout(5)
72
+ }
73
+
74
+ assert.fail('condition was not reached')
75
+ }
76
+
77
+ describe('Workload', () => {
78
+ it('should connect what the command built', async () => {
79
+ const workload = new Workload(async () => new TestConnector('a'))
80
+
81
+ await workload.connect()
82
+
83
+ assert.deepStrictEqual(sequence, ['+a'])
84
+
85
+ await workload.disconnect()
86
+ })
87
+
88
+ it('should take down what a gate holds, and leave the rest connected', async () => {
89
+ let built = 0
90
+
91
+ const workload = new Workload(async (workload) => {
92
+ const gate = workload.gate(async () => {
93
+ built++
94
+
95
+ return new TestConnector('gated')
96
+ })
97
+ const root = new Connector()
98
+
99
+ root.depends([gate, new TestConnector('above')])
100
+
101
+ return root
102
+ })
103
+
104
+ await workload.connect()
105
+
106
+ assert.deepStrictEqual(sequence.sort(), ['+above', '+gated'])
107
+
108
+ sequence = []
109
+
110
+ // a hundredth of a second, so the window is over before the assertions are
111
+ workload.stop(0.01)
112
+
113
+ // `-gated` and not `-above`: what a gate holds is closed, and nothing else is
114
+ await until(() => sequence.includes('*gated'))
115
+
116
+ assert.deepStrictEqual(sequence, ['-gated', '*gated'])
117
+
118
+ await until(() => built === 2)
119
+
120
+ assert.deepStrictEqual(sequence, ['-gated', '*gated', '+gated'])
121
+
122
+ await workload.disconnect()
123
+ })
124
+
125
+ it('should build again rather than reconnect', async () => {
126
+ const held = []
127
+
128
+ const workload = new Workload(async (workload) =>
129
+ workload.gate(async () => {
130
+ const connector = new TestConnector('a')
131
+
132
+ held.push(connector)
133
+
134
+ return connector
135
+ })
136
+ )
137
+
138
+ await workload.connect()
139
+
140
+ workload.stop(0.01)
141
+
142
+ await until(() => held.length === 2)
143
+
144
+ assert.notEqual(held[0], held[1])
145
+
146
+ await workload.disconnect()
147
+ })
148
+
149
+ it('should return before anything is closed', async () => {
150
+ const workload = new Workload(async (workload) =>
151
+ workload.gate(async () => new TestConnector('a'))
152
+ )
153
+
154
+ await workload.connect()
155
+
156
+ sequence = []
157
+ workload.stop(0.01)
158
+
159
+ // the caller is a consumer callback: the teardown must not have begun under it
160
+ assert.deepStrictEqual(sequence, [])
161
+
162
+ await until(() => sequence.includes('+a'))
163
+ await workload.disconnect()
164
+ })
165
+
166
+ it('should make one cycle of two overlapping halts', async () => {
167
+ let built = 0
168
+
169
+ const workload = new Workload(async (workload) =>
170
+ workload.gate(async () => {
171
+ built++
172
+
173
+ return new TestConnector('a')
174
+ })
175
+ )
176
+
177
+ await workload.connect()
178
+
179
+ workload.stop(0.01)
180
+ workload.stop(0.01)
181
+
182
+ await until(() => built === 2)
183
+ await timeout(50)
184
+
185
+ assert.equal(built, 2)
186
+
187
+ await workload.disconnect()
188
+ })
189
+
190
+ it('should not halt what is not connected', async () => {
191
+ const workload = new Workload(async () => new TestConnector('a'))
192
+
193
+ workload.stop(0.01)
194
+
195
+ await timeout(30)
196
+
197
+ assert.deepStrictEqual(sequence, [])
198
+ })
199
+
200
+ it('should quiesce what it holds, closing nothing', async () => {
201
+ const workload = new Workload(async (workload) =>
202
+ workload.gate(async () => new QuietConnector('a'))
203
+ )
204
+
205
+ await workload.connect()
206
+
207
+ sequence = []
208
+
209
+ await workload.quiesce()
210
+
211
+ assert.deepStrictEqual(sequence, ['~a'])
212
+ assert.equal(workload.quiescent(), true)
213
+
214
+ // nothing was taken down, so the process is still working as far as anyone watching it
215
+ assert.equal(workload.running(), true)
216
+
217
+ await workload.disconnect()
218
+ })
219
+
220
+ it('should undo a quiesce without building anything', async () => {
221
+ let built = 0
222
+
223
+ const workload = new Workload(async (workload) =>
224
+ workload.gate(async () => {
225
+ built++
226
+
227
+ return new QuietConnector('a')
228
+ })
229
+ )
230
+
231
+ await workload.connect()
232
+ await workload.quiesce()
233
+
234
+ sequence = []
235
+
236
+ await workload.cancel()
237
+
238
+ assert.deepStrictEqual(sequence, ['^a'])
239
+ assert.equal(workload.quiescent(), false)
240
+ assert.equal(built, 1)
241
+
242
+ await workload.disconnect()
243
+ })
244
+
245
+ it('should take down a process that is already quiet', async () => {
246
+ const workload = new Workload(async (workload) =>
247
+ workload.gate(async () => {
248
+ const root = new Connector()
249
+
250
+ root.depends([new QuietConnector('q'), new TestConnector('a')])
251
+
252
+ return root
253
+ })
254
+ )
255
+
256
+ await workload.connect()
257
+ await workload.quiesce()
258
+
259
+ sequence = []
260
+ workload.stop(0.01)
261
+
262
+ await until(() => sequence.includes('+a'))
263
+
264
+ // quieted once, on the quiesce, and not again on the way down
265
+ assert.deepStrictEqual(sequence, ['-a', '*a', '+a'])
266
+
267
+ await workload.disconnect()
268
+ })
269
+
270
+ it('should not let a cancel undo a stop', async () => {
271
+ const workload = new Workload(async (workload) =>
272
+ workload.gate(async () => new TestConnector('a'))
273
+ )
274
+
275
+ await workload.connect()
276
+
277
+ sequence = []
278
+ workload.stop(0.01)
279
+
280
+ await workload.cancel()
281
+
282
+ // it goes down and comes back: a cancel is for a quiesce, and this is past one
283
+ await until(() => sequence.includes('+a'))
284
+
285
+ assert.deepStrictEqual(sequence, ['-a', '*a', '+a'])
286
+
287
+ await workload.disconnect()
288
+ })
289
+
290
+ it('should keep the process alive while it is halted', async () => {
291
+ const workload = new Workload(async (workload) =>
292
+ workload.gate(async () => new TestConnector('a'))
293
+ )
294
+
295
+ await workload.connect()
296
+
297
+ // long enough that the process would have to be held open for it
298
+ workload.stop(10)
299
+
300
+ await until(() => sequence.includes('*a'))
301
+
302
+ // a halted process holds nothing else: unreferenced, this timer would let it exit
303
+ assert.ok(
304
+ process.getActiveResourcesInfo().includes('Timeout'),
305
+ 'nothing keeps the process alive until the rebuild'
306
+ )
307
+
308
+ await workload.disconnect()
309
+ })
310
+
311
+ it('should take the gates down on its own disconnection', async () => {
312
+ const workload = new Workload(async (workload) =>
313
+ workload.gate(async () => new TestConnector('a'))
314
+ )
315
+
316
+ await workload.connect()
317
+ await workload.disconnect()
318
+
319
+ assert.deepStrictEqual(sequence, ['+a', '-a', '*a'])
320
+ })
321
+ })
@@ -1,3 +1,7 @@
1
- import type { bindings } from '@toa.io/core'
1
+ import type { bindings } from '@toa.io/core/types'
2
2
 
3
- export function broadcast<T> (name: string, group?: string, binding?: string): bindings.Broadcast<T>
3
+ export function broadcast<T>(
4
+ name: string,
5
+ group?: string,
6
+ binding?: string
7
+ ): bindings.Broadcast<T>