@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.
@@ -1,342 +0,0 @@
1
- import { console } from 'openspan'
2
- import { Connector, Locator } from '@toa.io/core'
3
- import {
4
- BATCH,
5
- DISCRETENESS,
6
- LANES,
7
- number
8
- } from '@toa.io/definitions/extensions.cadence'
9
- import type { Local } from './Local.js'
10
- import type { atomicity } from '@toa.io/core/types'
11
-
12
- /**
13
- * Makes the calls that were put off.
14
- *
15
- * Two tiers, which is what a store polled rarely and a call due to the second need between
16
- * them: the rows are the record, and one scan primes the next two intervals of them into
17
- * timers. A crash loses the timers and nothing else — the next scan reads what is overdue
18
- * along with what is upcoming, so a dispatcher that died mid-interval costs a delay and not a
19
- * call.
20
- *
21
- * Partitioned by lane exactly as the outbox is, so every replica scans and dispatches its own
22
- * share of every interval rather than taking turns at whole ones.
23
- */
24
- export class Dispatcher extends Connector {
25
- private readonly metronome: Local
26
- private readonly resolve: (locator: Locator) => Local
27
- private readonly atom: atomicity.Atom
28
-
29
- /** rows in this replica's hands — a timer set for them, or a call of theirs on its way */
30
- private readonly armed = new Map<string, Armed>()
31
-
32
- /** ids called and not yet removed, held until a scan settles them */
33
- private called = new Set<string>()
34
-
35
- /** what it has called through, so that each is torn down with it */
36
- private readonly targets = new Set<Local>()
37
-
38
- /** milliseconds between passes, and how far ahead each one reaches */
39
- private readonly discreteness: number
40
-
41
- private timer?: NodeJS.Timeout
42
- private off?: () => void
43
- private scanning = false
44
- private closing = false
45
-
46
- /** when the scan that is running started, so a tick that finds it can say how long */
47
- private started = 0
48
-
49
- public constructor(
50
- metronome: Local,
51
- resolve: (locator: Locator) => Local,
52
- atom: atomicity.Atom
53
- ) {
54
- super()
55
-
56
- this.metronome = metronome
57
- this.resolve = resolve
58
- this.atom = atom
59
- this.discreteness = number('TOA_CADENCE_DISCRETENESS', DISCRETENESS * 1000)
60
-
61
- this.depends(metronome)
62
- this.depends(atom)
63
- }
64
-
65
- protected override async open(): Promise<void> {
66
- this.timer = setInterval(() => {
67
- this.tick()
68
- }, this.discreteness)
69
- this.timer.unref()
70
-
71
- // a lane changing hands is when its rows become this replica's, or stop being
72
- this.off = this.atom.onassigned(() => {
73
- this.adopt()
74
- })
75
- }
76
-
77
- protected override async close(): Promise<void> {
78
- this.closing = true
79
-
80
- this.off?.()
81
- clearInterval(this.timer)
82
-
83
- for (const { timer } of this.armed.values()) clearTimeout(timer)
84
-
85
- this.armed.clear()
86
-
87
- await this.settle()
88
- }
89
-
90
- /** One scan at a time: a slow one must not have another started underneath it. */
91
- private tick(): void {
92
- if (this.closing) return
93
-
94
- /*
95
- * Skipped, and said so. The guard itself is right — a second scan on top of one that has
96
- * not returned only makes it worse — but a pass that outlives its interval takes every
97
- * pass after it with it, and nothing is dispatched at all while that lasts. Silently, it
98
- * is a dispatcher that stops dispatching in a deployment that ran for months.
99
- */
100
- if (this.scanning) {
101
- const running = Date.now() - this.started
102
- const details = { running, discreteness: this.discreteness }
103
- const message = 'Delayed calls scan has not returned, so this pass is skipped'
104
-
105
- // past a few intervals it is not a slow pass but a stuck one
106
- if (running > STALLED * this.discreteness) console.error(message, details)
107
- else console.warn(message, details)
108
-
109
- return
110
- }
111
-
112
- this.scanning = true
113
- this.started = Date.now()
114
-
115
- void this.scan()
116
- .catch((error: unknown) => {
117
- console.error('Delayed calls scan failed', { error })
118
- })
119
- .finally(() => {
120
- this.scanning = false
121
- })
122
- }
123
-
124
- /**
125
- * What this replica owns has changed. Timers for lanes it no longer holds are dropped —
126
- * whoever holds them now will arm them — and a scan runs at once rather than at the next
127
- * interval, so lanes just acquired are not left waiting.
128
- */
129
- private adopt(): void {
130
- // nothing owned is nothing held: what was armed under a claim this replica no longer has
131
- // belongs to whoever has it now, and firing it anyway is the call twice
132
- this.disarm(this.atom.slots(LANES) ?? [])
133
-
134
- this.tick()
135
- }
136
-
137
- /** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
138
- private disarm(lanes: number[]): void {
139
- for (const [id, { timer, lane }] of this.armed) {
140
- if (lanes.includes(lane)) continue
141
-
142
- clearTimeout(timer)
143
- this.armed.delete(id)
144
- }
145
- }
146
-
147
- private async scan(): Promise<void> {
148
- // ahead of the claim, because settling needs none: the rows are ones this replica has
149
- // already called, and one left live is one the next owner of its lane calls again
150
- await this.settle()
151
-
152
- const lanes = this.atom.slots(LANES)
153
-
154
- if (lanes === null || lanes.length === 0) return
155
-
156
- const now = Date.now()
157
-
158
- /*
159
- * Two intervals, so that consecutive passes overlap by one whole one. Reaching exactly as
160
- * far as the next pass leaves a sliver beyond the horizon that the next pass reads already
161
- * overdue — the width of the drift between the two, which a row with a tight `overdue`
162
- * does not survive. The overlap absorbs that, a settle that ran long, a tick skipped
163
- * outright, and the clock skew between the replica that wrote `due` and this one.
164
- *
165
- * Reaching further costs nothing a cancellation would pay for, because what is armed is
166
- * given up again the moment a scan stops reading it — see `forget`.
167
- */
168
- const until = now + 2 * this.discreteness
169
-
170
- const rows = (await this.metronome.invoke('enumerate', {
171
- query: {
172
- // expiry is not a criterion: a row nobody reads is a row nobody settles, and it would
173
- // stay live for good. It is read, and settled without the call being made
174
- criteria: `lane=in=(${lanes.join(',')});due<${until}`,
175
- sort: ['due:asc'],
176
- limit: BATCH
177
- }
178
- })) as Row[]
179
-
180
- // the horizon is truncated to the last row of a full batch, and everything past it arrives
181
- // on a later pass already overdue. Where this is logged, the batch is too small for the rate
182
- const truncated = rows.length === BATCH
183
-
184
- if (truncated)
185
- console.warn('Delayed calls scan filled its batch', {
186
- limit: BATCH,
187
- lanes: lanes.length
188
- })
189
-
190
- for (const row of rows) this.arm(row)
191
-
192
- if (!truncated) this.forget(new Set(rows.map((row) => row.id)))
193
- }
194
-
195
- /**
196
- * Gives up what the store no longer answers with. A scan reads everything still owed in the
197
- * lanes this replica holds, so a row it armed before and does not read now is one nothing
198
- * owes any more — cancelled, most of the time — and a timer left for it makes the call
199
- * regardless.
200
- *
201
- * This is what lets a scan reach further ahead than the next one without a cancellation
202
- * becoming any less reliable: a row is held only for as long as the store keeps saying it is
203
- * owed, so what bounds cancelling is the period between passes and not how far one reaches.
204
- *
205
- * Two things are not evidence of a cancellation. A batch filled to its limit is a read that
206
- * was cut short, and says nothing about what it did not reach. And a call already on its way
207
- * is settled by its own path rather than by what a scan reads.
208
- */
209
- private forget(read: Set<string>): void {
210
- for (const [id, { timer }] of this.armed) {
211
- if (read.has(id) || this.called.has(id)) continue
212
-
213
- clearTimeout(timer)
214
- this.armed.delete(id)
215
- }
216
- }
217
-
218
- /**
219
- * A row already armed is left alone: a scan reads what it read before, because a row stays
220
- * until its call has been made.
221
- */
222
- private arm(row: Row): void {
223
- if (this.armed.has(row.id) || this.called.has(row.id)) return
224
-
225
- const now = Date.now()
226
-
227
- /*
228
- * Past the bound its caller gave it, so the call is not made — and the row is settled all
229
- * the same, silently, because an expired call is not an event anything sees. Left unread
230
- * it would never be settled either: a row nothing will ever act on, sitting in the
231
- * collection and in front of every scan, for good.
232
- */
233
- if (row.expires <= now) {
234
- this.called.add(row.id)
235
-
236
- return
237
- }
238
-
239
- const timer = setTimeout(
240
- () => {
241
- void this.dispatch(row)
242
- },
243
- Math.max(row.due - now, 0)
244
- )
245
-
246
- timer.unref()
247
- this.armed.set(row.id, { timer, lane: row.lane })
248
- }
249
-
250
- /**
251
- * The call goes out before the row is removed, never the other way round: a crash in between
252
- * makes the call twice, and the other order would lose it. It travels as a task, so the
253
- * broker holds it if the target is not there to take it.
254
- */
255
- private async dispatch(row: Row): Promise<void> {
256
- if (this.closing) {
257
- this.armed.delete(row.id)
258
-
259
- return
260
- }
261
-
262
- const [namespace, name, endpoint] = row.endpoint.split('.')
263
- const local = this.target(new Locator(name, namespace))
264
-
265
- /*
266
- * Attempted before it is made, not after it comes back. One attempt is what a dispatcher
267
- * has to give: what it can tell about a failure is nothing, and a request the target's
268
- * schema no longer fits never becomes one it does.
269
- */
270
- this.called.add(row.id)
271
-
272
- /*
273
- * The call travels as a task, so what raises here is this side of it: a stored request the
274
- * target's contract no longer fits, an endpoint it no longer has, a broker that refused the
275
- * enqueue. What the operation itself does with it happens in the target's own process and
276
- * never comes back.
277
- *
278
- * Skipped, as a pulse skips: reported, and the row settled on the attempt either way.
279
- */
280
- await local
281
- .invoke(endpoint, { input: null, ...row.request, task: true })
282
- .catch((error: unknown) => {
283
- console.error('Delayed call failed', {
284
- endpoint: row.endpoint,
285
- id: row.id,
286
- error
287
- })
288
- })
289
- // held for as long as the call is, so `armed` answers what this replica has in hand
290
- // rather than what it has a timer for
291
- .finally(() => {
292
- this.armed.delete(row.id)
293
- })
294
- }
295
-
296
- /**
297
- * A remote is resolved once per target and held: it opens a connection, and one nothing
298
- * depends on would outlive the dispatcher that made it.
299
- */
300
- private target(locator: Locator): Local {
301
- const local = this.resolve(locator)
302
-
303
- if (!this.targets.has(local)) {
304
- this.targets.add(local)
305
- this.depends(local)
306
- }
307
-
308
- return local
309
- }
310
-
311
- /** One write for many rows, which is why the ids are held rather than settled one by one. */
312
- private async settle(): Promise<void> {
313
- if (this.called.size === 0) return
314
-
315
- const ids = [...this.called]
316
-
317
- await this.metronome.invoke('settle', { query: { ids }, input: null })
318
-
319
- // held until the write comes back, so that one which does not leaves them to a later scan
320
- for (const id of ids) this.called.delete(id)
321
- }
322
- }
323
-
324
- interface Armed {
325
- timer: NodeJS.Timeout
326
- lane: number
327
- }
328
-
329
- interface Row {
330
- id: string
331
- lane: number
332
- due: number
333
-
334
- /** past this the call is not made; the row is settled without it */
335
- expires: number
336
-
337
- endpoint: string
338
- request?: object
339
- }
340
-
341
- /** intervals a scan may run for before it is a stuck pass rather than a slow one */
342
- const STALLED = 5
package/source/Factory.ts DELETED
@@ -1,76 +0,0 @@
1
- import { Connector, Locator } from '@toa.io/core'
2
- import { Composition } from './Composition.js'
3
- import { COMPONENT, NAMESPACE } from '@toa.io/definitions/extensions.cadence'
4
- import { Aspect } from './Aspect.js'
5
- import { Dispatcher } from './Dispatcher.js'
6
- import { Local } from './Local.js'
7
- import { Pulse } from './Pulse.js'
8
- import type { Declaration } from '@toa.io/definitions/extensions.cadence'
9
- import type { extensions } from '@toa.io/core/types'
10
-
11
- export class Factory implements extensions.Factory {
12
- private readonly host: Host
13
-
14
- /** one per component, however much of this extension calls through it */
15
- private readonly locals: Record<string, Local> = {}
16
-
17
- public constructor(host: Host) {
18
- this.host = host
19
- }
20
-
21
- /**
22
- * What runs beside a component. Its own component gets the dispatcher; everyone else gets a
23
- * timer per operation they declared a cadence for.
24
- *
25
- * A tenant is created before the components of its composition are, which is why nothing here
26
- * reaches for one.
27
- */
28
- public tenant(locator: Locator, declaration: object): Connector {
29
- if (mine(locator))
30
- return new Dispatcher(
31
- this.metronome(),
32
- (target) => this.local(target),
33
- this.host.atom(locator.id)
34
- )
35
-
36
- const tenant = new Connector()
37
- const pulses = declaration as Declaration | null
38
-
39
- // `cadence: ~` is a component that only delays calls
40
- if (pulses === null) return tenant
41
-
42
- const local = this.local(locator)
43
- const atom = this.host.atom(locator.id)
44
-
45
- for (const [endpoint, { cycle, intervals }] of Object.entries(pulses))
46
- tenant.depends(new Pulse({ locator, endpoint, cycle, intervals }, local, atom))
47
-
48
- return tenant
49
- }
50
-
51
- /** `context.delay` — for every component but the one that keeps the calls. */
52
- public aspect(locator: Locator): extensions.Aspect | extensions.Aspect[] {
53
- if (mine(locator)) return []
54
-
55
- return new Aspect(this.metronome())
56
- }
57
-
58
- public service(): Connector {
59
- return new Composition(this.host)
60
- }
61
-
62
- private metronome(): Local {
63
- return this.local(new Locator(COMPONENT, NAMESPACE))
64
- }
65
-
66
- private local(locator: Locator): Local {
67
- return (this.locals[locator.id] ??= new Local(this.host, locator))
68
- }
69
- }
70
-
71
- /** Whether this is a component the extension ships rather than one of the application's. */
72
- function mine(locator: Locator): boolean {
73
- return locator.namespace === NAMESPACE
74
- }
75
-
76
- export type Host = extensions.Host
package/source/Local.ts DELETED
@@ -1,40 +0,0 @@
1
- import { Connector } from '@toa.io/core'
2
- import type { Host } from './Factory.js'
3
- import type { Locator, Remote } from '@toa.io/core'
4
- import type { Reply, Request } from '@toa.io/core/types'
5
-
6
- /**
7
- * The component a tenant belongs to, as something to call.
8
- *
9
- * Resolved on the first call and not before: a tenant is created ahead of the components of its
10
- * composition, so at that point there is nothing to look up yet. A pulse calls at a boundary,
11
- * which is long after everything is up.
12
- */
13
- export class Local extends Connector {
14
- private readonly host: Host
15
- private readonly locator: Locator
16
- private remote?: Promise<Remote>
17
-
18
- public constructor(host: Host, locator: Locator) {
19
- super()
20
-
21
- this.host = host
22
- this.locator = locator
23
- }
24
-
25
- public async invoke(endpoint: string, request: Request): Promise<Reply> {
26
- this.remote ??= this.locate()
27
-
28
- return await (await this.remote).invoke(endpoint, request)
29
- }
30
-
31
- private async locate(): Promise<Remote> {
32
- const remote = await this.host.remote(this.locator)
33
-
34
- this.depends(remote)
35
-
36
- await remote.connect()
37
-
38
- return remote
39
- }
40
- }