Skip to content

Commit 35505d0

Browse files
authored
feat: deviate Redis expiration time (#2608)
This PR introduces a random deviation to Redis key expiration times to prevent cache stampedes and reduce the likelihood of simultaneous expirations. By spreading out key expirations, we help distribute network load more evenly and avoid putting unnecessary pressure on other services like the TX service.
1 parent 41aa298 commit 35505d0

File tree

9 files changed

+269
-19
lines changed

9 files changed

+269
-19
lines changed

src/config/configuration.validator.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('Configuration validator', () => {
2222
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
2323
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
2424
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
25+
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
2526
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
2627
INFURA_API_KEY: faker.string.uuid(),
2728
JWT_ISSUER: faker.string.uuid(),
@@ -118,6 +119,7 @@ describe('Configuration validator', () => {
118119
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
119120
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
120121
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
122+
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
121123
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
122124
INFURA_API_KEY: faker.string.uuid(),
123125
JWT_ISSUER: faker.string.uuid(),
@@ -168,6 +170,7 @@ describe('Configuration validator', () => {
168170
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
169171
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
170172
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
173+
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
171174
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
172175
INFURA_API_KEY: faker.string.uuid(),
173176
JWT_ISSUER: faker.string.uuid(),

src/config/entities/__tests__/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export default (): ReturnType<typeof configuration> => ({
157157
fromName: faker.person.fullName(),
158158
},
159159
expirationTimeInSeconds: {
160+
deviatePercent: faker.number.int({ min: 10, max: 20 }),
160161
default: faker.number.int(),
161162
rpc: faker.number.int(),
162163
hoodi: faker.number.int(),

src/config/entities/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export default () => ({
245245
fromName: process.env.EMAIL_API_FROM_NAME || 'Safe',
246246
},
247247
expirationTimeInSeconds: {
248+
deviatePercent: parseInt(process.env.EXPIRATION_DEVIATE_PERCENT ?? `${10}`),
248249
default: parseInt(process.env.EXPIRATION_TIME_DEFAULT_SECONDS ?? `${60}`),
249250
rpc: parseInt(process.env.EXPIRATION_TIME_RPC_SECONDS ?? `${15}`),
250251
hoodi: parseInt(process.env.HOODI_EXPIRATION_TIME_SECONDS ?? `${60}`),

src/config/entities/schemas/configuration.schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export const RootConfigurationSchema = z
1919
EMAIL_TEMPLATE_RECOVERY_TX: z.string(),
2020
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(),
2121
EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(),
22+
EXPIRATION_DEVIATE_PERCENT: z
23+
.number({ coerce: true })
24+
.min(0)
25+
.max(100)
26+
.optional(),
2227
FINGERPRINT_ENCRYPTION_KEY: z.string(),
2328
INFURA_API_KEY: z.string(),
2429
JWT_ISSUER: z.string(),

src/datasources/cache/redis.cache.service.key-prefix.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,20 @@ const mockConfigurationService = jest.mocked(configurationService);
3434
describe('RedisCacheService with a Key Prefix', () => {
3535
let redisCacheService: RedisCacheService;
3636
let defaultExpirationTimeInSeconds: number;
37+
let defaultExpirationDeviatePercent: number;
3738
const keyPrefix = faker.string.uuid();
3839

3940
beforeEach(() => {
4041
clearAllMocks();
4142
defaultExpirationTimeInSeconds = faker.number.int({ min: 1, max: 3600 });
43+
defaultExpirationDeviatePercent = faker.number.int({ min: 1, max: 3600 });
4244
mockConfigurationService.getOrThrow.mockImplementation((key) => {
4345
if (key === 'expirationTimeInSeconds.default') {
4446
return defaultExpirationTimeInSeconds;
4547
}
48+
if (key === 'expirationTimeInSeconds.deviatePercent') {
49+
return defaultExpirationDeviatePercent;
50+
}
4651
throw Error(`Unexpected key: ${key}`);
4752
});
4853

@@ -62,7 +67,7 @@ describe('RedisCacheService with a Key Prefix', () => {
6267
const value = fakeJson();
6368
const expireTime = faker.number.int();
6469

65-
await redisCacheService.hSet(cacheDir, value, expireTime);
70+
await redisCacheService.hSet(cacheDir, value, expireTime, 0);
6671

6772
expect(redisClientTypeMock.hSet).toHaveBeenCalledWith(
6873
`${keyPrefix}-${cacheDir.key}`,

src/datasources/cache/redis.cache.service.spec.ts

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { IConfigurationService } from '@/config/configuration.service.inter
88
import clearAllMocks = jest.clearAllMocks;
99
import { redisClientFactory } from '@/__tests__/redis-client.factory';
1010
import { MAX_TTL } from '@/datasources/cache/constants';
11+
import { offsetByPercentage } from '@/domain/common/utils/number';
1112

1213
const mockLoggingService: jest.MockedObjectDeep<ILoggingService> = {
1314
info: jest.fn(),
@@ -24,6 +25,8 @@ const mockConfigurationService = jest.mocked(configurationService);
2425
describe('RedisCacheService', () => {
2526
let redisCacheService: RedisCacheService;
2627
let defaultExpirationTimeInSeconds: number;
28+
let defaultExpirationDeviatePercent: number;
29+
let maxTtlDeviated: number;
2730
const keyPrefix = '';
2831
let redisClient: RedisClientType;
2932

@@ -39,10 +42,16 @@ describe('RedisCacheService', () => {
3942
clearAllMocks();
4043
await redisClient.flushDb();
4144
defaultExpirationTimeInSeconds = faker.number.int({ min: 1, max: 3600 });
45+
defaultExpirationDeviatePercent = faker.number.int({ min: 1, max: 99 });
46+
maxTtlDeviated =
47+
MAX_TTL - (MAX_TTL * defaultExpirationDeviatePercent) / 100;
4248
mockConfigurationService.getOrThrow.mockImplementation((key) => {
4349
if (key === 'expirationTimeInSeconds.default') {
4450
return defaultExpirationTimeInSeconds;
4551
}
52+
if (key === 'expirationTimeInSeconds.deviatePercent') {
53+
return defaultExpirationDeviatePercent;
54+
}
4655
throw Error(`Unexpected key: ${key}`);
4756
});
4857

@@ -75,7 +84,7 @@ describe('RedisCacheService', () => {
7584
const value = fakeJson();
7685
const expireTime = faker.number.int();
7786

78-
await redisCacheService.hSet(cacheDir, value, expireTime);
87+
await redisCacheService.hSet(cacheDir, value, expireTime, 0);
7988

8089
const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
8190
const ttl = await redisClient.ttl(cacheDir.key);
@@ -84,19 +93,48 @@ describe('RedisCacheService', () => {
8493
expect(ttl).toBeLessThanOrEqual(expireTime);
8594
});
8695

87-
it('Setting key throws on expire', async () => {
96+
it('Setting key with expireTimeSeconds and expireDeviatePercent does store the value with the deviated TTL', async () => {
8897
const cacheDir = new CacheDir(
8998
faker.string.alphanumeric(),
9099
faker.string.sample(),
91100
);
101+
const value = fakeJson();
102+
const expireTime = faker.number.int({ min: 1, max: 3600 });
103+
const expireDeviatePercent = faker.number.int({ min: 1, max: 100 });
104+
const maxDeviation = offsetByPercentage(expireTime, expireDeviatePercent);
105+
106+
await redisCacheService.hSet(
107+
cacheDir,
108+
value,
109+
expireTime,
110+
expireDeviatePercent,
111+
);
92112

93-
// Expiration time out of range to force an error
94-
await expect(
95-
redisCacheService.hSet(cacheDir, '', Number.MAX_VALUE + 1),
96-
).rejects.toThrow();
113+
const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
114+
const ttl = await redisClient.ttl(cacheDir.key);
115+
expect(storedValue).toEqual(value);
116+
expect(ttl).toBeGreaterThan(0);
117+
expect(ttl).toBeLessThanOrEqual(maxDeviation);
118+
});
119+
120+
it('Setting key with expireTimeSeconds and no expireDeviatePercent does store the value with the default TTL deviation', async () => {
121+
const cacheDir = new CacheDir(
122+
faker.string.alphanumeric(),
123+
faker.string.sample(),
124+
);
125+
const value = fakeJson();
126+
const expireTime = faker.number.int({ min: 1, max: 3600 });
127+
const maxDeviation = offsetByPercentage(
128+
expireTime,
129+
defaultExpirationDeviatePercent,
130+
);
131+
await redisCacheService.hSet(cacheDir, value, expireTime);
97132

98133
const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
99-
expect(storedValue).toBeNull();
134+
const ttl = await redisClient.ttl(cacheDir.key);
135+
expect(storedValue).toEqual(value);
136+
expect(ttl).toBeGreaterThan(0);
137+
expect(ttl).toBeLessThanOrEqual(maxDeviation);
100138
});
101139

102140
it('Getting key gets the stored value', async () => {
@@ -148,19 +186,27 @@ describe('RedisCacheService', () => {
148186
});
149187

150188
it('creates a missing key and increments its value', async () => {
151-
const expireTime = faker.number.int({ min: 1 });
189+
const expireTime = faker.number.int({ min: 1, max: maxTtlDeviated });
190+
const maxExpireTime = offsetByPercentage(
191+
expireTime,
192+
defaultExpirationDeviatePercent,
193+
);
152194
const key = faker.string.alphanumeric();
153195

154196
const firstResult = await redisCacheService.increment(key, expireTime);
155197

156198
const ttl = await redisClient.ttl(key);
157199
expect(firstResult).toEqual(1);
158200
expect(ttl).toBeGreaterThan(0);
159-
expect(ttl).toBeLessThanOrEqual(expireTime);
201+
expect(ttl).toBeLessThanOrEqual(maxExpireTime);
160202
});
161203

162204
it('increments the value of an existing key', async () => {
163-
const expireTime = faker.number.int({ min: 1 });
205+
const expireTime = faker.number.int({ min: 1, max: maxTtlDeviated });
206+
const maxExpireTime = offsetByPercentage(
207+
expireTime,
208+
defaultExpirationDeviatePercent,
209+
);
164210
const key = faker.string.alphanumeric();
165211
const initialValue = faker.number.int({ min: 100 });
166212
await redisClient.set(key, initialValue, { EX: expireTime });
@@ -172,21 +218,21 @@ describe('RedisCacheService', () => {
172218

173219
const ttl = await redisClient.ttl(key);
174220
expect(ttl).toBeGreaterThan(0);
175-
expect(ttl).toBeLessThanOrEqual(expireTime);
221+
expect(ttl).toBeLessThanOrEqual(maxExpireTime);
176222
});
177223

178224
it('sets and gets the value of a counter key', async () => {
179225
const key = faker.string.alphanumeric();
180226
const value = faker.number.int({ min: 100 });
181-
await redisCacheService.setCounter(key, value, MAX_TTL);
227+
await redisCacheService.setCounter(key, value, maxTtlDeviated);
182228

183229
const result = await redisCacheService.getCounter(key);
184230
expect(result).toEqual(value);
185231
});
186232

187233
it('sets and gets the value of a zero-value counter', async () => {
188234
const key = faker.string.alphanumeric();
189-
await redisCacheService.setCounter(key, 0, MAX_TTL);
235+
await redisCacheService.setCounter(key, 0, maxTtlDeviated);
190236

191237
const result = await redisCacheService.getCounter(key);
192238
expect(result).toEqual(0);
@@ -225,7 +271,7 @@ describe('RedisCacheService', () => {
225271
const value = faker.string.sample();
226272

227273
try {
228-
await redisCacheService.hSet(new CacheDir(key, ''), value, MAX_TTL);
274+
await redisCacheService.hSet(new CacheDir(key, ''), value, MAX_TTL, 0);
229275
} catch (err) {
230276
console.error(err);
231277
throw new Error('Should not throw');
@@ -237,4 +283,44 @@ describe('RedisCacheService', () => {
237283
expect(ttl).toBeGreaterThan(0);
238284
expect(ttl).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
239285
});
286+
287+
it('Setting key with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
288+
const cacheDir = new CacheDir(
289+
faker.string.alphanumeric(),
290+
faker.string.sample(),
291+
);
292+
const value = fakeJson();
293+
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });
294+
295+
await redisCacheService.hSet(cacheDir, value, expireTime, 0);
296+
297+
const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
298+
const ttl = await redisClient.ttl(cacheDir.key);
299+
expect(storedValue).toEqual(value);
300+
expect(ttl).toBeGreaterThan(0);
301+
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
302+
});
303+
304+
it('Increment with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
305+
const key = faker.string.alphanumeric();
306+
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });
307+
308+
await redisCacheService.increment(key, expireTime, 0);
309+
310+
const ttl = await redisClient.ttl(key);
311+
expect(ttl).toBeGreaterThan(0);
312+
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
313+
});
314+
315+
it('SetCounter with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
316+
const key = faker.string.alphanumeric();
317+
const value = faker.number.int({ min: 1, max: 100 });
318+
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });
319+
320+
await redisCacheService.setCounter(key, value, expireTime, 0);
321+
322+
const ttl = await redisClient.ttl(key);
323+
expect(ttl).toBeGreaterThan(0);
324+
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
325+
});
240326
});

0 commit comments

Comments
 (0)