Skip to content

Commit 7b65049

Browse files
committed
impr(CLDSRV-771): Rate limit client wrapper for redis
1 parent 35561a4 commit 7b65049

File tree

5 files changed

+237
-0
lines changed

5 files changed

+237
-0
lines changed

lib/Config.js

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

18471847

18481848
if (config.rateLimiting?.enabled) {
1849+
// rate limiting uses the same localCache config defined for S3 to avoid
1850+
// config duplication.
1851+
assert(this.localCache, 'missing required property of rateLimit ' +
1852+
'configuration: localCache');
1853+
18491854
this.rateLimiting.enabled = true;
18501855

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

0 commit comments

Comments
 (0)