From eb58536cbb2d48681709a4f3c029e10dc4f3a41d Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Tue, 23 Jun 2026 15:51:20 +0100 Subject: [PATCH] perf: implement Redis caching layer for gist queries --- Backend/.env.example | 13 +++ Backend/package-lock.json | 74 +++++++++++++ Backend/package.json | 1 + Backend/src/app.module.ts | 2 + Backend/src/cache/cache.module.ts | 10 ++ Backend/src/cache/cache.service.spec.ts | 121 +++++++++++++++++++++ Backend/src/cache/cache.service.ts | 136 ++++++++++++++++++++++++ Backend/src/gists/gists.module.ts | 3 +- Backend/src/gists/gists.service.ts | 62 ++++++++++- Backend/test/gists.e2e.spec.ts | 54 ++++++++++ 10 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 Backend/src/cache/cache.module.ts create mode 100644 Backend/src/cache/cache.service.spec.ts create mode 100644 Backend/src/cache/cache.service.ts diff --git a/Backend/.env.example b/Backend/.env.example index 231b1b0..2bac6e0 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -117,3 +117,16 @@ THROTTLE_TTL_MS=60000 # Maximum number of requests allowed per TTL window # Default: 10 requests per minute THROTTLE_LIMIT=10 + +# ============================================ +# Redis Cache Configuration +# ============================================ + +# REDIS_URL (optional) +# Redis connection URL for caching layer +# Format: redis://[password@]host:port/db +# Example: redis://localhost:6379/0 +# Example with password: redis://:password@localhost:6379/0 +# Example with ElastiCache: redis://my-cluster.xxxxxx.use1.cache.amazonaws.com:6379/0 +# Leave empty to disable caching (degrades gracefully) +REDIS_URL= diff --git a/Backend/package-lock.json b/Backend/package-lock.json index bc96de7..ff22d26 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -22,6 +22,7 @@ "cookie-parser": "^1.4.7", "csrf": "^3.1.0", "ethers": "^6.15.0", + "ioredis": "^5.11.1", "pg": "^8.13.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -1342,6 +1343,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5605,6 +5612,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6007,6 +6023,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7766,6 +7791,28 @@ "kind-of": "^6.0.2" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10243,6 +10290,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -10900,6 +10968,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/Backend/package.json b/Backend/package.json index 78f20b8..581526b 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -39,6 +39,7 @@ "cookie-parser": "^1.4.7", "csrf": "^3.1.0", "ethers": "^6.15.0", + "ioredis": "^5.11.1", "pg": "^8.13.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/Backend/src/app.module.ts b/Backend/src/app.module.ts index dbd63c6..d63c26e 100644 --- a/Backend/src/app.module.ts +++ b/Backend/src/app.module.ts @@ -8,6 +8,7 @@ import { IpfsModule } from './ipfs/ipfs.module'; import { SorobanModule } from './soroban/soroban.module'; import { GistsModule } from './gists/gists.module'; import { HealthModule } from './health/health.module'; +import { CacheModule } from './cache/cache.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -30,6 +31,7 @@ import { AppService } from './app.service'; SorobanModule, GistsModule, HealthModule, + CacheModule, ], controllers: [AppController], providers: [ diff --git a/Backend/src/cache/cache.module.ts b/Backend/src/cache/cache.module.ts new file mode 100644 index 0000000..9b6679d --- /dev/null +++ b/Backend/src/cache/cache.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ConfigModule], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/Backend/src/cache/cache.service.spec.ts b/Backend/src/cache/cache.service.spec.ts new file mode 100644 index 0000000..2401729 --- /dev/null +++ b/Backend/src/cache/cache.service.spec.ts @@ -0,0 +1,121 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from './cache.service'; + +describe('CacheService', () => { + let service: CacheService; + let mockConfigService: jest.Mocked; + + beforeEach(async () => { + mockConfigService = { + get: jest.fn(), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(CacheService); + }); + + afterEach(async () => { + if (service) { + await service.onModuleDestroy(); + } + }); + + describe('onModuleInit', () => { + it('should not initialize Redis when REDIS_URL is not configured', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + // Service should gracefully degrade without Redis + }); + + it('should handle Redis connection errors gracefully when REDIS_URL is set', async () => { + mockConfigService.get.mockReturnValue('redis://localhost:6379/0'); + // Since we don't have actual Redis, this will fail but should not throw + await service.onModuleInit(); + // Service should gracefully degrade + }); + }); + + describe('get', () => { + it('should return null when Redis is unavailable', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + const result = await service.get('test-key'); + expect(result).toBeNull(); + }); + + it('should track cache misses when Redis is unavailable', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + await service.get('test-key'); + const metrics = service.getMetrics(); + + expect(metrics.hits).toBe(0); + expect(metrics.misses).toBe(1); + }); + }); + + describe('set', () => { + it('should not attempt set when Redis is unavailable', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + await service.set('test-key', { data: 'test' }, 60); + // Should not throw + }); + }); + + describe('del', () => { + it('should not attempt delete when Redis is unavailable', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + await service.del('test-key'); + // Should not throw + }); + }); + + describe('delPattern', () => { + it('should not attempt pattern delete when Redis is unavailable', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + await service.delPattern('gist:nearby:*'); + // Should not throw + }); + }); + + describe('getMetrics', () => { + it('should return 0 hit rate when no requests', () => { + const metrics = service.getMetrics(); + expect(metrics.hitRate).toBe(0); + expect(metrics.hits).toBe(0); + expect(metrics.misses).toBe(0); + }); + }); + + describe('resetMetrics', () => { + it('should reset hit and miss counters', async () => { + mockConfigService.get.mockReturnValue(undefined); + await service.onModuleInit(); + + await service.get('test-key'); + service.resetMetrics(); + const metrics = service.getMetrics(); + + expect(metrics.hits).toBe(0); + expect(metrics.misses).toBe(0); + }); + }); +}); diff --git a/Backend/src/cache/cache.service.ts b/Backend/src/cache/cache.service.ts new file mode 100644 index 0000000..e4a7782 --- /dev/null +++ b/Backend/src/cache/cache.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class CacheService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CacheService.name); + private redis: Redis | null = null; + private cacheHits = 0; + private cacheMisses = 0; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + try { + const redisUrl = this.configService.get('REDIS_URL'); + if (!redisUrl) { + this.logger.warn('REDIS_URL not configured, caching disabled'); + return; + } + + this.redis = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + retryStrategy: (times) => { + if (times > 3) { + this.logger.error('Redis connection failed after retries, disabling cache'); + this.redis = null; + return null; + } + return Math.min(times * 100, 3000); + }, + }); + + this.redis.on('error', (err) => { + this.logger.error(`Redis error: ${err.message}`); + this.redis = null; + }); + + this.redis.on('connect', () => { + this.logger.log('Redis connected successfully'); + }); + + await this.redis.ping(); + } catch (error) { + this.logger.error(`Failed to initialize Redis: ${error.message}`); + this.redis = null; + } + } + + async onModuleDestroy() { + if (this.redis) { + await this.redis.quit(); + } + } + + private isAvailable(): boolean { + return this.redis !== null; + } + + async get(key: string): Promise { + if (!this.isAvailable()) { + this.cacheMisses++; + return null; + } + + try { + const value = await this.redis.get(key); + if (value === null) { + this.cacheMisses++; + return null; + } + + this.cacheHits++; + return JSON.parse(value) as T; + } catch (error) { + this.logger.error(`Cache get error for key ${key}: ${error.message}`); + this.cacheMisses++; + return null; + } + } + + async set(key: string, value: unknown, ttlSeconds: number): Promise { + if (!this.isAvailable()) { + return; + } + + try { + const serialized = JSON.stringify(value); + await this.redis.setex(key, ttlSeconds, serialized); + } catch (error) { + this.logger.error(`Cache set error for key ${key}: ${error.message}`); + } + } + + async del(key: string): Promise { + if (!this.isAvailable()) { + return; + } + + try { + await this.redis.del(key); + } catch (error) { + this.logger.error(`Cache delete error for key ${key}: ${error.message}`); + } + } + + async delPattern(pattern: string): Promise { + if (!this.isAvailable()) { + return; + } + + try { + const keys = await this.redis.keys(pattern); + if (keys.length > 0) { + await this.redis.del(...keys); + } + } catch (error) { + this.logger.error(`Cache delete pattern error for ${pattern}: ${error.message}`); + } + } + + getMetrics(): { hits: number; misses: number; hitRate: number } { + const total = this.cacheHits + this.cacheMisses; + const hitRate = total > 0 ? (this.cacheHits / total) * 100 : 0; + return { + hits: this.cacheHits, + misses: this.cacheMisses, + hitRate: Math.round(hitRate * 100) / 100, + }; + } + + resetMetrics(): void { + this.cacheHits = 0; + this.cacheMisses = 0; + } +} diff --git a/Backend/src/gists/gists.module.ts b/Backend/src/gists/gists.module.ts index 4133675..78f379f 100644 --- a/Backend/src/gists/gists.module.ts +++ b/Backend/src/gists/gists.module.ts @@ -7,9 +7,10 @@ import { GistsController } from './gists.controller'; import { GeoModule } from '../geo/geo.module'; import { IpfsModule } from '../ipfs/ipfs.module'; import { SorobanModule } from '../soroban/soroban.module'; +import { CacheModule } from '../cache/cache.module'; @Module({ - imports: [TypeOrmModule.forFeature([Gist]), GeoModule, IpfsModule, SorobanModule], + imports: [TypeOrmModule.forFeature([Gist]), GeoModule, IpfsModule, SorobanModule, CacheModule], controllers: [GistsController], providers: [GistRepository, GistsService], exports: [GistsService], diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index d27a406..89534ba 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -5,6 +5,7 @@ import { GistRepository } from './gist.repository'; import { GeoService } from '../geo/geo.service'; import { IpfsService } from '../ipfs/ipfs.service'; import { SorobanService } from '../soroban/soroban.service'; +import { CacheService } from '../cache/cache.service'; import { Gist } from './entities/gist.entity'; import { PaginatedResponse } from '../common/utils/pagination.helper'; import { stripHtml } from '../common/utils/sanitize'; @@ -18,6 +19,7 @@ export class GistsService { private readonly geoService: GeoService, private readonly ipfsService: IpfsService, private readonly sorobanService: SorobanService, + private readonly cacheService: CacheService, ) {} async create(dto: CreateGistDto): Promise { @@ -38,7 +40,7 @@ export class GistsService { this.logger.log(`Gist posted → cell=${locationCell} cid=${cid} gistId=${gistId}`); - return this.gistRepository.create({ + const gist = await this.gistRepository.create({ content, lat: dto.lat, lon: dto.lon, @@ -47,19 +49,73 @@ export class GistsService { stellar_gist_id: gistId, tx_hash: txHash, }); + + // Invalidate nearby cache for the affected area + await this.invalidateNearbyCache(dto.lat, dto.lon); + + return gist; } async findNearby(query: QueryGistsDto): Promise> { - return this.gistRepository.findNearby({ + // Don't cache paginated results (when cursor is present) + if (query.cursor) { + return this.gistRepository.findNearby({ + lat: query.lat, + lon: query.lon, + radiusMeters: query.radius, + limit: query.limit, + cursor: query.cursor, + }); + } + + const cacheKey = `gist:nearby:${query.lat}:${query.lon}:${query.radius || 500}:${query.limit || 20}`; + const cached = await this.cacheService.get>(cacheKey); + + if (cached) { + this.logger.debug(`Cache hit for nearby query: ${cacheKey}`); + return cached; + } + + this.logger.debug(`Cache miss for nearby query: ${cacheKey}`); + const result = await this.gistRepository.findNearby({ lat: query.lat, lon: query.lon, radiusMeters: query.radius, limit: query.limit, cursor: query.cursor, }); + + // Cache for 60 seconds + await this.cacheService.set(cacheKey, result, 60); + + return result; } async findOne(id: string): Promise { - return this.gistRepository.findByGistId(id); + const cacheKey = `gist:one:${id}`; + const cached = await this.cacheService.get(cacheKey); + + if (cached) { + this.logger.debug(`Cache hit for gist: ${cacheKey}`); + return cached; + } + + this.logger.debug(`Cache miss for gist: ${cacheKey}`); + const result = await this.gistRepository.findByGistId(id); + + if (result) { + // Cache for 300 seconds (5 minutes) + await this.cacheService.set(cacheKey, result, 300); + } + + return result; + } + + private async invalidateNearbyCache(lat: number, lon: number): Promise { + // Invalidate all nearby cache keys for this area + // We use a pattern to match all nearby queries + const pattern = `gist:nearby:${lat.toFixed(4)}:${lon.toFixed(4)}:*`; + await this.cacheService.delPattern(pattern); + this.logger.debug(`Invalidated nearby cache pattern: ${pattern}`); } } diff --git a/Backend/test/gists.e2e.spec.ts b/Backend/test/gists.e2e.spec.ts index a4d1927..a5a96cf 100644 --- a/Backend/test/gists.e2e.spec.ts +++ b/Backend/test/gists.e2e.spec.ts @@ -170,4 +170,58 @@ describe('Gists (e2e)', () => { expect(res.body.services.postgis.status).toBe('ok'); }); }); + + describe('Cache behavior (graceful degradation)', () => { + it('should handle gist queries when Redis is unavailable', async () => { + // This test verifies that the application works even without Redis configured + // First query should work (cache miss, hits DB) + const res1 = await request(app.getHttpServer()) + .get('/gists') + .query({ lat: 9.0579, lon: 7.4951, radius: 1000 }) + .expect(200); + + expect(res1.body).toMatchObject({ + data: expect.any(Array), + pagination: { + count: expect.any(Number), + hasMore: expect.any(Boolean), + }, + }); + + // Second identical query should also work (graceful degradation) + const res2 = await request(app.getHttpServer()) + .get('/gists') + .query({ lat: 9.0579, lon: 7.4951, radius: 1000 }) + .expect(200); + + expect(res2.body).toMatchObject({ + data: expect.any(Array), + pagination: { + count: expect.any(Number), + hasMore: expect.any(Boolean), + }, + }); + }); + + it('should handle findOne queries when Redis is unavailable', async () => { + // Create a gist first + const csrf = await getCsrfToken(app.getHttpServer()); + const createRes = await request(app.getHttpServer()) + .post('/gists') + .set('Cookie', csrf.cookie) + .set('x-csrf-token', csrf.token) + .send({ content: 'cache test gist', lat: 9.0579, lon: 7.4951 }) + .expect(201); + + const gistId = createRes.body.id; + + // Query by ID should work without Redis + const res = await request(app.getHttpServer()).get(`/gists/${gistId}`).expect(200); + + expect(res.body).toMatchObject({ + id: gistId, + content: 'cache test gist', + }); + }); + }); });