@toa.io/extensions.cadence 1.0.0-alpha.286 → 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 +10 -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/settle.js +6 -2
- package/package.json +4 -4
- package/readme.md +14 -3
- package/source/Dispatcher.test.ts +174 -5
- package/source/Dispatcher.ts +100 -10
- package/source/const.ts +6 -0
- package/transpiled/Dispatcher.d.ts +21 -3
- package/transpiled/Dispatcher.js +85 -9
- package/transpiled/Dispatcher.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/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **cadence:** the scan reads what is owed, and nothing that is done ([581610e](https://github.com/toa-io/toa/commit/581610e87ac54c42491c882d5749ff3307700d35))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# The index holds what is still owed, and nothing else.
|
|
2
|
+
#
|
|
3
|
+
# Without the filter a scan walks every row the collection has ever held: the criteria bounds
|
|
4
|
+
# `due` from above and not below, and `DELETED` is not in the index, so every tombstone is
|
|
5
|
+
# fetched and discarded on the way to the few rows that are live. The cost of a pass grows with
|
|
6
|
+
# the age of the deployment until it outlives its own interval.
|
|
7
|
+
#
|
|
8
|
+
# The name is the one 0002 gave it, deliberately: an index whose declaration changed is dropped
|
|
9
|
+
# and made again.
|
|
10
|
+
- index:
|
|
11
|
+
name: index_due
|
|
12
|
+
keys:
|
|
13
|
+
lane: asc
|
|
14
|
+
due: asc
|
|
15
|
+
partial:
|
|
16
|
+
DELETED: null
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# A settled row is kept, and not kept forever.
|
|
2
|
+
#
|
|
3
|
+
# `DELETED` is what `settle` writes and what the runtime holds as a date, so the row ages out
|
|
4
|
+
# on the moment its call was disposed of. A row still owed has `DELETED: null`, and the TTL
|
|
5
|
+
# monitor passes over a record whose field is not a date — so nothing waiting to be called can
|
|
6
|
+
# be reaped by this, however long it has been waiting.
|
|
7
|
+
#
|
|
8
|
+
# Thirty days. The record answers what was asked for and when it was disposed of, which is a
|
|
9
|
+
# question asked while an incident is recent; a year of it is a collection nobody reads and
|
|
10
|
+
# everybody stores.
|
|
11
|
+
- index:
|
|
12
|
+
name: index_deleted
|
|
13
|
+
keys:
|
|
14
|
+
DELETED: asc
|
|
15
|
+
ttl: 2592000
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rows whose calls have been
|
|
3
|
-
*
|
|
2
|
+
* The rows whose calls have been disposed of — made, or found past the bound their caller gave
|
|
3
|
+
* them. They are tombstoned rather than removed: the record of what was asked for outlives the
|
|
4
|
+
* call, and a query answers only what is still owed.
|
|
5
|
+
*
|
|
6
|
+
* `DELETED` is also what the retention index reaps by, so that the record outlives the call
|
|
7
|
+
* without outliving the deployment.
|
|
4
8
|
*/
|
|
5
9
|
export function transition(_, objects) {
|
|
6
10
|
const now = Date.now()
|
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.287",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Schedule",
|
|
6
6
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
22
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
21
|
+
"@toa.io/core": "1.0.0-alpha.287",
|
|
22
|
+
"@toa.io/schemas": "1.0.0-alpha.287",
|
|
23
23
|
"openspan": "1.0.0-alpha.286"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"transpile": "tsc"
|
|
27
27
|
},
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "5c7944a70c16065ab6372072385cc1f02f5db77c"
|
|
29
29
|
}
|
package/readme.md
CHANGED
|
@@ -142,12 +142,23 @@ it already past its bound and leaves it. State an `overdue` several times `discr
|
|
|
142
142
|
`null`.
|
|
143
143
|
|
|
144
144
|
**A cancellation is reliable while the call is further out than `discreteness`**, and is a race
|
|
145
|
-
closer than that, where the call may already be on its way.
|
|
145
|
+
closer than that, where the call may already be on its way. What bounds it is the period between
|
|
146
|
+
passes: a cancelled call stops being owed, the next pass stops reading it, and a call taken in
|
|
147
|
+
hand is given up again when that happens.
|
|
148
|
+
|
|
149
|
+
**A call is due by the clock of whichever replica took the `delay`**, and made by the clock of
|
|
150
|
+
whichever one dispatches it. The overlap covers the disagreement between two machines kept in
|
|
151
|
+
ordinary time; nothing here measures it.
|
|
146
152
|
|
|
147
153
|
So `discreteness` and `overdue` are chosen together against what the application needs. A call
|
|
148
154
|
that must be made within seconds of its time, or cancellable within seconds of it, needs a
|
|
149
155
|
`discreteness` of seconds — at the cost of looking the calls over that much more often.
|
|
150
156
|
|
|
157
|
+
**A pass takes a bounded number of calls**, 200 to a replica, oldest first. Where more than that
|
|
158
|
+
come due to one replica in one period, the rest are taken on a later pass and are late by however
|
|
159
|
+
long that took; where it is logged, the rate is past what one period can carry. Replicas divide
|
|
160
|
+
the lanes between them, so the ceiling is per replica rather than for the application.
|
|
161
|
+
|
|
151
162
|
### What to expect
|
|
152
163
|
|
|
153
164
|
**A call may be made more than once**, so where a second one would cost something, the target
|
|
@@ -186,6 +197,6 @@ cadence:
|
|
|
186
197
|
```
|
|
187
198
|
|
|
188
199
|
`discreteness` is how often the calls waiting to be made are looked over. It defaults to 60,
|
|
189
|
-
which is what an application that states nothing gets. It
|
|
190
|
-
|
|
200
|
+
which is what an application that states nothing gets. It sets how small an `overdue` is worth
|
|
201
|
+
stating, and it is the floor under how closely a delayed call can be cancelled — see
|
|
191
202
|
[Discreteness](#discreteness) — and lowering it costs a pass that runs more often.
|
|
@@ -1,8 +1,9 @@
|
|
|
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
8
|
import type { atomicity } from '@toa.io/core/types'
|
|
8
9
|
|
|
@@ -17,19 +18,23 @@ 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
|
-
|
|
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 => ({
|
|
30
34
|
id,
|
|
31
35
|
lane,
|
|
32
36
|
due,
|
|
37
|
+
expires,
|
|
33
38
|
endpoint: 'tea.pots.transit',
|
|
34
39
|
request: { input: { id } }
|
|
35
40
|
})
|
|
@@ -69,6 +74,7 @@ beforeEach(() => {
|
|
|
69
74
|
|
|
70
75
|
rows = []
|
|
71
76
|
listeners = []
|
|
77
|
+
warn = mock.method(console, 'warn', () => undefined)
|
|
72
78
|
|
|
73
79
|
metronome = connector({
|
|
74
80
|
invoke: mock.fn<Invoke>(async (endpoint: string) =>
|
|
@@ -93,10 +99,11 @@ beforeEach(() => {
|
|
|
93
99
|
|
|
94
100
|
afterEach(() => {
|
|
95
101
|
mock.timers.reset()
|
|
102
|
+
warn.mock.restore()
|
|
96
103
|
delete process.env.TOA_CADENCE_DISCRETENESS
|
|
97
104
|
})
|
|
98
105
|
|
|
99
|
-
it('should read
|
|
106
|
+
it('should read two intervals ahead, in the lanes it owns', async () => {
|
|
100
107
|
const dispatcher = create()
|
|
101
108
|
|
|
102
109
|
await dispatcher.connect()
|
|
@@ -104,14 +111,122 @@ it('should read what is due within the interval, in the lanes it owns', async ()
|
|
|
104
111
|
|
|
105
112
|
const [first] = reads()
|
|
106
113
|
|
|
107
|
-
// the first scan is the one the subscription runs as it is added, at time zero
|
|
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
|
|
108
117
|
assert.strictEqual(
|
|
109
118
|
first.criteria,
|
|
110
|
-
`lane=in=(${range(LANES).join(',')});due<${INTERVAL}
|
|
119
|
+
`lane=in=(${range(LANES).join(',')});due<${2 * INTERVAL}`
|
|
111
120
|
)
|
|
112
121
|
assert.deepStrictEqual(first.sort, ['due:asc'])
|
|
113
122
|
})
|
|
114
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
|
+
|
|
115
230
|
it('should read nothing while it owns nothing', async () => {
|
|
116
231
|
atom.slots.mock.mockImplementation(() => null)
|
|
117
232
|
|
|
@@ -308,3 +423,57 @@ it('should scan at once when what it owns changes', async () => {
|
|
|
308
423
|
|
|
309
424
|
assert.strictEqual(reads().length, 1, 'without waiting out an interval')
|
|
310
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
|
@@ -8,9 +8,10 @@ import type { atomicity } from '@toa.io/core/types'
|
|
|
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,6 +38,9 @@ export class Dispatcher extends Connector {
|
|
|
37
38
|
private scanning = false
|
|
38
39
|
private closing = false
|
|
39
40
|
|
|
41
|
+
/** when the scan that is running started, so a tick that finds it can say how long */
|
|
42
|
+
private started = 0
|
|
43
|
+
|
|
40
44
|
public constructor(
|
|
41
45
|
metronome: Local,
|
|
42
46
|
resolve: (locator: Locator) => Local,
|
|
@@ -80,9 +84,28 @@ export class Dispatcher extends Connector {
|
|
|
80
84
|
|
|
81
85
|
/** One scan at a time: a slow one must not have another started underneath it. */
|
|
82
86
|
private tick(): void {
|
|
83
|
-
if (this.
|
|
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
|
+
}
|
|
84
106
|
|
|
85
107
|
this.scanning = true
|
|
108
|
+
this.started = Date.now()
|
|
86
109
|
|
|
87
110
|
void this.scan()
|
|
88
111
|
.catch((error: unknown) => {
|
|
@@ -117,25 +140,71 @@ export class Dispatcher extends Connector {
|
|
|
117
140
|
}
|
|
118
141
|
|
|
119
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
|
+
|
|
120
147
|
const lanes = this.atom.slots(LANES)
|
|
121
148
|
|
|
122
149
|
if (lanes === null || lanes.length === 0) return
|
|
123
150
|
|
|
124
|
-
await this.settle()
|
|
125
|
-
|
|
126
151
|
const now = Date.now()
|
|
127
|
-
|
|
152
|
+
|
|
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
|
|
128
164
|
|
|
129
165
|
const rows = (await this.metronome.invoke('enumerate', {
|
|
130
166
|
query: {
|
|
131
|
-
// a
|
|
132
|
-
|
|
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}`,
|
|
133
170
|
sort: ['due:asc'],
|
|
134
171
|
limit: BATCH
|
|
135
172
|
}
|
|
136
173
|
})) as Row[]
|
|
137
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 })
|
|
181
|
+
|
|
138
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
|
+
}
|
|
139
208
|
}
|
|
140
209
|
|
|
141
210
|
/**
|
|
@@ -145,11 +214,25 @@ export class Dispatcher extends Connector {
|
|
|
145
214
|
private arm(row: Row): void {
|
|
146
215
|
if (this.armed.has(row.id) || this.called.has(row.id)) return
|
|
147
216
|
|
|
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
|
+
|
|
148
231
|
const timer = setTimeout(
|
|
149
232
|
() => {
|
|
150
233
|
void this.dispatch(row)
|
|
151
234
|
},
|
|
152
|
-
Math.max(row.due -
|
|
235
|
+
Math.max(row.due - now, 0)
|
|
153
236
|
)
|
|
154
237
|
|
|
155
238
|
timer.unref()
|
|
@@ -239,6 +322,13 @@ interface Row {
|
|
|
239
322
|
id: string
|
|
240
323
|
lane: number
|
|
241
324
|
due: number
|
|
325
|
+
|
|
326
|
+
/** past this the call is not made; the row is settled without it */
|
|
327
|
+
expires: number
|
|
328
|
+
|
|
242
329
|
endpoint: string
|
|
243
330
|
request?: object
|
|
244
331
|
}
|
|
332
|
+
|
|
333
|
+
/** intervals a scan may run for before it is a stuck pass rather than a slow one */
|
|
334
|
+
const STALLED = 5
|
package/source/const.ts
CHANGED
|
@@ -6,6 +6,12 @@ export const COMPONENT = 'metronome'
|
|
|
6
6
|
* Constant, never configuration: a row carries its lane, so lowering this would leave rows in
|
|
7
7
|
* lanes nobody reads any more. It is also the ceiling on replicas dispatching, and a power of
|
|
8
8
|
* two so that the common replica counts divide evenly. The outbox says the same of its own.
|
|
9
|
+
*
|
|
10
|
+
* A scan asks for its lanes as a set and sorts on `due`, which is the index's second key, so
|
|
11
|
+
* the plan that serves it explodes the set into one scan per lane and merges them in order.
|
|
12
|
+
* MongoDB stops exploding past `internalQueryMaxScansToExplode`, 200 by default, and falls
|
|
13
|
+
* back to sorting the whole result in memory — so this staying well under that is not
|
|
14
|
+
* incidental.
|
|
9
15
|
*/
|
|
10
16
|
export const LANES = 128
|
|
11
17
|
|
|
@@ -5,9 +5,10 @@ import type { atomicity } from '@toa.io/core/types';
|
|
|
5
5
|
* Makes the calls that were put off.
|
|
6
6
|
*
|
|
7
7
|
* Two tiers, which is what a store polled rarely and a call due to the second need between
|
|
8
|
-
* them: the rows are the record, and one scan primes the next
|
|
9
|
-
* A crash loses the timers and nothing else — the next scan reads what is overdue
|
|
10
|
-
* what is upcoming, so a dispatcher that died mid-interval costs a delay and not a
|
|
8
|
+
* them: the rows are the record, and one scan primes the next two intervals of them into
|
|
9
|
+
* timers. A crash loses the timers and nothing else — the next scan reads what is overdue
|
|
10
|
+
* along with what is upcoming, so a dispatcher that died mid-interval costs a delay and not a
|
|
11
|
+
* call.
|
|
11
12
|
*
|
|
12
13
|
* Partitioned by lane exactly as the outbox is, so every replica scans and dispatches its own
|
|
13
14
|
* share of every interval rather than taking turns at whole ones.
|
|
@@ -28,6 +29,8 @@ export declare class Dispatcher extends Connector {
|
|
|
28
29
|
private off?;
|
|
29
30
|
private scanning;
|
|
30
31
|
private closing;
|
|
32
|
+
/** when the scan that is running started, so a tick that finds it can say how long */
|
|
33
|
+
private started;
|
|
31
34
|
constructor(metronome: Local, resolve: (locator: Locator) => Local, atom: atomicity.Atom);
|
|
32
35
|
protected open(): Promise<void>;
|
|
33
36
|
protected close(): Promise<void>;
|
|
@@ -42,6 +45,21 @@ export declare class Dispatcher extends Connector {
|
|
|
42
45
|
/** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
|
|
43
46
|
private disarm;
|
|
44
47
|
private scan;
|
|
48
|
+
/**
|
|
49
|
+
* Gives up what the store no longer answers with. A scan reads everything still owed in the
|
|
50
|
+
* lanes this replica holds, so a row it armed before and does not read now is one nothing
|
|
51
|
+
* owes any more — cancelled, most of the time — and a timer left for it makes the call
|
|
52
|
+
* regardless.
|
|
53
|
+
*
|
|
54
|
+
* This is what lets a scan reach further ahead than the next one without a cancellation
|
|
55
|
+
* becoming any less reliable: a row is held only for as long as the store keeps saying it is
|
|
56
|
+
* owed, so what bounds cancelling is the period between passes and not how far one reaches.
|
|
57
|
+
*
|
|
58
|
+
* Two things are not evidence of a cancellation. A batch filled to its limit is a read that
|
|
59
|
+
* was cut short, and says nothing about what it did not reach. And a call already on its way
|
|
60
|
+
* is settled by its own path rather than by what a scan reads.
|
|
61
|
+
*/
|
|
62
|
+
private forget;
|
|
45
63
|
/**
|
|
46
64
|
* A row already armed is left alone: a scan reads what it read before, because a row stays
|
|
47
65
|
* until its call has been made.
|