@toa.io/core 1.0.0-alpha.27 → 1.0.0-alpha.272

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 (71) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/package.json +7 -9
  3. package/src/assignment.js +3 -2
  4. package/src/call.js +21 -1
  5. package/src/cascade.js +7 -5
  6. package/src/component.js +59 -15
  7. package/src/composition.js +1 -1
  8. package/src/connector.js +28 -17
  9. package/src/context.js +6 -0
  10. package/src/contract/contract.js +22 -0
  11. package/src/contract/reply.js +34 -9
  12. package/src/contract/request.js +19 -5
  13. package/src/contract/schemas/index.js +1 -0
  14. package/src/contract/schemas/query.yaml +14 -1
  15. package/src/contract/schemas/source.yaml +23 -0
  16. package/src/discovery.js +13 -7
  17. package/src/effect.js +19 -0
  18. package/src/entities/changeset.js +5 -8
  19. package/src/entities/entity.js +74 -24
  20. package/src/entities/factory.js +17 -6
  21. package/src/entities/newid.js +11 -0
  22. package/src/entities/set.js +13 -0
  23. package/src/event.js +19 -1
  24. package/src/exceptions.js +26 -19
  25. package/src/exposition.js +3 -2
  26. package/src/guard.js +17 -0
  27. package/src/index.js +8 -0
  28. package/src/observation.js +1 -9
  29. package/src/operation.js +38 -8
  30. package/src/outbox/index.js +6 -0
  31. package/src/outbox/outbox.js +301 -0
  32. package/src/query/options.js +3 -2
  33. package/src/query.js +32 -2
  34. package/src/receiver.js +73 -11
  35. package/src/remote.js +8 -7
  36. package/src/state.js +90 -47
  37. package/src/transition.js +11 -19
  38. package/src/transmission.js +12 -3
  39. package/src/unmanaged.js +11 -0
  40. package/test/component.test.js +4 -3
  41. package/test/connector.fixtures.js +8 -0
  42. package/test/connector.test.js +20 -0
  43. package/test/contract/conditions.test.js +5 -5
  44. package/test/contract/request.test.js +46 -7
  45. package/test/discovery.test.js +56 -0
  46. package/test/emission.fixtures.js +1 -2
  47. package/test/entities/entity.test.js +58 -48
  48. package/test/entities/factory.test.js +5 -3
  49. package/test/event.test.js +4 -4
  50. package/test/outbox.test.js +114 -0
  51. package/test/query.test.js +17 -0
  52. package/test/receiver.fixtures.js +1 -0
  53. package/test/state.fixtures.js +11 -8
  54. package/test/state.test.js +61 -13
  55. package/types/atomicity.d.ts +58 -0
  56. package/types/bindings.d.ts +7 -5
  57. package/types/bridges.ts +3 -0
  58. package/types/component.d.ts +4 -1
  59. package/types/context.d.ts +3 -0
  60. package/types/entity.d.ts +2 -2
  61. package/types/extensions.d.ts +7 -4
  62. package/types/index.ts +4 -1
  63. package/types/message.d.ts +1 -0
  64. package/types/operations.d.ts +6 -0
  65. package/types/outbox.d.ts +53 -0
  66. package/types/query.d.ts +2 -0
  67. package/types/remote.d.ts +18 -0
  68. package/types/request.d.ts +15 -0
  69. package/types/state.d.ts +9 -6
  70. package/types/storages.d.ts +34 -9
  71. package/src/contract/conditions.js +0 -21
@@ -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
@@ -33,8 +33,9 @@ const projection = (projection, properties) => {
33
33
  }
34
34
  }
35
35
 
36
- if (projection.includes('_version') === false)
37
- projection.push('_version')
36
+ for (const property of ['_version', '_created', '_updated', '_deleted'])
37
+ if (!projection.includes(property))
38
+ projection.push(property)
38
39
  }
39
40
 
40
41
  exports.options = options
package/src/query.js CHANGED
@@ -7,6 +7,9 @@ class Query {
7
7
  #properties
8
8
  #system
9
9
 
10
+ /** @type {Map<string, object>} parsed criteria by their expression */
11
+ #asts = new Map()
12
+
10
13
  constructor (properties) {
11
14
  this.#properties = properties
12
15
  this.#system = Object.keys(properties).filter((key) => properties[key].system === true)
@@ -19,13 +22,15 @@ class Query {
19
22
  parse (query) {
20
23
  /** @type {toa.core.storages.Query} */
21
24
  const result = {}
22
- const { id, version, criteria, ...rest } = query
25
+ const { id, ids, version, criteria, search, ...rest } = query
23
26
 
24
27
  const options = this.#options(rest)
25
28
 
26
29
  if (id !== undefined) result.id = id
30
+ if (ids !== undefined) result.ids = ids
27
31
  if (version !== undefined) result.version = version
28
- if (criteria !== undefined) result.criteria = parse.criteria(criteria, this.#properties)
32
+ if (criteria !== undefined) result.criteria = this.#criteria(criteria)
33
+ if (search !== undefined) result.search = search
29
34
  if (options !== undefined) result.options = options
30
35
 
31
36
  return result
@@ -36,6 +41,31 @@ class Query {
36
41
 
37
42
  return parse.options(options, this.#properties, this.#system)
38
43
  }
44
+
45
+ /**
46
+ * Lexing an RSQL expression is the most expensive thing this class does, and the same
47
+ * expression arrives over and over: a route builds it from its own declaration and the
48
+ * request parameters. The tree is only read downstream — a storage builds a fresh query
49
+ * from it — so it is kept rather than parsed again.
50
+ *
51
+ * Expressions come from the client, hence the bound. An invalid one throws before it
52
+ * reaches the cache.
53
+ */
54
+ #criteria (criteria) {
55
+ const known = this.#asts.get(criteria)
56
+
57
+ if (known !== undefined) return known
58
+
59
+ const ast = parse.criteria(criteria, this.#properties)
60
+
61
+ if (this.#asts.size >= LIMIT) this.#asts.clear()
62
+
63
+ this.#asts.set(criteria, ast)
64
+
65
+ return ast
66
+ }
39
67
  }
40
68
 
69
+ const LIMIT = 1024
70
+
41
71
  exports.Query = Query
package/src/receiver.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { console, decode, run } = require('openspan')
3
4
  const { add } = require('@toa.io/generic')
4
5
  const { Connector } = require('./connector')
5
6
 
@@ -16,18 +17,28 @@ class Receiver extends Connector {
16
17
  /** @type {string} */
17
18
  #endpoint
18
19
 
19
- /** @type {toa.core.Component} */
20
+ /** @type {string} */
21
+ #label
22
+
23
+ /** @type {string} */
24
+ #destination
25
+
26
+ /** @type {unknown[]} */
27
+ #arguments
28
+
29
+ /** @type {toa.core.Source} */
30
+ #origin
31
+
20
32
  #local
21
33
 
22
- /** @type {toa.core.bridges.Receiver} */
23
34
  #bridge
24
35
 
25
- /**
26
- *
27
- * @param {toa.norm.component.Receiver} definition
28
- * @param {toa.core.Component} local
29
- * @param {toa.core.bridges.Receiver} bridge
30
- */
36
+ /** @type {object} */
37
+ #delivery
38
+
39
+ /** @type {object} */
40
+ #processing
41
+
31
42
  constructor (definition, local, bridge) {
32
43
  super()
33
44
 
@@ -36,17 +47,42 @@ class Receiver extends Connector {
36
47
  this.#conditioned = conditioned
37
48
  this.#adaptive = adaptive
38
49
  this.#endpoint = operation
50
+ this.#label = definition.label ?? operation
51
+ this.#destination = definition.destination ?? this.#label
52
+ this.#arguments = definition.arguments
53
+ this.#origin = definition.origin
39
54
 
40
55
  this.#local = local
41
56
  this.#bridge = bridge
42
57
 
43
58
  this.depends(local)
44
59
  if (bridge !== undefined) this.depends(bridge)
60
+
61
+ /*
62
+ * The delivery span is created on behalf of the messaging destination, so that
63
+ * each consumer forms its own complete producer/consumer pair, and service graphs
64
+ * display fan-out correctly: producer -> destination -> each consumer
65
+ * (Tempo pairs spans one-to-one, thus multiple consumers can not pair
66
+ * with a single producer span, see grafana/tempo#5408)
67
+ */
68
+ this.#delivery = {
69
+ name: `${this.#label} deliver`,
70
+ kind: 'producer',
71
+ service: this.#destination,
72
+ attributes: { 'messaging.destination.name': this.#destination }
73
+ }
74
+
75
+ this.#processing = {
76
+ name: `${this.#label} process`,
77
+ kind: 'consumer',
78
+ service: local.locator.id,
79
+ attributes: { 'messaging.destination.name': this.#destination }
80
+ }
45
81
  }
46
82
 
47
83
  /** @hot */
48
84
  async receive (message) {
49
- const { payload, ...extensions } = message
85
+ const { payload, telemetry, ...extensions } = message
50
86
 
51
87
  if (this.#conditioned && await this.#bridge.condition(payload) === false) return
52
88
 
@@ -54,11 +90,37 @@ class Receiver extends Connector {
54
90
 
55
91
  add(request, extensions)
56
92
 
57
- await this.#local.invoke(this.#endpoint, request)
93
+ // set after `add`, so that a message field can not spoof the origin
94
+ if (this.#origin !== undefined) request.source = this.#origin
95
+
96
+ // continue the trace from the producer span
97
+ const remote = telemetry === undefined ? null : decode(telemetry)
98
+ const task = () => this.#process(request)
99
+
100
+ if (remote === null)
101
+ await task()
102
+ else
103
+ await run(remote, task)
104
+ }
105
+
106
+ async #process (request) {
107
+ return console.span(this.#delivery, async () => console.span(this.#processing, async () => {
108
+ try {
109
+ await this.#local.invoke(this.#endpoint, request)
110
+ } catch (error) {
111
+ console.error('Receiver error', {
112
+ component: this.#local.locator.id,
113
+ endpoint: this.#endpoint,
114
+ error
115
+ })
116
+
117
+ throw error
118
+ }
119
+ }))
58
120
  }
59
121
 
60
122
  async #request (payload) {
61
- return this.#adaptive ? await this.#bridge.request(payload) : { input: payload }
123
+ return this.#adaptive ? await this.#bridge.request(payload, ...(this.#arguments ?? [])) : { input: payload }
62
124
  }
63
125
  }
64
126
 
package/src/remote.js CHANGED
@@ -1,16 +1,17 @@
1
1
  'use strict'
2
2
 
3
- const { console } = require('@toa.io/console')
4
-
3
+ const assert = require('node:assert')
5
4
  const { Component } = require('./component')
6
5
 
7
6
  class Remote extends Component {
8
- async open () {
9
- console.info(`Remote '${this.locator.id}' connected`)
10
- }
7
+ kind = 'client'
8
+
9
+ explain (endpoint) {
10
+ if (!(endpoint in this.operations))
11
+ // `assert.fail`, not `assert.ok`: the message is built only when it is needed
12
+ assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
11
13
 
12
- async dispose () {
13
- console.info(`Remote '${this.locator.id}' disconnected`)
14
+ return this.operations[endpoint].explain()
14
15
  }
15
16
  }
16
17