@toa.io/atomicity 1.0.0-alpha.283 → 1.0.0-alpha.286

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/atomicity",
3
- "version": "1.0.0-alpha.283",
3
+ "version": "1.0.0-alpha.286",
4
4
  "type": "module",
5
5
  "description": "Toa Atomicity Connector",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -18,13 +18,13 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@sesamecare-oss/redlock": "1.4.0",
21
- "@toa.io/core": "1.0.0-alpha.283",
21
+ "@toa.io/core": "1.0.0-alpha.286",
22
22
  "ioredis": "6.0.0",
23
23
  "n-and-i": "0.3.2",
24
- "openspan": "1.0.0-alpha.277"
24
+ "openspan": "1.0.0-alpha.286"
25
25
  },
26
26
  "scripts": {
27
27
  "test": "echo \"Error: run tests from root\" && exit 1"
28
28
  },
29
- "gitHead": "2f52b023bed0ccb2ecdb2b82fe59fbe38bf282e1"
29
+ "gitHead": "81f3d0688f17668b902be336d1749cc715edcdb6"
30
30
  }
package/readme.md CHANGED
@@ -18,6 +18,26 @@ Returns `null` while this replica owns nothing — at startup, during a rollout,
18
18
 
19
19
  Uses [n-and-i](https://github.com/temich/nandi).
20
20
 
21
+ ### Being told
22
+
23
+ `slots` answers whoever asks. A consumer that asks rarely — once an hour, say — samples one
24
+ moment, and a rollout passing through that moment costs it the whole hour. `onassigned` tells it
25
+ instead.
26
+
27
+ ```javascript
28
+ const off = atom.onassigned((assignment) => { … }) // { i, n } — or null, owning nothing
29
+ ```
30
+
31
+ Called with the assignment this replica holds, and again whenever it changes: one arrived, was
32
+ lost, or the group resized. A change and not a heartbeat — a group that stays as it is never
33
+ calls back. It is called once as it is added, with the assignment as it stands, so a listener
34
+ arriving after the group settled is not left waiting for something that has already happened.
35
+
36
+ The answer removes the listener. A listener that raises ends the loop it was told through, and
37
+ that loop is what keeps this replica registered — so this replica owns nothing from that moment,
38
+ `slots` answers `null`, and it stands down. What to do about the exception is the runtime's;
39
+ nothing here catches it.
40
+
21
41
  ## Metering
22
42
 
23
43
  Debt the group has run up under each key, in milliseconds. A call adds its own deltas and answers
@@ -42,7 +62,7 @@ Uses [redlock](https://github.com/sesamecare/redlock). The key is written to eve
42
62
  majority holding it is the lock, so a minority can be lost or failed over without invalidating one.
43
63
  Against a single address there is no majority to lose: a restart that drops the key, or a failover
44
64
  to a replica that has not received it, can leave two holders. Entity writes do not rest on either —
45
- they have `_version`.
65
+ they have `VERSION`.
46
66
 
47
67
  The routine is given an `AbortSignal` and a context. Extension can fail while the routine runs, and
48
68
  the signal is how it says so:
@@ -72,8 +92,8 @@ Requires Redis.
72
92
  ```yaml
73
93
  # context.toa.yaml
74
94
  atomicity:
75
- redis: redis://redis.example.com # one address, or an odd number of them
76
- interval: 5000 # how often a replica registers, milliseconds
95
+ redis: redis://redis.example.com # one address, or an odd number of them
96
+ interval: 5000 # how often a replica registers, milliseconds
77
97
  ```
78
98
 
79
99
  Several addresses are **independent servers**, not the nodes of a cluster. The lock is taken on a
package/src/atom.js CHANGED
@@ -5,7 +5,7 @@ import { Connector } from '@toa.io/core'
5
5
  * What one group of replicas decides together, in one place: which of them owns what, what they
6
6
  * have spent between them, and which of them holds a name while it works.
7
7
  *
8
- * @implements {toa.core.atomicity.Atom}
8
+ * @implements {import('@toa.io/core/types').atomicity.Atom}
9
9
  */
10
10
  export class Atom extends Connector {
11
11
  #connection
@@ -15,6 +15,9 @@ export class Atom extends Connector {
15
15
  /** @type {{ i: number, n: number } | null} */
16
16
  #assignment = null
17
17
 
18
+ /** @type {Set<(assignment: { i: number, n: number } | null) => void>} */
19
+ #listeners = new Set()
20
+
18
21
  /** @type {AbortController} */
19
22
  #abort
20
23
 
@@ -24,7 +27,7 @@ export class Atom extends Connector {
24
27
  /** the loop's own logger, carrying the group every line belongs to */
25
28
  #console
26
29
 
27
- constructor (connection, name, interval) {
30
+ constructor(connection, name, interval) {
28
31
  super()
29
32
 
30
33
  this.#connection = connection
@@ -47,7 +50,7 @@ export class Atom extends Connector {
47
50
  * @param total {number}
48
51
  * @returns {number[] | null} null while this replica owns nothing
49
52
  */
50
- slots (total) {
53
+ slots(total) {
51
54
  const assignment = this.#assignment
52
55
 
53
56
  if (assignment === null) return null
@@ -60,6 +63,29 @@ export class Atom extends Connector {
60
63
  return owned
61
64
  }
62
65
 
66
+ /**
67
+ * Calls `listener` with the assignment this replica holds, and again whenever it changes —
68
+ * one arrived, was lost, or the group resized. Answers with what removes the listener again.
69
+ *
70
+ * A change and not a heartbeat: a group that stays as it is never calls back. It is called
71
+ * once as it is added, with the claim as it stands, so that a listener arriving after the
72
+ * group settled is not left waiting for something that has already happened.
73
+ *
74
+ * This is what `slots` cannot say. Reading answers a consumer that asks often, where a stale
75
+ * answer costs one cycle; a consumer that asks rarely needs to be told, or it samples the
76
+ * one moment a rollout was passing through and stands down for a whole cycle.
77
+ *
78
+ * @param listener {(assignment: { i: number, n: number } | null) => void}
79
+ * @returns {() => void}
80
+ */
81
+ onassigned(listener) {
82
+ this.#listeners.add(listener)
83
+
84
+ listener(this.#assignment)
85
+
86
+ return () => this.#listeners.delete(listener)
87
+ }
88
+
63
89
  /**
64
90
  * Debt the group has run up under each key, in milliseconds: every call adds its own deltas
65
91
  * and reads back where the group stands, so a replica reports only what it has spent and
@@ -69,7 +95,7 @@ export class Atom extends Connector {
69
95
  * @param {number[]} deltas
70
96
  * @returns {Promise<number[]>}
71
97
  */
72
- async meter (keys, deltas) {
98
+ async meter(keys, deltas) {
73
99
  const meter = this.#connection.meter
74
100
 
75
101
  if (meter === undefined)
@@ -86,7 +112,7 @@ export class Atom extends Connector {
86
112
  * @param {() => Promise<any>} routine
87
113
  * @returns {Promise<any>}
88
114
  */
89
- async lock (keys, routine) {
115
+ async lock(keys, routine) {
90
116
  const redlock = this.#connection.redlock
91
117
 
92
118
  if (redlock === undefined)
@@ -102,13 +128,13 @@ export class Atom extends Connector {
102
128
  *
103
129
  * @private
104
130
  */
105
- #keys (kind, keys) {
131
+ #keys(kind, keys) {
106
132
  if (typeof keys === 'string') keys = [keys]
107
133
 
108
134
  return keys.map((key) => `${ATOM}:${this.#name}:${kind}:${key}`)
109
135
  }
110
136
 
111
- async open () {
137
+ async open() {
112
138
  const redis = this.#connection.redis
113
139
 
114
140
  // without a Redis nothing can be owned exclusively, so nothing is claimed at all
@@ -118,7 +144,7 @@ export class Atom extends Connector {
118
144
  this.#discovering = this.#discover(redis)
119
145
  }
120
146
 
121
- async close () {
147
+ async close() {
122
148
  this.#assignment = null
123
149
 
124
150
  this.#abort?.abort()
@@ -134,7 +160,7 @@ export class Atom extends Connector {
134
160
  *
135
161
  * @private
136
162
  */
137
- async #discover (redis) {
163
+ async #discover(redis) {
138
164
  const { discover } = await import('n-and-i')
139
165
 
140
166
  const loop = discover({
@@ -146,13 +172,26 @@ export class Atom extends Connector {
146
172
  console: this.#console
147
173
  })
148
174
 
149
- // the loop yields when ownership changes, which is exactly when work has to be handed over
150
- for await (const { i, n } of loop)
151
- this.#assignment = i === null ? null : { i, n }
175
+ try {
176
+ // the loop yields when ownership changes, which is exactly when work has to be handed over
177
+ for await (const { i, n } of loop) {
178
+ this.#assignment = i === null ? null : { i, n }
179
+
180
+ for (const listener of this.#listeners) listener(this.#assignment)
181
+ }
182
+ } finally {
183
+ /*
184
+ * The loop is what keeps this replica registered, so once it has ended — because it was
185
+ * aborted, or because a listener raised through it — this replica owns nothing. Left as
186
+ * it was, the last assignment would answer `slots` for as long as the process ran, and
187
+ * a claim nothing supports is what the whole of this exists to prevent.
188
+ */
189
+ this.#assignment = null
190
+ }
152
191
  }
153
192
  }
154
193
 
155
- function override () {
194
+ function override() {
156
195
  const value = Number(process.env.TOA_ATOMICITY_INTERVAL)
157
196
 
158
197
  return Number.isNaN(value) || value <= 0 ? undefined : value
package/src/connection.js CHANGED
@@ -26,12 +26,14 @@ export class Connection extends Connector {
26
26
  /** @type {import('ioredis').Redis[]} */
27
27
  #clients = []
28
28
 
29
- async open () {
29
+ async open() {
30
30
  const urls = resolve()
31
31
 
32
32
  if (urls.length === 0) {
33
- console.warn('Atomicity is not configured, so nothing is owned and nothing is metered. ' +
34
- 'Set TOA_ATOMICITY_REDIS.')
33
+ console.warn(
34
+ 'Atomicity is not configured, so nothing is owned and nothing is metered. ' +
35
+ 'Set TOA_ATOMICITY_REDIS.'
36
+ )
35
37
 
36
38
  return
37
39
  }
@@ -56,8 +58,11 @@ export class Connection extends Connector {
56
58
  for (const client of this.#clients)
57
59
  // ioredis leaves `message` empty on a refused connection, where the code is the whole story
58
60
  client.on('error', (error) =>
59
- console.warn('Atomicity is unreachable, so nothing is owned',
60
- { host: client.options.host, error: error.code ?? error.message }))
61
+ console.warn('Atomicity is unreachable, so nothing is owned', {
62
+ host: client.options.host,
63
+ error: error.code ?? error.message
64
+ })
65
+ )
61
66
 
62
67
  this.redis = this.#clients[0]
63
68
 
@@ -75,7 +80,7 @@ export class Connection extends Connector {
75
80
  console.info('Atomicity connecting to redis', { nodes: this.#clients.length })
76
81
  }
77
82
 
78
- async close () {
83
+ async close() {
79
84
  for (const client of this.#clients) client.disconnect()
80
85
 
81
86
  // a closed connection holds nothing: it is opened again with whatever is configured then,
@@ -89,7 +94,7 @@ export class Connection extends Connector {
89
94
 
90
95
  const OPTIONS = { enableReadyCheck: true }
91
96
 
92
- function resolve () {
97
+ function resolve() {
93
98
  const value = process.env[VARIABLE]
94
99
 
95
100
  // an empty value is atomicity turned off, where an absent one in development is the
@@ -108,4 +113,8 @@ export const connection = () => (instance ??= new Connection())
108
113
  export const reset = () => (instance = undefined)
109
114
 
110
115
  export const VARIABLE = 'TOA_ATOMICITY_REDIS'
111
- const DEV = 'redis://localhost'
116
+ /**
117
+ * Toa's own development stack is not on the conventional ports: the applications built on
118
+ * Toa are, and they share the machine. See CONTRIBUTING.md.
119
+ */
120
+ const DEV = 'redis://localhost:31040'
package/src/factory.js CHANGED
@@ -9,7 +9,7 @@ export class Factory {
9
9
  * @param {string} group
10
10
  * @param {object} [options]
11
11
  */
12
- atom (group, options = {}) {
12
+ atom(group, options = {}) {
13
13
  return atom(group, options)
14
14
  }
15
15
  }
@@ -22,7 +22,7 @@ const atoms = new Map()
22
22
  * time in a group that has one replica, and every replica of that group would then be told it
23
23
  * owns half of what it does.
24
24
  */
25
- function atom (group, options) {
25
+ function atom(group, options) {
26
26
  let atom = atoms.get(group)
27
27
 
28
28
  if (atom === undefined) {
package/src/meter.js CHANGED
@@ -15,7 +15,7 @@
15
15
  export class Meter {
16
16
  #redis
17
17
 
18
- constructor (redis) {
18
+ constructor(redis) {
19
19
  this.#redis = redis
20
20
 
21
21
  redis.defineCommand(COMMAND, { lua: SCRIPT })
@@ -28,7 +28,7 @@ export class Meter {
28
28
  * @param {number[]} deltas
29
29
  * @returns {Promise<number[]>}
30
30
  */
31
- async meter (keys, deltas) {
31
+ async meter(keys, deltas) {
32
32
  if (keys.length <= CHUNK) return this.#call(keys, deltas)
33
33
 
34
34
  // arguments are spread into the call, so a batch is split rather than risking the
@@ -36,13 +36,15 @@ export class Meter {
36
36
  const debts = []
37
37
 
38
38
  for (let i = 0; i < keys.length; i += CHUNK)
39
- debts.push(...await this.#call(keys.slice(i, i + CHUNK), deltas.slice(i, i + CHUNK)))
39
+ debts.push(
40
+ ...(await this.#call(keys.slice(i, i + CHUNK), deltas.slice(i, i + CHUNK)))
41
+ )
40
42
 
41
43
  return debts
42
44
  }
43
45
 
44
46
  /** @private */
45
- async #call (keys, deltas) {
47
+ async #call(keys, deltas) {
46
48
  // `defineCommand` extends the client at runtime
47
49
  return this.#redis[COMMAND](keys.length, ...keys, ...deltas)
48
50
  }
package/CHANGELOG.md DELETED
@@ -1,89 +0,0 @@
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.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
7
-
8
- **Note:** Version bump only for package @toa.io/atomicity
9
-
10
-
11
-
12
-
13
-
14
- # [1.0.0-alpha.282](https://github.com/toa-io/toa/compare/v1.0.0-alpha.281...v1.0.0-alpha.282) (2026-09-03)
15
-
16
- ### Bug Fixes
17
-
18
- * **atomicity:** nest every key under atom:<group>:<kind> ([348f39f](https://github.com/toa-io/toa/commit/348f39f72d805a24dd4fdbaa83aede47fe95b894))
19
-
20
-
21
- # [1.0.0-alpha.278](https://github.com/toa-io/toa/compare/v1.0.0-alpha.277...v1.0.0-alpha.278) (2026-09-03)
22
-
23
- **Note:** Version bump only for package @toa.io/atomicity
24
-
25
-
26
-
27
-
28
-
29
- # [1.0.0-alpha.277](https://github.com/toa-io/toa/compare/v1.0.0-alpha.276...v1.0.0-alpha.277) (2026-09-03)
30
-
31
- **Note:** Version bump only for package @toa.io/atomicity
32
-
33
-
34
-
35
-
36
-
37
- # [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)
38
-
39
- **Note:** Version bump only for package @toa.io/atomicity
40
-
41
-
42
-
43
-
44
-
45
- # [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)
46
-
47
-
48
- ### Bug Fixes
49
-
50
- * **atomicity:** honour a cluster, and let it be unreachable ([c475d66](https://github.com/toa-io/toa/commit/c475d663ddf7329d86da517ccceee4cb03280e95))
51
- * **atomicity:** stop swallowing faults from the discovery loop ([449f79a](https://github.com/toa-io/toa/commit/449f79a011611f2e14ad5a325a9f7f7161433060))
52
-
53
-
54
- * feat(atomicity)!: take a quorum of independent servers ([862562f](https://github.com/toa-io/toa/commit/862562f24d77ec00127dbbd88d43802b208c643b))
55
- * refactor(atomicity)!: take one Redis, not a cluster ([b0a4770](https://github.com/toa-io/toa/commit/b0a4770dff5dfe8fee4b9ae637a4dd297454fcd4))
56
- * refactor(atomicity)!: take the lock manager from the stash ([4fd91eb](https://github.com/toa-io/toa/commit/4fd91eb2fdb68f7b87aaf16d182794bbc567ba57))
57
- * refactor(atomicity)!: make the connector a family, not a partitioner ([06fd63a](https://github.com/toa-io/toa/commit/06fd63ad6296b724ef83afccf4e4e25d0bcaf080))
58
- * refactor(atomicity)!: rename the connector and free it of the outbox ([21f1a41](https://github.com/toa-io/toa/commit/21f1a41aceafcfd35c7620014062fe46b54afa95))
59
-
60
-
61
- ### Features
62
-
63
- * **atomicity:** make the interval a setting, and stop repeating n-and-i ([5c3a6d1](https://github.com/toa-io/toa/commit/5c3a6d1533751a21b7eb7d9c737f1a270bf5a5ae))
64
- * **atomicity:** meter what the group has spent ([bb1118a](https://github.com/toa-io/toa/commit/bb1118aa60bfeb43d3212f2495b7797445f003c3))
65
-
66
-
67
- ### BREAKING CHANGES
68
-
69
- * `atomicity.redis` accepts a list again, of independent servers rather
70
- than cluster nodes, and refuses an even number of them.
71
-
72
- Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73
- * `atomicity.redis` is a string. A list is refused at export.
74
-
75
- Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76
- * `context.stash.lock` is gone; lock through `context.atom` instead.
77
- A stash pointer resolving to several addresses now uses the first.
78
-
79
- Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80
- * `Factory.partition(group)` is `Factory.atom(group)`, and the
81
- `Partition` it returned is an `Atom`. `slots(total)` is unchanged.
82
-
83
- Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84
- * `@toa.io/partitions.redis` is now `@toa.io/atomicity`, and its
85
- `lanes(total)` is `slots(total)`. Redis is declared as `atomicity` in the context
86
- rather than `outbox.redis`, and read from `TOA_ATOMICITY_REDIS`.
87
- `TOA_OUTBOX_PARTITION_INTERVAL` is `TOA_ATOMICITY_INTERVAL`.
88
-
89
- Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>