@toa.io/extensions.cadence 1.0.0-alpha.292 → 1.0.0-alpha.293
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/package.json +14 -6
- package/CHANGELOG.md +0 -44
- package/source/Aspect.ts +0 -73
- package/source/Composition.ts +0 -47
- package/source/Dispatcher.test.ts +0 -492
- package/source/Dispatcher.ts +0 -342
- package/source/Factory.ts +0 -76
- package/source/Local.ts +0 -40
- package/source/Pulse.test.ts +0 -303
- package/source/Pulse.ts +0 -211
- package/source/index.ts +0 -7
- package/tsconfig.json +0 -13
- package/tsconfig.tsbuildinfo +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.cadence",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.293",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Schedule",
|
|
6
6
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
@@ -17,15 +17,23 @@
|
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
|
+
"files": [
|
|
21
|
+
"transpiled",
|
|
22
|
+
"components",
|
|
23
|
+
"readme.md",
|
|
24
|
+
"!**/tsconfig.tsbuildinfo",
|
|
25
|
+
"!**/*.test.*",
|
|
26
|
+
"!**/.env*"
|
|
27
|
+
],
|
|
20
28
|
"dependencies": {
|
|
21
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
22
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
29
|
+
"@toa.io/core": "1.0.0-alpha.293",
|
|
30
|
+
"@toa.io/definitions": "1.0.0-alpha.293",
|
|
23
31
|
"@toa.io/generic": "1.0.0-alpha.292",
|
|
24
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
25
|
-
"openspan": "1.0.0-alpha.
|
|
32
|
+
"@toa.io/schemas": "1.0.0-alpha.293",
|
|
33
|
+
"openspan": "1.0.0-alpha.293"
|
|
26
34
|
},
|
|
27
35
|
"scripts": {
|
|
28
36
|
"transpile": "tsc"
|
|
29
37
|
},
|
|
30
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "1e05b3ab64758324c58c425f1f8e52e9f35f1da8"
|
|
31
39
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Change Log
|
|
2
|
-
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
4
|
-
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
-
|
|
6
|
-
# [1.0.0-alpha.292](https://github.com/toa-io/toa/compare/v1.0.0-alpha.291...v1.0.0-alpha.292) (2026-09-07)
|
|
7
|
-
|
|
8
|
-
### Features
|
|
9
|
-
|
|
10
|
-
* **definitions:** what a package declares is read from one package ([7052341](https://github.com/toa-io/toa/commit/70523411b5d9e2b204c999aa02365173f3bb518d))
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# [1.0.0-alpha.291](https://github.com/toa-io/toa/compare/v1.0.0-alpha.290...v1.0.0-alpha.291) (2026-09-07)
|
|
14
|
-
|
|
15
|
-
**Note:** Version bump only for package @toa.io/extensions.cadence
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# [1.0.0-alpha.289](https://github.com/toa-io/toa/compare/v1.0.0-alpha.288...v1.0.0-alpha.289) (2026-09-07)
|
|
22
|
-
|
|
23
|
-
* A deploy moves only what changed, and a component sees none of the runtime's environment (#1073) ([f38e3db](https://github.com/toa-io/toa/commit/f38e3db533f24db866c57bfa1ef294eff1eaf499)), closes [#1073](https://github.com/toa-io/toa/issues/1073) [#1064](https://github.com/toa-io/toa/issues/1064) [#1066](https://github.com/toa-io/toa/issues/1066) [#1067](https://github.com/toa-io/toa/issues/1067) [#1068](https://github.com/toa-io/toa/issues/1068) [#1069](https://github.com/toa-io/toa/issues/1069) [#1071](https://github.com/toa-io/toa/issues/1071) [#1070](https://github.com/toa-io/toa/issues/1070) [#1072](https://github.com/toa-io/toa/issues/1072)
|
|
24
|
-
|
|
25
|
-
### BREAKING CHANGES
|
|
26
|
-
|
|
27
|
-
* a component that read `process.env.TOA_*` reads `context` instead;
|
|
28
|
-
`echo(input)` no longer substitutes from the environment; a bash operation sees no
|
|
29
|
-
`TOA_*`; images no longer set `USER node` — see migrations/289.md.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# [1.0.0-alpha.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
|
|
33
|
-
|
|
34
|
-
**Note:** Version bump only for package @toa.io/extensions.cadence
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
|
|
41
|
-
|
|
42
|
-
### Bug Fixes
|
|
43
|
-
|
|
44
|
-
* **cadence:** the scan reads what is owed, and nothing that is done ([581610e](https://github.com/toa-io/toa/commit/581610e87ac54c42491c882d5749ff3307700d35))
|
package/source/Aspect.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { Connector } from '@toa.io/core'
|
|
2
|
-
import type { Local } from './Local.js'
|
|
3
|
-
import type { Options } from '@toa.io/definitions/extensions.cadence'
|
|
4
|
-
|
|
5
|
-
interface Input {
|
|
6
|
-
endpoint: string
|
|
7
|
-
interval: number
|
|
8
|
-
overdue: number | null
|
|
9
|
-
request?: object
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `context.delay(endpoint, request, delay)` — a call to be made later, handed to the component
|
|
14
|
-
* that keeps them.
|
|
15
|
-
*
|
|
16
|
-
* It is not transactional with the caller's state. A crash between committing a change and
|
|
17
|
-
* handing over the call it should have scheduled loses the call, and there is no way for this
|
|
18
|
-
* to know. Where the semantics allow it, hand the call over first.
|
|
19
|
-
*
|
|
20
|
-
*/
|
|
21
|
-
export class Aspect extends Connector {
|
|
22
|
-
public readonly name = 'delay'
|
|
23
|
-
|
|
24
|
-
private readonly metronome: Local
|
|
25
|
-
|
|
26
|
-
public constructor(metronome: Local) {
|
|
27
|
-
super()
|
|
28
|
-
|
|
29
|
-
this.metronome = metronome
|
|
30
|
-
|
|
31
|
-
this.depends(metronome)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// named rather than forwarded, so that the connector's lifecycle is not reachable from an
|
|
35
|
-
// algorithm — the same reason the atom aspect names its own
|
|
36
|
-
public async invoke(method: string, ...args: any[]): Promise<unknown> {
|
|
37
|
-
switch (method) {
|
|
38
|
-
case 'delay':
|
|
39
|
-
return await this.delay(args[0], args[1], args[2])
|
|
40
|
-
case 'cancel':
|
|
41
|
-
return await this.cancel(args[0])
|
|
42
|
-
default:
|
|
43
|
-
throw new Error(`Delay aspect has no '${method}' method`)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Answers the id the call was given, which is what cancels it. */
|
|
48
|
-
private async delay(
|
|
49
|
-
endpoint: string,
|
|
50
|
-
request: object | null,
|
|
51
|
-
options: Options
|
|
52
|
-
): Promise<unknown> {
|
|
53
|
-
const input: Input = {
|
|
54
|
-
endpoint,
|
|
55
|
-
interval: options.interval,
|
|
56
|
-
overdue: options.overdue
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// a call that takes no request carries none, rather than a null one
|
|
60
|
-
if (request !== null && request !== undefined) input.request = request
|
|
61
|
-
|
|
62
|
-
return await this.metronome.invoke('delay', { input })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Tombstones the row, which is the prototype's `terminate`. An id that was never issued has
|
|
67
|
-
* nothing to terminate and raises; one whose call has already been made has a row still, and
|
|
68
|
-
* terminating it again is not an error.
|
|
69
|
-
*/
|
|
70
|
-
private async cancel(id: string): Promise<unknown> {
|
|
71
|
-
return await this.metronome.invoke('terminate', { query: { id }, input: null })
|
|
72
|
-
}
|
|
73
|
-
}
|
package/source/Composition.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs'
|
|
2
|
-
import { resolve } from 'node:path'
|
|
3
|
-
import { Connector } from '@toa.io/core'
|
|
4
|
-
import type { Dirent } from 'node:fs'
|
|
5
|
-
import type { Host } from './Factory.js'
|
|
6
|
-
|
|
7
|
-
/** The components this extension ships, which run inside its own service. */
|
|
8
|
-
export function components(): Components {
|
|
9
|
-
const labels: string[] = []
|
|
10
|
-
const paths: string[] = []
|
|
11
|
-
|
|
12
|
-
for (const entry of entries()) {
|
|
13
|
-
labels.push(entry.name.replace('.', '-'))
|
|
14
|
-
paths.push(resolve(ROOT, entry.name))
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return { labels, paths }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class Composition extends Connector {
|
|
21
|
-
private readonly host: Host
|
|
22
|
-
|
|
23
|
-
public constructor(host: Host) {
|
|
24
|
-
super()
|
|
25
|
-
|
|
26
|
-
this.host = host
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
protected override async open(): Promise<void> {
|
|
30
|
-
const composition = await this.host.composition(components().paths)
|
|
31
|
-
|
|
32
|
-
await composition.connect()
|
|
33
|
-
|
|
34
|
-
this.depends(composition)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function entries(): Dirent[] {
|
|
39
|
-
return readdirSync(ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory())
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface Components {
|
|
43
|
-
labels: string[]
|
|
44
|
-
paths: string[]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const ROOT = resolve(import.meta.dirname, '../components/')
|
|
@@ -1,492 +0,0 @@
|
|
|
1
|
-
import { it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { console } from 'openspan'
|
|
4
|
-
|
|
5
|
-
import { Dispatcher } from './Dispatcher.js'
|
|
6
|
-
import { BATCH, LANES } from '@toa.io/definitions/extensions.cadence'
|
|
7
|
-
import type { Local } from './Local.js'
|
|
8
|
-
import type { atomicity } from '@toa.io/core/types'
|
|
9
|
-
|
|
10
|
-
type Invoke = (endpoint: string, request: any) => Promise<unknown>
|
|
11
|
-
type Slots = (total: number) => number[] | null
|
|
12
|
-
type Listener = () => void
|
|
13
|
-
|
|
14
|
-
const INTERVAL = 100
|
|
15
|
-
|
|
16
|
-
let metronome: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
17
|
-
let target: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
|
|
18
|
-
let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
|
|
19
|
-
let listeners: Listener[]
|
|
20
|
-
let rows: Row[]
|
|
21
|
-
let warn: ReturnType<typeof mock.method<typeof console, 'warn'>>
|
|
22
|
-
|
|
23
|
-
interface Row {
|
|
24
|
-
id: string
|
|
25
|
-
lane: number
|
|
26
|
-
due: number
|
|
27
|
-
expires: number
|
|
28
|
-
endpoint: string
|
|
29
|
-
request: object | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** `expires` defaults to no bound at all, which is what `overdue: null` writes */
|
|
33
|
-
const row = (
|
|
34
|
-
id: string,
|
|
35
|
-
due: number,
|
|
36
|
-
lane = 0,
|
|
37
|
-
expires = Number.MAX_SAFE_INTEGER
|
|
38
|
-
): Row => ({
|
|
39
|
-
id,
|
|
40
|
-
lane,
|
|
41
|
-
due,
|
|
42
|
-
expires,
|
|
43
|
-
endpoint: 'tea.pots.transit',
|
|
44
|
-
request: { input: { id } }
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const connector = (properties: object): any => ({
|
|
48
|
-
...properties,
|
|
49
|
-
link: mock.fn(),
|
|
50
|
-
connect: async () => {},
|
|
51
|
-
disconnect: async () => {}
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const advance = async (ms: number): Promise<void> => {
|
|
55
|
-
mock.timers.tick(ms)
|
|
56
|
-
|
|
57
|
-
for (let i = 0; i < 30; i++) await Promise.resolve()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** the query one scan made */
|
|
61
|
-
const reads = (): any[] =>
|
|
62
|
-
metronome.invoke.mock.calls
|
|
63
|
-
.filter((call) => call.arguments[0] === 'enumerate')
|
|
64
|
-
.map((call) => call.arguments[1].query)
|
|
65
|
-
|
|
66
|
-
/** what has been settled, flattened */
|
|
67
|
-
const settled = (): string[] =>
|
|
68
|
-
metronome.invoke.mock.calls
|
|
69
|
-
.filter((call) => call.arguments[0] === 'settle')
|
|
70
|
-
.flatMap((call) => call.arguments[1].query.ids as string[])
|
|
71
|
-
|
|
72
|
-
const range = (total: number): number[] => Array.from({ length: total }, (_, i) => i)
|
|
73
|
-
|
|
74
|
-
const create = (): Dispatcher => new Dispatcher(metronome, () => target, atom)
|
|
75
|
-
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
process.env.TOA_CADENCE_DISCRETENESS = String(INTERVAL)
|
|
78
|
-
mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 0 })
|
|
79
|
-
|
|
80
|
-
rows = []
|
|
81
|
-
listeners = []
|
|
82
|
-
warn = mock.method(console, 'warn', () => undefined)
|
|
83
|
-
|
|
84
|
-
metronome = connector({
|
|
85
|
-
invoke: mock.fn<Invoke>(async (endpoint: string) =>
|
|
86
|
-
endpoint === 'enumerate' ? rows : null
|
|
87
|
-
)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
target = connector({ invoke: mock.fn<Invoke>(async () => null) })
|
|
91
|
-
|
|
92
|
-
atom = connector({
|
|
93
|
-
slots: mock.fn<Slots>((total) => range(total)),
|
|
94
|
-
onassigned: (listener: Listener) => {
|
|
95
|
-
listeners.push(listener)
|
|
96
|
-
listener()
|
|
97
|
-
|
|
98
|
-
return () => {
|
|
99
|
-
listeners = listeners.filter((one) => one !== listener)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
afterEach(() => {
|
|
106
|
-
mock.timers.reset()
|
|
107
|
-
warn.mock.restore()
|
|
108
|
-
delete process.env.TOA_CADENCE_DISCRETENESS
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('should read two intervals ahead, in the lanes it owns', async () => {
|
|
112
|
-
const dispatcher = create()
|
|
113
|
-
|
|
114
|
-
await dispatcher.connect()
|
|
115
|
-
await advance(INTERVAL)
|
|
116
|
-
|
|
117
|
-
const [first] = reads()
|
|
118
|
-
|
|
119
|
-
// the first scan is the one the subscription runs as it is added, at time zero. Two
|
|
120
|
-
// intervals, so that consecutive passes overlap; and no bound on `expires`, because an
|
|
121
|
-
// expired row is settled rather than left where nothing will ever read it again
|
|
122
|
-
assert.strictEqual(
|
|
123
|
-
first.criteria,
|
|
124
|
-
`lane=in=(${range(LANES).join(',')});due<${2 * INTERVAL}`
|
|
125
|
-
)
|
|
126
|
-
assert.deepStrictEqual(first.sort, ['due:asc'])
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('should reach past the next pass, so a row on its horizon is armed before it is due', async () => {
|
|
130
|
-
rows = [row('a', INTERVAL + 10)]
|
|
131
|
-
|
|
132
|
-
const dispatcher = create()
|
|
133
|
-
|
|
134
|
-
await dispatcher.connect()
|
|
135
|
-
|
|
136
|
-
// gone from the store before the next pass, so nothing but the scan at time zero can have
|
|
137
|
-
// armed it — which is what reaching two intervals rather than one is for
|
|
138
|
-
rows = []
|
|
139
|
-
|
|
140
|
-
await advance(INTERVAL)
|
|
141
|
-
|
|
142
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not yet due')
|
|
143
|
-
|
|
144
|
-
await advance(10)
|
|
145
|
-
|
|
146
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1, 'and called at its due time')
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('should settle an expired row without calling it', async () => {
|
|
150
|
-
rows = [row('a', -10_000, 0, -5_000)]
|
|
151
|
-
|
|
152
|
-
const dispatcher = create()
|
|
153
|
-
|
|
154
|
-
await dispatcher.connect()
|
|
155
|
-
await advance(0)
|
|
156
|
-
|
|
157
|
-
assert.strictEqual(
|
|
158
|
-
target.invoke.mock.callCount(),
|
|
159
|
-
0,
|
|
160
|
-
'past the bound its caller gave it'
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
rows = []
|
|
164
|
-
await advance(INTERVAL)
|
|
165
|
-
|
|
166
|
-
assert.deepStrictEqual(
|
|
167
|
-
settled(),
|
|
168
|
-
['a'],
|
|
169
|
-
'and settled, or nothing would ever read it again'
|
|
170
|
-
)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('should settle what it has called even while it owns nothing', async () => {
|
|
174
|
-
rows = [row('a', 10)]
|
|
175
|
-
|
|
176
|
-
const dispatcher = create()
|
|
177
|
-
|
|
178
|
-
await dispatcher.connect()
|
|
179
|
-
await advance(10)
|
|
180
|
-
|
|
181
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
182
|
-
|
|
183
|
-
// the lane is somebody else's now, and a row left live is one they call again
|
|
184
|
-
rows = []
|
|
185
|
-
atom.slots.mock.mockImplementation(() => null)
|
|
186
|
-
|
|
187
|
-
await advance(INTERVAL)
|
|
188
|
-
|
|
189
|
-
assert.deepStrictEqual(settled(), ['a'])
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('should say so when a scan fills its batch', async () => {
|
|
193
|
-
rows = Array.from({ length: BATCH }, (_, i) => row(String(i), 10_000))
|
|
194
|
-
|
|
195
|
-
const dispatcher = create()
|
|
196
|
-
|
|
197
|
-
await dispatcher.connect()
|
|
198
|
-
await advance(0)
|
|
199
|
-
|
|
200
|
-
const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
|
|
201
|
-
|
|
202
|
-
assert.ok(
|
|
203
|
-
warned.some((message) => message.includes('filled its batch')),
|
|
204
|
-
'the horizon is truncated and the rest arrive late'
|
|
205
|
-
)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('should say so when a pass is skipped because the one before it is still running', async () => {
|
|
209
|
-
let release: () => void = () => {}
|
|
210
|
-
|
|
211
|
-
metronome.invoke.mock.mockImplementation(async (endpoint: string) => {
|
|
212
|
-
if (endpoint !== 'enumerate') return null
|
|
213
|
-
|
|
214
|
-
await new Promise<void>((resolve) => {
|
|
215
|
-
release = resolve
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
return []
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
const dispatcher = create()
|
|
222
|
-
|
|
223
|
-
await dispatcher.connect()
|
|
224
|
-
await advance(0) // the scan at time zero, which does not come back
|
|
225
|
-
|
|
226
|
-
await advance(INTERVAL)
|
|
227
|
-
|
|
228
|
-
const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
|
|
229
|
-
|
|
230
|
-
assert.ok(
|
|
231
|
-
warned.some((message) => message.includes('has not returned')),
|
|
232
|
-
'a dispatcher that stops dispatching says so'
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
release()
|
|
236
|
-
await advance(0)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
it('should read nothing while it owns nothing', async () => {
|
|
240
|
-
atom.slots.mock.mockImplementation(() => null)
|
|
241
|
-
|
|
242
|
-
const dispatcher = create()
|
|
243
|
-
|
|
244
|
-
await dispatcher.connect()
|
|
245
|
-
await advance(INTERVAL * 3)
|
|
246
|
-
|
|
247
|
-
assert.strictEqual(reads().length, 0)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
it('should call at the due time and not before', async () => {
|
|
251
|
-
rows = [row('a', 60)]
|
|
252
|
-
|
|
253
|
-
const dispatcher = create()
|
|
254
|
-
|
|
255
|
-
await dispatcher.connect()
|
|
256
|
-
await advance(1)
|
|
257
|
-
|
|
258
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not called')
|
|
259
|
-
|
|
260
|
-
await advance(60)
|
|
261
|
-
|
|
262
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
263
|
-
|
|
264
|
-
const [endpoint, request] = target.invoke.mock.calls[0].arguments
|
|
265
|
-
|
|
266
|
-
assert.strictEqual(endpoint, 'transit')
|
|
267
|
-
assert.deepStrictEqual(request, { input: { id: 'a' }, task: true })
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
it('should call one whose time has already passed at once', async () => {
|
|
271
|
-
rows = [row('a', -10_000)]
|
|
272
|
-
|
|
273
|
-
const dispatcher = create()
|
|
274
|
-
|
|
275
|
-
await dispatcher.connect()
|
|
276
|
-
await advance(0)
|
|
277
|
-
|
|
278
|
-
assert.strictEqual(
|
|
279
|
-
target.invoke.mock.callCount(),
|
|
280
|
-
1,
|
|
281
|
-
'a scan reads what is overdue along with what is coming'
|
|
282
|
-
)
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
it('should arm a row once, however often a scan reads it', async () => {
|
|
286
|
-
rows = [row('a', 10_000)]
|
|
287
|
-
|
|
288
|
-
const dispatcher = create()
|
|
289
|
-
|
|
290
|
-
await dispatcher.connect()
|
|
291
|
-
|
|
292
|
-
// one interval at a time: a scan is one at a time, and a single jump would find the first
|
|
293
|
-
// still running and skip the rest
|
|
294
|
-
for (let cycle = 0; cycle < 4; cycle++) await advance(INTERVAL)
|
|
295
|
-
|
|
296
|
-
assert.ok(reads().length >= 3, 'it kept scanning')
|
|
297
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0, 'and armed it only once')
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
it('should not arm a row again while its call is in flight', async () => {
|
|
301
|
-
let release: () => void = () => {}
|
|
302
|
-
|
|
303
|
-
rows = [row('a', 10)]
|
|
304
|
-
target.invoke.mock.mockImplementation(
|
|
305
|
-
async () =>
|
|
306
|
-
await new Promise<void>((resolve) => {
|
|
307
|
-
release = resolve
|
|
308
|
-
})
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
const dispatcher = create()
|
|
312
|
-
|
|
313
|
-
await dispatcher.connect()
|
|
314
|
-
await advance(10) // the call goes out, and does not come back
|
|
315
|
-
|
|
316
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
317
|
-
|
|
318
|
-
// a scan while it is in flight reads the row again, and settles what it thinks was called
|
|
319
|
-
await advance(INTERVAL)
|
|
320
|
-
|
|
321
|
-
// far enough for anything that scan armed to have fired
|
|
322
|
-
await advance(INTERVAL)
|
|
323
|
-
|
|
324
|
-
assert.strictEqual(
|
|
325
|
-
target.invoke.mock.callCount(),
|
|
326
|
-
1,
|
|
327
|
-
'and does not send it a second time'
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
release()
|
|
331
|
-
await advance(0)
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
it('should settle what it has called', async () => {
|
|
335
|
-
rows = [row('a', 10)]
|
|
336
|
-
|
|
337
|
-
const dispatcher = create()
|
|
338
|
-
|
|
339
|
-
await dispatcher.connect()
|
|
340
|
-
await advance(10)
|
|
341
|
-
|
|
342
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
343
|
-
|
|
344
|
-
rows = []
|
|
345
|
-
await advance(INTERVAL)
|
|
346
|
-
|
|
347
|
-
assert.deepStrictEqual(settled(), ['a'])
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
it('should settle a row whose call failed, and keep dispatching', async () => {
|
|
351
|
-
rows = [row('a', 10), row('b', 20)]
|
|
352
|
-
target.invoke.mock.mockImplementationOnce(async () => {
|
|
353
|
-
throw new Error('nope')
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
const dispatcher = create()
|
|
357
|
-
|
|
358
|
-
await dispatcher.connect()
|
|
359
|
-
await advance(20)
|
|
360
|
-
|
|
361
|
-
assert.strictEqual(
|
|
362
|
-
target.invoke.mock.callCount(),
|
|
363
|
-
2,
|
|
364
|
-
'the one after it is still called'
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
rows = []
|
|
368
|
-
await advance(INTERVAL)
|
|
369
|
-
|
|
370
|
-
assert.deepStrictEqual(
|
|
371
|
-
settled().sort(),
|
|
372
|
-
['a', 'b'],
|
|
373
|
-
'and the one that failed is settled'
|
|
374
|
-
)
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
it('should drop timers for lanes it no longer owns', async () => {
|
|
378
|
-
rows = [row('a', 10_000, 7)]
|
|
379
|
-
|
|
380
|
-
const dispatcher = create()
|
|
381
|
-
|
|
382
|
-
await dispatcher.connect()
|
|
383
|
-
await advance(INTERVAL)
|
|
384
|
-
|
|
385
|
-
rows = []
|
|
386
|
-
atom.slots.mock.mockImplementation(() => [0])
|
|
387
|
-
|
|
388
|
-
for (const listener of listeners) listener()
|
|
389
|
-
|
|
390
|
-
await advance(20_000)
|
|
391
|
-
|
|
392
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0, "lane 7 is somebody else's now")
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
it('should drop everything it holds when it owns nothing', async () => {
|
|
396
|
-
rows = [row('a', 10_000, 7)]
|
|
397
|
-
|
|
398
|
-
const dispatcher = create()
|
|
399
|
-
|
|
400
|
-
await dispatcher.connect()
|
|
401
|
-
await advance(INTERVAL)
|
|
402
|
-
|
|
403
|
-
rows = []
|
|
404
|
-
atom.slots.mock.mockImplementation(() => null)
|
|
405
|
-
|
|
406
|
-
for (const listener of listeners) listener()
|
|
407
|
-
|
|
408
|
-
await advance(20_000)
|
|
409
|
-
|
|
410
|
-
assert.strictEqual(
|
|
411
|
-
target.invoke.mock.callCount(),
|
|
412
|
-
0,
|
|
413
|
-
'whoever holds lane 7 now is the one to make that call'
|
|
414
|
-
)
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
it('should scan at once when what it owns changes', async () => {
|
|
418
|
-
atom.slots.mock.mockImplementation(() => null)
|
|
419
|
-
|
|
420
|
-
const dispatcher = create()
|
|
421
|
-
|
|
422
|
-
await dispatcher.connect()
|
|
423
|
-
await advance(0)
|
|
424
|
-
|
|
425
|
-
assert.strictEqual(reads().length, 0)
|
|
426
|
-
|
|
427
|
-
atom.slots.mock.mockImplementation((total) => range(total))
|
|
428
|
-
|
|
429
|
-
for (const listener of listeners) listener()
|
|
430
|
-
|
|
431
|
-
await advance(0)
|
|
432
|
-
|
|
433
|
-
assert.strictEqual(reads().length, 1, 'without waiting out an interval')
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
it('should give up a row a scan stops reading, so a cancellation still lands', async () => {
|
|
437
|
-
rows = [row('a', INTERVAL + 50)]
|
|
438
|
-
|
|
439
|
-
const dispatcher = create()
|
|
440
|
-
|
|
441
|
-
await dispatcher.connect()
|
|
442
|
-
await advance(1)
|
|
443
|
-
|
|
444
|
-
// cancelled: the row is tombstoned, so the next scan does not read it
|
|
445
|
-
rows = []
|
|
446
|
-
|
|
447
|
-
await advance(INTERVAL) // the scan that finds it gone
|
|
448
|
-
await advance(INTERVAL) // and past when it would have been called
|
|
449
|
-
|
|
450
|
-
assert.strictEqual(
|
|
451
|
-
target.invoke.mock.callCount(),
|
|
452
|
-
0,
|
|
453
|
-
'the timer was given up with the row'
|
|
454
|
-
)
|
|
455
|
-
})
|
|
456
|
-
|
|
457
|
-
it('should keep a row the scan still reads', async () => {
|
|
458
|
-
rows = [row('a', INTERVAL + 50)]
|
|
459
|
-
|
|
460
|
-
const dispatcher = create()
|
|
461
|
-
|
|
462
|
-
await dispatcher.connect()
|
|
463
|
-
await advance(INTERVAL)
|
|
464
|
-
|
|
465
|
-
assert.strictEqual(target.invoke.mock.callCount(), 0, 'not yet due')
|
|
466
|
-
|
|
467
|
-
await advance(50)
|
|
468
|
-
|
|
469
|
-
assert.strictEqual(target.invoke.mock.callCount(), 1)
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
it('should not give up a row when the batch was filled', async () => {
|
|
473
|
-
const held = row('held', INTERVAL + 50)
|
|
474
|
-
|
|
475
|
-
rows = [held, ...Array.from({ length: BATCH - 1 }, (_, i) => row('b' + i, 10_000))]
|
|
476
|
-
|
|
477
|
-
const dispatcher = create()
|
|
478
|
-
|
|
479
|
-
await dispatcher.connect()
|
|
480
|
-
await advance(1)
|
|
481
|
-
|
|
482
|
-
// a full batch is a read that was cut short, so absence from the next one is not a
|
|
483
|
-
// cancellation — these are rows it never reached, not rows nobody owes
|
|
484
|
-
rows = Array.from({ length: BATCH }, (_, i) => row('c' + i, 20_000))
|
|
485
|
-
|
|
486
|
-
await advance(INTERVAL)
|
|
487
|
-
await advance(50)
|
|
488
|
-
|
|
489
|
-
const called = target.invoke.mock.calls.map((call) => call.arguments[1].input.id)
|
|
490
|
-
|
|
491
|
-
assert.deepStrictEqual(called, ['held'], 'the row it armed is still called')
|
|
492
|
-
})
|