@toa.io/extensions.cadence 1.0.0-alpha.284
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 +10 -0
- package/components/cadence.metronome/manifest.toa.yaml +72 -0
- package/components/cadence.metronome/operations/delay.js +28 -0
- package/components/cadence.metronome/operations/settle.js +9 -0
- package/components/cadence.metronome/types/index.d.ts +3 -0
- package/components/cadence.metronome/types/toa.d.ts +45 -0
- package/package.json +28 -0
- package/readme.md +188 -0
- package/schemas/annotation.cos.yaml +9 -0
- package/schemas/declaration.cos.yaml +21 -0
- package/source/Aspect.ts +64 -0
- package/source/Composition.ts +47 -0
- package/source/Dispatcher.test.ts +271 -0
- package/source/Dispatcher.ts +221 -0
- package/source/Factory.ts +73 -0
- package/source/Local.ts +39 -0
- package/source/Pulse.test.ts +274 -0
- package/source/Pulse.ts +202 -0
- package/source/const.ts +23 -0
- package/source/context.ts +10 -0
- package/source/deployment.ts +33 -0
- package/source/index.ts +8 -0
- package/source/manifest.test.ts +58 -0
- package/source/manifest.ts +51 -0
- package/source/schemas.ts +10 -0
- package/source/types.ts +41 -0
- package/transpiled/Aspect.d.ts +26 -0
- package/transpiled/Aspect.js +46 -0
- package/transpiled/Aspect.js.map +1 -0
- package/transpiled/Composition.d.ts +14 -0
- package/transpiled/Composition.js +30 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Dispatcher.d.ts +63 -0
- package/transpiled/Dispatcher.js +170 -0
- package/transpiled/Dispatcher.js.map +1 -0
- package/transpiled/Factory.d.ts +22 -0
- package/transpiled/Factory.js +56 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Local.d.ts +18 -0
- package/transpiled/Local.js +29 -0
- package/transpiled/Local.js.map +1 -0
- package/transpiled/Pulse.d.ts +64 -0
- package/transpiled/Pulse.js +148 -0
- package/transpiled/Pulse.js.map +1 -0
- package/transpiled/const.d.ts +15 -0
- package/transpiled/const.js +19 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/context.d.ts +3 -0
- package/transpiled/context.js +9 -0
- package/transpiled/context.js.map +1 -0
- package/transpiled/deployment.d.ts +9 -0
- package/transpiled/deployment.js +26 -0
- package/transpiled/deployment.js.map +1 -0
- package/transpiled/index.d.ts +7 -0
- package/transpiled/index.js +7 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/manifest.d.ts +11 -0
- package/transpiled/manifest.js +39 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/schemas.d.ts +4 -0
- package/transpiled/schemas.js +7 -0
- package/transpiled/schemas.js.map +1 -0
- package/transpiled/types.d.ts +34 -0
- package/transpiled/types.js +2 -0
- package/transpiled/types.js.map +1 -0
- package/tsconfig.json +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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.284](https://github.com/toa-io/toa/compare/v1.0.0-alpha.283...v1.0.0-alpha.284) (2026-09-05)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **cadence:** calls a component makes to itself, and calls it puts off ([8bbde5f](https://github.com/toa-io/toa/commit/8bbde5fc89e8e5549d5aa2bfcddb1beec0871565))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
namespace: cadence
|
|
2
|
+
name: metronome
|
|
3
|
+
|
|
4
|
+
# The extension puts its dispatcher on this component, and nothing else here declares a cadence.
|
|
5
|
+
cadence: ~
|
|
6
|
+
|
|
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:
|
|
34
|
+
due:
|
|
35
|
+
lane: asc
|
|
36
|
+
due: asc
|
|
37
|
+
|
|
38
|
+
# `enumerate` reads what is due and `terminate` cancels one; both come from the prototype.
|
|
39
|
+
operations:
|
|
40
|
+
delay:
|
|
41
|
+
query: false
|
|
42
|
+
input:
|
|
43
|
+
type: object
|
|
44
|
+
properties:
|
|
45
|
+
endpoint:
|
|
46
|
+
type: string
|
|
47
|
+
pattern: ^[a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][_a-zA-Z0-9]*$
|
|
48
|
+
request:
|
|
49
|
+
type: object
|
|
50
|
+
interval:
|
|
51
|
+
description: Milliseconds from now
|
|
52
|
+
type: integer
|
|
53
|
+
minimum: 0
|
|
54
|
+
overdue:
|
|
55
|
+
description: >
|
|
56
|
+
Milliseconds the call may be late and still be made, or null for no bound. Stated
|
|
57
|
+
rather than defaulted: only the caller knows whether a late call is still the right
|
|
58
|
+
call.
|
|
59
|
+
type: integer
|
|
60
|
+
nullable: true
|
|
61
|
+
minimum: 0
|
|
62
|
+
required:
|
|
63
|
+
- endpoint
|
|
64
|
+
- interval
|
|
65
|
+
- overdue
|
|
66
|
+
output:
|
|
67
|
+
type: string
|
|
68
|
+
|
|
69
|
+
settle:
|
|
70
|
+
query: true
|
|
71
|
+
# a row may be cancelled while a page of them is being settled
|
|
72
|
+
concurrency: retry
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LANES } from '@toa.io/extensions.cadence'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A call to make later. The request carries no query, so the transition is handed a new object.
|
|
5
|
+
*
|
|
6
|
+
* The lane is random, not one this replica owns. An outbox row is written into an owned lane so
|
|
7
|
+
* that in steady state a replica settles its own rows before it ever reads them — but a delayed
|
|
8
|
+
* row has no immediate path to settle, so nothing is gained, and an even spread is what the
|
|
9
|
+
* dispatchers want.
|
|
10
|
+
*/
|
|
11
|
+
export function transition (input, object) {
|
|
12
|
+
const due = Date.now() + input.interval
|
|
13
|
+
|
|
14
|
+
object.lane = Math.floor(Math.random() * LANES)
|
|
15
|
+
object.due = due
|
|
16
|
+
object.endpoint = input.endpoint
|
|
17
|
+
|
|
18
|
+
// absolute, because the bound is the caller's and a scan reads rows of many callers at once.
|
|
19
|
+
// No bound is the end of representable time rather than an absent field, so that one
|
|
20
|
+
// comparison answers for every row
|
|
21
|
+
object.expires = input.overdue === null ? Number.MAX_SAFE_INTEGER : due + input.overdue
|
|
22
|
+
|
|
23
|
+
// a call that takes no request has none: the entity's `request` is an object where it is
|
|
24
|
+
// there at all, and a null would not fit it
|
|
25
|
+
if (input.request !== undefined) object.request = input.request
|
|
26
|
+
|
|
27
|
+
return object.id
|
|
28
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
export function transition (_, objects) {
|
|
6
|
+
const now = Date.now()
|
|
7
|
+
|
|
8
|
+
for (const object of objects) object._deleted = now
|
|
9
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Written by `toa types`. Every run rewrites it.
|
|
2
|
+
// What a manifest does not state belongs in a file of your own.
|
|
3
|
+
|
|
4
|
+
import type { Query } from '@toa.io/core'
|
|
5
|
+
import type { Readable } from 'node:stream'
|
|
6
|
+
|
|
7
|
+
export interface Entity {
|
|
8
|
+
/** Which dispatcher takes this row, as the outbox partitions its lanes */
|
|
9
|
+
lane: number
|
|
10
|
+
/** When the call is to be made, in milliseconds since the epoch */
|
|
11
|
+
due: number
|
|
12
|
+
/** After this the call is not made at all. A scan bounds on it, so a call nothing was running to make in time is a row nobody reads, rather than an event anything sees. */
|
|
13
|
+
expires: number
|
|
14
|
+
/** What to call, as `namespace.component.operation` */
|
|
15
|
+
endpoint: string
|
|
16
|
+
/** The request to make, as it was handed over */
|
|
17
|
+
request?: Record<string, unknown>
|
|
18
|
+
id: string
|
|
19
|
+
_version?: number
|
|
20
|
+
_created?: number
|
|
21
|
+
_updated?: number
|
|
22
|
+
_deleted?: number | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DelayInput = {
|
|
26
|
+
endpoint: string
|
|
27
|
+
request?: Record<string, unknown>
|
|
28
|
+
/** Milliseconds from now */
|
|
29
|
+
interval: number
|
|
30
|
+
/** Milliseconds the call may be late and still be made, or null for no bound. Stated rather than defaulted: only the caller knows whether a late call is still the right call. */
|
|
31
|
+
overdue: number | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DelayOutput = string
|
|
35
|
+
|
|
36
|
+
export interface Component {
|
|
37
|
+
delay: (request: { input: DelayInput, task?: boolean }) => Promise<DelayOutput>
|
|
38
|
+
settle: (request: { input?: null, query: Query<Entity>, task?: boolean }) => Promise<unknown>
|
|
39
|
+
assign: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Entity>
|
|
40
|
+
ensure: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Entity>
|
|
41
|
+
enumerate: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Entity[]>
|
|
42
|
+
observe: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Entity | null>
|
|
43
|
+
stream: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Readable>
|
|
44
|
+
terminate: (request: { input?: null, query?: Query<Entity>, task?: boolean }) => Promise<Entity>
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toa.io/extensions.cadence",
|
|
3
|
+
"version": "1.0.0-alpha.284",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Toa Schedule",
|
|
6
|
+
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/toa-io/toa#readme",
|
|
8
|
+
"main": "transpiled/index.js",
|
|
9
|
+
"types": "transpiled/index.d.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/toa-io/toa.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/toa-io/toa/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
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"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"transpile": "tsc"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Toa Cadence
|
|
2
|
+
|
|
3
|
+
Calls a component makes to itself, and calls it puts off.
|
|
4
|
+
|
|
5
|
+
A **pulse** calls an operation of its own component on a cadence its manifest states. A **delay**
|
|
6
|
+
hands one call over to be made later.
|
|
7
|
+
|
|
8
|
+
Both rest on [`atomicity`](/connectors/atomicity), which is what keeps two replicas from making
|
|
9
|
+
one call and where that promise is written down. Where it is not configured, nothing is called
|
|
10
|
+
at all.
|
|
11
|
+
|
|
12
|
+
## Pulse
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
# manifest.toa.yaml
|
|
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
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The gap between calls is `cycle / intervals`, derived rather than declared, so the two cannot
|
|
23
|
+
disagree. `intervals` defaults to `1`, one call per cycle, which is also what the shorthand
|
|
24
|
+
declares:
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
# manifest.toa.yaml
|
|
28
|
+
cadence:
|
|
29
|
+
sweep: 3600 # once an hour
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The operation is called with the cycle and the interval as its input:
|
|
33
|
+
|
|
34
|
+
```javascript
|
|
35
|
+
async function sweep ({ n, i }, context) { }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`n` is `intervals` and `i` is which of them this call is for, from `0` to `n - 1`. They are there
|
|
39
|
+
so that one sweep can be spread across its cycle rather than run whole every time: an operation
|
|
40
|
+
that takes `i` as its share of the work does a twenty-fourth of it every hour instead of all of
|
|
41
|
+
it at midnight.
|
|
42
|
+
|
|
43
|
+
A pulse may name any operation a receiver may — `transition`, `assignment`, `effect` or
|
|
44
|
+
`unmanaged`. An operation that produces no side effects has nothing to be called periodically
|
|
45
|
+
for.
|
|
46
|
+
|
|
47
|
+
A cycle is measured from the Unix epoch rather than from a calendar, so `86400` means UTC
|
|
48
|
+
midnight and `3600` the top of the hour. A cycle that is not a divisor of a day lands on a
|
|
49
|
+
boundary that is not any particular time of day. Time zones and calendar months are not
|
|
50
|
+
supported.
|
|
51
|
+
|
|
52
|
+
### What to expect
|
|
53
|
+
|
|
54
|
+
**An interval may not be called.** A rollout, a crash, or an operation that raised or ran past
|
|
55
|
+
its own interval each cost that interval, and nothing makes it up afterwards.
|
|
56
|
+
|
|
57
|
+
**So select by state, not by position.** With `intervals` above `1` the shares are a partition —
|
|
58
|
+
`i = 5` missed is not covered by `i = 6`, and waits a whole cycle. Write the operation to take
|
|
59
|
+
what in its share is still due, rather than everything in its share, and a missed interval costs
|
|
60
|
+
a delay instead of a day. An operation written the other way loses a day's work to a
|
|
61
|
+
thirty-second rollout.
|
|
62
|
+
|
|
63
|
+
Where a call has to happen even if nothing was running when it came due, it needs a stored
|
|
64
|
+
schedule: [`delay`](#delay) is one.
|
|
65
|
+
|
|
66
|
+
**An interval is called once.** Two calls for one interval need the clocks of two machines to
|
|
67
|
+
disagree and the work to change hands in the same moment, which is a window the width of that
|
|
68
|
+
disagreement.
|
|
69
|
+
|
|
70
|
+
**A call is never made while the one before it is still running.** If the work does not fit the
|
|
71
|
+
gap the interval is skipped and says so; where that is logged, the cycle is too short or has too
|
|
72
|
+
few intervals.
|
|
73
|
+
|
|
74
|
+
**An operation that raises loses its interval, and nothing is retried.** Skipping is a choice
|
|
75
|
+
rather than a conclusion, and there is no setting for it — retrying would be as defensible, and
|
|
76
|
+
either answer put to everyone who wanted an operation called every hour is a worse cost than
|
|
77
|
+
picking one.
|
|
78
|
+
|
|
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`
|
|
81
|
+
make none.
|
|
82
|
+
|
|
83
|
+
## Delay
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
const id = await context.delay('mail.sender.remind', { input: { user } },
|
|
87
|
+
{ interval: 7 * 24 * 3600 * 1000, overdue: null })
|
|
88
|
+
|
|
89
|
+
await context.delay.cancel(id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| | |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `interval` | milliseconds from now |
|
|
95
|
+
| `overdue` | milliseconds the call may be late and still be made, or `null` for no bound |
|
|
96
|
+
|
|
97
|
+
The call is made once the delay has passed, and waits for the target where it is not there to
|
|
98
|
+
take it. The id it answers cancels it, and `cancel` raises where the id was never issued.
|
|
99
|
+
|
|
100
|
+
Nothing is declared for it beyond naming the extension. A component that only delays calls says
|
|
101
|
+
so with an empty declaration:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# manifest.toa.yaml
|
|
105
|
+
cadence: ~
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### How late is too late
|
|
109
|
+
|
|
110
|
+
`overdue` bounds lateness, and lateness is mostly what a failure causes rather than what the
|
|
111
|
+
ordinary path has: a call is made at the moment it is due. How much is left of that in practice
|
|
112
|
+
depends on how coarsely the calls are looked over — see [Discreteness](#discreteness).
|
|
113
|
+
|
|
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 |
|
|
119
|
+
|
|
120
|
+
**There is no default, and it is stated on every call.** Only the caller knows whether a late
|
|
121
|
+
call is still the right call — an unpaid order expires on time or not at all, a report is wanted
|
|
122
|
+
whenever it can be had — and nothing else in the system can tell those apart. A default would
|
|
123
|
+
answer for it, and either answer is wrong for somebody: the wrong `null` makes a stale call, and
|
|
124
|
+
the wrong number drops a call and says nothing.
|
|
125
|
+
|
|
126
|
+
### Discreteness
|
|
127
|
+
|
|
128
|
+
Calls waiting to be made are looked over on a period — `discreteness`, a minute by default — and
|
|
129
|
+
everything here is measured against it.
|
|
130
|
+
|
|
131
|
+
**A delay shorter than `discreteness` is served late.** It is made at the next pass rather than
|
|
132
|
+
at the moment it was due, so a five second delay under a one minute `discreteness` lands up to a
|
|
133
|
+
minute after it was asked for. A delay longer than `discreteness` is made on time, because the
|
|
134
|
+
pass before it has already set it going.
|
|
135
|
+
|
|
136
|
+
**An `overdue` shorter than `discreteness` can be missed where nothing is wrong**, and combined
|
|
137
|
+
with a delay shorter than it will almost always be: the pass that would have made the call finds
|
|
138
|
+
it already past its bound and leaves it. State an `overdue` several times `discreteness`, or
|
|
139
|
+
`null`.
|
|
140
|
+
|
|
141
|
+
**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.
|
|
143
|
+
|
|
144
|
+
So `discreteness` and `overdue` are chosen together against what the application needs. A call
|
|
145
|
+
that must be made within seconds of its time, or cancellable within seconds of it, needs a
|
|
146
|
+
`discreteness` of seconds — at the cost of looking the calls over that much more often.
|
|
147
|
+
|
|
148
|
+
### What to expect
|
|
149
|
+
|
|
150
|
+
**A call may be made more than once**, so where a second one would cost something, the target
|
|
151
|
+
has to be the one that refuses it.
|
|
152
|
+
|
|
153
|
+
**A call may not be made at all**, in four ways:
|
|
154
|
+
|
|
155
|
+
- **the caller crashed** between committing its own change and handing the call over. This is
|
|
156
|
+
not transactional with your state, and nothing can tell — where the semantics allow it, hand
|
|
157
|
+
the call over first;
|
|
158
|
+
- **it expired**, having been given an `overdue` and nothing having been able to make it in
|
|
159
|
+
time;
|
|
160
|
+
- **it was cancelled** — which is reliable while the call is further out than `discreteness`,
|
|
161
|
+
and a race closer than that;
|
|
162
|
+
- **it could not be made.** One attempt is given, so a call that could not go out is reported
|
|
163
|
+
and dropped rather than tried again — the same choice a pulse makes, for the same reason.
|
|
164
|
+
|
|
165
|
+
What is reported here is a call that could not be made at all. An exception the operation itself
|
|
166
|
+
raises belongs to the component that raised it and is reported there.
|
|
167
|
+
|
|
168
|
+
A pulse trades the other way: its receiver selects by state, so a skipped interval heals itself
|
|
169
|
+
and a second call is the expensive failure.
|
|
170
|
+
|
|
171
|
+
### The horizon
|
|
172
|
+
|
|
173
|
+
A horizon of years costs nothing, and a call put off that long is worth no more than the target
|
|
174
|
+
that will be there to take it: the target may be gone by the time it comes due, and its input
|
|
175
|
+
may no longer be one it accepts. Either way the attempt is spent.
|
|
176
|
+
|
|
177
|
+
## Configuration
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
# context.toa.yaml
|
|
181
|
+
cadence:
|
|
182
|
+
discreteness: 60 # seconds between passes over the calls waiting to be made
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`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
|
|
188
|
+
[Discreteness](#discreteness) — and lowering it costs a pass that runs more often.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# manifest.toa.yaml cadence declaration: the operations called on a cadence
|
|
2
|
+
type: object
|
|
3
|
+
propertyNames:
|
|
4
|
+
pattern: ^[a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?$
|
|
5
|
+
patternProperties:
|
|
6
|
+
".*":
|
|
7
|
+
type: object
|
|
8
|
+
properties:
|
|
9
|
+
cycle:
|
|
10
|
+
description: Seconds one whole cycle takes
|
|
11
|
+
type: integer
|
|
12
|
+
minimum: 1
|
|
13
|
+
intervals:
|
|
14
|
+
description: >
|
|
15
|
+
Intervals the cycle is split into. The gap between calls is `cycle / intervals`,
|
|
16
|
+
and the operation is told which interval it is running for.
|
|
17
|
+
type: integer
|
|
18
|
+
minimum: 1
|
|
19
|
+
default: 1
|
|
20
|
+
required: [cycle]
|
|
21
|
+
additionalProperties: false
|
package/source/Aspect.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import type { Local } from './Local.js'
|
|
3
|
+
import type { Options } from './types.js'
|
|
4
|
+
|
|
5
|
+
interface Input {
|
|
6
|
+
endpoint: string
|
|
7
|
+
interval: number
|
|
8
|
+
overdue: number | null
|
|
9
|
+
request?: object
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `context.delay(endpoint, request, delay)` — a call to be made later, handed to the component
|
|
14
|
+
* that keeps them.
|
|
15
|
+
*
|
|
16
|
+
* It is not transactional with the caller's state. A crash between committing a change and
|
|
17
|
+
* handing over the call it should have scheduled loses the call, and there is no way for this
|
|
18
|
+
* to know. Where the semantics allow it, hand the call over first.
|
|
19
|
+
*
|
|
20
|
+
* @implements {toa.core.extensions.Aspect}
|
|
21
|
+
*/
|
|
22
|
+
export class Aspect extends Connector {
|
|
23
|
+
public readonly name = 'delay'
|
|
24
|
+
|
|
25
|
+
private readonly metronome: Local
|
|
26
|
+
|
|
27
|
+
public constructor (metronome: Local) {
|
|
28
|
+
super()
|
|
29
|
+
|
|
30
|
+
this.metronome = metronome
|
|
31
|
+
|
|
32
|
+
this.depends(metronome)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// named rather than forwarded, so that the connector's lifecycle is not reachable from an
|
|
36
|
+
// algorithm — the same reason the atom aspect names its own
|
|
37
|
+
public async invoke (method: string, ...args: any[]): Promise<unknown> {
|
|
38
|
+
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`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 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 }
|
|
49
|
+
|
|
50
|
+
// a call that takes no request carries none, rather than a null one
|
|
51
|
+
if (request !== null && request !== undefined) input.request = request
|
|
52
|
+
|
|
53
|
+
return await this.metronome.invoke('delay', { input })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tombstones the row, which is the prototype's `terminate`. An id that was never issued has
|
|
58
|
+
* nothing to terminate and raises; one whose call has already been made has a row still, and
|
|
59
|
+
* terminating it again is not an error.
|
|
60
|
+
*/
|
|
61
|
+
private async cancel (id: string): Promise<unknown> {
|
|
62
|
+
return await this.metronome.invoke('terminate', { query: { id }, input: null })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { Connector } from '@toa.io/core'
|
|
4
|
+
import type { Dirent } from 'node:fs'
|
|
5
|
+
import type { Host } from './Factory.js'
|
|
6
|
+
|
|
7
|
+
/** The components this extension ships, which run inside its own service. */
|
|
8
|
+
export function components (): Components {
|
|
9
|
+
const labels: string[] = []
|
|
10
|
+
const paths: string[] = []
|
|
11
|
+
|
|
12
|
+
for (const entry of entries()) {
|
|
13
|
+
labels.push(entry.name.replace('.', '-'))
|
|
14
|
+
paths.push(resolve(ROOT, entry.name))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { labels, paths }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Composition extends Connector {
|
|
21
|
+
private readonly host: Host
|
|
22
|
+
|
|
23
|
+
public constructor (host: Host) {
|
|
24
|
+
super()
|
|
25
|
+
|
|
26
|
+
this.host = host
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
protected override async open (): Promise<void> {
|
|
30
|
+
const composition = await this.host.composition(components().paths)
|
|
31
|
+
|
|
32
|
+
await composition.connect()
|
|
33
|
+
|
|
34
|
+
this.depends(composition)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function entries (): Dirent[] {
|
|
39
|
+
return readdirSync(ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Components {
|
|
43
|
+
labels: string[]
|
|
44
|
+
paths: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ROOT = resolve(import.meta.dirname, '../components/')
|