@toa.io/atomicity 1.0.0-alpha.271

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 ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@toa.io/atomicity",
3
+ "version": "1.0.0-alpha.271",
4
+ "description": "Toa Atomicity Connector",
5
+ "author": "temich <tema.gurtovoy@gmail.com>",
6
+ "homepage": "https://github.com/toa-io/toa#readme",
7
+ "main": "src/index.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/toa-io/toa.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/toa-io/toa/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "dependencies": {
19
+ "@sesamecare-oss/redlock": "1.4.0",
20
+ "@toa.io/core": "1.0.0-alpha.270",
21
+ "ioredis": "6.0.0",
22
+ "n-and-i": "0.3.0",
23
+ "openspan": "1.0.0-alpha.270"
24
+ },
25
+ "scripts": {
26
+ "test": "echo \"Error: run tests from root\" && exit 1"
27
+ }
28
+ }
package/readme.md ADDED
@@ -0,0 +1,90 @@
1
+ # Toa Atomicity
2
+
3
+ Decisions several replicas have to make together, arbitrated by Redis.
4
+
5
+ Every component has one as `context.atom`, shared by the replicas of that component. Nothing is
6
+ declared in the manifest.
7
+
8
+ ## Partitioning
9
+
10
+ An exclusive claim on one of a fixed number of slots: while a replica holds a slot, no other
11
+ replica of the group holds it.
12
+
13
+ ```javascript
14
+ const slots = context.atom.slots(128) // [0, 2, 4, …] — or null, owning nothing
15
+ ```
16
+
17
+ Returns `null` while this replica owns nothing — at startup, during a rollout, or while Redis is unreachable.
18
+
19
+ Uses [n-and-i](https://github.com/temich/nandi).
20
+
21
+ ## Metering
22
+
23
+ Debt the group has run up under each key, in milliseconds. A call adds its own deltas and answers
24
+ what the group has reached.
25
+
26
+ ```javascript
27
+ const [debt] = await context.atom.meter(['sam'], [1000])
28
+ ```
29
+
30
+ Debt drains a millisecond a millisecond, on Redis' clock. Keys belong to the group.
31
+
32
+ ## Locking
33
+
34
+ `routine` runs while no other replica of the group holds `keys`. Acquiring waits for as long as it
35
+ takes, and a lease is extended for as long as the routine runs.
36
+
37
+ ```javascript
38
+ await context.atom.lock('the ledger', async () => { … })
39
+ ```
40
+
41
+ Uses [redlock](https://github.com/sesamecare/redlock). The key is written to every address and a
42
+ majority holding it is the lock, so a minority can be lost or failed over without invalidating one.
43
+ Against a single address there is no majority to lose: a restart that drops the key, or a failover
44
+ to a replica that has not received it, can leave two holders. Entity writes do not rest on either —
45
+ they have `_version`.
46
+
47
+ The routine is given an `AbortSignal` and a context. Extension can fail while the routine runs, and
48
+ the signal is how it says so:
49
+
50
+ ```javascript
51
+ await context.atom.lock('the ledger', async (signal) => {
52
+ await something()
53
+
54
+ if (signal.aborted) throw signal.error
55
+ })
56
+ ```
57
+
58
+ ## Outside a component
59
+
60
+ ```javascript
61
+ const { Factory } = require('@toa.io/atomicity')
62
+
63
+ const atom = new Factory().atom('mail.sender')
64
+
65
+ await atom.connect()
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ Requires Redis.
71
+
72
+ ```yaml
73
+ # context.toa.yaml
74
+ atomicity:
75
+ redis: redis://redis.example.com # one address, or an odd number of them
76
+ interval: 5000 # how often a replica registers, milliseconds
77
+ ```
78
+
79
+ Several addresses are **independent servers**, not the nodes of a cluster. The lock is taken on a
80
+ quorum of them; partitioning and metering use the first, because each of them counts on a single
81
+ key. An even number is refused: `floor(n / 2) + 1` means four tolerate one loss exactly as three do,
82
+ and two tolerate none at all.
83
+
84
+ `redis://localhost` under `TOA_DEV=1`.
85
+
86
+ One set of clients per process, shared by every atom in it, and an unreachable Redis does not fail
87
+ a start.
88
+
89
+ A key names what it is for and whose it is: `slots:{group}:…`, `meter:<group>:<key>`,
90
+ `lock:<group>:<key>`.
package/src/atom.js ADDED
@@ -0,0 +1,176 @@
1
+ 'use strict'
2
+
3
+ const { console } = require('openspan')
4
+ const { Connector } = require('@toa.io/core')
5
+
6
+ /**
7
+ * What one group of replicas decides together, in one place: which of them owns what, what they
8
+ * have spent between them, and which of them holds a name while it works.
9
+ *
10
+ * @implements {toa.core.atomicity.Atom}
11
+ */
12
+ class Atom extends Connector {
13
+ #connection
14
+ #name
15
+ #interval
16
+
17
+ /** @type {{ i: number, n: number } | null} */
18
+ #assignment = null
19
+
20
+ /** @type {AbortController} */
21
+ #abort
22
+
23
+ /** @type {Promise<void>} */
24
+ #discovering
25
+
26
+ /** the loop's own logger, carrying the group every line belongs to */
27
+ #console
28
+
29
+ constructor (connection, name, interval) {
30
+ super()
31
+
32
+ this.#connection = connection
33
+ this.#name = name
34
+ this.#interval = interval ?? override() ?? INTERVAL
35
+ this.#console = console.fork({ group: name })
36
+
37
+ this.depends(connection)
38
+ }
39
+
40
+ /**
41
+ * An exclusive claim on slots of `0..total`: while this replica holds one, no other replica
42
+ * of the group does.
43
+ *
44
+ * The arithmetic is n-and-i. Every replica registers in a Redis counter once per interval
45
+ * and receives a `{ i, n }` pair once two consecutive intervals have agreed on it, so a
46
+ * replica that has just joined, stalled or restarted claims nothing until the group has
47
+ * settled.
48
+ *
49
+ * @param total {number}
50
+ * @returns {number[] | null} null while this replica owns nothing
51
+ */
52
+ slots (total) {
53
+ const assignment = this.#assignment
54
+
55
+ if (assignment === null) return null
56
+
57
+ const { i, n } = assignment
58
+ const owned = []
59
+
60
+ for (let slot = i; slot < total; slot += n) owned.push(slot)
61
+
62
+ return owned
63
+ }
64
+
65
+ /**
66
+ * Debt the group has run up under each key, in milliseconds: every call adds its own deltas
67
+ * and reads back where the group stands, so a replica reports only what it has spent and
68
+ * still throttles on what all of them have.
69
+ *
70
+ * @param {string[]} keys
71
+ * @param {number[]} deltas
72
+ * @returns {Promise<number[]>}
73
+ */
74
+ async meter (keys, deltas) {
75
+ const meter = this.#connection.meter
76
+
77
+ if (meter === undefined)
78
+ throw new Error('Metering requires atomicity. Set TOA_ATOMICITY_REDIS.')
79
+
80
+ return meter.meter(this.#keys(METER, keys), deltas)
81
+ }
82
+
83
+ /**
84
+ * Runs `routine` holding `keys`, and while it holds them no other replica of the group does.
85
+ * Waits for as long as it takes to acquire them.
86
+ *
87
+ * @param {string | string[]} keys
88
+ * @param {() => Promise<any>} routine
89
+ * @returns {Promise<any>}
90
+ */
91
+ async lock (keys, routine) {
92
+ const redlock = this.#connection.redlock
93
+
94
+ if (redlock === undefined)
95
+ throw new Error('Locking requires atomicity. Set TOA_ATOMICITY_REDIS.')
96
+
97
+ return redlock.using(this.#keys(LOCK, keys), LEASE, routine)
98
+ }
99
+
100
+ /**
101
+ * A key names what it is for and whose it is, in that order, so that a name used for a lock
102
+ * and for a meter is two keys, and two groups using the same name do not meet either.
103
+ *
104
+ * @private
105
+ */
106
+ #keys (kind, keys) {
107
+ if (typeof keys === 'string') keys = [keys]
108
+
109
+ return keys.map((key) => `${kind}${this.#name}:${key}`)
110
+ }
111
+
112
+ async open () {
113
+ const redis = this.#connection.redis
114
+
115
+ // without a Redis nothing can be owned exclusively, so nothing is claimed at all
116
+ if (redis === undefined) return
117
+
118
+ this.#abort = new AbortController()
119
+ this.#discovering = this.#discover(redis)
120
+ }
121
+
122
+ async close () {
123
+ this.#assignment = null
124
+
125
+ this.#abort?.abort()
126
+
127
+ await this.#discovering
128
+ }
129
+
130
+ /**
131
+ * Nothing is caught here on purpose. A registration that fails is handled inside n-and-i,
132
+ * which hands out the idle pair rather than raising, and an aborted signal ends the loop the
133
+ * way `break` does — so the only thing that can reach this is a fault in the call itself,
134
+ * and a fault deserves to be seen rather than turned into standing down.
135
+ *
136
+ * @private
137
+ */
138
+ async #discover (redis) {
139
+ const { discover } = await import('n-and-i')
140
+
141
+ const loop = discover({
142
+ redis,
143
+ prefix: SLOTS,
144
+ name: this.#name,
145
+ interval: this.#interval,
146
+ signal: this.#abort.signal,
147
+ console: this.#console
148
+ })
149
+
150
+ // the loop yields when ownership changes, which is exactly when work has to be handed over
151
+ for await (const { i, n } of loop)
152
+ this.#assignment = i === null ? null : { i, n }
153
+ }
154
+ }
155
+
156
+ function override () {
157
+ const value = Number(process.env.TOA_ATOMICITY_INTERVAL)
158
+
159
+ return Number.isNaN(value) || value <= 0 ? undefined : value
160
+ }
161
+
162
+ const INTERVAL = 5000
163
+
164
+ /** how long a lock is held before it has to be extended, in milliseconds */
165
+ const LEASE = 5000
166
+
167
+ /*
168
+ * What a key is for, ahead of whose it is. The three live in one Redis and are written by
169
+ * three different things, so nothing but this keeps a group's lock on a name apart from its
170
+ * meter on the same name.
171
+ */
172
+ const SLOTS = 'slots:'
173
+ const METER = 'meter:'
174
+ const LOCK = 'lock:'
175
+
176
+ exports.Atom = Atom
@@ -0,0 +1,118 @@
1
+ 'use strict'
2
+
3
+ const { Redlock } = require('@sesamecare-oss/redlock')
4
+ const { Redis } = require('ioredis')
5
+ const { console } = require('openspan')
6
+ const { Connector } = require('@toa.io/core')
7
+ const { Meter } = require('./meter')
8
+
9
+ /**
10
+ * One set of clients per process, shared by every atom in it. Each keeps its own keys, so a
11
+ * composition of five components opens one connection between them however much they decide.
12
+ */
13
+ class Connection extends Connector {
14
+ /**
15
+ * The one the registry and the meter use. Both count on a single key — replicas per interval,
16
+ * debt per name — and a key spread over several servers would be several answers.
17
+ *
18
+ * @type {import('ioredis').Redis}
19
+ */
20
+ redis
21
+
22
+ /** @type {Meter} */
23
+ meter
24
+
25
+ /** @type {Redlock} */
26
+ redlock
27
+
28
+ /** @type {import('ioredis').Redis[]} */
29
+ #clients = []
30
+
31
+ async open () {
32
+ const urls = resolve()
33
+
34
+ if (urls.length === 0) {
35
+ console.warn('Atomicity is not configured, so nothing is owned and nothing is metered. ' +
36
+ 'Set TOA_ATOMICITY_REDIS.')
37
+
38
+ return
39
+ }
40
+
41
+ /*
42
+ * Refused rather than degraded, because an even number can never be what it looks like: a
43
+ * lock is taken on `floor(n / 2) + 1` of the addresses, so four tolerate one loss exactly
44
+ * as three do, and two tolerate none at all — one fewer than one address does.
45
+ */
46
+ if (urls.length % 2 === 0)
47
+ throw new Error(`Atomicity takes an odd number of addresses, ${urls.length} given.`)
48
+
49
+ this.#clients = urls.map((url) => new Redis(url, OPTIONS))
50
+
51
+ /*
52
+ * Connecting is not awaited, and a failure to connect is not an error here. Coordination
53
+ * that cannot be reached must read exactly as coordination that was never configured —
54
+ * nothing is owned, and whoever asked stands down — and a process that could not start
55
+ * because of it would be the opposite of that. The client retries on its own, so a Redis
56
+ * that comes up later is picked up without anything being restarted.
57
+ */
58
+ for (const client of this.#clients)
59
+ // ioredis leaves `message` empty on a refused connection, where the code is the whole story
60
+ client.on('error', (error) =>
61
+ console.warn('Atomicity is unreachable, so nothing is owned',
62
+ { host: client.options.host, error: error.code ?? error.message }))
63
+
64
+ this.redis = this.#clients[0]
65
+
66
+ // one script per process, whatever the groups sharing this client meter under
67
+ this.meter = new Meter(this.redis)
68
+
69
+ /*
70
+ * Every address, because this is the one decision that can be taken on a quorum: the same
71
+ * key is written to all of them, and a majority holding it survives losing a minority. The
72
+ * registry and the meter cannot be held that way — their key is one key — so the addresses
73
+ * past the first buy an uninterrupted lock and nothing else.
74
+ */
75
+ this.redlock = new Redlock(this.#clients, { retryCount: -1 })
76
+
77
+ console.info('Atomicity connecting to redis', { nodes: this.#clients.length })
78
+ }
79
+
80
+ async close () {
81
+ for (const client of this.#clients) client.disconnect()
82
+
83
+ // a closed connection holds nothing: it is opened again with whatever is configured then,
84
+ // and a client that has been disconnected would fail every command put to it
85
+ this.#clients = []
86
+ this.redis = undefined
87
+ this.meter = undefined
88
+ this.redlock = undefined
89
+ }
90
+ }
91
+
92
+ const OPTIONS = { enableReadyCheck: true }
93
+
94
+ function resolve () {
95
+ const value = process.env[VARIABLE]
96
+
97
+ // an empty value is atomicity turned off, where an absent one in development is the
98
+ // local Redis — as everything else in development resolves
99
+ if (value === undefined) return process.env.TOA_DEV === '1' ? [DEV] : []
100
+
101
+ return value.split(' ').filter((url) => url !== '')
102
+ }
103
+
104
+ let instance
105
+
106
+ /** the connection every atom of the process shares */
107
+ const connection = () => (instance ??= new Connection())
108
+
109
+ /** @internal for tests */
110
+ const reset = () => (instance = undefined)
111
+
112
+ const VARIABLE = 'TOA_ATOMICITY_REDIS'
113
+ const DEV = 'redis://localhost'
114
+
115
+ exports.Connection = Connection
116
+ exports.connection = connection
117
+ exports.reset = reset
118
+ exports.VARIABLE = VARIABLE
package/src/factory.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ const { Atom } = require('./atom')
4
+ const { connection } = require('./connection')
5
+
6
+ class Factory {
7
+ /**
8
+ * What the replicas of one group decide together. They find each other by `group` and by
9
+ * nothing else, so what shares a name shares a decision.
10
+ *
11
+ * @param {string} group
12
+ * @param {object} [options]
13
+ */
14
+ atom (group, options = {}) {
15
+ return atom(group, options)
16
+ }
17
+ }
18
+
19
+ /** @type {Map<string, Atom>} */
20
+ const atoms = new Map()
21
+
22
+ /**
23
+ * One atom per group per process, however many ask for it. A second would register a second
24
+ * time in a group that has one replica, and every replica of that group would then be told it
25
+ * owns half of what it does.
26
+ */
27
+ function atom (group, options) {
28
+ let atom = atoms.get(group)
29
+
30
+ if (atom === undefined) {
31
+ atom = new Atom(connection(), group, options.interval)
32
+
33
+ atoms.set(group, atom)
34
+ }
35
+
36
+ return atom
37
+ }
38
+
39
+ exports.Factory = Factory
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const { Factory } = require('./factory')
4
+ const { VARIABLE } = require('./connection')
5
+
6
+ exports.Factory = Factory
7
+ exports.VARIABLE = VARIABLE
package/src/meter.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Debt shared with every other process metering under the same key.
5
+ *
6
+ * A key holds one number: the milliseconds of debt everyone metering it has run up
7
+ * between them. Debt drains at a millisecond a millisecond and grows by what each
8
+ * process reports, so it is additive — which is what lets a process report only its
9
+ * own increments, on its own schedule, and still read back where the group stands.
10
+ *
11
+ * The clock is Redis' own, so the processes reporting need not agree on the time, and
12
+ * what comes back is a duration rather than a moment, so neither does the caller.
13
+ *
14
+ * A whole batch is metered by one script, because the callers are rate limiters and
15
+ * the number of keys they watch is the number of clients they are watching for.
16
+ */
17
+ class Meter {
18
+ #redis
19
+
20
+ constructor (redis) {
21
+ this.#redis = redis
22
+
23
+ redis.defineCommand(COMMAND, { lua: SCRIPT })
24
+ }
25
+
26
+ /**
27
+ * Adds each delta to its key, and answers what the group has reached.
28
+ *
29
+ * @param {string[]} keys
30
+ * @param {number[]} deltas
31
+ * @returns {Promise<number[]>}
32
+ */
33
+ async meter (keys, deltas) {
34
+ if (keys.length <= CHUNK) return this.#call(keys, deltas)
35
+
36
+ // arguments are spread into the call, so a batch is split rather than risking the
37
+ // engine's limit on how many there may be
38
+ const debts = []
39
+
40
+ for (let i = 0; i < keys.length; i += CHUNK)
41
+ debts.push(...await this.#call(keys.slice(i, i + CHUNK), deltas.slice(i, i + CHUNK)))
42
+
43
+ return debts
44
+ }
45
+
46
+ /** @private */
47
+ async #call (keys, deltas) {
48
+ // `defineCommand` extends the client at runtime
49
+ return this.#redis[COMMAND](keys.length, ...keys, ...deltas)
50
+ }
51
+ }
52
+
53
+ const COMMAND = 'meter'
54
+
55
+ /** How long a key outlives the debt on it, in milliseconds. */
56
+ const GRACE = 10_000
57
+
58
+ /** Keys per call, bounding how many arguments are spread into one. */
59
+ const CHUNK = 2000
60
+
61
+ const SCRIPT = `
62
+ local time = redis.call('TIME')
63
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
64
+ local debts = {}
65
+
66
+ for i = 1, #KEYS do
67
+ local key = KEYS[i]
68
+ local state = redis.call('HMGET', key, 'd', 'a')
69
+ local debt = tonumber(state[1]) or 0
70
+ local at = tonumber(state[2]) or now
71
+
72
+ debt = math.max(0, debt - (now - at)) + tonumber(ARGV[i])
73
+
74
+ redis.call('HSET', key, 'd', debt, 'a', now)
75
+ redis.call('PEXPIRE', key, debt + ${GRACE})
76
+
77
+ debts[i] = debt
78
+ end
79
+
80
+ return debts
81
+ `
82
+
83
+ exports.Meter = Meter