@toa.io/extensions.cadence 1.0.0-alpha.284 → 1.0.0-alpha.287
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 +3 -3
- 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/migrations/0003-pending-index.yaml +16 -0
- package/components/cadence.metronome/migrations/0004-retention.yaml +15 -0
- package/components/cadence.metronome/operations/delay.js +1 -1
- package/components/cadence.metronome/operations/settle.js +8 -4
- package/components/cadence.metronome/types/toa.d.ts +5 -5
- package/package.json +6 -5
- package/readme.md +35 -21
- package/schemas/declaration.cos.yaml +0 -1
- package/source/Aspect.ts +19 -10
- package/source/Composition.ts +4 -4
- package/source/Dispatcher.test.ts +240 -32
- package/source/Dispatcher.ts +145 -32
- 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 +7 -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 +22 -4
- package/transpiled/Dispatcher.js +111 -18
- 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.d.ts +6 -0
- package/transpiled/const.js +6 -0
- 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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import { console } from 'openspan'
|
|
3
4
|
|
|
4
5
|
import { Dispatcher } from './Dispatcher.js'
|
|
5
|
-
import { LANES } from './const.js'
|
|
6
|
+
import { BATCH, LANES } from './const.js'
|
|
6
7
|
import type { Local } from './Local.js'
|
|
7
|
-
import type { atomicity } from '@toa.io/core'
|
|
8
|
+
import type { atomicity } from '@toa.io/core/types'
|
|
8
9
|
|
|
9
10
|
type Invoke = (endpoint: string, request: any) => Promise<unknown>
|
|
10
11
|
type Slots = (total: number) => number[] | null
|
|
@@ -17,20 +18,33 @@ let target: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
|
17
18
|
let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
|
|
18
19
|
let listeners: Listener[]
|
|
19
20
|
let rows: Row[]
|
|
21
|
+
let warn: ReturnType<typeof mock.method<typeof console, 'warn'>>
|
|
20
22
|
|
|
21
23
|
interface Row {
|
|
22
24
|
id: string
|
|
23
25
|
lane: number
|
|
24
26
|
due: number
|
|
27
|
+
expires: number
|
|
25
28
|
endpoint: string
|
|
26
29
|
request: object | null
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
/** `expires` defaults to no bound at all, which is what `overdue: null` writes */
|
|
33
|
+
const row = (id: string, due: number, lane = 0, expires = Number.MAX_SAFE_INTEGER): Row => ({
|
|
34
|
+
id,
|
|
35
|
+
lane,
|
|
36
|
+
due,
|
|
37
|
+
expires,
|
|
38
|
+
endpoint: 'tea.pots.transit',
|
|
39
|
+
request: { input: { id } }
|
|
40
|
+
})
|
|
31
41
|
|
|
32
|
-
const connector = (properties: object): any =>
|
|
33
|
-
|
|
42
|
+
const connector = (properties: object): any => ({
|
|
43
|
+
...properties,
|
|
44
|
+
link: mock.fn(),
|
|
45
|
+
connect: async () => {},
|
|
46
|
+
disconnect: async () => {}
|
|
47
|
+
})
|
|
34
48
|
|
|
35
49
|
const advance = async (ms: number): Promise<void> => {
|
|
36
50
|
mock.timers.tick(ms)
|
|
@@ -39,20 +53,20 @@ const advance = async (ms: number): Promise<void> => {
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/** the query one scan made */
|
|
42
|
-
const reads = (): any[] =>
|
|
43
|
-
.
|
|
44
|
-
|
|
56
|
+
const reads = (): any[] =>
|
|
57
|
+
metronome.invoke.mock.calls
|
|
58
|
+
.filter((call) => call.arguments[0] === 'enumerate')
|
|
59
|
+
.map((call) => call.arguments[1].query)
|
|
45
60
|
|
|
46
61
|
/** what has been settled, flattened */
|
|
47
|
-
const settled = (): string[] =>
|
|
48
|
-
.
|
|
49
|
-
|
|
62
|
+
const settled = (): string[] =>
|
|
63
|
+
metronome.invoke.mock.calls
|
|
64
|
+
.filter((call) => call.arguments[0] === 'settle')
|
|
65
|
+
.flatMap((call) => call.arguments[1].query.ids as string[])
|
|
50
66
|
|
|
51
67
|
const range = (total: number): number[] => Array.from({ length: total }, (_, i) => i)
|
|
52
68
|
|
|
53
|
-
const create = (): Dispatcher =>
|
|
54
|
-
new Dispatcher(metronome, () => target, atom)
|
|
55
|
-
|
|
69
|
+
const create = (): Dispatcher => new Dispatcher(metronome, () => target, atom)
|
|
56
70
|
|
|
57
71
|
beforeEach(() => {
|
|
58
72
|
process.env.TOA_CADENCE_DISCRETENESS = String(INTERVAL)
|
|
@@ -60,9 +74,12 @@ beforeEach(() => {
|
|
|
60
74
|
|
|
61
75
|
rows = []
|
|
62
76
|
listeners = []
|
|
77
|
+
warn = mock.method(console, 'warn', () => undefined)
|
|
63
78
|
|
|
64
79
|
metronome = connector({
|
|
65
|
-
invoke: mock.fn<Invoke>(async (endpoint: string) =>
|
|
80
|
+
invoke: mock.fn<Invoke>(async (endpoint: string) =>
|
|
81
|
+
endpoint === 'enumerate' ? rows : null
|
|
82
|
+
)
|
|
66
83
|
})
|
|
67
84
|
|
|
68
85
|
target = connector({ invoke: mock.fn<Invoke>(async () => null) })
|
|
@@ -73,17 +90,20 @@ beforeEach(() => {
|
|
|
73
90
|
listeners.push(listener)
|
|
74
91
|
listener()
|
|
75
92
|
|
|
76
|
-
return () => {
|
|
93
|
+
return () => {
|
|
94
|
+
listeners = listeners.filter((one) => one !== listener)
|
|
95
|
+
}
|
|
77
96
|
}
|
|
78
97
|
})
|
|
79
98
|
})
|
|
80
99
|
|
|
81
100
|
afterEach(() => {
|
|
82
101
|
mock.timers.reset()
|
|
102
|
+
warn.mock.restore()
|
|
83
103
|
delete process.env.TOA_CADENCE_DISCRETENESS
|
|
84
104
|
})
|
|
85
105
|
|
|
86
|
-
it('should read
|
|
106
|
+
it('should read two intervals ahead, in the lanes it owns', async () => {
|
|
87
107
|
const dispatcher = create()
|
|
88
108
|
|
|
89
109
|
await dispatcher.connect()
|
|
@@ -91,12 +111,122 @@ it('should read what is due within the interval, in the lanes it owns', async ()
|
|
|
91
111
|
|
|
92
112
|
const [first] = reads()
|
|
93
113
|
|
|
94
|
-
// the first scan is the one the subscription runs as it is added, at time zero
|
|
95
|
-
|
|
96
|
-
|
|
114
|
+
// the first scan is the one the subscription runs as it is added, at time zero. Two
|
|
115
|
+
// intervals, so that consecutive passes overlap; and no bound on `expires`, because an
|
|
116
|
+
// expired row is settled rather than left where nothing will ever read it again
|
|
117
|
+
assert.strictEqual(
|
|
118
|
+
first.criteria,
|
|
119
|
+
`lane=in=(${range(LANES).join(',')});due<${2 * INTERVAL}`
|
|
120
|
+
)
|
|
97
121
|
assert.deepStrictEqual(first.sort, ['due:asc'])
|
|
98
122
|
})
|
|
99
123
|
|
|
124
|
+
it('should reach past the next pass, so a row on its horizon is armed before it is due', async () => {
|
|
125
|
+
rows = [row('a', INTERVAL + 10)]
|
|
126
|
+
|
|
127
|
+
const dispatcher = create()
|
|
128
|
+
|
|
129
|
+
await dispatcher.connect()
|
|
130
|
+
|
|
131
|
+
// gone from the store before the next pass, so nothing but the scan at time zero can have
|
|
132
|
+
// armed it — which is what reaching two intervals rather than one is for
|
|
133
|
+
rows = []
|
|
134
|
+
|
|
135
|
+
await advance(INTERVAL)
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not yet due')
|
|
138
|
+
|
|
139
|
+
await advance(10)
|
|
140
|
+
|
|
141
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1, 'and called at its due time')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should settle an expired row without calling it', async () => {
|
|
145
|
+
rows = [row('a', -10_000, 0, -5_000)]
|
|
146
|
+
|
|
147
|
+
const dispatcher = create()
|
|
148
|
+
|
|
149
|
+
await dispatcher.connect()
|
|
150
|
+
await advance(0)
|
|
151
|
+
|
|
152
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'past the bound its caller gave it')
|
|
153
|
+
|
|
154
|
+
rows = []
|
|
155
|
+
await advance(INTERVAL)
|
|
156
|
+
|
|
157
|
+
assert.deepStrictEqual(
|
|
158
|
+
settled(),
|
|
159
|
+
['a'],
|
|
160
|
+
'and settled, or nothing would ever read it again'
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should settle what it has called even while it owns nothing', async () => {
|
|
165
|
+
rows = [row('a', 10)]
|
|
166
|
+
|
|
167
|
+
const dispatcher = create()
|
|
168
|
+
|
|
169
|
+
await dispatcher.connect()
|
|
170
|
+
await advance(10)
|
|
171
|
+
|
|
172
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
173
|
+
|
|
174
|
+
// the lane is somebody else's now, and a row left live is one they call again
|
|
175
|
+
rows = []
|
|
176
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
177
|
+
|
|
178
|
+
await advance(INTERVAL)
|
|
179
|
+
|
|
180
|
+
assert.deepStrictEqual(settled(), ['a'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should say so when a scan fills its batch', async () => {
|
|
184
|
+
rows = Array.from({ length: BATCH }, (_, i) => row(String(i), 10_000))
|
|
185
|
+
|
|
186
|
+
const dispatcher = create()
|
|
187
|
+
|
|
188
|
+
await dispatcher.connect()
|
|
189
|
+
await advance(0)
|
|
190
|
+
|
|
191
|
+
const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
|
|
192
|
+
|
|
193
|
+
assert.ok(
|
|
194
|
+
warned.some((message) => message.includes('filled its batch')),
|
|
195
|
+
'the horizon is truncated and the rest arrive late'
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should say so when a pass is skipped because the one before it is still running', async () => {
|
|
200
|
+
let release: () => void = () => {}
|
|
201
|
+
|
|
202
|
+
metronome.invoke.mock.mockImplementation(async (endpoint: string) => {
|
|
203
|
+
if (endpoint !== 'enumerate') return null
|
|
204
|
+
|
|
205
|
+
await new Promise<void>((resolve) => {
|
|
206
|
+
release = resolve
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
return []
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const dispatcher = create()
|
|
213
|
+
|
|
214
|
+
await dispatcher.connect()
|
|
215
|
+
await advance(0) // the scan at time zero, which does not come back
|
|
216
|
+
|
|
217
|
+
await advance(INTERVAL)
|
|
218
|
+
|
|
219
|
+
const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
|
|
220
|
+
|
|
221
|
+
assert.ok(
|
|
222
|
+
warned.some((message) => message.includes('has not returned')),
|
|
223
|
+
'a dispatcher that stops dispatching says so'
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
release()
|
|
227
|
+
await advance(0)
|
|
228
|
+
})
|
|
229
|
+
|
|
100
230
|
it('should read nothing while it owns nothing', async () => {
|
|
101
231
|
atom.slots.mock.mockImplementation(() => null)
|
|
102
232
|
|
|
@@ -136,8 +266,11 @@ it('should call one whose time has already passed at once', async () => {
|
|
|
136
266
|
await dispatcher.connect()
|
|
137
267
|
await advance(0)
|
|
138
268
|
|
|
139
|
-
assert.strictEqual(
|
|
140
|
-
|
|
269
|
+
assert.strictEqual(
|
|
270
|
+
target.invoke.mock.callCount(),
|
|
271
|
+
1,
|
|
272
|
+
'a scan reads what is overdue along with what is coming'
|
|
273
|
+
)
|
|
141
274
|
})
|
|
142
275
|
|
|
143
276
|
it('should arm a row once, however often a scan reads it', async () => {
|
|
@@ -159,8 +292,12 @@ it('should not arm a row again while its call is in flight', async () => {
|
|
|
159
292
|
let release: () => void = () => {}
|
|
160
293
|
|
|
161
294
|
rows = [row('a', 10)]
|
|
162
|
-
target.invoke.mock.mockImplementation(
|
|
163
|
-
|
|
295
|
+
target.invoke.mock.mockImplementation(
|
|
296
|
+
async () =>
|
|
297
|
+
await new Promise<void>((resolve) => {
|
|
298
|
+
release = resolve
|
|
299
|
+
})
|
|
300
|
+
)
|
|
164
301
|
|
|
165
302
|
const dispatcher = create()
|
|
166
303
|
|
|
@@ -175,7 +312,11 @@ it('should not arm a row again while its call is in flight', async () => {
|
|
|
175
312
|
// far enough for anything that scan armed to have fired
|
|
176
313
|
await advance(INTERVAL)
|
|
177
314
|
|
|
178
|
-
assert.strictEqual(
|
|
315
|
+
assert.strictEqual(
|
|
316
|
+
target.invoke.mock.callCount(),
|
|
317
|
+
1,
|
|
318
|
+
'and does not send it a second time'
|
|
319
|
+
)
|
|
179
320
|
|
|
180
321
|
release()
|
|
181
322
|
await advance(0)
|
|
@@ -199,19 +340,29 @@ it('should settle what it has called', async () => {
|
|
|
199
340
|
|
|
200
341
|
it('should settle a row whose call failed, and keep dispatching', async () => {
|
|
201
342
|
rows = [row('a', 10), row('b', 20)]
|
|
202
|
-
target.invoke.mock.mockImplementationOnce(async () => {
|
|
343
|
+
target.invoke.mock.mockImplementationOnce(async () => {
|
|
344
|
+
throw new Error('nope')
|
|
345
|
+
})
|
|
203
346
|
|
|
204
347
|
const dispatcher = create()
|
|
205
348
|
|
|
206
349
|
await dispatcher.connect()
|
|
207
350
|
await advance(20)
|
|
208
351
|
|
|
209
|
-
assert.strictEqual(
|
|
352
|
+
assert.strictEqual(
|
|
353
|
+
target.invoke.mock.callCount(),
|
|
354
|
+
2,
|
|
355
|
+
'the one after it is still called'
|
|
356
|
+
)
|
|
210
357
|
|
|
211
358
|
rows = []
|
|
212
359
|
await advance(INTERVAL)
|
|
213
360
|
|
|
214
|
-
assert.deepStrictEqual(
|
|
361
|
+
assert.deepStrictEqual(
|
|
362
|
+
settled().sort(),
|
|
363
|
+
['a', 'b'],
|
|
364
|
+
'and the one that failed is settled'
|
|
365
|
+
)
|
|
215
366
|
})
|
|
216
367
|
|
|
217
368
|
it('should drop timers for lanes it no longer owns', async () => {
|
|
@@ -229,7 +380,7 @@ it('should drop timers for lanes it no longer owns', async () => {
|
|
|
229
380
|
|
|
230
381
|
await advance(20_000)
|
|
231
382
|
|
|
232
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0,
|
|
383
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, "lane 7 is somebody else's now")
|
|
233
384
|
})
|
|
234
385
|
|
|
235
386
|
it('should drop everything it holds when it owns nothing', async () => {
|
|
@@ -247,8 +398,11 @@ it('should drop everything it holds when it owns nothing', async () => {
|
|
|
247
398
|
|
|
248
399
|
await advance(20_000)
|
|
249
400
|
|
|
250
|
-
assert.strictEqual(
|
|
251
|
-
|
|
401
|
+
assert.strictEqual(
|
|
402
|
+
target.invoke.mock.callCount(),
|
|
403
|
+
0,
|
|
404
|
+
'whoever holds lane 7 now is the one to make that call'
|
|
405
|
+
)
|
|
252
406
|
})
|
|
253
407
|
|
|
254
408
|
it('should scan at once when what it owns changes', async () => {
|
|
@@ -269,3 +423,57 @@ it('should scan at once when what it owns changes', async () => {
|
|
|
269
423
|
|
|
270
424
|
assert.strictEqual(reads().length, 1, 'without waiting out an interval')
|
|
271
425
|
})
|
|
426
|
+
|
|
427
|
+
it('should give up a row a scan stops reading, so a cancellation still lands', async () => {
|
|
428
|
+
rows = [row('a', INTERVAL + 50)]
|
|
429
|
+
|
|
430
|
+
const dispatcher = create()
|
|
431
|
+
|
|
432
|
+
await dispatcher.connect()
|
|
433
|
+
await advance(1)
|
|
434
|
+
|
|
435
|
+
// cancelled: the row is tombstoned, so the next scan does not read it
|
|
436
|
+
rows = []
|
|
437
|
+
|
|
438
|
+
await advance(INTERVAL) // the scan that finds it gone
|
|
439
|
+
await advance(INTERVAL) // and past when it would have been called
|
|
440
|
+
|
|
441
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'the timer was given up with the row')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('should keep a row the scan still reads', async () => {
|
|
445
|
+
rows = [row('a', INTERVAL + 50)]
|
|
446
|
+
|
|
447
|
+
const dispatcher = create()
|
|
448
|
+
|
|
449
|
+
await dispatcher.connect()
|
|
450
|
+
await advance(INTERVAL)
|
|
451
|
+
|
|
452
|
+
assert.strictEqual(target.invoke.mock.callCount(), 0, 'not yet due')
|
|
453
|
+
|
|
454
|
+
await advance(50)
|
|
455
|
+
|
|
456
|
+
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('should not give up a row when the batch was filled', async () => {
|
|
460
|
+
const held = row('held', INTERVAL + 50)
|
|
461
|
+
|
|
462
|
+
rows = [held, ...Array.from({ length: BATCH - 1 }, (_, i) => row('b' + i, 10_000))]
|
|
463
|
+
|
|
464
|
+
const dispatcher = create()
|
|
465
|
+
|
|
466
|
+
await dispatcher.connect()
|
|
467
|
+
await advance(1)
|
|
468
|
+
|
|
469
|
+
// a full batch is a read that was cut short, so absence from the next one is not a
|
|
470
|
+
// cancellation — these are rows it never reached, not rows nobody owes
|
|
471
|
+
rows = Array.from({ length: BATCH }, (_, i) => row('c' + i, 20_000))
|
|
472
|
+
|
|
473
|
+
await advance(INTERVAL)
|
|
474
|
+
await advance(50)
|
|
475
|
+
|
|
476
|
+
const called = target.invoke.mock.calls.map((call) => call.arguments[1].input.id)
|
|
477
|
+
|
|
478
|
+
assert.deepStrictEqual(called, ['held'], 'the row it armed is still called')
|
|
479
|
+
})
|
package/source/Dispatcher.ts
CHANGED
|
@@ -2,15 +2,16 @@ 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.
|
|
9
9
|
*
|
|
10
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
|
|
12
|
-
* A crash loses the timers and nothing else — the next scan reads what is overdue
|
|
13
|
-
* what is upcoming, so a dispatcher that died mid-interval costs a delay and not a
|
|
11
|
+
* them: the rows are the record, and one scan primes the next two intervals of them into
|
|
12
|
+
* timers. A crash loses the timers and nothing else — the next scan reads what is overdue
|
|
13
|
+
* along with what is upcoming, so a dispatcher that died mid-interval costs a delay and not a
|
|
14
|
+
* call.
|
|
14
15
|
*
|
|
15
16
|
* Partitioned by lane exactly as the outbox is, so every replica scans and dispatches its own
|
|
16
17
|
* share of every interval rather than taking turns at whole ones.
|
|
@@ -37,8 +38,14 @@ export class Dispatcher extends Connector {
|
|
|
37
38
|
private scanning = false
|
|
38
39
|
private closing = false
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
/** when the scan that is running started, so a tick that finds it can say how long */
|
|
42
|
+
private started = 0
|
|
43
|
+
|
|
44
|
+
public constructor(
|
|
45
|
+
metronome: Local,
|
|
46
|
+
resolve: (locator: Locator) => Local,
|
|
47
|
+
atom: atomicity.Atom
|
|
48
|
+
) {
|
|
42
49
|
super()
|
|
43
50
|
|
|
44
51
|
this.metronome = metronome
|
|
@@ -50,15 +57,19 @@ export class Dispatcher extends Connector {
|
|
|
50
57
|
this.depends(atom)
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
protected override async open
|
|
54
|
-
this.timer = setInterval(() => {
|
|
60
|
+
protected override async open(): Promise<void> {
|
|
61
|
+
this.timer = setInterval(() => {
|
|
62
|
+
this.tick()
|
|
63
|
+
}, this.discreteness)
|
|
55
64
|
this.timer.unref()
|
|
56
65
|
|
|
57
66
|
// a lane changing hands is when its rows become this replica's, or stop being
|
|
58
|
-
this.off = this.atom.onassigned(() => {
|
|
67
|
+
this.off = this.atom.onassigned(() => {
|
|
68
|
+
this.adopt()
|
|
69
|
+
})
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
protected override async close
|
|
72
|
+
protected override async close(): Promise<void> {
|
|
62
73
|
this.closing = true
|
|
63
74
|
|
|
64
75
|
this.off?.()
|
|
@@ -72,14 +83,37 @@ export class Dispatcher extends Connector {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
/** One scan at a time: a slow one must not have another started underneath it. */
|
|
75
|
-
private tick
|
|
76
|
-
if (this.
|
|
86
|
+
private tick(): void {
|
|
87
|
+
if (this.closing) return
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* Skipped, and said so. The guard itself is right — a second scan on top of one that has
|
|
91
|
+
* not returned only makes it worse — but a pass that outlives its interval takes every
|
|
92
|
+
* pass after it with it, and nothing is dispatched at all while that lasts. Silently, it
|
|
93
|
+
* is a dispatcher that stops dispatching in a deployment that ran for months.
|
|
94
|
+
*/
|
|
95
|
+
if (this.scanning) {
|
|
96
|
+
const running = Date.now() - this.started
|
|
97
|
+
const details = { running, discreteness: this.discreteness }
|
|
98
|
+
const message = 'Delayed calls scan has not returned, so this pass is skipped'
|
|
99
|
+
|
|
100
|
+
// past a few intervals it is not a slow pass but a stuck one
|
|
101
|
+
if (running > STALLED * this.discreteness) console.error(message, details)
|
|
102
|
+
else console.warn(message, details)
|
|
103
|
+
|
|
104
|
+
return
|
|
105
|
+
}
|
|
77
106
|
|
|
78
107
|
this.scanning = true
|
|
108
|
+
this.started = Date.now()
|
|
79
109
|
|
|
80
110
|
void this.scan()
|
|
81
|
-
.catch((error: unknown) => {
|
|
82
|
-
|
|
111
|
+
.catch((error: unknown) => {
|
|
112
|
+
console.error('Delayed calls scan failed', { error })
|
|
113
|
+
})
|
|
114
|
+
.finally(() => {
|
|
115
|
+
this.scanning = false
|
|
116
|
+
})
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
/**
|
|
@@ -87,7 +121,7 @@ export class Dispatcher extends Connector {
|
|
|
87
121
|
* whoever holds them now will arm them — and a scan runs at once rather than at the next
|
|
88
122
|
* interval, so lanes just acquired are not left waiting.
|
|
89
123
|
*/
|
|
90
|
-
private adopt
|
|
124
|
+
private adopt(): void {
|
|
91
125
|
// nothing owned is nothing held: what was armed under a claim this replica no longer has
|
|
92
126
|
// belongs to whoever has it now, and firing it anyway is the call twice
|
|
93
127
|
this.disarm(this.atom.slots(LANES) ?? [])
|
|
@@ -96,7 +130,7 @@ export class Dispatcher extends Connector {
|
|
|
96
130
|
}
|
|
97
131
|
|
|
98
132
|
/** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
|
|
99
|
-
private disarm
|
|
133
|
+
private disarm(lanes: number[]): void {
|
|
100
134
|
for (const [id, { timer, lane }] of this.armed) {
|
|
101
135
|
if (lanes.includes(lane)) continue
|
|
102
136
|
|
|
@@ -105,36 +139,101 @@ export class Dispatcher extends Connector {
|
|
|
105
139
|
}
|
|
106
140
|
}
|
|
107
141
|
|
|
108
|
-
private async scan
|
|
142
|
+
private async scan(): Promise<void> {
|
|
143
|
+
// ahead of the claim, because settling needs none: the rows are ones this replica has
|
|
144
|
+
// already called, and one left live is one the next owner of its lane calls again
|
|
145
|
+
await this.settle()
|
|
146
|
+
|
|
109
147
|
const lanes = this.atom.slots(LANES)
|
|
110
148
|
|
|
111
149
|
if (lanes === null || lanes.length === 0) return
|
|
112
150
|
|
|
113
|
-
await this.settle()
|
|
114
|
-
|
|
115
151
|
const now = Date.now()
|
|
116
|
-
const until = now + this.discreteness
|
|
117
152
|
|
|
118
|
-
|
|
153
|
+
/*
|
|
154
|
+
* Two intervals, so that consecutive passes overlap by one whole one. Reaching exactly as
|
|
155
|
+
* far as the next pass leaves a sliver beyond the horizon that the next pass reads already
|
|
156
|
+
* overdue — the width of the drift between the two, which a row with a tight `overdue`
|
|
157
|
+
* does not survive. The overlap absorbs that, a settle that ran long, a tick skipped
|
|
158
|
+
* outright, and the clock skew between the replica that wrote `due` and this one.
|
|
159
|
+
*
|
|
160
|
+
* Reaching further costs nothing a cancellation would pay for, because what is armed is
|
|
161
|
+
* given up again the moment a scan stops reading it — see `forget`.
|
|
162
|
+
*/
|
|
163
|
+
const until = now + 2 * this.discreteness
|
|
164
|
+
|
|
165
|
+
const rows = (await this.metronome.invoke('enumerate', {
|
|
119
166
|
query: {
|
|
120
|
-
// a
|
|
121
|
-
|
|
167
|
+
// expiry is not a criterion: a row nobody reads is a row nobody settles, and it would
|
|
168
|
+
// stay live for good. It is read, and settled without the call being made
|
|
169
|
+
criteria: `lane=in=(${lanes.join(',')});due<${until}`,
|
|
122
170
|
sort: ['due:asc'],
|
|
123
171
|
limit: BATCH
|
|
124
172
|
}
|
|
125
|
-
}) as Row[]
|
|
173
|
+
})) as Row[]
|
|
174
|
+
|
|
175
|
+
// the horizon is truncated to the last row of a full batch, and everything past it arrives
|
|
176
|
+
// on a later pass already overdue. Where this is logged, the batch is too small for the rate
|
|
177
|
+
const truncated = rows.length === BATCH
|
|
178
|
+
|
|
179
|
+
if (truncated)
|
|
180
|
+
console.warn('Delayed calls scan filled its batch', { limit: BATCH, lanes: lanes.length })
|
|
126
181
|
|
|
127
182
|
for (const row of rows) this.arm(row)
|
|
183
|
+
|
|
184
|
+
if (!truncated) this.forget(new Set(rows.map((row) => row.id)))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Gives up what the store no longer answers with. A scan reads everything still owed in the
|
|
189
|
+
* lanes this replica holds, so a row it armed before and does not read now is one nothing
|
|
190
|
+
* owes any more — cancelled, most of the time — and a timer left for it makes the call
|
|
191
|
+
* regardless.
|
|
192
|
+
*
|
|
193
|
+
* This is what lets a scan reach further ahead than the next one without a cancellation
|
|
194
|
+
* becoming any less reliable: a row is held only for as long as the store keeps saying it is
|
|
195
|
+
* owed, so what bounds cancelling is the period between passes and not how far one reaches.
|
|
196
|
+
*
|
|
197
|
+
* Two things are not evidence of a cancellation. A batch filled to its limit is a read that
|
|
198
|
+
* was cut short, and says nothing about what it did not reach. And a call already on its way
|
|
199
|
+
* is settled by its own path rather than by what a scan reads.
|
|
200
|
+
*/
|
|
201
|
+
private forget(read: Set<string>): void {
|
|
202
|
+
for (const [id, { timer }] of this.armed) {
|
|
203
|
+
if (read.has(id) || this.called.has(id)) continue
|
|
204
|
+
|
|
205
|
+
clearTimeout(timer)
|
|
206
|
+
this.armed.delete(id)
|
|
207
|
+
}
|
|
128
208
|
}
|
|
129
209
|
|
|
130
210
|
/**
|
|
131
211
|
* A row already armed is left alone: a scan reads what it read before, because a row stays
|
|
132
212
|
* until its call has been made.
|
|
133
213
|
*/
|
|
134
|
-
private arm
|
|
214
|
+
private arm(row: Row): void {
|
|
135
215
|
if (this.armed.has(row.id) || this.called.has(row.id)) return
|
|
136
216
|
|
|
137
|
-
const
|
|
217
|
+
const now = Date.now()
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* Past the bound its caller gave it, so the call is not made — and the row is settled all
|
|
221
|
+
* the same, silently, because an expired call is not an event anything sees. Left unread
|
|
222
|
+
* it would never be settled either: a row nothing will ever act on, sitting in the
|
|
223
|
+
* collection and in front of every scan, for good.
|
|
224
|
+
*/
|
|
225
|
+
if (row.expires <= now) {
|
|
226
|
+
this.called.add(row.id)
|
|
227
|
+
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const timer = setTimeout(
|
|
232
|
+
() => {
|
|
233
|
+
void this.dispatch(row)
|
|
234
|
+
},
|
|
235
|
+
Math.max(row.due - now, 0)
|
|
236
|
+
)
|
|
138
237
|
|
|
139
238
|
timer.unref()
|
|
140
239
|
this.armed.set(row.id, { timer, lane: row.lane })
|
|
@@ -145,7 +244,7 @@ export class Dispatcher extends Connector {
|
|
|
145
244
|
* makes the call twice, and the other order would lose it. It travels as a task, so the
|
|
146
245
|
* broker holds it if the target is not there to take it.
|
|
147
246
|
*/
|
|
148
|
-
private async dispatch
|
|
247
|
+
private async dispatch(row: Row): Promise<void> {
|
|
149
248
|
if (this.closing) {
|
|
150
249
|
this.armed.delete(row.id)
|
|
151
250
|
|
|
@@ -170,20 +269,27 @@ export class Dispatcher extends Connector {
|
|
|
170
269
|
*
|
|
171
270
|
* Skipped, as a pulse skips: reported, and the row settled on the attempt either way.
|
|
172
271
|
*/
|
|
173
|
-
await local
|
|
272
|
+
await local
|
|
273
|
+
.invoke(endpoint, { input: null, ...row.request, task: true })
|
|
174
274
|
.catch((error: unknown) => {
|
|
175
|
-
console.error('Delayed call failed', {
|
|
275
|
+
console.error('Delayed call failed', {
|
|
276
|
+
endpoint: row.endpoint,
|
|
277
|
+
id: row.id,
|
|
278
|
+
error
|
|
279
|
+
})
|
|
176
280
|
})
|
|
177
281
|
// held for as long as the call is, so `armed` answers what this replica has in hand
|
|
178
282
|
// rather than what it has a timer for
|
|
179
|
-
.finally(() => {
|
|
283
|
+
.finally(() => {
|
|
284
|
+
this.armed.delete(row.id)
|
|
285
|
+
})
|
|
180
286
|
}
|
|
181
287
|
|
|
182
288
|
/**
|
|
183
289
|
* A remote is resolved once per target and held: it opens a connection, and one nothing
|
|
184
290
|
* depends on would outlive the dispatcher that made it.
|
|
185
291
|
*/
|
|
186
|
-
private target
|
|
292
|
+
private target(locator: Locator): Local {
|
|
187
293
|
const local = this.resolve(locator)
|
|
188
294
|
|
|
189
295
|
if (!this.targets.has(local)) {
|
|
@@ -195,7 +301,7 @@ export class Dispatcher extends Connector {
|
|
|
195
301
|
}
|
|
196
302
|
|
|
197
303
|
/** One write for many rows, which is why the ids are held rather than settled one by one. */
|
|
198
|
-
private async settle
|
|
304
|
+
private async settle(): Promise<void> {
|
|
199
305
|
if (this.called.size === 0) return
|
|
200
306
|
|
|
201
307
|
const ids = [...this.called]
|
|
@@ -216,6 +322,13 @@ interface Row {
|
|
|
216
322
|
id: string
|
|
217
323
|
lane: number
|
|
218
324
|
due: number
|
|
325
|
+
|
|
326
|
+
/** past this the call is not made; the row is settled without it */
|
|
327
|
+
expires: number
|
|
328
|
+
|
|
219
329
|
endpoint: string
|
|
220
330
|
request?: object
|
|
221
331
|
}
|
|
332
|
+
|
|
333
|
+
/** intervals a scan may run for before it is a stuck pass rather than a slow one */
|
|
334
|
+
const STALLED = 5
|