@toa.io/extensions.cadence 1.0.0-alpha.284
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 +10 -0
- package/components/cadence.metronome/manifest.toa.yaml +72 -0
- package/components/cadence.metronome/operations/delay.js +28 -0
- package/components/cadence.metronome/operations/settle.js +9 -0
- package/components/cadence.metronome/types/index.d.ts +3 -0
- package/components/cadence.metronome/types/toa.d.ts +45 -0
- package/package.json +28 -0
- package/readme.md +188 -0
- package/schemas/annotation.cos.yaml +9 -0
- package/schemas/declaration.cos.yaml +21 -0
- package/source/Aspect.ts +64 -0
- package/source/Composition.ts +47 -0
- package/source/Dispatcher.test.ts +271 -0
- package/source/Dispatcher.ts +221 -0
- package/source/Factory.ts +73 -0
- package/source/Local.ts +39 -0
- package/source/Pulse.test.ts +274 -0
- package/source/Pulse.ts +202 -0
- package/source/const.ts +23 -0
- package/source/context.ts +10 -0
- package/source/deployment.ts +33 -0
- package/source/index.ts +8 -0
- package/source/manifest.test.ts +58 -0
- package/source/manifest.ts +51 -0
- package/source/schemas.ts +10 -0
- package/source/types.ts +41 -0
- package/transpiled/Aspect.d.ts +26 -0
- package/transpiled/Aspect.js +46 -0
- package/transpiled/Aspect.js.map +1 -0
- package/transpiled/Composition.d.ts +14 -0
- package/transpiled/Composition.js +30 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Dispatcher.d.ts +63 -0
- package/transpiled/Dispatcher.js +170 -0
- package/transpiled/Dispatcher.js.map +1 -0
- package/transpiled/Factory.d.ts +22 -0
- package/transpiled/Factory.js +56 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Local.d.ts +18 -0
- package/transpiled/Local.js +29 -0
- package/transpiled/Local.js.map +1 -0
- package/transpiled/Pulse.d.ts +64 -0
- package/transpiled/Pulse.js +148 -0
- package/transpiled/Pulse.js.map +1 -0
- package/transpiled/const.d.ts +15 -0
- package/transpiled/const.js +19 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/context.d.ts +3 -0
- package/transpiled/context.js +9 -0
- package/transpiled/context.js.map +1 -0
- package/transpiled/deployment.d.ts +9 -0
- package/transpiled/deployment.js +26 -0
- package/transpiled/deployment.js.map +1 -0
- package/transpiled/index.d.ts +7 -0
- package/transpiled/index.js +7 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/manifest.d.ts +11 -0
- package/transpiled/manifest.js +39 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/schemas.d.ts +4 -0
- package/transpiled/schemas.js +7 -0
- package/transpiled/schemas.js.map +1 -0
- package/transpiled/types.d.ts +34 -0
- package/transpiled/types.js +2 -0
- package/transpiled/types.js.map +1 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { Pulse } from './Pulse.js'
|
|
5
|
+
import type { Local } from './Local.js'
|
|
6
|
+
import type { Locator, Request, atomicity } from '@toa.io/core'
|
|
7
|
+
|
|
8
|
+
type Invoke = (endpoint: string, request: Request) => Promise<unknown>
|
|
9
|
+
type Slots = (total: number) => number[] | null
|
|
10
|
+
|
|
11
|
+
let local: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
12
|
+
let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
|
|
13
|
+
|
|
14
|
+
const SECOND = 1000
|
|
15
|
+
const HOUR = 3600
|
|
16
|
+
const DAY = 24 * HOUR
|
|
17
|
+
|
|
18
|
+
const locator = { id: 'tea.pots' } as Locator
|
|
19
|
+
|
|
20
|
+
const range = (total: number): number[] => Array.from({ length: total }, (_, index) => index)
|
|
21
|
+
|
|
22
|
+
/** what a Pulse reaches for, with a lifecycle it can connect through */
|
|
23
|
+
const connector = (properties: object): any =>
|
|
24
|
+
({ ...properties, link: mock.fn(), connect: async () => {}, disconnect: async () => {} })
|
|
25
|
+
|
|
26
|
+
const create = (cycle: number, intervals: number): Pulse =>
|
|
27
|
+
new Pulse({ locator, endpoint: 'sweep', cycle, intervals }, local, atom)
|
|
28
|
+
|
|
29
|
+
/** the timers the tick arms are the only ones, so a tick is a jump and its microtasks */
|
|
30
|
+
const advance = async (ms: number): Promise<void> => {
|
|
31
|
+
mock.timers.tick(ms)
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** which interval each call was told it was for */
|
|
37
|
+
const intervals = (): number[] =>
|
|
38
|
+
local.invoke.mock.calls.map((call) => (call.arguments[1].input as { i: number }).i)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 })
|
|
43
|
+
|
|
44
|
+
local = connector({ invoke: mock.fn<Invoke>(async () => undefined) })
|
|
45
|
+
|
|
46
|
+
// one replica of one: every slot of the cycle is its own
|
|
47
|
+
atom = connector({ slots: mock.fn<Slots>((total) => range(total)) })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
mock.timers.reset()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should split a cycle into intervals', () => {
|
|
55
|
+
const pulse = create(DAY, 24)
|
|
56
|
+
|
|
57
|
+
assert.strictEqual(pulse.index(0), 0)
|
|
58
|
+
assert.strictEqual(pulse.index(HOUR * SECOND - 1), 0)
|
|
59
|
+
assert.strictEqual(pulse.index(HOUR * SECOND), 1)
|
|
60
|
+
assert.strictEqual(pulse.index(23 * HOUR * SECOND), 23)
|
|
61
|
+
assert.strictEqual(pulse.index(DAY * SECOND), 0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should give every interval of a cycle that does not divide evenly', () => {
|
|
65
|
+
const pulse = create(DAY, 7)
|
|
66
|
+
const seen = new Set<number>()
|
|
67
|
+
|
|
68
|
+
let now = 0
|
|
69
|
+
|
|
70
|
+
for (let step = 0; step < 7; step++) {
|
|
71
|
+
seen.add(pulse.index(now))
|
|
72
|
+
now = pulse.boundary(now)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
assert.deepStrictEqual([...seen].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5, 6])
|
|
76
|
+
assert.strictEqual(now, DAY * SECOND, 'seven boundaries land exactly on the next cycle')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should not drift across cycles', () => {
|
|
80
|
+
const pulse = create(DAY, 7)
|
|
81
|
+
|
|
82
|
+
let now = 0
|
|
83
|
+
|
|
84
|
+
for (let step = 0; step < 7 * 100; step++) now = pulse.boundary(now)
|
|
85
|
+
|
|
86
|
+
assert.strictEqual(now, 100 * DAY * SECOND)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should treat the interval it starts in as already handled', async () => {
|
|
90
|
+
const pulse = create(DAY, 24)
|
|
91
|
+
|
|
92
|
+
mock.timers.setTime(30 * 60 * SECOND) // half past the first hour
|
|
93
|
+
|
|
94
|
+
await pulse.connect()
|
|
95
|
+
await advance(0)
|
|
96
|
+
|
|
97
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should call at the boundary', async () => {
|
|
101
|
+
const pulse = create(DAY, 24)
|
|
102
|
+
|
|
103
|
+
mock.timers.setTime(30 * 60 * SECOND)
|
|
104
|
+
|
|
105
|
+
await pulse.connect()
|
|
106
|
+
|
|
107
|
+
await advance(30 * 60 * SECOND - 1)
|
|
108
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0, 'not a millisecond early')
|
|
109
|
+
|
|
110
|
+
await advance(1)
|
|
111
|
+
assert.strictEqual(local.invoke.mock.callCount(), 1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should tell the operation which interval it is', async () => {
|
|
115
|
+
const pulse = create(DAY, 24)
|
|
116
|
+
|
|
117
|
+
await pulse.connect()
|
|
118
|
+
await advance(HOUR * SECOND)
|
|
119
|
+
|
|
120
|
+
const [endpoint, request] = local.invoke.mock.calls[0].arguments
|
|
121
|
+
|
|
122
|
+
assert.strictEqual(endpoint, 'sweep')
|
|
123
|
+
assert.deepStrictEqual(request, { input: { n: 24, i: 1 } })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should call once per interval, and every interval of a cycle', async () => {
|
|
127
|
+
const pulse = create(DAY, 24)
|
|
128
|
+
|
|
129
|
+
await pulse.connect()
|
|
130
|
+
|
|
131
|
+
for (let hour = 0; hour < 24; hour++) await advance(HOUR * SECOND)
|
|
132
|
+
|
|
133
|
+
assert.deepStrictEqual(intervals(), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
134
|
+
17, 18, 19, 20, 21, 22, 23, 0])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should keep calling a cycle it is not splitting', async () => {
|
|
138
|
+
const pulse = create(HOUR, 1)
|
|
139
|
+
|
|
140
|
+
await pulse.connect()
|
|
141
|
+
|
|
142
|
+
for (let cycle = 0; cycle < 3; cycle++) await advance(HOUR * SECOND)
|
|
143
|
+
|
|
144
|
+
assert.deepStrictEqual(intervals(), [0, 0, 0])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should not call while this replica owns nothing', async () => {
|
|
148
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
149
|
+
|
|
150
|
+
const pulse = create(DAY, 24)
|
|
151
|
+
|
|
152
|
+
await pulse.connect()
|
|
153
|
+
await advance(HOUR * SECOND)
|
|
154
|
+
|
|
155
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should not call an interval it acquired after the boundary', async () => {
|
|
159
|
+
atom.slots.mock.mockImplementation(() => [])
|
|
160
|
+
|
|
161
|
+
const pulse = create(DAY, 24)
|
|
162
|
+
|
|
163
|
+
await pulse.connect()
|
|
164
|
+
await advance(HOUR * SECOND) // the boundary passes, and the interval is not this one's
|
|
165
|
+
|
|
166
|
+
atom.slots.mock.mockImplementation((total) => range(total))
|
|
167
|
+
|
|
168
|
+
await advance(30 * 60 * SECOND) // and half an hour later it is
|
|
169
|
+
|
|
170
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0,
|
|
171
|
+
'the replica that held it at the boundary is the only one that could have called')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should not call an interval another replica owns', async () => {
|
|
175
|
+
atom.slots.mock.mockImplementation((total) =>
|
|
176
|
+
range(total).filter((slot) => slot % 2 === 0))
|
|
177
|
+
|
|
178
|
+
const pulse = create(DAY, 24)
|
|
179
|
+
|
|
180
|
+
await pulse.connect()
|
|
181
|
+
|
|
182
|
+
for (let hour = 0; hour < 24; hour++) await advance(HOUR * SECOND)
|
|
183
|
+
|
|
184
|
+
const called = intervals()
|
|
185
|
+
|
|
186
|
+
assert.strictEqual(called.length, 12)
|
|
187
|
+
assert.ok(called.every((i) => i % 2 === 0))
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should not start a call while the one before has not returned', async () => {
|
|
191
|
+
let release: () => void = () => {}
|
|
192
|
+
|
|
193
|
+
local.invoke.mock.mockImplementation(async () =>
|
|
194
|
+
await new Promise<void>((resolve) => { release = resolve }))
|
|
195
|
+
|
|
196
|
+
const pulse = create(DAY, 24)
|
|
197
|
+
|
|
198
|
+
await pulse.connect()
|
|
199
|
+
|
|
200
|
+
await advance(HOUR * SECOND)
|
|
201
|
+
await advance(HOUR * SECOND)
|
|
202
|
+
|
|
203
|
+
assert.strictEqual(local.invoke.mock.callCount(), 1)
|
|
204
|
+
|
|
205
|
+
release()
|
|
206
|
+
await advance(0) // the call settles, and only then is the pulse free again
|
|
207
|
+
|
|
208
|
+
await advance(HOUR * SECOND)
|
|
209
|
+
|
|
210
|
+
assert.strictEqual(local.invoke.mock.callCount(), 2)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should keep its cadence when a call raises', async () => {
|
|
214
|
+
local.invoke.mock.mockImplementationOnce(async () => { throw new Error('nope') })
|
|
215
|
+
|
|
216
|
+
const pulse = create(DAY, 24)
|
|
217
|
+
|
|
218
|
+
await pulse.connect()
|
|
219
|
+
|
|
220
|
+
await advance(HOUR * SECOND)
|
|
221
|
+
await advance(HOUR * SECOND)
|
|
222
|
+
|
|
223
|
+
assert.strictEqual(local.invoke.mock.callCount(), 2,
|
|
224
|
+
'the interval that raised is skipped, and the next one is called')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should wait out a cycle longer than one timer in instalments', async () => {
|
|
228
|
+
const YEAR = 365 * DAY
|
|
229
|
+
|
|
230
|
+
const pulse = create(YEAR, 1)
|
|
231
|
+
|
|
232
|
+
await pulse.connect()
|
|
233
|
+
|
|
234
|
+
// more than `setTimeout` takes at once, and nothing is due yet
|
|
235
|
+
await advance(2 ** 31)
|
|
236
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0)
|
|
237
|
+
|
|
238
|
+
await advance(YEAR * SECOND)
|
|
239
|
+
assert.strictEqual(local.invoke.mock.callCount(), 1)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should wait for a call in flight before closing', async () => {
|
|
243
|
+
let release: () => void = () => {}
|
|
244
|
+
let closed = false
|
|
245
|
+
|
|
246
|
+
local.invoke.mock.mockImplementation(async () =>
|
|
247
|
+
await new Promise<void>((resolve) => { release = resolve }))
|
|
248
|
+
|
|
249
|
+
const pulse = create(DAY, 24)
|
|
250
|
+
|
|
251
|
+
await pulse.connect()
|
|
252
|
+
await advance(HOUR * SECOND)
|
|
253
|
+
|
|
254
|
+
const closing = pulse.disconnect().then(() => { closed = true })
|
|
255
|
+
|
|
256
|
+
await advance(0)
|
|
257
|
+
assert.strictEqual(closed, false, 'the call is still running')
|
|
258
|
+
|
|
259
|
+
release()
|
|
260
|
+
await advance(0)
|
|
261
|
+
await closing
|
|
262
|
+
|
|
263
|
+
assert.strictEqual(closed, true)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should stop calling once closed', async () => {
|
|
267
|
+
const pulse = create(DAY, 24)
|
|
268
|
+
|
|
269
|
+
await pulse.connect()
|
|
270
|
+
await pulse.disconnect()
|
|
271
|
+
await advance(HOUR * SECOND)
|
|
272
|
+
|
|
273
|
+
assert.strictEqual(local.invoke.mock.callCount(), 0)
|
|
274
|
+
})
|
package/source/Pulse.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { console } from 'openspan'
|
|
2
|
+
import { Connector } from '@toa.io/core'
|
|
3
|
+
import type { Local } from './Local.js'
|
|
4
|
+
import type { SpanOptions } from 'openspan'
|
|
5
|
+
import type { Locator, atomicity } from '@toa.io/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A recurring call to an operation of its own component, on the cadence its manifest states.
|
|
9
|
+
*
|
|
10
|
+
* Nothing is stored. The cycle is split into `n` intervals and the current one is a function of
|
|
11
|
+
* the clock, so every replica computes the same `i` without talking to any other; the one that
|
|
12
|
+
* owns `i` is the one that calls. An interval no replica was alive for is not made up
|
|
13
|
+
* afterwards — the operation is expected to select by what is still due rather than by where
|
|
14
|
+
* the cycle has reached.
|
|
15
|
+
*/
|
|
16
|
+
export class Pulse extends Connector {
|
|
17
|
+
private readonly endpoint: string
|
|
18
|
+
private readonly label: string
|
|
19
|
+
|
|
20
|
+
/** milliseconds one whole cycle takes */
|
|
21
|
+
private readonly cycle: number
|
|
22
|
+
|
|
23
|
+
/** intervals the cycle is split into */
|
|
24
|
+
private readonly n: number
|
|
25
|
+
|
|
26
|
+
private readonly local: Local
|
|
27
|
+
private readonly atom: atomicity.Atom
|
|
28
|
+
|
|
29
|
+
/** span options, built once */
|
|
30
|
+
private readonly options: SpanOptions
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ordinal of the interval this process has already handled, so an early wake-up is not a
|
|
34
|
+
* second call. An ordinal and not an index: with one interval per cycle the index never
|
|
35
|
+
* changes, and comparing it would suppress every call after the first.
|
|
36
|
+
*/
|
|
37
|
+
private fired = 0
|
|
38
|
+
|
|
39
|
+
private timer?: NodeJS.Timeout
|
|
40
|
+
|
|
41
|
+
/** the call in flight, awaited on close */
|
|
42
|
+
private firing?: Promise<unknown>
|
|
43
|
+
|
|
44
|
+
private closing = false
|
|
45
|
+
|
|
46
|
+
public constructor (definition: Definition, local: Local, atom: atomicity.Atom) {
|
|
47
|
+
super()
|
|
48
|
+
|
|
49
|
+
const { locator, endpoint, cycle, intervals } = definition
|
|
50
|
+
|
|
51
|
+
this.endpoint = endpoint
|
|
52
|
+
this.label = `${locator.id}.${endpoint}`
|
|
53
|
+
this.cycle = cycle * 1000
|
|
54
|
+
this.n = intervals
|
|
55
|
+
|
|
56
|
+
this.local = local
|
|
57
|
+
this.atom = atom
|
|
58
|
+
|
|
59
|
+
this.depends(local)
|
|
60
|
+
this.depends(atom)
|
|
61
|
+
|
|
62
|
+
this.options = {
|
|
63
|
+
name: `${this.label} pulse`,
|
|
64
|
+
kind: 'producer',
|
|
65
|
+
service: locator.id
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How many intervals have passed since the epoch, `now` included. Against the cycle rather
|
|
71
|
+
* than by adding up gaps, so a cycle that does not divide evenly into its intervals neither
|
|
72
|
+
* drifts nor accumulates error.
|
|
73
|
+
*
|
|
74
|
+
* The arithmetic is exact rather than double: `now` is milliseconds since 1970 and `n` may be
|
|
75
|
+
* as large as the cycle has seconds, so their product leaves the range a double is exact in
|
|
76
|
+
* for a long cycle cut finely. It is evaluated once per interval, where this costs nothing.
|
|
77
|
+
*/
|
|
78
|
+
public ordinal (now: number): number {
|
|
79
|
+
return Number(BigInt(now) * BigInt(this.n) / BigInt(this.cycle))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Which of the cycle's intervals `now` falls in. */
|
|
83
|
+
public index (now: number): number {
|
|
84
|
+
return this.ordinal(now) % this.n
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** When the interval `now` falls in gives way to the next one. */
|
|
88
|
+
public boundary (now: number): number {
|
|
89
|
+
const cycle = BigInt(this.cycle)
|
|
90
|
+
const n = BigInt(this.n)
|
|
91
|
+
const next = BigInt(this.ordinal(now) + 1)
|
|
92
|
+
|
|
93
|
+
// the earliest millisecond whose ordinal is the next one
|
|
94
|
+
return Number((next * cycle + n - 1n) / n)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
protected override async open (): Promise<void> {
|
|
98
|
+
// whatever interval it is now, this replica was not there when it began, so it is not
|
|
99
|
+
// this one's to call — the first call is at the next boundary
|
|
100
|
+
this.fired = this.ordinal(Date.now())
|
|
101
|
+
|
|
102
|
+
this.arm()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected override async close (): Promise<void> {
|
|
106
|
+
this.closing = true
|
|
107
|
+
|
|
108
|
+
clearTimeout(this.timer)
|
|
109
|
+
|
|
110
|
+
// a call still running holds the remote it was made through, which is torn down after
|
|
111
|
+
// this returns. Bounded, because an operation that never returns would outlive any
|
|
112
|
+
// grace period and the interval is over either way
|
|
113
|
+
if (this.firing !== undefined)
|
|
114
|
+
await Promise.race([this.firing, delay(DRAIN)])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private arm (): void {
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
|
|
120
|
+
// a delay past this fires at once rather than late, so a long cycle is waited out in
|
|
121
|
+
// instalments; the wake-up finds the same interval and simply arms again
|
|
122
|
+
const wait = Math.min(this.boundary(now) - now, MAX_DELAY)
|
|
123
|
+
|
|
124
|
+
this.timer = setTimeout(() => this.tick(), wait)
|
|
125
|
+
this.timer.unref()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private tick (): void {
|
|
129
|
+
if (this.closing) return
|
|
130
|
+
|
|
131
|
+
const ordinal = this.ordinal(Date.now())
|
|
132
|
+
|
|
133
|
+
// a timer may wake a millisecond before the boundary it was set for, and a cycle longer
|
|
134
|
+
// than one timer wakes several times on the way — both are the interval already handled
|
|
135
|
+
if (ordinal !== this.fired) {
|
|
136
|
+
this.fired = ordinal
|
|
137
|
+
|
|
138
|
+
void this.fire(ordinal % this.n)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.arm()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Nothing queues here. A call still running when the next boundary arrives means the work
|
|
146
|
+
* does not fit the gap, and starting a second one would only make that worse.
|
|
147
|
+
*/
|
|
148
|
+
private async fire (i: number): Promise<void> {
|
|
149
|
+
if (this.firing !== undefined) {
|
|
150
|
+
console.warn('Pulse skipped: the previous call has not returned',
|
|
151
|
+
{ pulse: this.label, interval: i })
|
|
152
|
+
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const owned = this.atom.slots(this.n)
|
|
157
|
+
|
|
158
|
+
if (owned === null) {
|
|
159
|
+
console.warn('Pulse skipped: this replica owns nothing',
|
|
160
|
+
{ pulse: this.label, interval: i })
|
|
161
|
+
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!owned.includes(i)) return
|
|
166
|
+
|
|
167
|
+
/*
|
|
168
|
+
* A pulse calls and waits, so an operation that raised comes back as an exception here —
|
|
169
|
+
* the runtime carries it across and rethrows it in this process. What to do about one is a
|
|
170
|
+
* choice with no right answer, and asking would put the whole of it in front of anyone who
|
|
171
|
+
* only wanted an operation called every hour. So the interval is skipped: it is reported,
|
|
172
|
+
* and the next one is called as though it had not happened.
|
|
173
|
+
*/
|
|
174
|
+
const firing = console.span(this.options,
|
|
175
|
+
async () => await this.local.invoke(this.endpoint, { input: { n: this.n, i } }))
|
|
176
|
+
.catch((error: unknown) => {
|
|
177
|
+
console.error('Pulse failed', { pulse: this.label, interval: i, error })
|
|
178
|
+
})
|
|
179
|
+
.finally(() => { this.firing = undefined })
|
|
180
|
+
|
|
181
|
+
this.firing = firing
|
|
182
|
+
|
|
183
|
+
await firing
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface Definition {
|
|
188
|
+
locator: Locator
|
|
189
|
+
endpoint: string
|
|
190
|
+
cycle: number
|
|
191
|
+
intervals: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function delay (ms: number): Promise<void> {
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, ms).unref())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `setTimeout` fires immediately past this, so a longer wait is taken in instalments */
|
|
199
|
+
const MAX_DELAY = 2 ** 31 - 1
|
|
200
|
+
|
|
201
|
+
/** how long a call in flight is waited for on close */
|
|
202
|
+
const DRAIN = 10_000
|
package/source/const.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The component the extension ships to hold delayed calls. */
|
|
2
|
+
export const NAMESPACE = 'cadence'
|
|
3
|
+
export const COMPONENT = 'metronome'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Constant, never configuration: a row carries its lane, so lowering this would leave rows in
|
|
7
|
+
* lanes nobody reads any more. It is also the ceiling on replicas dispatching, and a power of
|
|
8
|
+
* two so that the common replica counts divide evenly. The outbox says the same of its own.
|
|
9
|
+
*/
|
|
10
|
+
export const LANES = 128
|
|
11
|
+
|
|
12
|
+
/** seconds between passes over the calls waiting to be made, and how far ahead each reaches */
|
|
13
|
+
export const DISCRETENESS = 60
|
|
14
|
+
|
|
15
|
+
/** rows one scan brings back */
|
|
16
|
+
export const BATCH = 200
|
|
17
|
+
|
|
18
|
+
/** A number from the environment, where a deployment or a test suite states one. */
|
|
19
|
+
export function number (variable: string, fallback: number): number {
|
|
20
|
+
const value = Number(process.env[variable])
|
|
21
|
+
|
|
22
|
+
return Number.isNaN(value) || value <= 0 ? fallback : value
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Contribution } from '@toa.io/core'
|
|
2
|
+
|
|
3
|
+
/** What this extension puts on the context of a component that declares it. */
|
|
4
|
+
export function context (): Contribution {
|
|
5
|
+
return {
|
|
6
|
+
name: 'delay',
|
|
7
|
+
type: 'Delay',
|
|
8
|
+
imports: { '@toa.io/extensions.cadence': ['Delay'] }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { components } from './Composition.js'
|
|
3
|
+
import { DISCRETENESS, NAMESPACE } from './const.js'
|
|
4
|
+
import * as schemas from './schemas.js'
|
|
5
|
+
import type { Annotation } from './types.js'
|
|
6
|
+
import type { Dependency, Service, Variable } from '@toa.io/operations'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The component that keeps delayed calls runs in a service of its own, the way the identity
|
|
10
|
+
* components run in the gateway. A composition may claim it and run it in its own pods instead.
|
|
11
|
+
*/
|
|
12
|
+
export function deployment (_: unknown, annotation?: Annotation | null): Dependency {
|
|
13
|
+
schemas.annotation.validate<Annotation | null>(annotation ?? null, 'Invalid cadence annotation')
|
|
14
|
+
|
|
15
|
+
// stated in seconds and carried in milliseconds, so that what an application writes is what
|
|
16
|
+
// the rest of a manifest is written in
|
|
17
|
+
const discreteness = (annotation?.discreteness ?? DISCRETENESS) * 1000
|
|
18
|
+
const variables: Variable[] =
|
|
19
|
+
[{ name: 'TOA_CADENCE_DISCRETENESS', value: String(discreteness) }]
|
|
20
|
+
|
|
21
|
+
const service: Service = {
|
|
22
|
+
group: NAMESPACE,
|
|
23
|
+
name: 'metronome',
|
|
24
|
+
version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
|
|
25
|
+
components: components().labels,
|
|
26
|
+
variables
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { services: [service] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The extension is deployed for the sake of `delay`, which nothing has to declare to use. */
|
|
33
|
+
export const standalone = true
|
package/source/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Factory } from './Factory.js'
|
|
2
|
+
export { manifest } from './manifest.js'
|
|
3
|
+
export { components } from './Composition.js'
|
|
4
|
+
export { deployment, standalone } from './deployment.js'
|
|
5
|
+
export { context } from './context.js'
|
|
6
|
+
export { LANES } from './const.js'
|
|
7
|
+
|
|
8
|
+
export type { Declaration, Delay, Pulse } from './types.js'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { manifest } from './manifest.js'
|
|
5
|
+
import type { Manifest } from '@toa.io/norm'
|
|
6
|
+
|
|
7
|
+
const component = {
|
|
8
|
+
operations: {
|
|
9
|
+
sweep: { type: 'effect' },
|
|
10
|
+
gather: { type: 'transition' },
|
|
11
|
+
peek: { type: 'observation' }
|
|
12
|
+
}
|
|
13
|
+
} as unknown as Manifest
|
|
14
|
+
|
|
15
|
+
it('should normalize a declaration', () => {
|
|
16
|
+
const declaration = manifest({ sweep: { cycle: 86400, intervals: 24 } }, component)
|
|
17
|
+
|
|
18
|
+
assert.deepStrictEqual(declaration, { sweep: { cycle: 86400, intervals: 24 } })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should split a cycle into one interval by default', () => {
|
|
22
|
+
const declaration = manifest({ sweep: { cycle: 3600 } } as any, component)
|
|
23
|
+
|
|
24
|
+
assert.deepStrictEqual(declaration, { sweep: { cycle: 3600, intervals: 1 } })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should expand the shorthand', () => {
|
|
28
|
+
const declaration = manifest({ sweep: 3600 } as any, component)
|
|
29
|
+
|
|
30
|
+
assert.deepStrictEqual(declaration, { sweep: { cycle: 3600, intervals: 1 } })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should answer a component that only delays calls', () => {
|
|
34
|
+
assert.deepStrictEqual(manifest(null, component), {})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should refuse an undefined operation', () => {
|
|
38
|
+
assert.throws(() => manifest({ nothing: 3600 } as any, component),
|
|
39
|
+
/refers to undefined operation 'nothing'/)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should refuse an operation that produces no side effects', () => {
|
|
43
|
+
assert.throws(() => manifest({ peek: 3600 } as any, component),
|
|
44
|
+
/of the allowed types/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should refuse an interval shorter than a second', () => {
|
|
48
|
+
assert.throws(() => manifest({ sweep: { cycle: 60, intervals: 61 } }, component),
|
|
49
|
+
/less than a second/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should refuse a cycle of no time at all', () => {
|
|
53
|
+
assert.throws(() => manifest({ sweep: { cycle: 0, intervals: 1 } }, component))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should refuse what the declaration does not describe', () => {
|
|
57
|
+
assert.throws(() => manifest({ sweep: { cycle: 60, every: 3 } } as any, component))
|
|
58
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as schemas from './schemas.js'
|
|
2
|
+
import type { Declaration } from './types.js'
|
|
3
|
+
import type { Manifest } from '@toa.io/norm'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `cadence:` block of a component manifest: the operations called on a cadence, by the
|
|
7
|
+
* operation each one calls. Norm hands over the whole manifest, so what the declaration refers
|
|
8
|
+
* to is checked here rather than left to fail at boot.
|
|
9
|
+
*
|
|
10
|
+
* A component that declares nothing still has a declaration. `cadence: ~` is how one that only
|
|
11
|
+
* delays calls says so, and it has to produce a value or norm rejects the extension.
|
|
12
|
+
*/
|
|
13
|
+
export function manifest (declaration: Declaration | null | undefined,
|
|
14
|
+
component: Manifest): Declaration {
|
|
15
|
+
const normalized: Record<string, unknown> = expand(declaration)
|
|
16
|
+
|
|
17
|
+
// `intervals` is defaulted by the schema, so the declaration is only whole after this
|
|
18
|
+
schemas.declaration.validate<Declaration>(normalized, 'Invalid cadence declaration')
|
|
19
|
+
|
|
20
|
+
for (const [endpoint, pulse] of Object.entries(normalized)) {
|
|
21
|
+
const operation = component.operations?.[endpoint]
|
|
22
|
+
|
|
23
|
+
if (operation === undefined)
|
|
24
|
+
throw new Error(`Pulse refers to undefined operation '${endpoint}'`)
|
|
25
|
+
|
|
26
|
+
if (!TYPES.has(operation.type))
|
|
27
|
+
throw new Error(`Pulse '${endpoint}' must refer to an operation of the allowed types: ` +
|
|
28
|
+
[...TYPES].join(', '))
|
|
29
|
+
|
|
30
|
+
if (pulse.intervals > pulse.cycle)
|
|
31
|
+
throw new Error(`Pulse '${endpoint}' splits a cycle of ${pulse.cycle} seconds into ` +
|
|
32
|
+
`${pulse.intervals} intervals, which is less than a second each`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return normalized
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A pulse whose cycle is not split has nothing to state but the cycle. */
|
|
39
|
+
function expand (declaration: Declaration | null | undefined): Record<string, unknown> {
|
|
40
|
+
if (declaration === null || declaration === undefined) return {}
|
|
41
|
+
|
|
42
|
+
return Object.fromEntries(Object.entries(declaration)
|
|
43
|
+
.map(([endpoint, pulse]) =>
|
|
44
|
+
[endpoint, typeof pulse === 'number' ? { cycle: pulse } : pulse]))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The types a receiver may refer to. An operation that produces no side effects has nothing to
|
|
49
|
+
* be called periodically for.
|
|
50
|
+
*/
|
|
51
|
+
const TYPES = new Set(['transition', 'assignment', 'effect', 'unmanaged'])
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import * as schemas from '@toa.io/schemas'
|
|
3
|
+
import type { Schema } from '@toa.io/schemas'
|
|
4
|
+
import type { Annotation, Declaration } from './types.js'
|
|
5
|
+
|
|
6
|
+
const path = resolve(import.meta.dirname, '../schemas')
|
|
7
|
+
const namespace = schemas.namespace(path)
|
|
8
|
+
|
|
9
|
+
export const declaration: Schema<Declaration> = namespace.schema('declaration')
|
|
10
|
+
export const annotation: Schema<Annotation> = namespace.schema('annotation')
|