@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/LICENSE +22 -0
  3. package/components/cadence.metronome/manifest.toa.yaml +22 -28
  4. package/components/cadence.metronome/migrations/0001-system-properties.yaml +14 -0
  5. package/components/cadence.metronome/migrations/0002-indexes.yaml +5 -0
  6. package/components/cadence.metronome/migrations/0003-pending-index.yaml +16 -0
  7. package/components/cadence.metronome/migrations/0004-retention.yaml +15 -0
  8. package/components/cadence.metronome/operations/delay.js +1 -1
  9. package/components/cadence.metronome/operations/settle.js +8 -4
  10. package/components/cadence.metronome/types/toa.d.ts +5 -5
  11. package/package.json +6 -5
  12. package/readme.md +35 -21
  13. package/schemas/declaration.cos.yaml +0 -1
  14. package/source/Aspect.ts +19 -10
  15. package/source/Composition.ts +4 -4
  16. package/source/Dispatcher.test.ts +240 -32
  17. package/source/Dispatcher.ts +145 -32
  18. package/source/Factory.ts +13 -10
  19. package/source/Local.ts +5 -4
  20. package/source/Pulse.test.ts +48 -19
  21. package/source/Pulse.ts +30 -21
  22. package/source/const.ts +7 -1
  23. package/source/context.ts +2 -2
  24. package/source/deployment.ts +10 -5
  25. package/source/manifest.test.ts +9 -6
  26. package/source/manifest.ts +24 -11
  27. package/transpiled/Aspect.d.ts +0 -1
  28. package/transpiled/Aspect.js +11 -5
  29. package/transpiled/Aspect.js.map +1 -1
  30. package/transpiled/Composition.js.map +1 -1
  31. package/transpiled/Dispatcher.d.ts +22 -4
  32. package/transpiled/Dispatcher.js +111 -18
  33. package/transpiled/Dispatcher.js.map +1 -1
  34. package/transpiled/Factory.d.ts +1 -1
  35. package/transpiled/Factory.js.map +1 -1
  36. package/transpiled/Local.d.ts +2 -1
  37. package/transpiled/Local.js.map +1 -1
  38. package/transpiled/Pulse.d.ts +2 -1
  39. package/transpiled/Pulse.js +14 -5
  40. package/transpiled/Pulse.js.map +1 -1
  41. package/transpiled/const.d.ts +6 -0
  42. package/transpiled/const.js +6 -0
  43. package/transpiled/const.js.map +1 -1
  44. package/transpiled/context.d.ts +1 -1
  45. package/transpiled/deployment.js +5 -2
  46. package/transpiled/deployment.js.map +1 -1
  47. package/transpiled/manifest.js +6 -3
  48. package/transpiled/manifest.js.map +1 -1
  49. 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
- const row = (id: string, due: number, lane = 0): Row =>
30
- ({ id, lane, due, endpoint: 'tea.pots.transit', request: { input: { id } } })
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
- ({ ...properties, link: mock.fn(), connect: async () => {}, disconnect: async () => {} })
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[] => metronome.invoke.mock.calls
43
- .filter((call) => call.arguments[0] === 'enumerate')
44
- .map((call) => call.arguments[1].query)
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[] => metronome.invoke.mock.calls
48
- .filter((call) => call.arguments[0] === 'settle')
49
- .flatMap((call) => call.arguments[1].query.ids as string[])
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) => endpoint === 'enumerate' ? rows : null)
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 () => { listeners = listeners.filter((one) => one !== listener) }
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 what is due within the interval, in the lanes it owns', async () => {
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
- assert.strictEqual(first.criteria,
96
- `lane=in=(${range(LANES).join(',')});due<${INTERVAL};expires>0`)
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(target.invoke.mock.callCount(), 1,
140
- 'a scan reads what is overdue along with what is coming')
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(async () =>
163
- await new Promise<void>((resolve) => { release = resolve }))
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(target.invoke.mock.callCount(), 1, 'and does not send it a second time')
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 () => { throw new Error('nope') })
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(target.invoke.mock.callCount(), 2, 'the one after it is still called')
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(settled().sort(), ['a', 'b'], 'and the one that failed is settled')
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, 'lane 7 is somebody else\'s now')
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(target.invoke.mock.callCount(), 0,
251
- 'whoever holds lane 7 now is the one to make that call')
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
+ })
@@ -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 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.
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
- public constructor (metronome: Local, resolve: (locator: Locator) => Local,
41
- atom: atomicity.Atom) {
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 (): Promise<void> {
54
- this.timer = setInterval(() => { this.tick() }, this.discreteness)
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(() => { this.adopt() })
67
+ this.off = this.atom.onassigned(() => {
68
+ this.adopt()
69
+ })
59
70
  }
60
71
 
61
- protected override async close (): Promise<void> {
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 (): void {
76
- if (this.scanning || this.closing) return
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) => { console.error('Delayed calls scan failed', { error }) })
82
- .finally(() => { this.scanning = false })
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 (): void {
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 (lanes: number[]): void {
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 (): Promise<void> {
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
- const rows = await this.metronome.invoke('enumerate', {
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 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}`,
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 (row: Row): void {
214
+ private arm(row: Row): void {
135
215
  if (this.armed.has(row.id) || this.called.has(row.id)) return
136
216
 
137
- const timer = setTimeout(() => { void this.dispatch(row) }, Math.max(row.due - Date.now(), 0))
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 (row: Row): Promise<void> {
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.invoke(endpoint, { input: null, ...row.request, task: true })
272
+ await local
273
+ .invoke(endpoint, { input: null, ...row.request, task: true })
174
274
  .catch((error: unknown) => {
175
- console.error('Delayed call failed', { endpoint: row.endpoint, id: row.id, error })
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(() => { this.armed.delete(row.id) })
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 (locator: Locator): Local {
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 (): Promise<void> {
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