@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
package/CHANGELOG.md CHANGED
@@ -3,8 +3,8 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
- # [1.0.0-alpha.284](https://github.com/toa-io/toa/compare/v1.0.0-alpha.283...v1.0.0-alpha.284) (2026-09-05)
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
7
 
8
- ### Features
8
+ ### Bug Fixes
9
9
 
10
- * **cadence:** calls a component makes to itself, and calls it puts off ([8bbde5f](https://github.com/toa-io/toa/commit/8bbde5fc89e8e5549d5aa2bfcddb1beec0871565))
10
+ * **cadence:** the scan reads what is owed, and nothing that is done ([581610e](https://github.com/toa-io/toa/commit/581610e87ac54c42491c882d5749ff3307700d35))
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2020-present Artem Gurtovoi
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -5,35 +5,29 @@ name: metronome
5
5
  cadence: ~
6
6
 
7
7
  entity:
8
- schema:
9
- type: object
10
- properties:
11
- lane:
12
- description: Which dispatcher takes this row, as the outbox partitions its lanes
13
- type: integer
14
- due:
15
- description: When the call is to be made, in milliseconds since the epoch
16
- type: integer
17
- expires:
18
- description: >
19
- After this the call is not made at all. A scan bounds on it, so a call nothing was
20
- running to make in time is a row nobody reads, rather than an event anything sees.
21
- type: integer
22
- endpoint:
23
- description: What to call, as `namespace.component.operation`
24
- type: string
25
- request:
26
- description: The request to make, as it was handed over
27
- type: object
28
- required:
29
- - lane
30
- - due
31
- - expires
32
- - endpoint
33
- index:
8
+ properties:
9
+ lane:
10
+ description: Which dispatcher takes this row, as the outbox partitions its lanes
11
+ type: integer
34
12
  due:
35
- lane: asc
36
- due: asc
13
+ description: When the call is to be made, in milliseconds since the epoch
14
+ type: integer
15
+ expires:
16
+ description: >
17
+ After this the call is not made at all. A scan bounds on it, so a call nothing was
18
+ running to make in time is a row nobody reads, rather than an event anything sees.
19
+ type: integer
20
+ endpoint:
21
+ description: What to call, as `namespace.component.operation`
22
+ type: string
23
+ request:
24
+ description: The request to make, as it was handed over
25
+ type: object
26
+ required:
27
+ - lane
28
+ - due
29
+ - expires
30
+ - endpoint
37
31
 
38
32
  # `enumerate` reads what is due and `terminate` cancels one; both come from the prototype.
39
33
  operations:
@@ -0,0 +1,14 @@
1
+ # 1.0.0-alpha.286 renamed the four system properties of a record. A database written before it
2
+ # holds them under the old names, where nothing reads them: such a record is never created,
3
+ # never updated, never deleted, and at version zero.
4
+ #
5
+ # `$rename` leaves a record that has none of them alone, so this is a no-op for a component
6
+ # that has only ever run this release.
7
+ - update:
8
+ filter: {}
9
+ update:
10
+ $rename:
11
+ _version: VERSION
12
+ _created: CREATED
13
+ _updated: UPDATED
14
+ _deleted: DELETED
@@ -0,0 +1,5 @@
1
+ - index:
2
+ name: index_due
3
+ keys:
4
+ lane: asc
5
+ due: asc
@@ -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
@@ -8,7 +8,7 @@ import { LANES } from '@toa.io/extensions.cadence'
8
8
  * row has no immediate path to settle, so nothing is gained, and an even spread is what the
9
9
  * dispatchers want.
10
10
  */
11
- export function transition (input, object) {
11
+ export function transition(input, object) {
12
12
  const due = Date.now() + input.interval
13
13
 
14
14
  object.lane = Math.floor(Math.random() * LANES)
@@ -1,9 +1,13 @@
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
- export function transition (_, objects) {
9
+ export function transition(_, objects) {
6
10
  const now = Date.now()
7
11
 
8
- for (const object of objects) object._deleted = now
12
+ for (const object of objects) object.DELETED = now
9
13
  }
@@ -1,7 +1,7 @@
1
1
  // Written by `toa types`. Every run rewrites it.
2
2
  // What a manifest does not state belongs in a file of your own.
3
3
 
4
- import type { Query } from '@toa.io/core'
4
+ import type { Query } from '@toa.io/core/types'
5
5
  import type { Readable } from 'node:stream'
6
6
 
7
7
  export interface Entity {
@@ -16,10 +16,10 @@ export interface Entity {
16
16
  /** The request to make, as it was handed over */
17
17
  request?: Record<string, unknown>
18
18
  id: string
19
- _version?: number
20
- _created?: number
21
- _updated?: number
22
- _deleted?: number | null
19
+ VERSION?: number
20
+ CREATED?: number
21
+ UPDATED?: number
22
+ DELETED?: number | null
23
23
  }
24
24
 
25
25
  export type DelayInput = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.cadence",
3
- "version": "1.0.0-alpha.284",
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,11 +18,12 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@toa.io/core": "1.0.0-alpha.284",
22
- "@toa.io/schemas": "1.0.0-alpha.283",
23
- "openspan": "1.0.0-alpha.277"
21
+ "@toa.io/core": "1.0.0-alpha.287",
22
+ "@toa.io/schemas": "1.0.0-alpha.287",
23
+ "openspan": "1.0.0-alpha.286"
24
24
  },
25
25
  "scripts": {
26
26
  "transpile": "tsc"
27
- }
27
+ },
28
+ "gitHead": "5c7944a70c16065ab6372072385cc1f02f5db77c"
28
29
  }
package/readme.md CHANGED
@@ -14,9 +14,9 @@ at all.
14
14
  ```yaml
15
15
  # manifest.toa.yaml
16
16
  cadence:
17
- sweep: # an operation of this component
18
- cycle: 86400 # seconds one whole cycle takes
19
- intervals: 24 # what it is split into, so one call an hour
17
+ sweep: # an operation of this component
18
+ cycle: 86400 # seconds one whole cycle takes
19
+ intervals: 24 # what it is split into, so one call an hour
20
20
  ```
21
21
 
22
22
  The gap between calls is `cycle / intervals`, derived rather than declared, so the two cannot
@@ -26,13 +26,13 @@ declares:
26
26
  ```yaml
27
27
  # manifest.toa.yaml
28
28
  cadence:
29
- sweep: 3600 # once an hour
29
+ sweep: 3600 # once an hour
30
30
  ```
31
31
 
32
32
  The operation is called with the cycle and the interval as its input:
33
33
 
34
34
  ```javascript
35
- async function sweep ({ n, i }, context) { }
35
+ async function sweep({ n, i }, context) {}
36
36
  ```
37
37
 
38
38
  `n` is `intervals` and `i` is which of them this call is for, from `0` to `n - 1`. They are there
@@ -77,22 +77,25 @@ either answer put to everyone who wanted an operation called every hour is a wor
77
77
  picking one.
78
78
 
79
79
  `intervals` is not a replica count. With `intervals: 24` and three replicas each makes eight of
80
- the day's calls: the cycle is spread over *time*, not over the fleet. Replicas beyond `intervals`
80
+ the day's calls: the cycle is spread over _time_, not over the fleet. Replicas beyond `intervals`
81
81
  make none.
82
82
 
83
83
  ## Delay
84
84
 
85
85
  ```javascript
86
- const id = await context.delay('mail.sender.remind', { input: { user } },
87
- { interval: 7 * 24 * 3600 * 1000, overdue: null })
86
+ const id = await context.delay(
87
+ 'mail.sender.remind',
88
+ { input: { user } },
89
+ { interval: 7 * 24 * 3600 * 1000, overdue: null }
90
+ )
88
91
 
89
92
  await context.delay.cancel(id)
90
93
  ```
91
94
 
92
- | | |
93
- |---|---|
94
- | `interval` | milliseconds from now |
95
- | `overdue` | milliseconds the call may be late and still be made, or `null` for no bound |
95
+ | | |
96
+ | ---------- | --------------------------------------------------------------------------- |
97
+ | `interval` | milliseconds from now |
98
+ | `overdue` | milliseconds the call may be late and still be made, or `null` for no bound |
96
99
 
97
100
  The call is made once the delay has passed, and waits for the target where it is not there to
98
101
  take it. The id it answers cancels it, and `cancel` raises where the id was never issued.
@@ -111,11 +114,11 @@ cadence: ~
111
114
  ordinary path has: a call is made at the moment it is due. How much is left of that in practice
112
115
  depends on how coarsely the calls are looked over — see [Discreteness](#discreteness).
113
116
 
114
- | | |
115
- |---|---|
116
- | `null` | no bound: made whenever it can be, however late that is |
117
- | `0` | on time or never |
118
- | a number | made if no more than that many milliseconds late |
117
+ | | |
118
+ | -------- | ------------------------------------------------------- |
119
+ | `null` | no bound: made whenever it can be, however late that is |
120
+ | `0` | on time or never |
121
+ | a number | made if no more than that many milliseconds late |
119
122
 
120
123
  **There is no default, and it is stated on every call.** Only the caller knows whether a late
121
124
  call is still the right call — an unpaid order expires on time or not at all, a report is wanted
@@ -139,12 +142,23 @@ it already past its bound and leaves it. State an `overdue` several times `discr
139
142
  `null`.
140
143
 
141
144
  **A cancellation is reliable while the call is further out than `discreteness`**, and is a race
142
- 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.
143
152
 
144
153
  So `discreteness` and `overdue` are chosen together against what the application needs. A call
145
154
  that must be made within seconds of its time, or cancellable within seconds of it, needs a
146
155
  `discreteness` of seconds — at the cost of looking the calls over that much more often.
147
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
+
148
162
  ### What to expect
149
163
 
150
164
  **A call may be made more than once**, so where a second one would cost something, the target
@@ -179,10 +193,10 @@ may no longer be one it accepts. Either way the attempt is spent.
179
193
  ```yaml
180
194
  # context.toa.yaml
181
195
  cadence:
182
- discreteness: 60 # seconds between passes over the calls waiting to be made
196
+ discreteness: 60 # seconds between passes over the calls waiting to be made
183
197
  ```
184
198
 
185
199
  `discreteness` is how often the calls waiting to be made are looked over. It defaults to 60,
186
- which is what an application that states nothing gets. It is the floor under how closely a
187
- 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
188
202
  [Discreteness](#discreteness) — and lowering it costs a pass that runs more often.
@@ -16,6 +16,5 @@ patternProperties:
16
16
  and the operation is told which interval it is running for.
17
17
  type: integer
18
18
  minimum: 1
19
- default: 1
20
19
  required: [cycle]
21
20
  additionalProperties: false
package/source/Aspect.ts CHANGED
@@ -17,14 +17,13 @@ interface Input {
17
17
  * handing over the call it should have scheduled loses the call, and there is no way for this
18
18
  * to know. Where the semantics allow it, hand the call over first.
19
19
  *
20
- * @implements {toa.core.extensions.Aspect}
21
20
  */
22
21
  export class Aspect extends Connector {
23
22
  public readonly name = 'delay'
24
23
 
25
24
  private readonly metronome: Local
26
25
 
27
- public constructor (metronome: Local) {
26
+ public constructor(metronome: Local) {
28
27
  super()
29
28
 
30
29
  this.metronome = metronome
@@ -34,18 +33,28 @@ export class Aspect extends Connector {
34
33
 
35
34
  // named rather than forwarded, so that the connector's lifecycle is not reachable from an
36
35
  // algorithm — the same reason the atom aspect names its own
37
- public async invoke (method: string, ...args: any[]): Promise<unknown> {
36
+ public async invoke(method: string, ...args: any[]): Promise<unknown> {
38
37
  switch (method) {
39
- case 'delay': return await this.delay(args[0], args[1], args[2])
40
- case 'cancel': return await this.cancel(args[0])
41
- default: throw new Error(`Delay aspect has no '${method}' method`)
38
+ case 'delay':
39
+ return await this.delay(args[0], args[1], args[2])
40
+ case 'cancel':
41
+ return await this.cancel(args[0])
42
+ default:
43
+ throw new Error(`Delay aspect has no '${method}' method`)
42
44
  }
43
45
  }
44
46
 
45
47
  /** Answers the id the call was given, which is what cancels it. */
46
- private async delay (endpoint: string, request: object | null,
47
- options: Options): Promise<unknown> {
48
- const input: Input = { endpoint, interval: options.interval, overdue: options.overdue }
48
+ private async delay(
49
+ endpoint: string,
50
+ request: object | null,
51
+ options: Options
52
+ ): Promise<unknown> {
53
+ const input: Input = {
54
+ endpoint,
55
+ interval: options.interval,
56
+ overdue: options.overdue
57
+ }
49
58
 
50
59
  // a call that takes no request carries none, rather than a null one
51
60
  if (request !== null && request !== undefined) input.request = request
@@ -58,7 +67,7 @@ export class Aspect extends Connector {
58
67
  * nothing to terminate and raises; one whose call has already been made has a row still, and
59
68
  * terminating it again is not an error.
60
69
  */
61
- private async cancel (id: string): Promise<unknown> {
70
+ private async cancel(id: string): Promise<unknown> {
62
71
  return await this.metronome.invoke('terminate', { query: { id }, input: null })
63
72
  }
64
73
  }
@@ -5,7 +5,7 @@ import type { Dirent } from 'node:fs'
5
5
  import type { Host } from './Factory.js'
6
6
 
7
7
  /** The components this extension ships, which run inside its own service. */
8
- export function components (): Components {
8
+ export function components(): Components {
9
9
  const labels: string[] = []
10
10
  const paths: string[] = []
11
11
 
@@ -20,13 +20,13 @@ export function components (): Components {
20
20
  export class Composition extends Connector {
21
21
  private readonly host: Host
22
22
 
23
- public constructor (host: Host) {
23
+ public constructor(host: Host) {
24
24
  super()
25
25
 
26
26
  this.host = host
27
27
  }
28
28
 
29
- protected override async open (): Promise<void> {
29
+ protected override async open(): Promise<void> {
30
30
  const composition = await this.host.composition(components().paths)
31
31
 
32
32
  await composition.connect()
@@ -35,7 +35,7 @@ export class Composition extends Connector {
35
35
  }
36
36
  }
37
37
 
38
- function entries (): Dirent[] {
38
+ function entries(): Dirent[] {
39
39
  return readdirSync(ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory())
40
40
  }
41
41