@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,271 @@
|
|
|
1
|
+
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { Dispatcher } from './Dispatcher.js'
|
|
5
|
+
import { LANES } from './const.js'
|
|
6
|
+
import type { Local } from './Local.js'
|
|
7
|
+
import type { atomicity } from '@toa.io/core'
|
|
8
|
+
|
|
9
|
+
type Invoke = (endpoint: string, request: any) => Promise<unknown>
|
|
10
|
+
type Slots = (total: number) => number[] | null
|
|
11
|
+
type Listener = () => void
|
|
12
|
+
|
|
13
|
+
const INTERVAL = 100
|
|
14
|
+
|
|
15
|
+
let metronome: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
16
|
+
let target: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
17
|
+
let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
|
|
18
|
+
let listeners: Listener[]
|
|
19
|
+
let rows: Row[]
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
id: string
|
|
23
|
+
lane: number
|
|
24
|
+
due: number
|
|
25
|
+
endpoint: string
|
|
26
|
+
request: object | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const row = (id: string, due: number, lane = 0): Row =>
|
|
30
|
+
({ id, lane, due, endpoint: 'tea.pots.transit', request: { input: { id } } })
|
|
31
|
+
|
|
32
|
+
const connector = (properties: object): any =>
|
|
33
|
+
({ ...properties, link: mock.fn(), connect: async () => {}, disconnect: async () => {} })
|
|
34
|
+
|
|
35
|
+
const advance = async (ms: number): Promise<void> => {
|
|
36
|
+
mock.timers.tick(ms)
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < 30; i++) await Promise.resolve()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the query one scan made */
|
|
42
|
+
const reads = (): any[] => metronome.invoke.mock.calls
|
|
43
|
+
.filter((call) => call.arguments[0] === 'enumerate')
|
|
44
|
+
.map((call) => call.arguments[1].query)
|
|
45
|
+
|
|
46
|
+
/** what has been settled, flattened */
|
|
47
|
+
const settled = (): string[] => metronome.invoke.mock.calls
|
|
48
|
+
.filter((call) => call.arguments[0] === 'settle')
|
|
49
|
+
.flatMap((call) => call.arguments[1].query.ids as string[])
|
|
50
|
+
|
|
51
|
+
const range = (total: number): number[] => Array.from({ length: total }, (_, i) => i)
|
|
52
|
+
|
|
53
|
+
const create = (): Dispatcher =>
|
|
54
|
+
new Dispatcher(metronome, () => target, atom)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
process.env.TOA_CADENCE_DISCRETENESS = String(INTERVAL)
|
|
59
|
+
mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 0 })
|
|
60
|
+
|
|
61
|
+
rows = []
|
|
62
|
+
listeners = []
|
|
63
|
+
|
|
64
|
+
metronome = connector({
|
|
65
|
+
invoke: mock.fn<Invoke>(async (endpoint: string) => endpoint === 'enumerate' ? rows : null)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
target = connector({ invoke: mock.fn<Invoke>(async () => null) })
|
|
69
|
+
|
|
70
|
+
atom = connector({
|
|
71
|
+
slots: mock.fn<Slots>((total) => range(total)),
|
|
72
|
+
onassigned: (listener: Listener) => {
|
|
73
|
+
listeners.push(listener)
|
|
74
|
+
listener()
|
|
75
|
+
|
|
76
|
+
return () => { listeners = listeners.filter((one) => one !== listener) }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
mock.timers.reset()
|
|
83
|
+
delete process.env.TOA_CADENCE_DISCRETENESS
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should read what is due within the interval, in the lanes it owns', async () => {
|
|
87
|
+
const dispatcher = create()
|
|
88
|
+
|
|
89
|
+
await dispatcher.connect()
|
|
90
|
+
await advance(INTERVAL)
|
|
91
|
+
|
|
92
|
+
const [first] = reads()
|
|
93
|
+
|
|
94
|
+
// the first scan is the one the subscription runs as it is added, at time zero
|
|
95
|
+
assert.strictEqual(first.criteria,
|
|
96
|
+
`lane=in=(${range(LANES).join(',')});due<${INTERVAL};expires>0`)
|
|
97
|
+
assert.deepStrictEqual(first.sort, ['due:asc'])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should read nothing while it owns nothing', async () => {
|
|
101
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
102
|
+
|
|
103
|
+
const dispatcher = create()
|
|
104
|
+
|
|
105
|
+
await dispatcher.connect()
|
|
106
|
+
await advance(INTERVAL * 3)
|
|
107
|
+
|
|
108
|
+
assert.strictEqual(reads().length, 0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should call at the due time and not before', async () => {
|
|
112
|
+
rows = [row('a', 60)]
|
|
113
|
+
|
|
114
|
+
const dispatcher = create()
|
|
115
|
+
|
|
116
|
+
await dispatcher.connect()
|
|
117
|
+
await advance(1)
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not called')
|
|
120
|
+
|
|
121
|
+
await advance(60)
|
|
122
|
+
|
|
123
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
124
|
+
|
|
125
|
+
const [endpoint, request] = target.invoke.mock.calls[0].arguments
|
|
126
|
+
|
|
127
|
+
assert.strictEqual(endpoint, 'transit')
|
|
128
|
+
assert.deepStrictEqual(request, { input: { id: 'a' }, task: true })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should call one whose time has already passed at once', async () => {
|
|
132
|
+
rows = [row('a', -10_000)]
|
|
133
|
+
|
|
134
|
+
const dispatcher = create()
|
|
135
|
+
|
|
136
|
+
await dispatcher.connect()
|
|
137
|
+
await advance(0)
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1,
|
|
140
|
+
'a scan reads what is overdue along with what is coming')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should arm a row once, however often a scan reads it', async () => {
|
|
144
|
+
rows = [row('a', 10_000)]
|
|
145
|
+
|
|
146
|
+
const dispatcher = create()
|
|
147
|
+
|
|
148
|
+
await dispatcher.connect()
|
|
149
|
+
|
|
150
|
+
// one interval at a time: a scan is one at a time, and a single jump would find the first
|
|
151
|
+
// still running and skip the rest
|
|
152
|
+
for (let cycle = 0; cycle < 4; cycle++) await advance(INTERVAL)
|
|
153
|
+
|
|
154
|
+
assert.ok(reads().length >= 3, 'it kept scanning')
|
|
155
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'and armed it only once')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should not arm a row again while its call is in flight', async () => {
|
|
159
|
+
let release: () => void = () => {}
|
|
160
|
+
|
|
161
|
+
rows = [row('a', 10)]
|
|
162
|
+
target.invoke.mock.mockImplementation(async () =>
|
|
163
|
+
await new Promise<void>((resolve) => { release = resolve }))
|
|
164
|
+
|
|
165
|
+
const dispatcher = create()
|
|
166
|
+
|
|
167
|
+
await dispatcher.connect()
|
|
168
|
+
await advance(10) // the call goes out, and does not come back
|
|
169
|
+
|
|
170
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
171
|
+
|
|
172
|
+
// a scan while it is in flight reads the row again, and settles what it thinks was called
|
|
173
|
+
await advance(INTERVAL)
|
|
174
|
+
|
|
175
|
+
// far enough for anything that scan armed to have fired
|
|
176
|
+
await advance(INTERVAL)
|
|
177
|
+
|
|
178
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1, 'and does not send it a second time')
|
|
179
|
+
|
|
180
|
+
release()
|
|
181
|
+
await advance(0)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should settle what it has called', async () => {
|
|
185
|
+
rows = [row('a', 10)]
|
|
186
|
+
|
|
187
|
+
const dispatcher = create()
|
|
188
|
+
|
|
189
|
+
await dispatcher.connect()
|
|
190
|
+
await advance(10)
|
|
191
|
+
|
|
192
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
193
|
+
|
|
194
|
+
rows = []
|
|
195
|
+
await advance(INTERVAL)
|
|
196
|
+
|
|
197
|
+
assert.deepStrictEqual(settled(), ['a'])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should settle a row whose call failed, and keep dispatching', async () => {
|
|
201
|
+
rows = [row('a', 10), row('b', 20)]
|
|
202
|
+
target.invoke.mock.mockImplementationOnce(async () => { throw new Error('nope') })
|
|
203
|
+
|
|
204
|
+
const dispatcher = create()
|
|
205
|
+
|
|
206
|
+
await dispatcher.connect()
|
|
207
|
+
await advance(20)
|
|
208
|
+
|
|
209
|
+
assert.strictEqual(target.invoke.mock.callCount(), 2, 'the one after it is still called')
|
|
210
|
+
|
|
211
|
+
rows = []
|
|
212
|
+
await advance(INTERVAL)
|
|
213
|
+
|
|
214
|
+
assert.deepStrictEqual(settled().sort(), ['a', 'b'], 'and the one that failed is settled')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should drop timers for lanes it no longer owns', async () => {
|
|
218
|
+
rows = [row('a', 10_000, 7)]
|
|
219
|
+
|
|
220
|
+
const dispatcher = create()
|
|
221
|
+
|
|
222
|
+
await dispatcher.connect()
|
|
223
|
+
await advance(INTERVAL)
|
|
224
|
+
|
|
225
|
+
rows = []
|
|
226
|
+
atom.slots.mock.mockImplementation(() => [0])
|
|
227
|
+
|
|
228
|
+
for (const listener of listeners) listener()
|
|
229
|
+
|
|
230
|
+
await advance(20_000)
|
|
231
|
+
|
|
232
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'lane 7 is somebody else\'s now')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should drop everything it holds when it owns nothing', async () => {
|
|
236
|
+
rows = [row('a', 10_000, 7)]
|
|
237
|
+
|
|
238
|
+
const dispatcher = create()
|
|
239
|
+
|
|
240
|
+
await dispatcher.connect()
|
|
241
|
+
await advance(INTERVAL)
|
|
242
|
+
|
|
243
|
+
rows = []
|
|
244
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
245
|
+
|
|
246
|
+
for (const listener of listeners) listener()
|
|
247
|
+
|
|
248
|
+
await advance(20_000)
|
|
249
|
+
|
|
250
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0,
|
|
251
|
+
'whoever holds lane 7 now is the one to make that call')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should scan at once when what it owns changes', async () => {
|
|
255
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
256
|
+
|
|
257
|
+
const dispatcher = create()
|
|
258
|
+
|
|
259
|
+
await dispatcher.connect()
|
|
260
|
+
await advance(0)
|
|
261
|
+
|
|
262
|
+
assert.strictEqual(reads().length, 0)
|
|
263
|
+
|
|
264
|
+
atom.slots.mock.mockImplementation((total) => range(total))
|
|
265
|
+
|
|
266
|
+
for (const listener of listeners) listener()
|
|
267
|
+
|
|
268
|
+
await advance(0)
|
|
269
|
+
|
|
270
|
+
assert.strictEqual(reads().length, 1, 'without waiting out an interval')
|
|
271
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { console } from 'openspan'
|
|
2
|
+
import { Connector, Locator } from '@toa.io/core'
|
|
3
|
+
import { BATCH, DISCRETENESS, LANES, number } from './const.js'
|
|
4
|
+
import type { Local } from './Local.js'
|
|
5
|
+
import type { atomicity } from '@toa.io/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Makes the calls that were put off.
|
|
9
|
+
*
|
|
10
|
+
* Two tiers, which is what a store polled rarely and a call due to the second need between
|
|
11
|
+
* them: the rows are the record, and one scan primes the next interval of them into timers.
|
|
12
|
+
* A crash loses the timers and nothing else — the next scan reads what is overdue along with
|
|
13
|
+
* what is upcoming, so a dispatcher that died mid-interval costs a delay and not a call.
|
|
14
|
+
*
|
|
15
|
+
* Partitioned by lane exactly as the outbox is, so every replica scans and dispatches its own
|
|
16
|
+
* share of every interval rather than taking turns at whole ones.
|
|
17
|
+
*/
|
|
18
|
+
export class Dispatcher extends Connector {
|
|
19
|
+
private readonly metronome: Local
|
|
20
|
+
private readonly resolve: (locator: Locator) => Local
|
|
21
|
+
private readonly atom: atomicity.Atom
|
|
22
|
+
|
|
23
|
+
/** rows in this replica's hands — a timer set for them, or a call of theirs on its way */
|
|
24
|
+
private readonly armed = new Map<string, Armed>()
|
|
25
|
+
|
|
26
|
+
/** ids called and not yet removed, held until a scan settles them */
|
|
27
|
+
private called = new Set<string>()
|
|
28
|
+
|
|
29
|
+
/** what it has called through, so that each is torn down with it */
|
|
30
|
+
private readonly targets = new Set<Local>()
|
|
31
|
+
|
|
32
|
+
/** milliseconds between passes, and how far ahead each one reaches */
|
|
33
|
+
private readonly discreteness: number
|
|
34
|
+
|
|
35
|
+
private timer?: NodeJS.Timeout
|
|
36
|
+
private off?: () => void
|
|
37
|
+
private scanning = false
|
|
38
|
+
private closing = false
|
|
39
|
+
|
|
40
|
+
public constructor (metronome: Local, resolve: (locator: Locator) => Local,
|
|
41
|
+
atom: atomicity.Atom) {
|
|
42
|
+
super()
|
|
43
|
+
|
|
44
|
+
this.metronome = metronome
|
|
45
|
+
this.resolve = resolve
|
|
46
|
+
this.atom = atom
|
|
47
|
+
this.discreteness = number('TOA_CADENCE_DISCRETENESS', DISCRETENESS * 1000)
|
|
48
|
+
|
|
49
|
+
this.depends(metronome)
|
|
50
|
+
this.depends(atom)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected override async open (): Promise<void> {
|
|
54
|
+
this.timer = setInterval(() => { this.tick() }, this.discreteness)
|
|
55
|
+
this.timer.unref()
|
|
56
|
+
|
|
57
|
+
// a lane changing hands is when its rows become this replica's, or stop being
|
|
58
|
+
this.off = this.atom.onassigned(() => { this.adopt() })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected override async close (): Promise<void> {
|
|
62
|
+
this.closing = true
|
|
63
|
+
|
|
64
|
+
this.off?.()
|
|
65
|
+
clearInterval(this.timer)
|
|
66
|
+
|
|
67
|
+
for (const { timer } of this.armed.values()) clearTimeout(timer)
|
|
68
|
+
|
|
69
|
+
this.armed.clear()
|
|
70
|
+
|
|
71
|
+
await this.settle()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One scan at a time: a slow one must not have another started underneath it. */
|
|
75
|
+
private tick (): void {
|
|
76
|
+
if (this.scanning || this.closing) return
|
|
77
|
+
|
|
78
|
+
this.scanning = true
|
|
79
|
+
|
|
80
|
+
void this.scan()
|
|
81
|
+
.catch((error: unknown) => { console.error('Delayed calls scan failed', { error }) })
|
|
82
|
+
.finally(() => { this.scanning = false })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What this replica owns has changed. Timers for lanes it no longer holds are dropped —
|
|
87
|
+
* whoever holds them now will arm them — and a scan runs at once rather than at the next
|
|
88
|
+
* interval, so lanes just acquired are not left waiting.
|
|
89
|
+
*/
|
|
90
|
+
private adopt (): void {
|
|
91
|
+
// nothing owned is nothing held: what was armed under a claim this replica no longer has
|
|
92
|
+
// belongs to whoever has it now, and firing it anyway is the call twice
|
|
93
|
+
this.disarm(this.atom.slots(LANES) ?? [])
|
|
94
|
+
|
|
95
|
+
this.tick()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
|
|
99
|
+
private disarm (lanes: number[]): void {
|
|
100
|
+
for (const [id, { timer, lane }] of this.armed) {
|
|
101
|
+
if (lanes.includes(lane)) continue
|
|
102
|
+
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
this.armed.delete(id)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async scan (): Promise<void> {
|
|
109
|
+
const lanes = this.atom.slots(LANES)
|
|
110
|
+
|
|
111
|
+
if (lanes === null || lanes.length === 0) return
|
|
112
|
+
|
|
113
|
+
await this.settle()
|
|
114
|
+
|
|
115
|
+
const now = Date.now()
|
|
116
|
+
const until = now + this.discreteness
|
|
117
|
+
|
|
118
|
+
const rows = await this.metronome.invoke('enumerate', {
|
|
119
|
+
query: {
|
|
120
|
+
// a call nothing was running to make in time has expired, and is simply not read
|
|
121
|
+
criteria: `lane=in=(${lanes.join(',')});due<${until};expires>${now}`,
|
|
122
|
+
sort: ['due:asc'],
|
|
123
|
+
limit: BATCH
|
|
124
|
+
}
|
|
125
|
+
}) as Row[]
|
|
126
|
+
|
|
127
|
+
for (const row of rows) this.arm(row)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A row already armed is left alone: a scan reads what it read before, because a row stays
|
|
132
|
+
* until its call has been made.
|
|
133
|
+
*/
|
|
134
|
+
private arm (row: Row): void {
|
|
135
|
+
if (this.armed.has(row.id) || this.called.has(row.id)) return
|
|
136
|
+
|
|
137
|
+
const timer = setTimeout(() => { void this.dispatch(row) }, Math.max(row.due - Date.now(), 0))
|
|
138
|
+
|
|
139
|
+
timer.unref()
|
|
140
|
+
this.armed.set(row.id, { timer, lane: row.lane })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The call goes out before the row is removed, never the other way round: a crash in between
|
|
145
|
+
* makes the call twice, and the other order would lose it. It travels as a task, so the
|
|
146
|
+
* broker holds it if the target is not there to take it.
|
|
147
|
+
*/
|
|
148
|
+
private async dispatch (row: Row): Promise<void> {
|
|
149
|
+
if (this.closing) {
|
|
150
|
+
this.armed.delete(row.id)
|
|
151
|
+
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const [namespace, name, endpoint] = row.endpoint.split('.')
|
|
156
|
+
const local = this.target(new Locator(name, namespace))
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* Attempted before it is made, not after it comes back. One attempt is what a dispatcher
|
|
160
|
+
* has to give: what it can tell about a failure is nothing, and a request the target's
|
|
161
|
+
* schema no longer fits never becomes one it does.
|
|
162
|
+
*/
|
|
163
|
+
this.called.add(row.id)
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* The call travels as a task, so what raises here is this side of it: a stored request the
|
|
167
|
+
* target's contract no longer fits, an endpoint it no longer has, a broker that refused the
|
|
168
|
+
* enqueue. What the operation itself does with it happens in the target's own process and
|
|
169
|
+
* never comes back.
|
|
170
|
+
*
|
|
171
|
+
* Skipped, as a pulse skips: reported, and the row settled on the attempt either way.
|
|
172
|
+
*/
|
|
173
|
+
await local.invoke(endpoint, { input: null, ...row.request, task: true })
|
|
174
|
+
.catch((error: unknown) => {
|
|
175
|
+
console.error('Delayed call failed', { endpoint: row.endpoint, id: row.id, error })
|
|
176
|
+
})
|
|
177
|
+
// held for as long as the call is, so `armed` answers what this replica has in hand
|
|
178
|
+
// rather than what it has a timer for
|
|
179
|
+
.finally(() => { this.armed.delete(row.id) })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A remote is resolved once per target and held: it opens a connection, and one nothing
|
|
184
|
+
* depends on would outlive the dispatcher that made it.
|
|
185
|
+
*/
|
|
186
|
+
private target (locator: Locator): Local {
|
|
187
|
+
const local = this.resolve(locator)
|
|
188
|
+
|
|
189
|
+
if (!this.targets.has(local)) {
|
|
190
|
+
this.targets.add(local)
|
|
191
|
+
this.depends(local)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return local
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One write for many rows, which is why the ids are held rather than settled one by one. */
|
|
198
|
+
private async settle (): Promise<void> {
|
|
199
|
+
if (this.called.size === 0) return
|
|
200
|
+
|
|
201
|
+
const ids = [...this.called]
|
|
202
|
+
|
|
203
|
+
await this.metronome.invoke('settle', { query: { ids }, input: null })
|
|
204
|
+
|
|
205
|
+
// held until the write comes back, so that one which does not leaves them to a later scan
|
|
206
|
+
for (const id of ids) this.called.delete(id)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Armed {
|
|
211
|
+
timer: NodeJS.Timeout
|
|
212
|
+
lane: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface Row {
|
|
216
|
+
id: string
|
|
217
|
+
lane: number
|
|
218
|
+
due: number
|
|
219
|
+
endpoint: string
|
|
220
|
+
request?: object
|
|
221
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Connector, Locator } from '@toa.io/core'
|
|
2
|
+
import { Composition } from './Composition.js'
|
|
3
|
+
import { COMPONENT, NAMESPACE } from './const.js'
|
|
4
|
+
import { Aspect } from './Aspect.js'
|
|
5
|
+
import { Dispatcher } from './Dispatcher.js'
|
|
6
|
+
import { Local } from './Local.js'
|
|
7
|
+
import { Pulse } from './Pulse.js'
|
|
8
|
+
import type { Declaration } from './types.js'
|
|
9
|
+
import type { extensions } from '@toa.io/core'
|
|
10
|
+
|
|
11
|
+
export class Factory implements extensions.Factory {
|
|
12
|
+
private readonly host: Host
|
|
13
|
+
|
|
14
|
+
/** one per component, however much of this extension calls through it */
|
|
15
|
+
private readonly locals: Record<string, Local> = {}
|
|
16
|
+
|
|
17
|
+
public constructor (host: Host) {
|
|
18
|
+
this.host = host
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What runs beside a component. Its own component gets the dispatcher; everyone else gets a
|
|
23
|
+
* timer per operation they declared a cadence for.
|
|
24
|
+
*
|
|
25
|
+
* A tenant is created before the components of its composition are, which is why nothing here
|
|
26
|
+
* reaches for one.
|
|
27
|
+
*/
|
|
28
|
+
public tenant (locator: Locator, declaration: object): Connector {
|
|
29
|
+
if (mine(locator))
|
|
30
|
+
return new Dispatcher(this.metronome(), (target) => this.local(target),
|
|
31
|
+
this.host.atom(locator.id))
|
|
32
|
+
|
|
33
|
+
const tenant = new Connector()
|
|
34
|
+
const pulses = declaration as Declaration | null
|
|
35
|
+
|
|
36
|
+
// `cadence: ~` is a component that only delays calls
|
|
37
|
+
if (pulses === null) return tenant
|
|
38
|
+
|
|
39
|
+
const local = this.local(locator)
|
|
40
|
+
const atom = this.host.atom(locator.id)
|
|
41
|
+
|
|
42
|
+
for (const [endpoint, { cycle, intervals }] of Object.entries(pulses))
|
|
43
|
+
tenant.depends(new Pulse({ locator, endpoint, cycle, intervals }, local, atom))
|
|
44
|
+
|
|
45
|
+
return tenant
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `context.delay` — for every component but the one that keeps the calls. */
|
|
49
|
+
public aspect (locator: Locator): extensions.Aspect | extensions.Aspect[] {
|
|
50
|
+
if (mine(locator)) return []
|
|
51
|
+
|
|
52
|
+
return new Aspect(this.metronome())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public service (): Connector {
|
|
56
|
+
return new Composition(this.host)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private metronome (): Local {
|
|
60
|
+
return this.local(new Locator(COMPONENT, NAMESPACE))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private local (locator: Locator): Local {
|
|
64
|
+
return (this.locals[locator.id] ??= new Local(this.host, locator))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether this is a component the extension ships rather than one of the application's. */
|
|
69
|
+
function mine (locator: Locator): boolean {
|
|
70
|
+
return locator.namespace === NAMESPACE
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type Host = extensions.Host
|
package/source/Local.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import type { Host } from './Factory.js'
|
|
3
|
+
import type { Locator, Remote, Reply, Request } from '@toa.io/core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The component a tenant belongs to, as something to call.
|
|
7
|
+
*
|
|
8
|
+
* Resolved on the first call and not before: a tenant is created ahead of the components of its
|
|
9
|
+
* composition, so at that point there is nothing to look up yet. A pulse calls at a boundary,
|
|
10
|
+
* which is long after everything is up.
|
|
11
|
+
*/
|
|
12
|
+
export class Local extends Connector {
|
|
13
|
+
private readonly host: Host
|
|
14
|
+
private readonly locator: Locator
|
|
15
|
+
private remote?: Promise<Remote>
|
|
16
|
+
|
|
17
|
+
public constructor (host: Host, locator: Locator) {
|
|
18
|
+
super()
|
|
19
|
+
|
|
20
|
+
this.host = host
|
|
21
|
+
this.locator = locator
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async invoke (endpoint: string, request: Request): Promise<Reply> {
|
|
25
|
+
this.remote ??= this.locate()
|
|
26
|
+
|
|
27
|
+
return await (await this.remote).invoke(endpoint, request)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async locate (): Promise<Remote> {
|
|
31
|
+
const remote = await this.host.remote(this.locator)
|
|
32
|
+
|
|
33
|
+
this.depends(remote)
|
|
34
|
+
|
|
35
|
+
await remote.connect()
|
|
36
|
+
|
|
37
|
+
return remote
|
|
38
|
+
}
|
|
39
|
+
}
|