Skip to content

Commit 80d22fe

Browse files
committed
impr(CLDSRV-771): Rate limit client wrapper for redis
1 parent 84fa622 commit 80d22fe

5 files changed

Lines changed: 205 additions & 0 deletions

File tree

lib/Config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,11 @@ class Config extends EventEmitter {
18391839

18401840

18411841
if (config.rateLimiting?.enabled) {
1842+
// rate limiting uses the same localCache config defined for S3 to avoid
1843+
// config duplication.
1844+
assert(config.localCache, 'missing required property of rateLimit ' +
1845+
'configuration: localCache');
1846+
18421847
this.rateLimiting.enabled = true;
18431848

18441849
assert.strictEqual(typeof config.rateLimiting.serviceUserArn, 'string');
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const fs = require('fs');
2+
3+
const Redis = require('ioredis');
4+
5+
const { config } = require('../../../Config');
6+
7+
8+
const updateCounterScript = fs.readFileSync(`${__dirname }/updateCounter.lua`).toString();
9+
10+
const SCRIPTS = {
11+
updateCounter: {
12+
numberOfKeys: 1,
13+
lua: updateCounterScript,
14+
},
15+
};
16+
17+
class RateLimitClient {
18+
constructor(redisConfig) {
19+
this.redis = new Redis({
20+
...redisConfig,
21+
scripts: SCRIPTS,
22+
lazyConnect: true,
23+
});
24+
}
25+
26+
/**
27+
* @typedef {Object} CounterUpdateBatch
28+
* @property {string} key - counter key
29+
* @property {number} cost - cost to add to counter
30+
*/
31+
32+
/**
33+
* @typedef {Object} CounterUpdateBatchResult
34+
* @property {string} key - counter key
35+
* @property {number} value - current value of counter
36+
*/
37+
38+
/**
39+
* @callback RateLimitClient~batchUpdate
40+
* @param {Error|null} err
41+
* @param {CounterUpdateBatchResult[]|undefined}
42+
*/
43+
44+
/**
45+
* Add cost to the counter at key.
46+
* Returns the new value for the counter
47+
*
48+
* @param {CounterUpdateBatch[]} batch - batch of counter updates
49+
* @param {RateLimitClient~batchUpdate} cb
50+
*/
51+
updateLocalCounters(batch, cb) {
52+
const pipeline = this.redis.pipeline();
53+
for (const { key, cost } of batch) {
54+
pipeline.updateCounter(key, cost);
55+
}
56+
57+
pipeline.exec((err, results) => {
58+
if (err) {
59+
cb(err);
60+
return;
61+
}
62+
63+
cb(null, results.map((res, i) => ({
64+
key: batch[i].key,
65+
value: res[1],
66+
})));
67+
});
68+
}
69+
}
70+
71+
let counterClient;
72+
if (config.rateLimiting.enabled) {
73+
counterClient = new RateLimitClient(config.localCache);
74+
}
75+
76+
module.exports = {
77+
counterClient,
78+
RateLimitClient
79+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
local ts = redis.call('TIME')
2+
local currentTime = ts[1] * 1000
3+
currentTime = currentTime + math.floor(ts[2] / 1000)
4+
5+
local newValue = currentTime + tonumber(ARGV[1])
6+
7+
local counterExists = redis.call('EXISTS', KEYS[1])
8+
if counterExists == 1 then
9+
local currentValue = tonumber(redis.call('GET', KEYS[1]))
10+
if currentValue > currentTime then
11+
newValue = currentValue + tonumber(ARGV[1])
12+
end
13+
end
14+
15+
redis.call('SET', KEYS[1], newValue)
16+
17+
local expiry = math.ceil(newValue / 1000)
18+
redis.call('EXPIREAT', KEYS[1], expiry)
19+
20+
return newValue
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const assert = require('assert');
2+
3+
const { config } = require('../../../../../lib/Config');
4+
const { RateLimitClient } = require('../../../../../lib/api/apiUtils/rateLimit/client');
5+
6+
7+
const counterKey = 'foo';
8+
9+
describe('Test RateLimitClient', () => {
10+
let client;
11+
12+
before(done => {
13+
client = new RateLimitClient(config.localCache);
14+
client.redis.connect(done);
15+
});
16+
17+
beforeEach(done => {
18+
client.redis.del(counterKey, err => done(err));
19+
});
20+
21+
it('should set the value of an empty counter', done => {
22+
const batch = [{ key: counterKey, cost: 10000 }];
23+
client.updateLocalCounters(batch, (err, res) => {
24+
assert.ifError(err);
25+
assert.strictEqual(res.length, 1);
26+
assert.strictEqual(res[0].key, counterKey);
27+
done();
28+
});
29+
});
30+
31+
it('should increment the value of an existing counter', done => {
32+
const batch = [{ key: counterKey, cost: 10000 }];
33+
client.updateLocalCounters(batch, (err, res) => {
34+
assert.ifError(err);
35+
const { value: existingValue } = res[0];
36+
client.updateLocalCounters(batch, (err, res) => {
37+
assert.ifError(err);
38+
const { value: newValue } = res[0];
39+
assert(newValue > existingValue, `${newValue} is not greater than ${existingValue}`);
40+
done();
41+
});
42+
});
43+
});
44+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const assert = require('assert');
2+
3+
const { RateLimitClient } = require('../../../../../lib/api/apiUtils/rateLimit/client');
4+
5+
class RedisStub {
6+
constructor() {
7+
this.data = {};
8+
}
9+
10+
pipeline() {
11+
return new PipelineStub();
12+
}
13+
}
14+
15+
class PipelineStub {
16+
constructor() {
17+
this.ops = [];
18+
}
19+
20+
updateCounter(key, cost) {
21+
this.ops.push([key, cost]);
22+
}
23+
24+
exec(cb) {
25+
cb(null, this.ops.map(v => [1, v[1]]));
26+
}
27+
}
28+
29+
describe('test RateLimitClient', () => {
30+
let client;
31+
32+
before(() => {
33+
client = new RateLimitClient({});
34+
});
35+
36+
beforeEach(() => {
37+
client.redis = new RedisStub();
38+
});
39+
40+
it('should update a batch of counters', done => {
41+
const batch = [
42+
{ key: 'foo', cost: 100 },
43+
{ key: 'bar', cost: 200 },
44+
{ key: 'qux', cost: 300 },
45+
];
46+
47+
client.updateLocalCounters(batch, (err, results) => {
48+
assert.ifError(err);
49+
assert.deepStrictEqual(results, [
50+
{ key: 'foo', value: 100 },
51+
{ key: 'bar', value: 200 },
52+
{ key: 'qux', value: 300 },
53+
]);
54+
done();
55+
});
56+
});
57+
});

0 commit comments

Comments
 (0)