@toa.io/extensions.cadence 1.0.0-alpha.286 → 1.0.0-alpha.288

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 ADDED
@@ -0,0 +1,18 @@
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.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
7
+
8
+ **Note:** Version bump only for package @toa.io/extensions.cadence
9
+
10
+
11
+
12
+
13
+
14
+ # [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)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **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 made. They are tombstoned rather than removed: the record of
3
- * what was asked for outlives the call, and a query answers only what is still owed.
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.286",
3
+ "version": "1.0.0-alpha.288",
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.286",
22
- "@toa.io/schemas": "1.0.0-alpha.286",
21
+ "@toa.io/core": "1.0.0-alpha.288",
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": "81f3d0688f17668b902be336d1749cc715edcdb6"
28
+ "gitHead": "d3d696f0a4e287c3c297ac81028d600ad7574112"
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 is the floor under how closely a
190
- delayed call can be cancelled and how small an `overdue` is worth stating — see
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
- const row = (id: string, due: number, lane = 0): Row => ({
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 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 () => {
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};expires>0`
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
+ })
@@ -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 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,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.scanning || this.closing) return
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
- const until = now + this.discreteness
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 call nothing was running to make in time has expired, and is simply not read
132
- 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}`,
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 - Date.now(), 0)
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 interval of them into timers.
9
- * A crash loses the timers and nothing else — the next scan reads what is overdue along with
10
- * what is upcoming, so a dispatcher that died mid-interval costs a delay and not a call.
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.