@toa.io/boot 1.0.0-alpha.304 → 1.0.0-alpha.306
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/CHANGELOG.md +30 -0
- package/package.json +7 -7
- package/src/call.js +16 -2
- package/src/component.js +3 -0
- package/src/context.js +1 -1
- package/src/discovery.js +24 -3
- package/src/host.js +15 -3
- package/src/index.js +1 -0
- package/src/map.js +53 -0
- package/src/receivers.js +12 -2
- package/src/remote.js +11 -5
- package/src/workload.js +201 -4
- package/src/workload.test.js +321 -0
- package/types/index.d.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.0.0-alpha.306](https://github.com/toa-io/toa/compare/v1.0.0-alpha.305...v1.0.0-alpha.306) (2026-09-14)
|
|
7
|
+
|
|
8
|
+
* refactor(core)!: name the quiesce hooks pause and unpause ([6e4e5f5](https://github.com/toa-io/toa/commit/6e4e5f55d43f03ea95ca8bbdaae57b949564dfa2))
|
|
9
|
+
* feat(boot)!: a process can be halted and come back ([7196fa5](https://github.com/toa-io/toa/commit/7196fa51a9c882e84c3767a4fdd329fd2df319f5))
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **boot:** declare what a workload does with a quiesce ([0e1521d](https://github.com/toa-io/toa/commit/0e1521d7abae3a3860a29978d267927d814c1d3a))
|
|
14
|
+
* **boot:** keep a halted process alive until it comes back ([3d3cc23](https://github.com/toa-io/toa/commit/3d3cc23835f836f9f98b1d9a448090a2b0d473c8))
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **boot:** let a process go quiet and be let go again ([0fc3e66](https://github.com/toa-io/toa/commit/0fc3e66f217ab45e85f70271ab0768232b3fa088))
|
|
19
|
+
* **boot:** quiet a tree before taking it down ([7a339ca](https://github.com/toa-io/toa/commit/7a339caea4499509b55daf91061fc9836c0f6e68))
|
|
20
|
+
* **bridges.node:** let a component stop what it started and start it again ([d10d5d1](https://github.com/toa-io/toa/commit/d10d5d16fcd181e717d9d2cef4c7814ae8f2a93f))
|
|
21
|
+
|
|
22
|
+
### BREAKING CHANGES
|
|
23
|
+
|
|
24
|
+
* `Host.halt` is `Host.stop`.
|
|
25
|
+
* `Host` gains `gate` and `halt`, and `Factory.service` may answer a gate. An
|
|
26
|
+
extension that gates nothing keeps its connections through a halt.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# [1.0.0-alpha.305](https://github.com/toa-io/toa/compare/v1.0.0-alpha.304...v1.0.0-alpha.305) (2026-09-13)
|
|
30
|
+
|
|
31
|
+
### Features
|
|
32
|
+
|
|
33
|
+
* **core:** refuse a call that writes, made under a request that only reads ([92951d0](https://github.com/toa-io/toa/commit/92951d0f7b95bd5b838097dd6a42db5586968d0b))
|
|
34
|
+
|
|
35
|
+
|
|
6
36
|
# [1.0.0-alpha.304](https://github.com/toa-io/toa/compare/v1.0.0-alpha.303...v1.0.0-alpha.304) (2026-09-13)
|
|
7
37
|
|
|
8
38
|
**Note:** Version bump only for package @toa.io/boot
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/boot",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.306",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Boot",
|
|
6
6
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@toa.io/atomicity": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
26
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
24
|
+
"@toa.io/atomicity": "1.0.0-alpha.306",
|
|
25
|
+
"@toa.io/core": "1.0.0-alpha.306",
|
|
26
|
+
"@toa.io/definitions": "1.0.0-alpha.306",
|
|
27
27
|
"@toa.io/generic": "1.0.0-alpha.304",
|
|
28
|
-
"@toa.io/norm": "1.0.0-alpha.
|
|
28
|
+
"@toa.io/norm": "1.0.0-alpha.306",
|
|
29
29
|
"@toa.io/schemas": "1.0.0-alpha.304",
|
|
30
30
|
"clone-deep": "4.0.1",
|
|
31
31
|
"dotenv": "17.4.2",
|
|
32
|
-
"openspan": "1.0.0-alpha.
|
|
32
|
+
"openspan": "1.0.0-alpha.305"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "0693d180a8053a7d07dca879dac2a3c4784d6d45"
|
|
35
35
|
}
|
package/src/call.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Call, Transmission } from '@toa.io/core'
|
|
1
|
+
import { Call, safe, Transmission } from '@toa.io/core'
|
|
2
2
|
|
|
3
3
|
import * as boot from './index.js'
|
|
4
4
|
|
|
@@ -10,5 +10,19 @@ export const call = async (locator, endpoint, definition, entity, source) => {
|
|
|
10
10
|
|
|
11
11
|
const stateful = definition.stateful === true
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/*
|
|
14
|
+
* An endpoint beginning with `.` is the runtime's own rather than an operation — a lookup, an
|
|
15
|
+
* exposition — so the safety of operations has nothing to say about it. `Component.invoke`
|
|
16
|
+
* counts it as no hop in a call chain for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
const readable = endpoint[0] === '.' || safe(definition.type)
|
|
19
|
+
|
|
20
|
+
return new Call(
|
|
21
|
+
transmission,
|
|
22
|
+
contract,
|
|
23
|
+
`${locator.id}.${endpoint}`,
|
|
24
|
+
source,
|
|
25
|
+
stateful,
|
|
26
|
+
readable
|
|
27
|
+
)
|
|
14
28
|
}
|
package/src/component.js
CHANGED
|
@@ -74,6 +74,9 @@ const create = async (manifest, locator) => {
|
|
|
74
74
|
// when the RC releases what it opened
|
|
75
75
|
if (phases?.dispose !== undefined) decorated.depends(phases.dispose)
|
|
76
76
|
|
|
77
|
+
// a dependency of the component, so the walk that stops a tree reaches it there
|
|
78
|
+
if (phases?.quiescence !== undefined) decorated.depends(phases.quiescence)
|
|
79
|
+
|
|
77
80
|
return decorated
|
|
78
81
|
}
|
|
79
82
|
|
package/src/context.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Context, Locator } from '@toa.io/core'
|
|
|
3
3
|
import * as boot from './index.js'
|
|
4
4
|
|
|
5
5
|
export const context = async (manifest) => {
|
|
6
|
-
const local = await boot.remote(manifest.locator, undefined, manifest)
|
|
6
|
+
const local = await boot.remote(manifest.locator, undefined, { manifest })
|
|
7
7
|
const aspects = boot.extensions.aspects(manifest)
|
|
8
8
|
|
|
9
9
|
// a system aspect: it is there in every component, declared by none of them
|
package/src/discovery.js
CHANGED
|
@@ -31,17 +31,35 @@ export const discovery = async () => {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const lookup = async (locator) => {
|
|
35
|
-
const call = await boot.call(locator,
|
|
34
|
+
const lookup = async (locator, version) => {
|
|
35
|
+
const call = await boot.call(locator, endpoint(version), { bindings: BINDINGS })
|
|
36
36
|
|
|
37
37
|
await call.connect()
|
|
38
38
|
|
|
39
39
|
return call
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Where a lookup of a named version of a component is answered. Absent a version, the name every
|
|
44
|
+
* version of it answers on, which is what a caller with no map asks.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} [version]
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export const endpoint = (version) =>
|
|
50
|
+
version === undefined ? ENDPOINT : ENDPOINT + SEPARATOR + version
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Both names, always: the versioned one is what a caller that knows which version it wants asks,
|
|
54
|
+
* and the shared one is what everything else asks — a caller outside the deployment, a process of
|
|
55
|
+
* a runtime that has never heard of this.
|
|
56
|
+
*/
|
|
42
57
|
export const expose = async (manifest) => {
|
|
43
58
|
const exposition = new Exposition(manifest.locator, manifest)
|
|
44
|
-
const operations = {
|
|
59
|
+
const operations = {
|
|
60
|
+
[ENDPOINT]: { bindings: BINDINGS },
|
|
61
|
+
[endpoint(manifest.version)]: { bindings: BINDINGS }
|
|
62
|
+
}
|
|
45
63
|
const { local, other } = await boot.bindings.produce(exposition, operations)
|
|
46
64
|
const producers = local.concat(other)
|
|
47
65
|
|
|
@@ -52,3 +70,6 @@ export const expose = async (manifest) => {
|
|
|
52
70
|
|
|
53
71
|
const BINDINGS = ['@toa.io/bindings.amqp']
|
|
54
72
|
const ENDPOINT = '.lookup'
|
|
73
|
+
|
|
74
|
+
/** the runtime's own, as `..tasks` and `..instances` are */
|
|
75
|
+
const SEPARATOR = '..'
|
package/src/host.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
+
import { Gate } from '@toa.io/core'
|
|
2
|
+
|
|
1
3
|
import * as boot from './index.js'
|
|
2
4
|
|
|
3
5
|
/**
|
|
6
|
+
* @param {import('./workload.js').Workload} [workload] the process, where there is one
|
|
7
|
+
*
|
|
4
8
|
* What the process hosting an extension provides to it. The counterpart of a component's
|
|
5
9
|
* context: an extension reaches the core through this and through nothing else.
|
|
6
10
|
*
|
|
7
11
|
* @returns {import('@toa.io/core/types').extensions.Host}
|
|
8
12
|
*/
|
|
9
|
-
export const host = () => ({
|
|
10
|
-
|
|
13
|
+
export const host = (workload) => ({
|
|
14
|
+
// an extension knows a version only where something told it one; see `Remotes` in exposition
|
|
15
|
+
remote: (locator, source, version) => boot.remote(locator, source, { version }),
|
|
11
16
|
broadcast: boot.bindings.broadcast,
|
|
12
17
|
composition: boot.composition,
|
|
13
18
|
receive: boot.receive,
|
|
14
19
|
atom: boot.atomicity,
|
|
15
20
|
outbound: boot.bindings.outbound,
|
|
16
|
-
inbound: boot.bindings.inbound
|
|
21
|
+
inbound: boot.bindings.inbound,
|
|
22
|
+
|
|
23
|
+
// a composition booted outside a process — by a scenario, by `toa call` — has no workload, so
|
|
24
|
+
// its gates are ones nothing ever takes down and a halt asked of it is nobody's to perform
|
|
25
|
+
gate: (build) => workload?.gate(build) ?? new Gate(build),
|
|
26
|
+
quiesce: async () => await workload?.quiesce(),
|
|
27
|
+
cancel: async () => await workload?.cancel(),
|
|
28
|
+
stop: (seconds) => workload?.stop(seconds)
|
|
17
29
|
})
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { inbox } from './inbox.js'
|
|
|
18
18
|
export { outbox } from './outbox.js'
|
|
19
19
|
export { atomicity } from './atomicity.js'
|
|
20
20
|
export { atom } from './atom.js'
|
|
21
|
+
export * as map from './map.js'
|
|
21
22
|
export { manifest } from './manifest.js'
|
|
22
23
|
export { operation } from './operation.js'
|
|
23
24
|
export { receivers, receive } from './receivers.js'
|
package/src/map.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which version of a component this process asks what that component provides.
|
|
5
|
+
*
|
|
6
|
+
* Held as the file it was given rather than as what the file said, and read at every lookup: a
|
|
7
|
+
* deployment rewrites it under a running process, and a process may first call a peer long after
|
|
8
|
+
* that peer was replaced. A lookup happens once per peer, so this is a read per peer.
|
|
9
|
+
*
|
|
10
|
+
* @type {string | Record<string, string> | undefined}
|
|
11
|
+
*/
|
|
12
|
+
let source
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string | Record<string, string> | undefined} value the file, or the map itself
|
|
16
|
+
*/
|
|
17
|
+
export const use = (value) => {
|
|
18
|
+
source = value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The version of a component, where this process was given a map that names one. Absent, a
|
|
23
|
+
* lookup goes to the name every version of that component serves.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} id
|
|
26
|
+
* @returns {Promise<string | undefined>}
|
|
27
|
+
*/
|
|
28
|
+
export const version = async (id) => {
|
|
29
|
+
if (source === undefined) return undefined
|
|
30
|
+
if (typeof source !== 'string') return source[id]
|
|
31
|
+
|
|
32
|
+
return (await read(source))[id]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} path
|
|
37
|
+
* @returns {Promise<Record<string, string>>}
|
|
38
|
+
*/
|
|
39
|
+
async function read(path) {
|
|
40
|
+
let contents
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
contents = await readFile(path, 'utf8')
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
throw new Error(`Cannot read the component map '${path}'`, { cause })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(contents)
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new Error(`The component map '${path}' is not JSON`, { cause })
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/receivers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
1
2
|
import { Receiver, Locator } from '@toa.io/core'
|
|
2
3
|
|
|
3
4
|
import * as boot from './index.js'
|
|
@@ -7,7 +8,7 @@ export const receivers = async (manifest, component) => {
|
|
|
7
8
|
if (manifest.receivers === undefined) return []
|
|
8
9
|
|
|
9
10
|
const receivers = []
|
|
10
|
-
const local = await boot.remote(manifest.locator, undefined, manifest)
|
|
11
|
+
const local = await boot.remote(manifest.locator, undefined, { manifest })
|
|
11
12
|
|
|
12
13
|
for (const [label, definition] of Object.entries(manifest.receivers)) {
|
|
13
14
|
const locator = Locator.parse(label)
|
|
@@ -58,14 +59,23 @@ export async function receive(label, group, callback) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
62
|
+
* Which transport carries an event, as the component whose event it is declares it.
|
|
63
|
+
*
|
|
61
64
|
* @param {import('@toa.io/core').Locator} locator
|
|
62
65
|
* @param {string} label
|
|
63
66
|
* @return {Promise<string>}
|
|
64
67
|
*/
|
|
65
68
|
async function resolveBinding(locator, label) {
|
|
66
69
|
const event = label.split('.').pop()
|
|
70
|
+
const version = await boot.map.version(locator.id)
|
|
67
71
|
const discovery = await boot.discovery.discovery()
|
|
68
|
-
const { events } = await discovery.lookup(locator)
|
|
72
|
+
const { events } = await discovery.lookup(locator, version)
|
|
73
|
+
|
|
74
|
+
assert.ok(
|
|
75
|
+
events?.[event] !== undefined,
|
|
76
|
+
`'${locator.id}'${version === undefined ? '' : ` at version ${version}`} declares no ` +
|
|
77
|
+
`event '${event}', which is what '${label}' receives`
|
|
78
|
+
)
|
|
69
79
|
|
|
70
80
|
return events[event].binding
|
|
71
81
|
}
|
package/src/remote.js
CHANGED
|
@@ -7,16 +7,22 @@ import * as boot from './index.js'
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {import('@toa.io/core').Locator} locator
|
|
9
9
|
* @param {import('@toa.io/core/types').Source} [source] the origin stamped on every call made through this remote
|
|
10
|
-
* @param {toa.norm.Component} [
|
|
10
|
+
* @param {{ manifest?: toa.norm.Component, version?: string }} [options]
|
|
11
|
+
* `manifest` skips discovery where it is already known; `version` is which version of the
|
|
12
|
+
* component answers, where the caller knows one — the gateway, from the branch it merged.
|
|
13
|
+
* Absent, the map this process was given says, and absent that too, whichever version answers.
|
|
11
14
|
*/
|
|
12
|
-
export const remote = async (locator, source,
|
|
15
|
+
export const remote = async (locator, source, options = {}) => {
|
|
16
|
+
let { manifest } = options
|
|
13
17
|
let discovery
|
|
14
18
|
|
|
15
19
|
if (manifest === undefined) {
|
|
16
|
-
|
|
20
|
+
const version = options.version ?? (await boot.map.version(locator.id))
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
console.debug('Lookup', { locator: locator.id, version })
|
|
23
|
+
|
|
24
|
+
discovery = await boot.discovery.discovery()
|
|
25
|
+
manifest = await discovery.lookup(locator, version)
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
// a call binds its consumers, which are loaded rather than required
|
package/src/workload.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { console } from 'openspan'
|
|
2
|
+
import { Connector, Gate, deliveries, halting } from '@toa.io/core'
|
|
2
3
|
import { PREDEFINED } from '@toa.io/definitions'
|
|
3
4
|
|
|
4
5
|
import { resolve } from './extensions/resolve.js'
|
|
@@ -22,6 +23,16 @@ export class Workload extends Connector {
|
|
|
22
23
|
/** @type {import('@toa.io/core/types').extensions.Resident[]} */
|
|
23
24
|
#residents = []
|
|
24
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
|
+
|
|
25
36
|
/**
|
|
26
37
|
* @param {(workload: Workload) => Promise<import('@toa.io/core').Connector>} build
|
|
27
38
|
* what the command runs, called once
|
|
@@ -32,13 +43,87 @@ export class Workload extends Connector {
|
|
|
32
43
|
this.#build = build
|
|
33
44
|
}
|
|
34
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
|
+
|
|
35
120
|
async open() {
|
|
36
121
|
/*
|
|
37
122
|
* Connected here rather than declared as dependencies, because what they are is read from
|
|
38
123
|
* the extensions and a connector's dependencies are walked before `open` runs. The probe
|
|
39
124
|
* goes up before the build so that it answers while the build is still happening.
|
|
40
125
|
*/
|
|
41
|
-
this.#residents = await residents()
|
|
126
|
+
this.#residents = await residents(this)
|
|
42
127
|
|
|
43
128
|
for (const resident of this.#residents) {
|
|
44
129
|
this.depends(resident)
|
|
@@ -54,6 +139,118 @@ export class Workload extends Connector {
|
|
|
54
139
|
|
|
55
140
|
for (const resident of this.#residents) await resident.complete?.()
|
|
56
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
|
+
}
|
|
57
254
|
}
|
|
58
255
|
|
|
59
256
|
/**
|
|
@@ -65,7 +262,7 @@ export class Workload extends Connector {
|
|
|
65
262
|
*
|
|
66
263
|
* @returns {Promise<import('@toa.io/core/types').extensions.Resident[]>}
|
|
67
264
|
*/
|
|
68
|
-
async function residents() {
|
|
265
|
+
async function residents(workload) {
|
|
69
266
|
const { instances } = await import('./extensions/instances.js')
|
|
70
267
|
|
|
71
268
|
await Promise.all(Object.keys(PREDEFINED).map(async (name) => await resolve(name)))
|
|
@@ -75,7 +272,7 @@ async function residents() {
|
|
|
75
272
|
for (const factory of Object.values(instances)) {
|
|
76
273
|
if (factory.resident === undefined) continue
|
|
77
274
|
|
|
78
|
-
const resident = await factory.resident(host())
|
|
275
|
+
const resident = await factory.resident(host(workload))
|
|
79
276
|
|
|
80
277
|
if (resident !== null && resident !== undefined) residents.push(resident)
|
|
81
278
|
}
|
|
@@ -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
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -32,4 +32,22 @@ export function host(): types.extensions.Host
|
|
|
32
32
|
/** A process, as a connector: what the command built, and what the extensions keep in one. */
|
|
33
33
|
export class Workload extends core.Connector {
|
|
34
34
|
constructor(build: (workload: Workload) => Promise<core.Connector>)
|
|
35
|
+
|
|
36
|
+
/** A part of this process a halt takes down and builds again. */
|
|
37
|
+
gate(build: () => Promise<core.Connector>): core.Gate
|
|
38
|
+
|
|
39
|
+
/** Whether what a halt takes down is up. */
|
|
40
|
+
running(): boolean
|
|
41
|
+
|
|
42
|
+
/** Whether this process is quiet: it does nothing of its own accord and holds everything. */
|
|
43
|
+
quiescent(): boolean
|
|
44
|
+
|
|
45
|
+
/** Stops what this process does of its own accord, holding open everything it has. */
|
|
46
|
+
quiesce(): Promise<void>
|
|
47
|
+
|
|
48
|
+
/** Undoes a quiesce. */
|
|
49
|
+
cancel(): Promise<void>
|
|
50
|
+
|
|
51
|
+
/** Stops this process for `seconds`, then builds it again. */
|
|
52
|
+
stop(seconds: number): void
|
|
35
53
|
}
|