@toa.io/extensions.cadence 1.0.0-alpha.291 → 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.
Files changed (47) hide show
  1. package/package.json +15 -6
  2. package/transpiled/Dispatcher.js +5 -2
  3. package/transpiled/Dispatcher.js.map +1 -1
  4. package/transpiled/Factory.js +1 -1
  5. package/transpiled/Factory.js.map +1 -1
  6. package/transpiled/index.d.ts +2 -5
  7. package/transpiled/index.js +2 -4
  8. package/transpiled/index.js.map +1 -1
  9. package/CHANGELOG.md +0 -37
  10. package/schemas/annotation.cos.yaml +0 -9
  11. package/schemas/declaration.cos.yaml +0 -20
  12. package/source/Aspect.ts +0 -73
  13. package/source/Composition.ts +0 -47
  14. package/source/Dispatcher.test.ts +0 -479
  15. package/source/Dispatcher.ts +0 -334
  16. package/source/Factory.ts +0 -76
  17. package/source/Local.ts +0 -40
  18. package/source/Pulse.test.ts +0 -303
  19. package/source/Pulse.ts +0 -211
  20. package/source/const.ts +0 -31
  21. package/source/context.ts +0 -10
  22. package/source/deployment.ts +0 -38
  23. package/source/index.ts +0 -8
  24. package/source/manifest.test.ts +0 -61
  25. package/source/manifest.ts +0 -64
  26. package/source/schemas.ts +0 -10
  27. package/source/types.ts +0 -41
  28. package/transpiled/const.d.ts +0 -21
  29. package/transpiled/const.js +0 -26
  30. package/transpiled/const.js.map +0 -1
  31. package/transpiled/context.d.ts +0 -3
  32. package/transpiled/context.js +0 -9
  33. package/transpiled/context.js.map +0 -1
  34. package/transpiled/deployment.d.ts +0 -9
  35. package/transpiled/deployment.js +0 -29
  36. package/transpiled/deployment.js.map +0 -1
  37. package/transpiled/manifest.d.ts +0 -11
  38. package/transpiled/manifest.js +0 -42
  39. package/transpiled/manifest.js.map +0 -1
  40. package/transpiled/schemas.d.ts +0 -4
  41. package/transpiled/schemas.js +0 -7
  42. package/transpiled/schemas.js.map +0 -1
  43. package/transpiled/types.d.ts +0 -34
  44. package/transpiled/types.js +0 -2
  45. package/transpiled/types.js.map +0 -1
  46. package/tsconfig.json +0 -13
  47. package/tsconfig.tsbuildinfo +0 -1
@@ -1,334 +0,0 @@
1
- import { console } from 'openspan'
2
- import { Connector, Locator } from '@toa.io/core'
3
- import { BATCH, DISCRETENESS, LANES, number } from './const.js'
4
- import type { Local } from './Local.js'
5
- import type { atomicity } from '@toa.io/core/types'
6
-
7
- /**
8
- * Makes the calls that were put off.
9
- *
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 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.
15
- *
16
- * Partitioned by lane exactly as the outbox is, so every replica scans and dispatches its own
17
- * share of every interval rather than taking turns at whole ones.
18
- */
19
- export class Dispatcher extends Connector {
20
- private readonly metronome: Local
21
- private readonly resolve: (locator: Locator) => Local
22
- private readonly atom: atomicity.Atom
23
-
24
- /** rows in this replica's hands — a timer set for them, or a call of theirs on its way */
25
- private readonly armed = new Map<string, Armed>()
26
-
27
- /** ids called and not yet removed, held until a scan settles them */
28
- private called = new Set<string>()
29
-
30
- /** what it has called through, so that each is torn down with it */
31
- private readonly targets = new Set<Local>()
32
-
33
- /** milliseconds between passes, and how far ahead each one reaches */
34
- private readonly discreteness: number
35
-
36
- private timer?: NodeJS.Timeout
37
- private off?: () => void
38
- private scanning = false
39
- private closing = false
40
-
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
- ) {
49
- super()
50
-
51
- this.metronome = metronome
52
- this.resolve = resolve
53
- this.atom = atom
54
- this.discreteness = number('TOA_CADENCE_DISCRETENESS', DISCRETENESS * 1000)
55
-
56
- this.depends(metronome)
57
- this.depends(atom)
58
- }
59
-
60
- protected override async open(): Promise<void> {
61
- this.timer = setInterval(() => {
62
- this.tick()
63
- }, this.discreteness)
64
- this.timer.unref()
65
-
66
- // a lane changing hands is when its rows become this replica's, or stop being
67
- this.off = this.atom.onassigned(() => {
68
- this.adopt()
69
- })
70
- }
71
-
72
- protected override async close(): Promise<void> {
73
- this.closing = true
74
-
75
- this.off?.()
76
- clearInterval(this.timer)
77
-
78
- for (const { timer } of this.armed.values()) clearTimeout(timer)
79
-
80
- this.armed.clear()
81
-
82
- await this.settle()
83
- }
84
-
85
- /** One scan at a time: a slow one must not have another started underneath it. */
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
- }
106
-
107
- this.scanning = true
108
- this.started = Date.now()
109
-
110
- void this.scan()
111
- .catch((error: unknown) => {
112
- console.error('Delayed calls scan failed', { error })
113
- })
114
- .finally(() => {
115
- this.scanning = false
116
- })
117
- }
118
-
119
- /**
120
- * What this replica owns has changed. Timers for lanes it no longer holds are dropped —
121
- * whoever holds them now will arm them — and a scan runs at once rather than at the next
122
- * interval, so lanes just acquired are not left waiting.
123
- */
124
- private adopt(): void {
125
- // nothing owned is nothing held: what was armed under a claim this replica no longer has
126
- // belongs to whoever has it now, and firing it anyway is the call twice
127
- this.disarm(this.atom.slots(LANES) ?? [])
128
-
129
- this.tick()
130
- }
131
-
132
- /** What is armed for a lane this replica no longer holds: whoever holds it now will arm it. */
133
- private disarm(lanes: number[]): void {
134
- for (const [id, { timer, lane }] of this.armed) {
135
- if (lanes.includes(lane)) continue
136
-
137
- clearTimeout(timer)
138
- this.armed.delete(id)
139
- }
140
- }
141
-
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
-
147
- const lanes = this.atom.slots(LANES)
148
-
149
- if (lanes === null || lanes.length === 0) return
150
-
151
- const now = Date.now()
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
164
-
165
- const rows = (await this.metronome.invoke('enumerate', {
166
- query: {
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}`,
170
- sort: ['due:asc'],
171
- limit: BATCH
172
- }
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 })
181
-
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
- }
208
- }
209
-
210
- /**
211
- * A row already armed is left alone: a scan reads what it read before, because a row stays
212
- * until its call has been made.
213
- */
214
- private arm(row: Row): void {
215
- if (this.armed.has(row.id) || this.called.has(row.id)) return
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
-
231
- const timer = setTimeout(
232
- () => {
233
- void this.dispatch(row)
234
- },
235
- Math.max(row.due - now, 0)
236
- )
237
-
238
- timer.unref()
239
- this.armed.set(row.id, { timer, lane: row.lane })
240
- }
241
-
242
- /**
243
- * The call goes out before the row is removed, never the other way round: a crash in between
244
- * makes the call twice, and the other order would lose it. It travels as a task, so the
245
- * broker holds it if the target is not there to take it.
246
- */
247
- private async dispatch(row: Row): Promise<void> {
248
- if (this.closing) {
249
- this.armed.delete(row.id)
250
-
251
- return
252
- }
253
-
254
- const [namespace, name, endpoint] = row.endpoint.split('.')
255
- const local = this.target(new Locator(name, namespace))
256
-
257
- /*
258
- * Attempted before it is made, not after it comes back. One attempt is what a dispatcher
259
- * has to give: what it can tell about a failure is nothing, and a request the target's
260
- * schema no longer fits never becomes one it does.
261
- */
262
- this.called.add(row.id)
263
-
264
- /*
265
- * The call travels as a task, so what raises here is this side of it: a stored request the
266
- * target's contract no longer fits, an endpoint it no longer has, a broker that refused the
267
- * enqueue. What the operation itself does with it happens in the target's own process and
268
- * never comes back.
269
- *
270
- * Skipped, as a pulse skips: reported, and the row settled on the attempt either way.
271
- */
272
- await local
273
- .invoke(endpoint, { input: null, ...row.request, task: true })
274
- .catch((error: unknown) => {
275
- console.error('Delayed call failed', {
276
- endpoint: row.endpoint,
277
- id: row.id,
278
- error
279
- })
280
- })
281
- // held for as long as the call is, so `armed` answers what this replica has in hand
282
- // rather than what it has a timer for
283
- .finally(() => {
284
- this.armed.delete(row.id)
285
- })
286
- }
287
-
288
- /**
289
- * A remote is resolved once per target and held: it opens a connection, and one nothing
290
- * depends on would outlive the dispatcher that made it.
291
- */
292
- private target(locator: Locator): Local {
293
- const local = this.resolve(locator)
294
-
295
- if (!this.targets.has(local)) {
296
- this.targets.add(local)
297
- this.depends(local)
298
- }
299
-
300
- return local
301
- }
302
-
303
- /** One write for many rows, which is why the ids are held rather than settled one by one. */
304
- private async settle(): Promise<void> {
305
- if (this.called.size === 0) return
306
-
307
- const ids = [...this.called]
308
-
309
- await this.metronome.invoke('settle', { query: { ids }, input: null })
310
-
311
- // held until the write comes back, so that one which does not leaves them to a later scan
312
- for (const id of ids) this.called.delete(id)
313
- }
314
- }
315
-
316
- interface Armed {
317
- timer: NodeJS.Timeout
318
- lane: number
319
- }
320
-
321
- interface Row {
322
- id: string
323
- lane: number
324
- due: number
325
-
326
- /** past this the call is not made; the row is settled without it */
327
- expires: number
328
-
329
- endpoint: string
330
- request?: object
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/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 './const.js'
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 './types.js'
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
- }