@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 +28 -0
- package/readme.md +90 -0
- package/src/atom.js +176 -0
- package/src/connection.js +118 -0
- package/src/factory.js +39 -0
- package/src/index.js +7 -0
- package/src/meter.js +83 -0
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
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
|