@toa.io/extensions.cadence 1.0.0-alpha.284 → 1.0.0-alpha.286
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/LICENSE +22 -0
- package/components/cadence.metronome/manifest.toa.yaml +22 -28
- package/components/cadence.metronome/migrations/0001-system-properties.yaml +14 -0
- package/components/cadence.metronome/migrations/0002-indexes.yaml +5 -0
- package/components/cadence.metronome/operations/delay.js +1 -1
- package/components/cadence.metronome/operations/settle.js +2 -2
- package/components/cadence.metronome/types/toa.d.ts +5 -5
- package/package.json +6 -5
- package/readme.md +21 -18
- package/schemas/declaration.cos.yaml +0 -1
- package/source/Aspect.ts +19 -10
- package/source/Composition.ts +4 -4
- package/source/Dispatcher.test.ts +68 -29
- package/source/Dispatcher.ts +46 -23
- package/source/Factory.ts +13 -10
- package/source/Local.ts +5 -4
- package/source/Pulse.test.ts +48 -19
- package/source/Pulse.ts +30 -21
- package/source/const.ts +1 -1
- package/source/context.ts +2 -2
- package/source/deployment.ts +10 -5
- package/source/manifest.test.ts +9 -6
- package/source/manifest.ts +24 -11
- package/transpiled/Aspect.d.ts +0 -1
- package/transpiled/Aspect.js +11 -5
- package/transpiled/Aspect.js.map +1 -1
- package/transpiled/Composition.js.map +1 -1
- package/transpiled/Dispatcher.d.ts +1 -1
- package/transpiled/Dispatcher.js +27 -10
- package/transpiled/Dispatcher.js.map +1 -1
- package/transpiled/Factory.d.ts +1 -1
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Local.d.ts +2 -1
- package/transpiled/Local.js.map +1 -1
- package/transpiled/Pulse.d.ts +2 -1
- package/transpiled/Pulse.js +14 -5
- package/transpiled/Pulse.js.map +1 -1
- package/transpiled/const.js.map +1 -1
- package/transpiled/context.d.ts +1 -1
- package/transpiled/deployment.js +5 -2
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/manifest.js +6 -3
- package/transpiled/manifest.js.map +1 -1
- package/tsconfig.tsbuildinfo +1 -0
- package/CHANGELOG.md +0 -10
package/source/Dispatcher.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { console } from 'openspan'
|
|
|
2
2
|
import { Connector, Locator } from '@toa.io/core'
|
|
3
3
|
import { BATCH, DISCRETENESS, LANES, number } from './const.js'
|
|
4
4
|
import type { Local } from './Local.js'
|
|
5
|
-
import type { atomicity } from '@toa.io/core'
|
|
5
|
+
import type { atomicity } from '@toa.io/core/types'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Makes the calls that were put off.
|
|
@@ -37,8 +37,11 @@ export class Dispatcher extends Connector {
|
|
|
37
37
|
private scanning = false
|
|
38
38
|
private closing = false
|
|
39
39
|
|
|
40
|
-
public constructor
|
|
41
|
-
|
|
40
|
+
public constructor(
|
|
41
|
+
metronome: Local,
|
|
42
|
+
resolve: (locator: Locator) => Local,
|
|
43
|
+
atom: atomicity.Atom
|
|
44
|
+
) {
|
|
42
45
|
super()
|
|
43
46
|
|
|
44
47
|
this.metronome = metronome
|
|
@@ -50,15 +53,19 @@ export class Dispatcher extends Connector {
|
|
|
50
53
|
this.depends(atom)
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
protected override async open
|
|
54
|
-
this.timer = setInterval(() => {
|
|
56
|
+
protected override async open(): Promise<void> {
|
|
57
|
+
this.timer = setInterval(() => {
|
|
58
|
+
this.tick()
|
|
59
|
+
}, this.discreteness)
|
|
55
60
|
this.timer.unref()
|
|
56
61
|
|
|
57
62
|
// a lane changing hands is when its rows become this replica's, or stop being
|
|
58
|
-
this.off = this.atom.onassigned(() => {
|
|
63
|
+
this.off = this.atom.onassigned(() => {
|
|
64
|
+
this.adopt()
|
|
65
|
+
})
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
protected override async close
|
|
68
|
+
protected override async close(): Promise<void> {
|
|
62
69
|
this.closing = true
|
|
63
70
|
|
|
64
71
|
this.off?.()
|
|
@@ -72,14 +79,18 @@ export class Dispatcher extends Connector {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/** One scan at a time: a slow one must not have another started underneath it. */
|
|
75
|
-
private tick
|
|
82
|
+
private tick(): void {
|
|
76
83
|
if (this.scanning || this.closing) return
|
|
77
84
|
|
|
78
85
|
this.scanning = true
|
|
79
86
|
|
|
80
87
|
void this.scan()
|
|
81
|
-
.catch((error: unknown) => {
|
|
82
|
-
|
|
88
|
+
.catch((error: unknown) => {
|
|
89
|
+
console.error('Delayed calls scan failed', { error })
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
this.scanning = false
|
|
93
|
+
})
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
/**
|
|
@@ -87,7 +98,7 @@ export class Dispatcher extends Connector {
|
|
|
87
98
|
* whoever holds them now will arm them — and a scan runs at once rather than at the next
|
|
88
99
|
* interval, so lanes just acquired are not left waiting.
|
|
89
100
|
*/
|
|
90
|
-
private adopt
|
|
101
|
+
private adopt(): void {
|
|
91
102
|
// nothing owned is nothing held: what was armed under a claim this replica no longer has
|
|
92
103
|
// belongs to whoever has it now, and firing it anyway is the call twice
|
|
93
104
|
this.disarm(this.atom.slots(LANES) ?? [])
|
|
@@ -96,7 +107,7 @@ export class Dispatcher extends Connector {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
|
|
99
|
-
private disarm
|
|
110
|
+
private disarm(lanes: number[]): void {
|
|
100
111
|
for (const [id, { timer, lane }] of this.armed) {
|
|
101
112
|
if (lanes.includes(lane)) continue
|
|
102
113
|
|
|
@@ -105,7 +116,7 @@ export class Dispatcher extends Connector {
|
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
private async scan
|
|
119
|
+
private async scan(): Promise<void> {
|
|
109
120
|
const lanes = this.atom.slots(LANES)
|
|
110
121
|
|
|
111
122
|
if (lanes === null || lanes.length === 0) return
|
|
@@ -115,14 +126,14 @@ export class Dispatcher extends Connector {
|
|
|
115
126
|
const now = Date.now()
|
|
116
127
|
const until = now + this.discreteness
|
|
117
128
|
|
|
118
|
-
const rows = await this.metronome.invoke('enumerate', {
|
|
129
|
+
const rows = (await this.metronome.invoke('enumerate', {
|
|
119
130
|
query: {
|
|
120
131
|
// a call nothing was running to make in time has expired, and is simply not read
|
|
121
132
|
criteria: `lane=in=(${lanes.join(',')});due<${until};expires>${now}`,
|
|
122
133
|
sort: ['due:asc'],
|
|
123
134
|
limit: BATCH
|
|
124
135
|
}
|
|
125
|
-
}) as Row[]
|
|
136
|
+
})) as Row[]
|
|
126
137
|
|
|
127
138
|
for (const row of rows) this.arm(row)
|
|
128
139
|
}
|
|
@@ -131,10 +142,15 @@ export class Dispatcher extends Connector {
|
|
|
131
142
|
* A row already armed is left alone: a scan reads what it read before, because a row stays
|
|
132
143
|
* until its call has been made.
|
|
133
144
|
*/
|
|
134
|
-
private arm
|
|
145
|
+
private arm(row: Row): void {
|
|
135
146
|
if (this.armed.has(row.id) || this.called.has(row.id)) return
|
|
136
147
|
|
|
137
|
-
const timer = setTimeout(
|
|
148
|
+
const timer = setTimeout(
|
|
149
|
+
() => {
|
|
150
|
+
void this.dispatch(row)
|
|
151
|
+
},
|
|
152
|
+
Math.max(row.due - Date.now(), 0)
|
|
153
|
+
)
|
|
138
154
|
|
|
139
155
|
timer.unref()
|
|
140
156
|
this.armed.set(row.id, { timer, lane: row.lane })
|
|
@@ -145,7 +161,7 @@ export class Dispatcher extends Connector {
|
|
|
145
161
|
* makes the call twice, and the other order would lose it. It travels as a task, so the
|
|
146
162
|
* broker holds it if the target is not there to take it.
|
|
147
163
|
*/
|
|
148
|
-
private async dispatch
|
|
164
|
+
private async dispatch(row: Row): Promise<void> {
|
|
149
165
|
if (this.closing) {
|
|
150
166
|
this.armed.delete(row.id)
|
|
151
167
|
|
|
@@ -170,20 +186,27 @@ export class Dispatcher extends Connector {
|
|
|
170
186
|
*
|
|
171
187
|
* Skipped, as a pulse skips: reported, and the row settled on the attempt either way.
|
|
172
188
|
*/
|
|
173
|
-
await local
|
|
189
|
+
await local
|
|
190
|
+
.invoke(endpoint, { input: null, ...row.request, task: true })
|
|
174
191
|
.catch((error: unknown) => {
|
|
175
|
-
console.error('Delayed call failed', {
|
|
192
|
+
console.error('Delayed call failed', {
|
|
193
|
+
endpoint: row.endpoint,
|
|
194
|
+
id: row.id,
|
|
195
|
+
error
|
|
196
|
+
})
|
|
176
197
|
})
|
|
177
198
|
// held for as long as the call is, so `armed` answers what this replica has in hand
|
|
178
199
|
// rather than what it has a timer for
|
|
179
|
-
.finally(() => {
|
|
200
|
+
.finally(() => {
|
|
201
|
+
this.armed.delete(row.id)
|
|
202
|
+
})
|
|
180
203
|
}
|
|
181
204
|
|
|
182
205
|
/**
|
|
183
206
|
* A remote is resolved once per target and held: it opens a connection, and one nothing
|
|
184
207
|
* depends on would outlive the dispatcher that made it.
|
|
185
208
|
*/
|
|
186
|
-
private target
|
|
209
|
+
private target(locator: Locator): Local {
|
|
187
210
|
const local = this.resolve(locator)
|
|
188
211
|
|
|
189
212
|
if (!this.targets.has(local)) {
|
|
@@ -195,7 +218,7 @@ export class Dispatcher extends Connector {
|
|
|
195
218
|
}
|
|
196
219
|
|
|
197
220
|
/** One write for many rows, which is why the ids are held rather than settled one by one. */
|
|
198
|
-
private async settle
|
|
221
|
+
private async settle(): Promise<void> {
|
|
199
222
|
if (this.called.size === 0) return
|
|
200
223
|
|
|
201
224
|
const ids = [...this.called]
|
package/source/Factory.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Dispatcher } from './Dispatcher.js'
|
|
|
6
6
|
import { Local } from './Local.js'
|
|
7
7
|
import { Pulse } from './Pulse.js'
|
|
8
8
|
import type { Declaration } from './types.js'
|
|
9
|
-
import type { extensions } from '@toa.io/core'
|
|
9
|
+
import type { extensions } from '@toa.io/core/types'
|
|
10
10
|
|
|
11
11
|
export class Factory implements extensions.Factory {
|
|
12
12
|
private readonly host: Host
|
|
@@ -14,7 +14,7 @@ export class Factory implements extensions.Factory {
|
|
|
14
14
|
/** one per component, however much of this extension calls through it */
|
|
15
15
|
private readonly locals: Record<string, Local> = {}
|
|
16
16
|
|
|
17
|
-
public constructor
|
|
17
|
+
public constructor(host: Host) {
|
|
18
18
|
this.host = host
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -25,10 +25,13 @@ export class Factory implements extensions.Factory {
|
|
|
25
25
|
* A tenant is created before the components of its composition are, which is why nothing here
|
|
26
26
|
* reaches for one.
|
|
27
27
|
*/
|
|
28
|
-
public tenant
|
|
28
|
+
public tenant(locator: Locator, declaration: object): Connector {
|
|
29
29
|
if (mine(locator))
|
|
30
|
-
return new Dispatcher(
|
|
31
|
-
this.
|
|
30
|
+
return new Dispatcher(
|
|
31
|
+
this.metronome(),
|
|
32
|
+
(target) => this.local(target),
|
|
33
|
+
this.host.atom(locator.id)
|
|
34
|
+
)
|
|
32
35
|
|
|
33
36
|
const tenant = new Connector()
|
|
34
37
|
const pulses = declaration as Declaration | null
|
|
@@ -46,27 +49,27 @@ export class Factory implements extensions.Factory {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** `context.delay` — for every component but the one that keeps the calls. */
|
|
49
|
-
public aspect
|
|
52
|
+
public aspect(locator: Locator): extensions.Aspect | extensions.Aspect[] {
|
|
50
53
|
if (mine(locator)) return []
|
|
51
54
|
|
|
52
55
|
return new Aspect(this.metronome())
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
public service
|
|
58
|
+
public service(): Connector {
|
|
56
59
|
return new Composition(this.host)
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
private metronome
|
|
62
|
+
private metronome(): Local {
|
|
60
63
|
return this.local(new Locator(COMPONENT, NAMESPACE))
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
private local
|
|
66
|
+
private local(locator: Locator): Local {
|
|
64
67
|
return (this.locals[locator.id] ??= new Local(this.host, locator))
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/** Whether this is a component the extension ships rather than one of the application's. */
|
|
69
|
-
function mine
|
|
72
|
+
function mine(locator: Locator): boolean {
|
|
70
73
|
return locator.namespace === NAMESPACE
|
|
71
74
|
}
|
|
72
75
|
|
package/source/Local.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Connector } from '@toa.io/core'
|
|
2
2
|
import type { Host } from './Factory.js'
|
|
3
|
-
import type { Locator, Remote
|
|
3
|
+
import type { Locator, Remote } from '@toa.io/core'
|
|
4
|
+
import type { Reply, Request } from '@toa.io/core/types'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The component a tenant belongs to, as something to call.
|
|
@@ -14,20 +15,20 @@ export class Local extends Connector {
|
|
|
14
15
|
private readonly locator: Locator
|
|
15
16
|
private remote?: Promise<Remote>
|
|
16
17
|
|
|
17
|
-
public constructor
|
|
18
|
+
public constructor(host: Host, locator: Locator) {
|
|
18
19
|
super()
|
|
19
20
|
|
|
20
21
|
this.host = host
|
|
21
22
|
this.locator = locator
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
public async invoke
|
|
25
|
+
public async invoke(endpoint: string, request: Request): Promise<Reply> {
|
|
25
26
|
this.remote ??= this.locate()
|
|
26
27
|
|
|
27
28
|
return await (await this.remote).invoke(endpoint, request)
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
private async locate
|
|
31
|
+
private async locate(): Promise<Remote> {
|
|
31
32
|
const remote = await this.host.remote(this.locator)
|
|
32
33
|
|
|
33
34
|
this.depends(remote)
|
package/source/Pulse.test.ts
CHANGED
|
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import { Pulse } from './Pulse.js'
|
|
5
5
|
import type { Local } from './Local.js'
|
|
6
|
-
import type { Locator
|
|
6
|
+
import type { Locator } from '@toa.io/core'
|
|
7
|
+
import type { Request, atomicity } from '@toa.io/core/types'
|
|
7
8
|
|
|
8
9
|
type Invoke = (endpoint: string, request: Request) => Promise<unknown>
|
|
9
10
|
type Slots = (total: number) => number[] | null
|
|
@@ -17,11 +18,16 @@ const DAY = 24 * HOUR
|
|
|
17
18
|
|
|
18
19
|
const locator = { id: 'tea.pots' } as Locator
|
|
19
20
|
|
|
20
|
-
const range = (total: number): number[] =>
|
|
21
|
+
const range = (total: number): number[] =>
|
|
22
|
+
Array.from({ length: total }, (_, index) => index)
|
|
21
23
|
|
|
22
24
|
/** what a Pulse reaches for, with a lifecycle it can connect through */
|
|
23
|
-
const connector = (properties: object): any =>
|
|
24
|
-
|
|
25
|
+
const connector = (properties: object): any => ({
|
|
26
|
+
...properties,
|
|
27
|
+
link: mock.fn(),
|
|
28
|
+
connect: async () => {},
|
|
29
|
+
disconnect: async () => {}
|
|
30
|
+
})
|
|
25
31
|
|
|
26
32
|
const create = (cycle: number, intervals: number): Pulse =>
|
|
27
33
|
new Pulse({ locator, endpoint: 'sweep', cycle, intervals }, local, atom)
|
|
@@ -37,7 +43,6 @@ const advance = async (ms: number): Promise<void> => {
|
|
|
37
43
|
const intervals = (): number[] =>
|
|
38
44
|
local.invoke.mock.calls.map((call) => (call.arguments[1].input as { i: number }).i)
|
|
39
45
|
|
|
40
|
-
|
|
41
46
|
beforeEach(() => {
|
|
42
47
|
mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 })
|
|
43
48
|
|
|
@@ -72,7 +77,10 @@ it('should give every interval of a cycle that does not divide evenly', () => {
|
|
|
72
77
|
now = pulse.boundary(now)
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
assert.deepStrictEqual(
|
|
80
|
+
assert.deepStrictEqual(
|
|
81
|
+
[...seen].sort((a, b) => a - b),
|
|
82
|
+
[0, 1, 2, 3, 4, 5, 6]
|
|
83
|
+
)
|
|
76
84
|
assert.strictEqual(now, DAY * SECOND, 'seven boundaries land exactly on the next cycle')
|
|
77
85
|
})
|
|
78
86
|
|
|
@@ -130,8 +138,10 @@ it('should call once per interval, and every interval of a cycle', async () => {
|
|
|
130
138
|
|
|
131
139
|
for (let hour = 0; hour < 24; hour++) await advance(HOUR * SECOND)
|
|
132
140
|
|
|
133
|
-
assert.deepStrictEqual(
|
|
134
|
-
|
|
141
|
+
assert.deepStrictEqual(
|
|
142
|
+
intervals(),
|
|
143
|
+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 0]
|
|
144
|
+
)
|
|
135
145
|
})
|
|
136
146
|
|
|
137
147
|
it('should keep calling a cycle it is not splitting', async () => {
|
|
@@ -167,13 +177,17 @@ it('should not call an interval it acquired after the boundary', async () => {
|
|
|
167
177
|
|
|
168
178
|
await advance(30 * 60 * SECOND) // and half an hour later it is
|
|
169
179
|
|
|
170
|
-
assert.strictEqual(
|
|
171
|
-
|
|
180
|
+
assert.strictEqual(
|
|
181
|
+
local.invoke.mock.callCount(),
|
|
182
|
+
0,
|
|
183
|
+
'the replica that held it at the boundary is the only one that could have called'
|
|
184
|
+
)
|
|
172
185
|
})
|
|
173
186
|
|
|
174
187
|
it('should not call an interval another replica owns', async () => {
|
|
175
188
|
atom.slots.mock.mockImplementation((total) =>
|
|
176
|
-
range(total).filter((slot) => slot % 2 === 0)
|
|
189
|
+
range(total).filter((slot) => slot % 2 === 0)
|
|
190
|
+
)
|
|
177
191
|
|
|
178
192
|
const pulse = create(DAY, 24)
|
|
179
193
|
|
|
@@ -190,8 +204,12 @@ it('should not call an interval another replica owns', async () => {
|
|
|
190
204
|
it('should not start a call while the one before has not returned', async () => {
|
|
191
205
|
let release: () => void = () => {}
|
|
192
206
|
|
|
193
|
-
local.invoke.mock.mockImplementation(
|
|
194
|
-
|
|
207
|
+
local.invoke.mock.mockImplementation(
|
|
208
|
+
async () =>
|
|
209
|
+
await new Promise<void>((resolve) => {
|
|
210
|
+
release = resolve
|
|
211
|
+
})
|
|
212
|
+
)
|
|
195
213
|
|
|
196
214
|
const pulse = create(DAY, 24)
|
|
197
215
|
|
|
@@ -211,7 +229,9 @@ it('should not start a call while the one before has not returned', async () =>
|
|
|
211
229
|
})
|
|
212
230
|
|
|
213
231
|
it('should keep its cadence when a call raises', async () => {
|
|
214
|
-
local.invoke.mock.mockImplementationOnce(async () => {
|
|
232
|
+
local.invoke.mock.mockImplementationOnce(async () => {
|
|
233
|
+
throw new Error('nope')
|
|
234
|
+
})
|
|
215
235
|
|
|
216
236
|
const pulse = create(DAY, 24)
|
|
217
237
|
|
|
@@ -220,8 +240,11 @@ it('should keep its cadence when a call raises', async () => {
|
|
|
220
240
|
await advance(HOUR * SECOND)
|
|
221
241
|
await advance(HOUR * SECOND)
|
|
222
242
|
|
|
223
|
-
assert.strictEqual(
|
|
224
|
-
|
|
243
|
+
assert.strictEqual(
|
|
244
|
+
local.invoke.mock.callCount(),
|
|
245
|
+
2,
|
|
246
|
+
'the interval that raised is skipped, and the next one is called'
|
|
247
|
+
)
|
|
225
248
|
})
|
|
226
249
|
|
|
227
250
|
it('should wait out a cycle longer than one timer in instalments', async () => {
|
|
@@ -243,15 +266,21 @@ it('should wait for a call in flight before closing', async () => {
|
|
|
243
266
|
let release: () => void = () => {}
|
|
244
267
|
let closed = false
|
|
245
268
|
|
|
246
|
-
local.invoke.mock.mockImplementation(
|
|
247
|
-
|
|
269
|
+
local.invoke.mock.mockImplementation(
|
|
270
|
+
async () =>
|
|
271
|
+
await new Promise<void>((resolve) => {
|
|
272
|
+
release = resolve
|
|
273
|
+
})
|
|
274
|
+
)
|
|
248
275
|
|
|
249
276
|
const pulse = create(DAY, 24)
|
|
250
277
|
|
|
251
278
|
await pulse.connect()
|
|
252
279
|
await advance(HOUR * SECOND)
|
|
253
280
|
|
|
254
|
-
const closing = pulse.disconnect().then(() => {
|
|
281
|
+
const closing = pulse.disconnect().then(() => {
|
|
282
|
+
closed = true
|
|
283
|
+
})
|
|
255
284
|
|
|
256
285
|
await advance(0)
|
|
257
286
|
assert.strictEqual(closed, false, 'the call is still running')
|
package/source/Pulse.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { console } from 'openspan'
|
|
|
2
2
|
import { Connector } from '@toa.io/core'
|
|
3
3
|
import type { Local } from './Local.js'
|
|
4
4
|
import type { SpanOptions } from 'openspan'
|
|
5
|
-
import type { Locator
|
|
5
|
+
import type { Locator } from '@toa.io/core'
|
|
6
|
+
import type { atomicity } from '@toa.io/core/types'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* A recurring call to an operation of its own component, on the cadence its manifest states.
|
|
@@ -43,7 +44,7 @@ export class Pulse extends Connector {
|
|
|
43
44
|
|
|
44
45
|
private closing = false
|
|
45
46
|
|
|
46
|
-
public constructor
|
|
47
|
+
public constructor(definition: Definition, local: Local, atom: atomicity.Atom) {
|
|
47
48
|
super()
|
|
48
49
|
|
|
49
50
|
const { locator, endpoint, cycle, intervals } = definition
|
|
@@ -75,17 +76,17 @@ export class Pulse extends Connector {
|
|
|
75
76
|
* as large as the cycle has seconds, so their product leaves the range a double is exact in
|
|
76
77
|
* for a long cycle cut finely. It is evaluated once per interval, where this costs nothing.
|
|
77
78
|
*/
|
|
78
|
-
public ordinal
|
|
79
|
-
return Number(BigInt(now) * BigInt(this.n) / BigInt(this.cycle))
|
|
79
|
+
public ordinal(now: number): number {
|
|
80
|
+
return Number((BigInt(now) * BigInt(this.n)) / BigInt(this.cycle))
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Which of the cycle's intervals `now` falls in. */
|
|
83
|
-
public index
|
|
84
|
+
public index(now: number): number {
|
|
84
85
|
return this.ordinal(now) % this.n
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/** When the interval `now` falls in gives way to the next one. */
|
|
88
|
-
public boundary
|
|
89
|
+
public boundary(now: number): number {
|
|
89
90
|
const cycle = BigInt(this.cycle)
|
|
90
91
|
const n = BigInt(this.n)
|
|
91
92
|
const next = BigInt(this.ordinal(now) + 1)
|
|
@@ -94,7 +95,7 @@ export class Pulse extends Connector {
|
|
|
94
95
|
return Number((next * cycle + n - 1n) / n)
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
protected override async open
|
|
98
|
+
protected override async open(): Promise<void> {
|
|
98
99
|
// whatever interval it is now, this replica was not there when it began, so it is not
|
|
99
100
|
// this one's to call — the first call is at the next boundary
|
|
100
101
|
this.fired = this.ordinal(Date.now())
|
|
@@ -102,7 +103,7 @@ export class Pulse extends Connector {
|
|
|
102
103
|
this.arm()
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
protected override async close
|
|
106
|
+
protected override async close(): Promise<void> {
|
|
106
107
|
this.closing = true
|
|
107
108
|
|
|
108
109
|
clearTimeout(this.timer)
|
|
@@ -110,11 +111,10 @@ export class Pulse extends Connector {
|
|
|
110
111
|
// a call still running holds the remote it was made through, which is torn down after
|
|
111
112
|
// this returns. Bounded, because an operation that never returns would outlive any
|
|
112
113
|
// grace period and the interval is over either way
|
|
113
|
-
if (this.firing !== undefined)
|
|
114
|
-
await Promise.race([this.firing, delay(DRAIN)])
|
|
114
|
+
if (this.firing !== undefined) await Promise.race([this.firing, delay(DRAIN)])
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
private arm
|
|
117
|
+
private arm(): void {
|
|
118
118
|
const now = Date.now()
|
|
119
119
|
|
|
120
120
|
// a delay past this fires at once rather than late, so a long cycle is waited out in
|
|
@@ -125,7 +125,7 @@ export class Pulse extends Connector {
|
|
|
125
125
|
this.timer.unref()
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
private tick
|
|
128
|
+
private tick(): void {
|
|
129
129
|
if (this.closing) return
|
|
130
130
|
|
|
131
131
|
const ordinal = this.ordinal(Date.now())
|
|
@@ -145,10 +145,12 @@ export class Pulse extends Connector {
|
|
|
145
145
|
* Nothing queues here. A call still running when the next boundary arrives means the work
|
|
146
146
|
* does not fit the gap, and starting a second one would only make that worse.
|
|
147
147
|
*/
|
|
148
|
-
private async fire
|
|
148
|
+
private async fire(i: number): Promise<void> {
|
|
149
149
|
if (this.firing !== undefined) {
|
|
150
|
-
console.warn('Pulse skipped: the previous call has not returned',
|
|
151
|
-
|
|
150
|
+
console.warn('Pulse skipped: the previous call has not returned', {
|
|
151
|
+
pulse: this.label,
|
|
152
|
+
interval: i
|
|
153
|
+
})
|
|
152
154
|
|
|
153
155
|
return
|
|
154
156
|
}
|
|
@@ -156,8 +158,10 @@ export class Pulse extends Connector {
|
|
|
156
158
|
const owned = this.atom.slots(this.n)
|
|
157
159
|
|
|
158
160
|
if (owned === null) {
|
|
159
|
-
console.warn('Pulse skipped: this replica owns nothing',
|
|
160
|
-
|
|
161
|
+
console.warn('Pulse skipped: this replica owns nothing', {
|
|
162
|
+
pulse: this.label,
|
|
163
|
+
interval: i
|
|
164
|
+
})
|
|
161
165
|
|
|
162
166
|
return
|
|
163
167
|
}
|
|
@@ -171,12 +175,17 @@ export class Pulse extends Connector {
|
|
|
171
175
|
* only wanted an operation called every hour. So the interval is skipped: it is reported,
|
|
172
176
|
* and the next one is called as though it had not happened.
|
|
173
177
|
*/
|
|
174
|
-
const firing = console
|
|
175
|
-
|
|
178
|
+
const firing = console
|
|
179
|
+
.span(
|
|
180
|
+
this.options,
|
|
181
|
+
async () => await this.local.invoke(this.endpoint, { input: { n: this.n, i } })
|
|
182
|
+
)
|
|
176
183
|
.catch((error: unknown) => {
|
|
177
184
|
console.error('Pulse failed', { pulse: this.label, interval: i, error })
|
|
178
185
|
})
|
|
179
|
-
.finally(() => {
|
|
186
|
+
.finally(() => {
|
|
187
|
+
this.firing = undefined
|
|
188
|
+
})
|
|
180
189
|
|
|
181
190
|
this.firing = firing
|
|
182
191
|
|
|
@@ -191,7 +200,7 @@ export interface Definition {
|
|
|
191
200
|
intervals: number
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
async function delay
|
|
203
|
+
async function delay(ms: number): Promise<void> {
|
|
195
204
|
await new Promise((resolve) => setTimeout(resolve, ms).unref())
|
|
196
205
|
}
|
|
197
206
|
|
package/source/const.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const DISCRETENESS = 60
|
|
|
16
16
|
export const BATCH = 200
|
|
17
17
|
|
|
18
18
|
/** A number from the environment, where a deployment or a test suite states one. */
|
|
19
|
-
export function number
|
|
19
|
+
export function number(variable: string, fallback: number): number {
|
|
20
20
|
const value = Number(process.env[variable])
|
|
21
21
|
|
|
22
22
|
return Number.isNaN(value) || value <= 0 ? fallback : value
|
package/source/context.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Contribution } from '@toa.io/core'
|
|
1
|
+
import type { Contribution } from '@toa.io/core/types'
|
|
2
2
|
|
|
3
3
|
/** What this extension puts on the context of a component that declares it. */
|
|
4
|
-
export function context
|
|
4
|
+
export function context(): Contribution {
|
|
5
5
|
return {
|
|
6
6
|
name: 'delay',
|
|
7
7
|
type: 'Delay',
|
package/source/deployment.ts
CHANGED
|
@@ -9,19 +9,24 @@ import type { Dependency, Service, Variable } from '@toa.io/operations'
|
|
|
9
9
|
* The component that keeps delayed calls runs in a service of its own, the way the identity
|
|
10
10
|
* components run in the gateway. A composition may claim it and run it in its own pods instead.
|
|
11
11
|
*/
|
|
12
|
-
export function deployment
|
|
13
|
-
schemas.annotation.validate<Annotation | null>(
|
|
12
|
+
export function deployment(_: unknown, annotation?: Annotation | null): Dependency {
|
|
13
|
+
schemas.annotation.validate<Annotation | null>(
|
|
14
|
+
annotation ?? null,
|
|
15
|
+
'Invalid cadence annotation'
|
|
16
|
+
)
|
|
14
17
|
|
|
15
18
|
// stated in seconds and carried in milliseconds, so that what an application writes is what
|
|
16
19
|
// the rest of a manifest is written in
|
|
17
20
|
const discreteness = (annotation?.discreteness ?? DISCRETENESS) * 1000
|
|
18
|
-
const variables: Variable[] =
|
|
19
|
-
|
|
21
|
+
const variables: Variable[] = [
|
|
22
|
+
{ name: 'TOA_CADENCE_DISCRETENESS', value: String(discreteness) }
|
|
23
|
+
]
|
|
20
24
|
|
|
21
25
|
const service: Service = {
|
|
22
26
|
group: NAMESPACE,
|
|
23
27
|
name: 'metronome',
|
|
24
|
-
version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
28
|
+
version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
29
|
+
.version,
|
|
25
30
|
components: components().labels,
|
|
26
31
|
variables
|
|
27
32
|
}
|
package/source/manifest.test.ts
CHANGED
|
@@ -35,18 +35,21 @@ it('should answer a component that only delays calls', () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it('should refuse an undefined operation', () => {
|
|
38
|
-
assert.throws(
|
|
39
|
-
|
|
38
|
+
assert.throws(
|
|
39
|
+
() => manifest({ nothing: 3600 } as any, component),
|
|
40
|
+
/refers to undefined operation 'nothing'/
|
|
41
|
+
)
|
|
40
42
|
})
|
|
41
43
|
|
|
42
44
|
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
|
+
assert.throws(() => manifest({ peek: 3600 } as any, component), /of the allowed types/)
|
|
45
46
|
})
|
|
46
47
|
|
|
47
48
|
it('should refuse an interval shorter than a second', () => {
|
|
48
|
-
assert.throws(
|
|
49
|
-
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => manifest({ sweep: { cycle: 60, intervals: 61 } }, component),
|
|
51
|
+
/less than a second/
|
|
52
|
+
)
|
|
50
53
|
})
|
|
51
54
|
|
|
52
55
|
it('should refuse a cycle of no time at all', () => {
|