Skip to content

feat: deviate Redis expiration time #2608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 5, 2025
Merged
3 changes: 3 additions & 0 deletions src/config/configuration.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('Configuration validator', () => {
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
INFURA_API_KEY: faker.string.uuid(),
JWT_ISSUER: faker.string.uuid(),
Expand Down Expand Up @@ -118,6 +119,7 @@ describe('Configuration validator', () => {
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
INFURA_API_KEY: faker.string.uuid(),
JWT_ISSUER: faker.string.uuid(),
Expand Down Expand Up @@ -168,6 +170,7 @@ describe('Configuration validator', () => {
EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(),
EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(),
EXPIRATION_DEVIATE_PERCENT: faker.number.int({ min: 0, max: 100 }),
FINGERPRINT_ENCRYPTION_KEY: faker.string.uuid(),
INFURA_API_KEY: faker.string.uuid(),
JWT_ISSUER: faker.string.uuid(),
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export default (): ReturnType<typeof configuration> => ({
fromName: faker.person.fullName(),
},
expirationTimeInSeconds: {
deviatePercent: faker.number.int({ min: 10, max: 20 }),
default: faker.number.int(),
rpc: faker.number.int(),
hoodi: faker.number.int(),
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export default () => ({
fromName: process.env.EMAIL_API_FROM_NAME || 'Safe',
},
expirationTimeInSeconds: {
deviatePercent: parseInt(process.env.EXPIRATION_DEVIATE_PERCENT ?? `${10}`),
default: parseInt(process.env.EXPIRATION_TIME_DEFAULT_SECONDS ?? `${60}`),
rpc: parseInt(process.env.EXPIRATION_TIME_RPC_SECONDS ?? `${15}`),
hoodi: parseInt(process.env.HOODI_EXPIRATION_TIME_SECONDS ?? `${60}`),
Expand Down
5 changes: 5 additions & 0 deletions src/config/entities/schemas/configuration.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const RootConfigurationSchema = z
EMAIL_TEMPLATE_RECOVERY_TX: z.string(),
EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(),
EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(),
EXPIRATION_DEVIATE_PERCENT: z
.number({ coerce: true })
.min(0)
.max(100)
.optional(),
FINGERPRINT_ENCRYPTION_KEY: z.string(),
INFURA_API_KEY: z.string(),
JWT_ISSUER: z.string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,20 @@ const mockConfigurationService = jest.mocked(configurationService);
describe('RedisCacheService with a Key Prefix', () => {
let redisCacheService: RedisCacheService;
let defaultExpirationTimeInSeconds: number;
let defaultExpirationDeviatePercent: number;
const keyPrefix = faker.string.uuid();

beforeEach(() => {
clearAllMocks();
defaultExpirationTimeInSeconds = faker.number.int({ min: 1, max: 3600 });
defaultExpirationDeviatePercent = faker.number.int({ min: 1, max: 3600 });
mockConfigurationService.getOrThrow.mockImplementation((key) => {
if (key === 'expirationTimeInSeconds.default') {
return defaultExpirationTimeInSeconds;
}
if (key === 'expirationTimeInSeconds.deviatePercent') {
return defaultExpirationDeviatePercent;
}
throw Error(`Unexpected key: ${key}`);
});

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

await redisCacheService.hSet(cacheDir, value, expireTime);
await redisCacheService.hSet(cacheDir, value, expireTime, 0);

expect(redisClientTypeMock.hSet).toHaveBeenCalledWith(
`${keyPrefix}-${cacheDir.key}`,
Expand Down
114 changes: 100 additions & 14 deletions src/datasources/cache/redis.cache.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { IConfigurationService } from '@/config/configuration.service.inter
import clearAllMocks = jest.clearAllMocks;
import { redisClientFactory } from '@/__tests__/redis-client.factory';
import { MAX_TTL } from '@/datasources/cache/constants';
import { offsetByPercentage } from '@/domain/common/utils/number';

const mockLoggingService: jest.MockedObjectDeep<ILoggingService> = {
info: jest.fn(),
Expand All @@ -24,6 +25,8 @@ const mockConfigurationService = jest.mocked(configurationService);
describe('RedisCacheService', () => {
let redisCacheService: RedisCacheService;
let defaultExpirationTimeInSeconds: number;
let defaultExpirationDeviatePercent: number;
let maxTtlDeviated: number;
const keyPrefix = '';
let redisClient: RedisClientType;

Expand All @@ -39,10 +42,16 @@ describe('RedisCacheService', () => {
clearAllMocks();
await redisClient.flushDb();
defaultExpirationTimeInSeconds = faker.number.int({ min: 1, max: 3600 });
defaultExpirationDeviatePercent = faker.number.int({ min: 1, max: 99 });
maxTtlDeviated =
MAX_TTL - (MAX_TTL * defaultExpirationDeviatePercent) / 100;
mockConfigurationService.getOrThrow.mockImplementation((key) => {
if (key === 'expirationTimeInSeconds.default') {
return defaultExpirationTimeInSeconds;
}
if (key === 'expirationTimeInSeconds.deviatePercent') {
return defaultExpirationDeviatePercent;
}
throw Error(`Unexpected key: ${key}`);
});

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

await redisCacheService.hSet(cacheDir, value, expireTime);
await redisCacheService.hSet(cacheDir, value, expireTime, 0);

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

it('Setting key throws on expire', async () => {
it('Setting key with expireTimeSeconds and expireDeviatePercent does store the value with the deviated TTL', async () => {
const cacheDir = new CacheDir(
faker.string.alphanumeric(),
faker.string.sample(),
);
const value = fakeJson();
const expireTime = faker.number.int({ min: 1, max: 3600 });
const expireDeviatePercent = faker.number.int({ min: 1, max: 100 });
const maxDeviation = offsetByPercentage(expireTime, expireDeviatePercent);

await redisCacheService.hSet(
cacheDir,
value,
expireTime,
expireDeviatePercent,
);

// Expiration time out of range to force an error
await expect(
redisCacheService.hSet(cacheDir, '', Number.MAX_VALUE + 1),
).rejects.toThrow();
const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
const ttl = await redisClient.ttl(cacheDir.key);
expect(storedValue).toEqual(value);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(maxDeviation);
});

it('Setting key with expireTimeSeconds and no expireDeviatePercent does store the value with the default TTL deviation', async () => {
const cacheDir = new CacheDir(
faker.string.alphanumeric(),
faker.string.sample(),
);
const value = fakeJson();
const expireTime = faker.number.int({ min: 1, max: 3600 });
const maxDeviation = offsetByPercentage(
expireTime,
defaultExpirationDeviatePercent,
);
await redisCacheService.hSet(cacheDir, value, expireTime);

const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
expect(storedValue).toBeNull();
const ttl = await redisClient.ttl(cacheDir.key);
expect(storedValue).toEqual(value);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(maxDeviation);
});

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

it('creates a missing key and increments its value', async () => {
const expireTime = faker.number.int({ min: 1 });
const expireTime = faker.number.int({ min: 1, max: maxTtlDeviated });
const maxExpireTime = offsetByPercentage(
expireTime,
defaultExpirationDeviatePercent,
);
const key = faker.string.alphanumeric();

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

const ttl = await redisClient.ttl(key);
expect(firstResult).toEqual(1);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(expireTime);
expect(ttl).toBeLessThanOrEqual(maxExpireTime);
});

it('increments the value of an existing key', async () => {
const expireTime = faker.number.int({ min: 1 });
const expireTime = faker.number.int({ min: 1, max: maxTtlDeviated });
const maxExpireTime = offsetByPercentage(
expireTime,
defaultExpirationDeviatePercent,
);
const key = faker.string.alphanumeric();
const initialValue = faker.number.int({ min: 100 });
await redisClient.set(key, initialValue, { EX: expireTime });
Expand All @@ -172,21 +218,21 @@ describe('RedisCacheService', () => {

const ttl = await redisClient.ttl(key);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(expireTime);
expect(ttl).toBeLessThanOrEqual(maxExpireTime);
});

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

const result = await redisCacheService.getCounter(key);
expect(result).toEqual(value);
});

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

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

try {
await redisCacheService.hSet(new CacheDir(key, ''), value, MAX_TTL);
await redisCacheService.hSet(new CacheDir(key, ''), value, MAX_TTL, 0);
} catch (err) {
console.error(err);
throw new Error('Should not throw');
Expand All @@ -237,4 +283,44 @@ describe('RedisCacheService', () => {
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
});

it('Setting key with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
const cacheDir = new CacheDir(
faker.string.alphanumeric(),
faker.string.sample(),
);
const value = fakeJson();
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });

await redisCacheService.hSet(cacheDir, value, expireTime, 0);

const storedValue = await redisClient.hGet(cacheDir.key, cacheDir.field);
const ttl = await redisClient.ttl(cacheDir.key);
expect(storedValue).toEqual(value);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
});

it('Increment with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
const key = faker.string.alphanumeric();
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });

await redisCacheService.increment(key, expireTime, 0);

const ttl = await redisClient.ttl(key);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
});

it('SetCounter with TTL larger than MAX_TTL enforces MAX_TTL limit', async () => {
const key = faker.string.alphanumeric();
const value = faker.number.int({ min: 1, max: 100 });
const expireTime = MAX_TTL + faker.number.int({ min: 1000, max: 10000 });

await redisCacheService.setCounter(key, value, expireTime, 0);

const ttl = await redisClient.ttl(key);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
});
});
Loading