@toa.io/core 1.0.0-alpha.270 → 1.0.0-alpha.273
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 +64 -0
- package/package.json +5 -6
- package/src/assignment.js +3 -2
- package/src/call.js +10 -2
- package/src/contract/reply.js +8 -0
- package/src/contract/schemas/index.js +9 -4
- package/src/entities/entity.js +0 -2
- package/src/index.js +2 -0
- package/src/outbox/index.js +6 -0
- package/src/outbox/outbox.js +301 -0
- package/src/state.js +31 -27
- package/test/contract/contract.fixtures.js +3 -2
- package/test/emission.fixtures.js +1 -2
- package/test/entities/entity.test.js +5 -9
- package/test/outbox.test.js +114 -0
- package/test/state.fixtures.js +11 -8
- package/test/state.test.js +51 -4
- package/types/atomicity.d.ts +58 -0
- package/types/index.ts +2 -0
- package/types/outbox.d.ts +53 -0
- package/types/state.d.ts +7 -4
- package/types/storages.d.ts +26 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
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.273](https://github.com/toa-io/toa/compare/v1.0.0-alpha.272...v1.0.0-alpha.273) (2026-09-02)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @toa.io/core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# [1.0.0-alpha.272](https://github.com/toa-io/toa/compare/v1.0.0-alpha.271...v1.0.0-alpha.272) (2026-09-01)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **core:** stop the reply contract from relaxing the declaration ([6222893](https://github.com/toa-io/toa/commit/622289326630b94deff93499d6a851bfaa57c69a))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
* feat(atomicity)!: take a quorum of independent servers ([862562f](https://github.com/toa-io/toa/commit/862562f24d77ec00127dbbd88d43802b208c643b))
|
|
23
|
+
* refactor(atomicity)!: take the lock manager from the stash ([4fd91eb](https://github.com/toa-io/toa/commit/4fd91eb2fdb68f7b87aaf16d182794bbc567ba57))
|
|
24
|
+
* refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
|
|
25
|
+
* refactor(atomicity)!: make the connector a family, not a partitioner ([06fd63a](https://github.com/toa-io/toa/commit/06fd63ad6296b724ef83afccf4e4e25d0bcaf080))
|
|
26
|
+
* refactor(atomicity)!: rename the connector and free it of the outbox ([21f1a41](https://github.com/toa-io/toa/commit/21f1a41aceafcfd35c7620014062fe46b54afa95))
|
|
27
|
+
* feat(core)!: commit events with the state that produced them ([1eb68cc](https://github.com/toa-io/toa/commit/1eb68cc435dbfa03faa16009fceb866693d22e1a)), closes [#20](https://github.com/toa-io/toa/issues/20)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Features
|
|
31
|
+
|
|
32
|
+
* **atomicity:** meter what the group has spent ([bb1118a](https://github.com/toa-io/toa/commit/bb1118aa60bfeb43d3212f2495b7797445f003c3))
|
|
33
|
+
* **core:** give every component an atom aspect ([b501b9c](https://github.com/toa-io/toa/commit/b501b9cd3d5f5408d9faa71213e953fe2792cf9f))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### BREAKING CHANGES
|
|
37
|
+
|
|
38
|
+
* `atomicity.redis` accepts a list again, of independent servers rather
|
|
39
|
+
than cluster nodes, and refuses an even number of them.
|
|
40
|
+
|
|
41
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
42
|
+
* `context.stash.lock` is gone; lock through `context.atom` instead.
|
|
43
|
+
A stash pointer resolving to several addresses now uses the first.
|
|
44
|
+
|
|
45
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
46
|
+
* `Storage.outbox.pending` takes a fourth argument, the id to
|
|
47
|
+
continue from.
|
|
48
|
+
|
|
49
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
50
|
+
* `Factory.partition(group)` is `Factory.atom(group)`, and the
|
|
51
|
+
`Partition` it returned is an `Atom`. `slots(total)` is unchanged.
|
|
52
|
+
|
|
53
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
54
|
+
* `@toa.io/partitions.redis` is now `@toa.io/atomicity`, and its
|
|
55
|
+
`lanes(total)` is `slots(total)`. Redis is declared as `atomicity` in the context
|
|
56
|
+
rather than `outbox.redis`, and read from `TOA_ATOMICITY_REDIS`.
|
|
57
|
+
`TOA_OUTBOX_PARTITION_INTERVAL` is `TOA_ATOMICITY_INTERVAL`.
|
|
58
|
+
|
|
59
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
60
|
+
* `event.changeset` is removed; use `origin` and `state`. `State`
|
|
61
|
+
takes an `Outbox` in place of an `Emission`. `difference` is dropped from
|
|
62
|
+
`@toa.io/generic` along with its last caller.
|
|
63
|
+
|
|
64
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
6
70
|
# [1.0.0-alpha.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
|
|
7
71
|
|
|
8
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/core",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.273",
|
|
4
4
|
"description": "Toa Core",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -21,11 +21,10 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@rsql/parser": "1.2.4",
|
|
24
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"openspan": "1.0.0-alpha.270",
|
|
24
|
+
"@toa.io/generic": "1.0.0-alpha.273",
|
|
25
|
+
"js-yaml": "4.3.1",
|
|
26
|
+
"openspan": "1.0.0-alpha.272",
|
|
28
27
|
"uuid": "11.1.1"
|
|
29
28
|
},
|
|
30
|
-
"gitHead": "
|
|
29
|
+
"gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
|
|
31
30
|
}
|
package/src/assignment.js
CHANGED
|
@@ -12,14 +12,15 @@ class Assignment extends Operation {
|
|
|
12
12
|
const {
|
|
13
13
|
scope,
|
|
14
14
|
state,
|
|
15
|
-
reply
|
|
15
|
+
reply,
|
|
16
|
+
request
|
|
16
17
|
} = store
|
|
17
18
|
|
|
18
19
|
if (reply.error !== undefined) return
|
|
19
20
|
|
|
20
21
|
scope.set(state)
|
|
21
22
|
|
|
22
|
-
const output = await this.scope.apply(scope)
|
|
23
|
+
const output = await this.scope.apply(scope, request.input)
|
|
23
24
|
|
|
24
25
|
// assignment returns new state by default
|
|
25
26
|
if (store.reply.output === undefined) {
|
package/src/call.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
4
|
const { current, encode } = require('openspan')
|
|
5
5
|
const { Connector } = require('./connector')
|
|
6
|
-
const { Err } = require('error-value')
|
|
7
6
|
|
|
8
7
|
class Call extends Connector {
|
|
9
8
|
#transmitter
|
|
@@ -48,7 +47,7 @@ class Call extends Connector {
|
|
|
48
47
|
throw reply.exception
|
|
49
48
|
|
|
50
49
|
if (reply.error !== undefined)
|
|
51
|
-
return
|
|
50
|
+
return new RemoteError(reply.error)
|
|
52
51
|
else
|
|
53
52
|
return reply.output
|
|
54
53
|
}
|
|
@@ -60,3 +59,12 @@ class Call extends Connector {
|
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
exports.Call = Call
|
|
62
|
+
|
|
63
|
+
// the remote error as a value: every property it carries, and nothing else enumerable
|
|
64
|
+
class RemoteError extends Error {
|
|
65
|
+
constructor (error) {
|
|
66
|
+
super()
|
|
67
|
+
|
|
68
|
+
Object.assign(this, error)
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/contract/reply.js
CHANGED
|
@@ -11,6 +11,14 @@ class Reply extends Contract {
|
|
|
11
11
|
const schema = { type: 'object', properties: {}, additionalProperties: false }
|
|
12
12
|
|
|
13
13
|
if (output !== undefined) {
|
|
14
|
+
/*
|
|
15
|
+
* A reply carries more than the operation declares — `_version` and the rest of the
|
|
16
|
+
* record's own fields — so what it is validated against is the declaration relaxed.
|
|
17
|
+
* On a copy: the declaration itself is what a component publishes when asked to
|
|
18
|
+
* explain, and relaxing that would publish a contract nobody wrote.
|
|
19
|
+
*/
|
|
20
|
+
output = structuredClone(output)
|
|
21
|
+
|
|
14
22
|
if (output.type === 'object')
|
|
15
23
|
output.additionalProperties = true
|
|
16
24
|
else if (output.type === 'array' && output.items?.type === 'object')
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { resolve } = require('path')
|
|
4
|
-
const {
|
|
4
|
+
const { readFileSync } = require('node:fs')
|
|
5
|
+
const { load: parseYAML } = require('js-yaml')
|
|
5
6
|
|
|
6
|
-
exports.query =
|
|
7
|
-
exports.error =
|
|
8
|
-
exports.source =
|
|
7
|
+
exports.query = read(resolve(__dirname, './query.yaml'))
|
|
8
|
+
exports.error = read(resolve(__dirname, './error.yaml'))
|
|
9
|
+
exports.source = read(resolve(__dirname, './source.yaml'))
|
|
10
|
+
|
|
11
|
+
function read (path) {
|
|
12
|
+
return parseYAML(readFileSync(path, 'utf8'))
|
|
13
|
+
}
|
package/src/entities/entity.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { difference } = require('@toa.io/generic')
|
|
4
3
|
const { EntityContractException, EntityGuardException } = require('../exceptions')
|
|
5
4
|
const { newid } = require('./newid')
|
|
6
5
|
|
|
@@ -62,7 +61,6 @@ class Entity {
|
|
|
62
61
|
return {
|
|
63
62
|
origin: this.#origin,
|
|
64
63
|
state: this.#state,
|
|
65
|
-
changeset: this.#origin === null ? this.#state : difference(this.#origin, this.#state),
|
|
66
64
|
trailers: this.#state._trailers,
|
|
67
65
|
input
|
|
68
66
|
}
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const { Event } = require('./event')
|
|
|
11
11
|
const { Exposition } = require('./exposition')
|
|
12
12
|
const { Locator } = require('./locator')
|
|
13
13
|
const { Observation } = require('./observation')
|
|
14
|
+
const { Outbox } = require('./outbox')
|
|
14
15
|
const { Operation } = require('./operation')
|
|
15
16
|
const { Query } = require('./query')
|
|
16
17
|
const { Receiver } = require('./receiver')
|
|
@@ -41,6 +42,7 @@ exports.Event = Event
|
|
|
41
42
|
exports.Exposition = Exposition
|
|
42
43
|
exports.Locator = Locator
|
|
43
44
|
exports.Observation = Observation
|
|
45
|
+
exports.Outbox = Outbox
|
|
44
46
|
exports.Operation = Operation
|
|
45
47
|
exports.Query = Query
|
|
46
48
|
exports.Receiver = Receiver
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { console } = require('openspan')
|
|
4
|
+
const { Connector } = require('../connector')
|
|
5
|
+
const { newid } = require('../entities/newid')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Owns the intent to publish. A row is built before the write so that the storage can commit
|
|
9
|
+
* it in the same transaction as the entity; publication then happens off the operation's path,
|
|
10
|
+
* and anything that fails to publish is recovered from the row.
|
|
11
|
+
*
|
|
12
|
+
* The mechanism is a safety net: in a healthy system the row is written, published within
|
|
13
|
+
* milliseconds by the same process, and marked published on that process's next tick.
|
|
14
|
+
*
|
|
15
|
+
* A storage that cannot commit a row atomically has no outbox, and this degrades to the
|
|
16
|
+
* inline emission it replaces.
|
|
17
|
+
*/
|
|
18
|
+
class Outbox extends Connector {
|
|
19
|
+
#emission
|
|
20
|
+
#storage
|
|
21
|
+
#atom
|
|
22
|
+
|
|
23
|
+
#gap
|
|
24
|
+
#interval
|
|
25
|
+
#batch
|
|
26
|
+
#defer
|
|
27
|
+
|
|
28
|
+
/** ids this process has published, held until a cycle marks them */
|
|
29
|
+
#published = new Set()
|
|
30
|
+
|
|
31
|
+
/** in-flight publications, awaited (with a bound) on close */
|
|
32
|
+
#inflight = new Set()
|
|
33
|
+
|
|
34
|
+
/** rows this replica is publishing right now, so a cycle does not pick them up again */
|
|
35
|
+
#publishing = new Set()
|
|
36
|
+
|
|
37
|
+
#timer
|
|
38
|
+
#pumping = false
|
|
39
|
+
#closing = false
|
|
40
|
+
|
|
41
|
+
constructor (emission, storage, atom, options = {}) {
|
|
42
|
+
super()
|
|
43
|
+
|
|
44
|
+
this.#emission = emission
|
|
45
|
+
this.#storage = storage
|
|
46
|
+
this.#atom = atom
|
|
47
|
+
|
|
48
|
+
this.#interval = number('TOA_OUTBOX_INTERVAL', options.interval, INTERVAL)
|
|
49
|
+
this.#batch = number('TOA_OUTBOX_BATCH', options.batch, BATCH)
|
|
50
|
+
this.#gap = options.gap ?? this.#interval * K
|
|
51
|
+
this.#defer = process.env.TOA_OUTBOX_DEFER === '1'
|
|
52
|
+
|
|
53
|
+
this.depends(emission)
|
|
54
|
+
this.depends(atom)
|
|
55
|
+
|
|
56
|
+
if (storage !== undefined) this.depends(storage)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** whether the storage can commit a row atomically with the entity */
|
|
60
|
+
get durable () {
|
|
61
|
+
return this.#storage?.outbox !== undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param event {toa.core.transition.Event}
|
|
66
|
+
* @returns {object}
|
|
67
|
+
*/
|
|
68
|
+
row (event) {
|
|
69
|
+
return {
|
|
70
|
+
id: newid(),
|
|
71
|
+
lane: this.#lane(),
|
|
72
|
+
published: false,
|
|
73
|
+
pending: Date.now() + this.#gap,
|
|
74
|
+
event
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hands a committed row over. Awaited by the caller only on the legacy path — with an
|
|
80
|
+
* outbox this returns at once and the broker leaves the operation's path.
|
|
81
|
+
*/
|
|
82
|
+
publish (row) {
|
|
83
|
+
if (!this.durable)
|
|
84
|
+
return this.#emission.emit(row.event)
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* A publication started while the pump is closing would outlive the emitters it needs,
|
|
88
|
+
* and `comq` waits on a connection that is going rather than failing. The row is already
|
|
89
|
+
* durable, so leaving it is exactly what it is for.
|
|
90
|
+
*/
|
|
91
|
+
if (this.#closing || this.#defer ||
|
|
92
|
+
this.#inflight.size >= INFLIGHT || this.#published.size >= PUBLISHED)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
void this.#publish(row)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async open () {
|
|
99
|
+
if (!this.durable) return
|
|
100
|
+
|
|
101
|
+
if (this.#defer)
|
|
102
|
+
console.warn('Outbox immediate publication is deferred; events are published by the pump only')
|
|
103
|
+
|
|
104
|
+
this.#timer = setInterval(() => this.#tick(), this.#interval)
|
|
105
|
+
this.#timer.unref()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async close () {
|
|
109
|
+
this.#closing = true
|
|
110
|
+
|
|
111
|
+
if (this.#timer !== undefined) clearInterval(this.#timer)
|
|
112
|
+
|
|
113
|
+
await this.#drain()
|
|
114
|
+
await this.#mark()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Publishes one row and swallows the failure: the row stays unpublished and comes back on a
|
|
119
|
+
* later cycle, which is the whole point of having written it.
|
|
120
|
+
*
|
|
121
|
+
* There is no timeout here on purpose. A publication is a confirmed write to a durable
|
|
122
|
+
* exchange, and `comq` waits for the broker to come back rather than failing — abandoning
|
|
123
|
+
* it would not stop it, it would only mean the row is published twice once it lands. What
|
|
124
|
+
* bounds this instead is the in-flight cap and the drain on close.
|
|
125
|
+
*
|
|
126
|
+
* @private
|
|
127
|
+
*/
|
|
128
|
+
async #publish (row) {
|
|
129
|
+
this.#publishing.add(row.id)
|
|
130
|
+
|
|
131
|
+
const publishing = this.#emission.emit(row.event)
|
|
132
|
+
|
|
133
|
+
this.#inflight.add(publishing)
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await publishing
|
|
137
|
+
|
|
138
|
+
this.#published.add(row.id)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.warn('Event publication failed', { row: row.id, error })
|
|
141
|
+
} finally {
|
|
142
|
+
this.#inflight.delete(publishing)
|
|
143
|
+
this.#publishing.delete(row.id)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `comq` retries a publish for as long as the broker is down rather than rejecting, so an
|
|
149
|
+
* unbounded drain outlives any grace period.
|
|
150
|
+
*
|
|
151
|
+
* @private
|
|
152
|
+
*/
|
|
153
|
+
async #drain () {
|
|
154
|
+
if (this.#inflight.size === 0) return
|
|
155
|
+
|
|
156
|
+
await Promise.race([Promise.allSettled([...this.#inflight]), delay(DRAIN)])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reads what is due, publishes it, and marks everything this process has sent — what it just
|
|
161
|
+
* published and what the immediate path published since the last cycle. One cycle at a time.
|
|
162
|
+
*
|
|
163
|
+
* @private
|
|
164
|
+
*/
|
|
165
|
+
#tick () {
|
|
166
|
+
if (this.#pumping) return
|
|
167
|
+
|
|
168
|
+
this.#pumping = true
|
|
169
|
+
|
|
170
|
+
void this.#pump().finally(() => (this.#pumping = false))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @private */
|
|
174
|
+
async #pump () {
|
|
175
|
+
let page
|
|
176
|
+
|
|
177
|
+
do {
|
|
178
|
+
page = await this.#read(page?.[page.length - 1]?.id)
|
|
179
|
+
|
|
180
|
+
if (page.length === 0) break
|
|
181
|
+
|
|
182
|
+
/*
|
|
183
|
+
* A row is unpublished in the database until a cycle marks it, so a page includes what
|
|
184
|
+
* this replica is sending right now and what a failed marking left behind. Only this
|
|
185
|
+
* process knows either.
|
|
186
|
+
*/
|
|
187
|
+
const rows = page.filter((row) =>
|
|
188
|
+
!this.#published.has(row.id) && !this.#publishing.has(row.id))
|
|
189
|
+
|
|
190
|
+
if (rows.length > 0) {
|
|
191
|
+
console.info('Outbox recovering unpublished events', { count: rows.length })
|
|
192
|
+
|
|
193
|
+
// every row is given its chance; what the broker refused stays unpublished and comes
|
|
194
|
+
// back on a later cycle
|
|
195
|
+
await Promise.allSettled(rows.map((row) => this.#publish(row)))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// a full page is a page that may have been cut short
|
|
199
|
+
} while (page.length === this.#batch)
|
|
200
|
+
|
|
201
|
+
await this.#mark()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One page of what is due. In a healthy system the first one is empty, every cycle — a row is
|
|
206
|
+
* due only if the process that wrote it failed to publish or died before marking it.
|
|
207
|
+
*
|
|
208
|
+
* Reading is suspended, not stopped, while this replica does not know which lanes are its
|
|
209
|
+
* own: the cycle keeps running and keeps marking, and reading resumes as soon as an
|
|
210
|
+
* assignment arrives. Reading without an assignment would be a different guarantee, where
|
|
211
|
+
* every replica publishes every stranded row.
|
|
212
|
+
*
|
|
213
|
+
* @private
|
|
214
|
+
* @param {string} [after] the last id of the page before, so a page is never read twice
|
|
215
|
+
*/
|
|
216
|
+
async #read (after) {
|
|
217
|
+
const lanes = this.#atom.slots(LANES)
|
|
218
|
+
|
|
219
|
+
if (lanes === null || lanes.length === 0) return []
|
|
220
|
+
|
|
221
|
+
return this.#storage.outbox.pending(lanes, Date.now(), this.#batch, after)
|
|
222
|
+
.catch((error) => {
|
|
223
|
+
console.warn('Outbox read failed', { error })
|
|
224
|
+
|
|
225
|
+
return []
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One batched write for many events, which is why the ids are held in memory rather than
|
|
231
|
+
* marked one by one. Ids that fail to be marked are kept and retried; a row that is never
|
|
232
|
+
* marked is simply published again, which is within the contract.
|
|
233
|
+
*
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
async #mark () {
|
|
237
|
+
if (this.#published.size === 0) return
|
|
238
|
+
|
|
239
|
+
const ids = [...this.#published]
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await this.#storage.outbox.settle(ids)
|
|
243
|
+
|
|
244
|
+
for (const id of ids) this.#published.delete(id)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.warn('Outbox marking failed', { count: ids.length, error })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A lane this replica currently owns, so that in steady state it settles its own rows
|
|
252
|
+
* before it ever reads them. Any lane at all when it owns none: the row still has to be
|
|
253
|
+
* written, and whoever ends up owning that lane will pump it.
|
|
254
|
+
*
|
|
255
|
+
* @private
|
|
256
|
+
*/
|
|
257
|
+
#lane () {
|
|
258
|
+
const owned = this.#atom.slots(LANES)
|
|
259
|
+
|
|
260
|
+
return owned === null || owned.length === 0
|
|
261
|
+
? Math.floor(Math.random() * LANES)
|
|
262
|
+
: owned[Math.floor(Math.random() * owned.length)]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function number (variable, declared, fallback) {
|
|
267
|
+
if (declared !== undefined) return declared
|
|
268
|
+
|
|
269
|
+
const value = Number(process.env[variable])
|
|
270
|
+
|
|
271
|
+
return Number.isNaN(value) || value <= 0 ? fallback : value
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref())
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Constant, never configuration: rows carry their lane, so lowering this would leave rows in
|
|
278
|
+
* lanes nobody reads any more. It is also the ceiling on replicas of one component, and a
|
|
279
|
+
* power of two so that the common replica counts divide evenly.
|
|
280
|
+
*/
|
|
281
|
+
const LANES = 128
|
|
282
|
+
|
|
283
|
+
/** one cycle reads, publishes and marks; in steady state it finds nothing to read */
|
|
284
|
+
const INTERVAL = 5000
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* `gap = interval * K`. Not a steady-state necessity — a replica writes into a lane it owns
|
|
288
|
+
* and marks what it published — but a guard for when a lane changes hands between the write
|
|
289
|
+
* and the settle. Two cycles of separation, plus one of margin.
|
|
290
|
+
*/
|
|
291
|
+
const K = 3
|
|
292
|
+
|
|
293
|
+
/** how many rows one read brings back; the pump reads on while a page comes back full */
|
|
294
|
+
const BATCH = 200
|
|
295
|
+
|
|
296
|
+
const DRAIN = 10_000
|
|
297
|
+
const INFLIGHT = 1000
|
|
298
|
+
const PUBLISHED = 10_000
|
|
299
|
+
|
|
300
|
+
exports.Outbox = Outbox
|
|
301
|
+
exports.LANES = LANES
|
package/src/state.js
CHANGED
|
@@ -7,12 +7,12 @@ class State {
|
|
|
7
7
|
|
|
8
8
|
#associated
|
|
9
9
|
#entities
|
|
10
|
-
#
|
|
10
|
+
#outbox
|
|
11
11
|
|
|
12
|
-
constructor (storage, entity,
|
|
12
|
+
constructor (storage, entity, outbox, associated) {
|
|
13
13
|
this.storage = storage
|
|
14
14
|
this.#entities = entity
|
|
15
|
-
this.#
|
|
15
|
+
this.#outbox = outbox
|
|
16
16
|
this.#associated = associated === true
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -66,14 +66,14 @@ class State {
|
|
|
66
66
|
|
|
67
67
|
object.set(blank)
|
|
68
68
|
|
|
69
|
-
const
|
|
69
|
+
const row = this.#outbox?.row(object.event(input))
|
|
70
|
+
const record = await this.storage.ensure(query, properties, object.get(), row)
|
|
70
71
|
|
|
71
72
|
if (record.id !== blank.id) // exists
|
|
72
73
|
return this.#entities.object(record, NOT_MUTABLE)
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await this.#emission.emit(event)
|
|
75
|
+
if (row !== undefined)
|
|
76
|
+
await this.#outbox.publish(row)
|
|
77
77
|
|
|
78
78
|
return object
|
|
79
79
|
}
|
|
@@ -83,36 +83,39 @@ class State {
|
|
|
83
83
|
return this.massCommit(state, input)
|
|
84
84
|
|
|
85
85
|
const data = state.get()
|
|
86
|
-
const ok = await this.storage.store(data)
|
|
87
86
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// the row is built before the write so that the storage can commit it in the same
|
|
88
|
+
// transaction, closing the window this used to have
|
|
89
|
+
const row = this.#outbox?.row(state.event(input))
|
|
90
|
+
const ok = await this.storage.store(data, row)
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if (ok === true && row !== undefined)
|
|
93
|
+
await this.#outbox.publish(row)
|
|
94
94
|
|
|
95
95
|
return ok
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
async massCommit (state, input) {
|
|
99
99
|
const data = state.get()
|
|
100
|
-
const
|
|
100
|
+
const rows = this.#outbox === undefined
|
|
101
|
+
? undefined
|
|
102
|
+
: state.events(input).map((event) => this.#outbox.row(event))
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
if (ok === true) {
|
|
104
|
-
const events = state.events(input)
|
|
104
|
+
const ok = await this.storage.massStore(data, rows)
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
if (ok === true && rows !== undefined)
|
|
107
|
+
await Promise.all(rows.map((row) => this.#outbox.publish(row)))
|
|
108
108
|
|
|
109
109
|
return ok
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
async apply (state) {
|
|
112
|
+
async apply (state, input) {
|
|
113
113
|
const changeset = state.export()
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// an assignment's event is the write's own images, so the storage fills them in;
|
|
116
|
+
// see `apply` in the outbox design
|
|
117
|
+
const row = this.#outbox?.row({ input })
|
|
118
|
+
const result = await this.storage.upsert(state.query, changeset, row)
|
|
116
119
|
|
|
117
120
|
if (result === null) {
|
|
118
121
|
if (state.query.version !== undefined) {
|
|
@@ -120,12 +123,13 @@ class State {
|
|
|
120
123
|
} else {
|
|
121
124
|
throw new StateNotFoundException()
|
|
122
125
|
}
|
|
123
|
-
} else {
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
} else if (row !== undefined) {
|
|
127
|
+
// the storage fills `origin` and `state` from its own write; a storage that does not
|
|
128
|
+
// know how leaves the event with what it was given
|
|
129
|
+
row.event.state ??= result
|
|
130
|
+
row.event.origin ??= null
|
|
131
|
+
|
|
132
|
+
await this.#outbox.publish(row)
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
return result
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
|
-
const {
|
|
4
|
+
const { readFileSync } = require('node:fs')
|
|
5
|
+
const { load: parseYAML } = require('js-yaml')
|
|
5
6
|
const { resolve } = require('path')
|
|
6
7
|
|
|
7
8
|
// noinspection JSCheckFunctionSignatures
|
|
@@ -20,7 +21,7 @@ const schemas = {
|
|
|
20
21
|
type: 'object',
|
|
21
22
|
properties: {
|
|
22
23
|
input: { type: 'null' },
|
|
23
|
-
query:
|
|
24
|
+
query: parseYAML(readFileSync(resolve(__dirname, '../../src/contract/schemas/query.yaml'), 'utf8')),
|
|
24
25
|
authentic: { type: 'boolean' }
|
|
25
26
|
},
|
|
26
27
|
additionalProperties: true
|
|
@@ -9,8 +9,7 @@ const events = [0, 1, 2].map((index) => ({
|
|
|
9
9
|
|
|
10
10
|
const event = {
|
|
11
11
|
origin: { [generate()]: generate() },
|
|
12
|
-
state: { [generate()]: generate() }
|
|
13
|
-
changeset: { [generate()]: generate() }
|
|
12
|
+
state: { [generate()]: generate() }
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
exports.events = events
|
|
@@ -58,7 +58,7 @@ describe('tombstone', () => {
|
|
|
58
58
|
|
|
59
59
|
expect(entity.get()._deleted).toBeNull()
|
|
60
60
|
expect(entity.deleted).toBe(false)
|
|
61
|
-
expect(entity.event().
|
|
61
|
+
expect(entity.event().state._deleted).toBeNull()
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('should keep tombstone written by transition', () => {
|
|
@@ -85,12 +85,8 @@ it('should provide event', () => {
|
|
|
85
85
|
|
|
86
86
|
const event = entity.event()
|
|
87
87
|
|
|
88
|
-
expect(event).toEqual(expect.objectContaining({
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
foo: 'new value',
|
|
93
|
-
_version: 1
|
|
94
|
-
})
|
|
95
|
-
}))
|
|
88
|
+
expect(event).toEqual(expect.objectContaining({ state, origin }))
|
|
89
|
+
expect(event.state.foo).toBe('new value')
|
|
90
|
+
expect(event.state._version).toBe(1)
|
|
91
|
+
expect(event.origin.foo).not.toBe('new value')
|
|
96
92
|
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Outbox } = require('../src/outbox')
|
|
4
|
+
|
|
5
|
+
let emission, storage, atom, outbox
|
|
6
|
+
|
|
7
|
+
const BATCH = 4
|
|
8
|
+
|
|
9
|
+
/** rows as the storage hands them back, ids ascending like the uuid v7 they are */
|
|
10
|
+
const page = (from, count) =>
|
|
11
|
+
Array.from({ length: count }, (_, i) => ({
|
|
12
|
+
id: String(from + i).padStart(4, '0'),
|
|
13
|
+
event: { state: {} }
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
jest.clearAllMocks()
|
|
18
|
+
jest.useFakeTimers()
|
|
19
|
+
|
|
20
|
+
emission = { emit: jest.fn(async () => undefined), link: jest.fn() }
|
|
21
|
+
|
|
22
|
+
storage = {
|
|
23
|
+
link: jest.fn(),
|
|
24
|
+
outbox: {
|
|
25
|
+
pending: jest.fn(async () => []),
|
|
26
|
+
settle: jest.fn(async () => undefined)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
atom = { slots: jest.fn(() => [0]), link: jest.fn() }
|
|
31
|
+
outbox = new Outbox(emission, storage, atom, { interval: 1000, batch: BATCH })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
jest.useRealTimers()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** one cycle, and everything it awaited */
|
|
39
|
+
const cycle = async () => {
|
|
40
|
+
jest.advanceTimersByTime(1000)
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('should read nothing more when the first page is short', async () => {
|
|
46
|
+
storage.outbox.pending.mockResolvedValueOnce(page(0, 3))
|
|
47
|
+
|
|
48
|
+
await outbox.open()
|
|
49
|
+
await cycle()
|
|
50
|
+
|
|
51
|
+
expect(storage.outbox.pending).toHaveBeenCalledTimes(1)
|
|
52
|
+
expect(emission.emit).toHaveBeenCalledTimes(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should keep reading while a page comes back full', async () => {
|
|
56
|
+
storage.outbox.pending
|
|
57
|
+
.mockResolvedValueOnce(page(0, BATCH))
|
|
58
|
+
.mockResolvedValueOnce(page(BATCH, BATCH))
|
|
59
|
+
.mockResolvedValueOnce(page(2 * BATCH, 5))
|
|
60
|
+
|
|
61
|
+
await outbox.open()
|
|
62
|
+
await cycle()
|
|
63
|
+
|
|
64
|
+
expect(storage.outbox.pending).toHaveBeenCalledTimes(3)
|
|
65
|
+
expect(emission.emit).toHaveBeenCalledTimes(2 * BATCH + 5)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should continue each page from the id the one before ended on', async () => {
|
|
69
|
+
storage.outbox.pending
|
|
70
|
+
.mockResolvedValueOnce(page(0, BATCH))
|
|
71
|
+
.mockResolvedValueOnce(page(BATCH, 1))
|
|
72
|
+
|
|
73
|
+
await outbox.open()
|
|
74
|
+
await cycle()
|
|
75
|
+
|
|
76
|
+
const [, second] = storage.outbox.pending.mock.calls
|
|
77
|
+
|
|
78
|
+
expect(storage.outbox.pending.mock.calls[0][3]).toBeUndefined()
|
|
79
|
+
expect(second[3]).toStrictEqual(String(BATCH - 1).padStart(4, '0'))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should mark what it published, once, after the last page', async () => {
|
|
83
|
+
storage.outbox.pending
|
|
84
|
+
.mockResolvedValueOnce(page(0, BATCH))
|
|
85
|
+
.mockResolvedValueOnce(page(BATCH, 2))
|
|
86
|
+
|
|
87
|
+
await outbox.open()
|
|
88
|
+
await cycle()
|
|
89
|
+
|
|
90
|
+
expect(storage.outbox.settle).toHaveBeenCalledTimes(1)
|
|
91
|
+
expect(storage.outbox.settle.mock.calls[0][0]).toHaveLength(BATCH + 2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should not publish a row it has published and not yet marked', async () => {
|
|
95
|
+
storage.outbox.settle.mockRejectedValueOnce(new Error('mongo is out'))
|
|
96
|
+
storage.outbox.pending
|
|
97
|
+
.mockResolvedValueOnce(page(0, 2))
|
|
98
|
+
.mockResolvedValueOnce(page(0, 2))
|
|
99
|
+
|
|
100
|
+
await outbox.open()
|
|
101
|
+
await cycle()
|
|
102
|
+
await cycle()
|
|
103
|
+
|
|
104
|
+
expect(emission.emit).toHaveBeenCalledTimes(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should read nothing while it owns no slots', async () => {
|
|
108
|
+
atom.slots.mockReturnValue(null)
|
|
109
|
+
|
|
110
|
+
await outbox.open()
|
|
111
|
+
await cycle()
|
|
112
|
+
|
|
113
|
+
expect(storage.outbox.pending).not.toHaveBeenCalled()
|
|
114
|
+
})
|
package/test/state.fixtures.js
CHANGED
|
@@ -8,7 +8,10 @@ const storage = {
|
|
|
8
8
|
find: jest.fn(() => ([{ id: generate() }])),
|
|
9
9
|
add: jest.fn(() => true),
|
|
10
10
|
set: jest.fn(() => true),
|
|
11
|
-
store: jest.fn(() => true)
|
|
11
|
+
store: jest.fn(() => true),
|
|
12
|
+
massStore: jest.fn(() => true),
|
|
13
|
+
upsert: jest.fn(() => ({ id: generate() })),
|
|
14
|
+
ensure: jest.fn((query, properties, state) => state)
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
const factory = {
|
|
@@ -20,9 +23,7 @@ const query = generate()
|
|
|
20
23
|
|
|
21
24
|
const entity = {
|
|
22
25
|
get: jest.fn(() => ({ [generate()]: generate() })),
|
|
23
|
-
event: jest.fn(() => ({
|
|
24
|
-
state: { [generate()]: generate() }, changeset: { [generate()]: generate() }
|
|
25
|
-
}))
|
|
26
|
+
event: jest.fn(() => ({ state: { [generate()]: generate() } }))
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const initial = {
|
|
@@ -31,16 +32,18 @@ const initial = {
|
|
|
31
32
|
|
|
32
33
|
const unchanged = {
|
|
33
34
|
...entity,
|
|
34
|
-
event: jest.fn(() => ({ state: { [generate()]: generate() }
|
|
35
|
+
event: jest.fn(() => ({ state: { [generate()]: generate() } }))
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// a legacy outbox: no storage capability, so `publish` emits inline
|
|
39
|
+
const outbox = {
|
|
40
|
+
row: jest.fn((event) => ({ id: generate(), lane: 0, published: false, pending: 0, event })),
|
|
41
|
+
publish: jest.fn()
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
exports.storage = storage
|
|
42
45
|
exports.factory = factory
|
|
43
|
-
exports.
|
|
46
|
+
exports.outbox = outbox
|
|
44
47
|
exports.query = query
|
|
45
48
|
exports.entity = entity
|
|
46
49
|
exports.initial = initial
|
package/test/state.test.js
CHANGED
|
@@ -8,7 +8,7 @@ let state
|
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
jest.clearAllMocks()
|
|
10
10
|
|
|
11
|
-
state = new State(fixtures.storage, fixtures.factory, fixtures.
|
|
11
|
+
state = new State(fixtures.storage, fixtures.factory, fixtures.outbox)
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
it('should provide object', async () => {
|
|
@@ -37,11 +37,58 @@ it('should provide read-only objects', async () => {
|
|
|
37
37
|
it('should store entity', async () => {
|
|
38
38
|
await state.commit(fixtures.initial)
|
|
39
39
|
|
|
40
|
-
expect(fixtures.storage.store).toHaveBeenCalledWith(
|
|
40
|
+
expect(fixtures.storage.store).toHaveBeenCalledWith(
|
|
41
|
+
fixtures.initial.get.mock.results[0].value,
|
|
42
|
+
fixtures.outbox.row.mock.results[0].value)
|
|
41
43
|
})
|
|
42
44
|
|
|
43
|
-
it('should
|
|
45
|
+
it('should publish the row', async () => {
|
|
44
46
|
await state.commit(fixtures.entity)
|
|
45
47
|
|
|
46
|
-
expect(fixtures.
|
|
48
|
+
expect(fixtures.outbox.row).toHaveBeenCalledWith(fixtures.entity.event.mock.results[0].value)
|
|
49
|
+
expect(fixtures.outbox.publish).toHaveBeenCalledWith(fixtures.outbox.row.mock.results[0].value)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should not publish if the write did not happen', async () => {
|
|
53
|
+
fixtures.storage.store.mockImplementationOnce(() => false)
|
|
54
|
+
|
|
55
|
+
await state.commit(fixtures.entity)
|
|
56
|
+
|
|
57
|
+
expect(fixtures.outbox.publish).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should build the row before the write', async () => {
|
|
61
|
+
// the storage commits the row in the same transaction, so it must already exist
|
|
62
|
+
fixtures.storage.store.mockImplementationOnce((_, row) => {
|
|
63
|
+
expect(row).toBeDefined()
|
|
64
|
+
|
|
65
|
+
return true
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect.assertions(1)
|
|
69
|
+
|
|
70
|
+
await state.commit(fixtures.entity)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('assignment', () => {
|
|
74
|
+
const changeset = { query: 'q', export: () => ({ foo: 1 }) }
|
|
75
|
+
|
|
76
|
+
it('should pass the row to upsert and publish it', async () => {
|
|
77
|
+
const result = await state.apply(changeset, { foo: 1 })
|
|
78
|
+
|
|
79
|
+
expect(fixtures.storage.upsert).toHaveBeenCalledWith(
|
|
80
|
+
changeset.query, { foo: 1 }, fixtures.outbox.row.mock.results[0].value)
|
|
81
|
+
|
|
82
|
+
expect(fixtures.outbox.publish).toHaveBeenCalledWith(fixtures.outbox.row.mock.results[0].value)
|
|
83
|
+
expect(result).toStrictEqual(fixtures.storage.upsert.mock.results[0].value)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should fill the state a storage without the outbox left alone', async () => {
|
|
87
|
+
await state.apply(changeset, { foo: 1 })
|
|
88
|
+
|
|
89
|
+
const row = fixtures.outbox.row.mock.results[0].value
|
|
90
|
+
|
|
91
|
+
expect(row.event.state).toStrictEqual(fixtures.storage.upsert.mock.results[0].value)
|
|
92
|
+
expect(row.event.input).toStrictEqual({ foo: 1 })
|
|
93
|
+
})
|
|
47
94
|
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// noinspection ES6UnusedImports
|
|
2
|
+
|
|
3
|
+
import { Connector } from './connector'
|
|
4
|
+
|
|
5
|
+
declare namespace toa.core {
|
|
6
|
+
|
|
7
|
+
namespace atomicity {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What one group of replicas decides together, in one place. The decisions here are the
|
|
11
|
+
* ones processes cannot arrange by talking to each other: they need a single arbiter and a
|
|
12
|
+
* step indivisible from its point of view.
|
|
13
|
+
*/
|
|
14
|
+
interface Atom extends Connector {
|
|
15
|
+
/**
|
|
16
|
+
* An exclusive claim on slots of `0..total`: while this replica holds one, no other
|
|
17
|
+
* replica of the group does. Answered from memory, so it costs nothing to ask.
|
|
18
|
+
*
|
|
19
|
+
* `null` while this replica owns nothing — after a restart, during a rollout, or while
|
|
20
|
+
* coordination is unreachable. Whoever asks must be able to stand down: acting on a
|
|
21
|
+
* claim that cannot be supported is a different guarantee, not a degraded one.
|
|
22
|
+
*/
|
|
23
|
+
slots (total: number): number[] | null
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Debt the group has run up under each key, in milliseconds. Every call adds its own
|
|
27
|
+
* deltas and reads back where the group stands, so a replica reports what it alone has
|
|
28
|
+
* spent and still decides on what all of them have.
|
|
29
|
+
*
|
|
30
|
+
* Rejects where there is nothing to arbitrate through.
|
|
31
|
+
*/
|
|
32
|
+
meter (keys: string[], deltas: number[]): Promise<number[]>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Runs `routine` holding `keys`, and while it holds them no other replica of the group
|
|
36
|
+
* does. Waits for as long as it takes to acquire them.
|
|
37
|
+
*
|
|
38
|
+
* The lease is extended for as long as the routine runs. An extension that fails aborts
|
|
39
|
+
* the signal the routine is given, which is the only way it learns it no longer holds
|
|
40
|
+
* what it is working under.
|
|
41
|
+
*
|
|
42
|
+
* Rejects where there is nothing to arbitrate through.
|
|
43
|
+
*/
|
|
44
|
+
lock<T> (keys: string | string[],
|
|
45
|
+
routine: (signal: AbortSignal, context: unknown) => Promise<T>): Promise<T>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Factory {
|
|
49
|
+
/** @param group what the replicas deciding together have in common */
|
|
50
|
+
atom (group: string, options?: object): Atom
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Atom = toa.core.atomicity.Atom
|
|
58
|
+
export type Factory = toa.core.atomicity.Factory
|
package/types/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * as bindings from './bindings'
|
|
2
2
|
export * as extensions from './extensions'
|
|
3
3
|
export * as storages from './storages'
|
|
4
|
+
export * as outbox from './outbox'
|
|
5
|
+
export * as atomicity from './atomicity'
|
|
4
6
|
export * as bridges from './bridges'
|
|
5
7
|
export * as operations from './operations'
|
|
6
8
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as _state from './state'
|
|
2
|
+
import * as _atomicity from './atomicity'
|
|
3
|
+
|
|
4
|
+
declare namespace toa.core {
|
|
5
|
+
|
|
6
|
+
namespace outbox {
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The intent to publish, committed with the state change it belongs to. Everything about
|
|
10
|
+
* it is core's: the storage writes it and, where the images are the write's own, fills
|
|
11
|
+
* `event.origin` and `event.state` in.
|
|
12
|
+
*/
|
|
13
|
+
interface Row {
|
|
14
|
+
id: string
|
|
15
|
+
|
|
16
|
+
/** which replica pumps this row; carries no other meaning, and no ordering */
|
|
17
|
+
lane: number
|
|
18
|
+
|
|
19
|
+
published: boolean
|
|
20
|
+
|
|
21
|
+
/** not before this */
|
|
22
|
+
pending: number
|
|
23
|
+
|
|
24
|
+
event: _state.Event
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a storage offers when it can commit a row atomically with the entity. */
|
|
28
|
+
interface Storage {
|
|
29
|
+
insert (row: Row, session?: unknown): Promise<void>
|
|
30
|
+
|
|
31
|
+
insertMany (rows: Row[], session?: unknown): Promise<void>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One page of what is due, still unpublished, and in one of the given lanes, in the
|
|
35
|
+
* order the rows were written. `after` continues from the last id of the page before.
|
|
36
|
+
*/
|
|
37
|
+
pending (lanes: number[], now: number, limit: number, after?: string): Promise<Row[]>
|
|
38
|
+
|
|
39
|
+
settle (ids: string[]): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Row = toa.core.outbox.Row
|
|
47
|
+
export type Storage = toa.core.outbox.Storage
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A lane is a slot of `atomicity`: which replica pumps a row, and nothing else. The outbox is
|
|
51
|
+
* the first thing to claim slots this way, not the last.
|
|
52
|
+
*/
|
|
53
|
+
export type Atom = _atomicity.Atom
|
package/types/state.d.ts
CHANGED
|
@@ -6,9 +6,12 @@ declare namespace toa.core {
|
|
|
6
6
|
namespace transition {
|
|
7
7
|
|
|
8
8
|
type Event = {
|
|
9
|
-
|
|
9
|
+
/** the pre-image; null when the entity did not exist before */
|
|
10
|
+
origin: Object | null
|
|
10
11
|
state: Object
|
|
11
|
-
|
|
12
|
+
/** out-of-band values an algorithm wrote into `state._trailers`; must be serializable */
|
|
13
|
+
trailers?: Object
|
|
14
|
+
input?: Object
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
}
|
|
@@ -24,9 +27,9 @@ declare namespace toa.core {
|
|
|
24
27
|
|
|
25
28
|
none(): null
|
|
26
29
|
|
|
27
|
-
commit(entity: _entity.Entity): Promise<boolean>
|
|
30
|
+
commit(entity: _entity.Entity, input?: Object): Promise<boolean>
|
|
28
31
|
|
|
29
|
-
apply(changeset: _entity.Changeset): Promise<
|
|
32
|
+
apply(changeset: _entity.Changeset, input?: Object): Promise<_storages.Record>
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
}
|
package/types/storages.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Locator } from './locator'
|
|
4
4
|
import { Connector } from './connector'
|
|
5
|
+
import * as outbox from './outbox'
|
|
5
6
|
|
|
6
7
|
declare namespace toa.core {
|
|
7
8
|
|
|
@@ -43,8 +44,13 @@ declare namespace toa.core {
|
|
|
43
44
|
table (database: string, locator: Locator, schema: Object, reset?: boolean): Promise<string>
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
interface Options {
|
|
48
|
+
/** whether this component publishes anything, and so needs an outbox */
|
|
49
|
+
outbox?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
interface Factory {
|
|
47
|
-
storage (locator: Locator, properties?: object): Storage
|
|
53
|
+
storage (locator: Locator, properties?: object, options?: Options): Storage
|
|
48
54
|
|
|
49
55
|
migration? (driver?: string): Migration
|
|
50
56
|
}
|
|
@@ -58,10 +64,27 @@ declare namespace toa.core {
|
|
|
58
64
|
find? (query: storages.Query): Promise<storages.Record[]>
|
|
59
65
|
|
|
60
66
|
// commit
|
|
61
|
-
store? (record: storages.Record): Promise<boolean>
|
|
67
|
+
store? (record: storages.Record, row?: outbox.Row): Promise<boolean>
|
|
68
|
+
|
|
69
|
+
// mass commit
|
|
70
|
+
massStore? (records: storages.Record[], rows?: outbox.Row[]): Promise<boolean>
|
|
62
71
|
|
|
63
72
|
// assignment
|
|
64
|
-
upsert? (query: storages.Query, changeset: Object,
|
|
73
|
+
upsert? (query: storages.Query, changeset: Object, row?: outbox.Row): Promise<storages.Record>
|
|
74
|
+
|
|
75
|
+
// atomic get-or-create
|
|
76
|
+
ensure? (query: storages.Query, properties: Object, record: storages.Record, row?: outbox.Row): Promise<storages.Record>
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Present only where a row can be committed atomically with the entity. Its absence is
|
|
80
|
+
* what makes the runtime fall back to publishing inline, so a storage that cannot do
|
|
81
|
+
* this must not offer it: a row written outside the transaction would be a second write
|
|
82
|
+
* with a crash window in front of it, which is the defect the outbox exists to close.
|
|
83
|
+
*
|
|
84
|
+
* The row schema is the connector's own — it also owns `pending` and `settle` — and
|
|
85
|
+
* never crosses this boundary.
|
|
86
|
+
*/
|
|
87
|
+
outbox?: outbox.Storage
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
}
|