This document describes the caching layer implementation for the StellarEarn backend API. The caching strategy uses NestJS Cache Manager with support for both in-memory and Redis-backed caching.
- CacheModule - Global NestJS module providing caching services
- CacheService - Wrapper service with cache management utilities
- Cache Decorators -
@Cacheableand@CacheInvalidatedecorators for method-level caching - Cache Interceptor - Global interceptor for automatic GET request caching
- Cache Controller - Admin endpoints for cache management and statistics
# Cache type: 'memory' or 'redis'
CACHE_TYPE=memory
# Cache TTL in seconds (default: 300 = 5 minutes)
CACHE_TTL=300
# Redis Configuration (if CACHE_TYPE=redis)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_password
REDIS_DB=0CACHE_TTL = {
DEFAULT: 300, // 5 minutes
SHORT: 60, // 1 minute
MEDIUM: 600, // 10 minutes
LONG: 3600, // 1 hour
VERY_LONG: 86400, // 24 hours
};Predefined cache key prefixes for organization:
CACHE_KEYS = {
QUESTS: 'quests',
QUEST_DETAIL: 'quest_detail',
SUBMISSIONS: 'submissions',
SUBMISSION_DETAIL: 'submission_detail',
PAYOUTS: 'payouts',
PAYOUT_DETAIL: 'payout_detail',
USERS: 'users',
USER_DETAIL: 'user_detail',
ANALYTICS: 'analytics',
NOTIFICATIONS: 'notifications',
};Inject CacheService into your service:
import { Injectable } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { CACHE_KEYS, CACHE_TTL } from '../../config/cache.config';
@Injectable()
export class QuestsService {
constructor(private cacheService: CacheService) {}
async findOne(id: string) {
const cacheKey = `${CACHE_KEYS.QUEST_DETAIL}:${id}`;
// Try cache first
const cached = await this.cacheService.get<Quest>(cacheKey);
if (cached) return cached;
// Fetch from database
const quest = await this.questRepository.findOne({ where: { id } });
// Cache the result
await this.cacheService.set(cacheKey, quest, CACHE_TTL.LONG * 1000);
return quest;
}
}Simplify cache-aside pattern:
async findOne(id: string) {
const cacheKey = `${CACHE_KEYS.QUEST_DETAIL}:${id}`;
return this.cacheService.getOrSet(
cacheKey,
() => this.questRepository.findOne({ where: { id } }),
CACHE_TTL.LONG * 1000,
);
}After create/update/delete operations, invalidate related cache:
async update(id: string, updateDto: UpdateQuestDto) {
const updated = await this.questRepository.save({ id, ...updateDto });
// Invalidate specific key
await this.cacheService.delete(`${CACHE_KEYS.QUEST_DETAIL}:${id}`);
// Invalidate all keys matching pattern
await this.cacheService.deletePattern(CACHE_KEYS.QUESTS);
return updated;
}@Cacheable({ ttl: 300, key: 'quest_detail_{{id}}' })
async findOne(@Param('id') id: string) {
return this.questRepository.findOne({ where: { id } });
}
@CacheInvalidate(['quests_', 'quest_detail_'])
async updateQuest(id: string, dto: UpdateQuestDto) {
return this.questRepository.save({ id, ...dto });
}All endpoints require JWT authentication.
GET /cache/stats
GET /cache/stats?key=quest_detail:123Response:
{
"hits": 150,
"misses": 50,
"hitRate": 75
}DELETE /cache/clearDELETE /cache/clear-pattern?pattern=quests_DELETE /cache/reset-stats- Expensive Queries: List with filters, pagination
- Cache Keys:
quests:{queryParams}- Paginated listsquest_detail:{id}- Individual quest details
- TTL: MEDIUM (10 min) for lists, LONG (1 hour) for details
- Invalidation: On create, update, delete
Implementation Details:
// findAll - cache with query parameters
const cacheKey = `${CACHE_KEYS.QUESTS}:${JSON.stringify(queryDto)}`;
// findOne - cache individual quests
const cacheKey = `${CACHE_KEYS.QUEST_DETAIL}:${id}`;
// create/update/delete - invalidate related keys
await this.cacheService.deletePattern(CACHE_KEYS.QUESTS);
await this.cacheService.delete(`${CACHE_KEYS.QUEST_DETAIL}:${id}`);- Expensive Queries: List submissions, detailed verification checks
- Cache Keys:
submissions:{userId}- User submissionssubmission_detail:{id}- Individual submission
- TTL: SHORT (1 min) for lists (frequent updates), LONG for details
- Invalidation: On status change, verification
- Expensive Queries: Payout calculations, history
- Cache Keys:
payouts:{userId}- User payoutspayout_detail:{id}- Individual payoutpayout_poll:{id}:{viewerScope}- Payout status polling snapshots (#1983, seedocs/JOB_RESULT_STATUS_CACHE.md)job_status:{jobId}- Background job status/result snapshots
- TTL: LONG (1 hour) - infrequent changes
- Invalidation: On approval/rejection
- Expensive Queries: Aggregations, statistics
- Cache Keys:
analytics:daily- Daily statsanalytics:weekly- Weekly statsanalytics:user:{userId}- User analytics
- TTL: VERY_LONG (24 hours) - stable data
- Invalidation: On schedule (daily refresh)
✅ Good candidates:
- Read-heavy endpoints
- Database aggregations
- Frequently accessed data
- Expensive computations
- Stable data (not rapidly changing)
❌ Bad candidates:
- Real-time data
- User-specific sensitive data
- Frequently mutated data
- Small/fast database queries
- Target: 70-80% cache hit rate
- Monitor: Use
/cache/statsendpoint - Optimize: Adjust TTL based on hit rate
// In cache.service.ts
private recordAccess(key: string, isHit: boolean): void {
if (process.env.NODE_ENV === 'development') {
console.log(`Cache ${isHit ? 'HIT' : 'MISS'}: ${key}`);
}
// ... record stats
}# Get all cache stats
curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/cache/stats
# Get stats for specific key
curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/cache/stats?key=quest_detail:123Compare response times with/without cache:
# Without cache (first request)
time curl http://localhost:3001/quests
# With cache (second request)
time curl http://localhost:3001/questsnpm run test test/cache/cache.service.spec.ts
npm run test test/cache/cache.decorators.spec.tsnpm run test test/cache/quests-cache.integration.spec.ts# Cache should work transparently with E2E tests
npm run test:e2e test/quests/quests.e2e-spec.ts- Install CacheModule in your module:
@Module({
imports: [CacheModule],
// ...
})
export class MyModule {}- Inject CacheService:
constructor(private cacheService: CacheService) {}- Wrap expensive queries:
async getData() {
return this.cacheService.getOrSet(
'data_key',
() => this.expensiveQuery(),
CACHE_TTL.MEDIUM * 1000,
);
}- Add cache invalidation:
async updateData(id: string, dto: UpdateDto) {
const result = await this.repository.save({ id, ...dto });
await this.cacheService.delete('data_key');
return result;
}- Add tests:
describe('Service with Cache', () => {
it('should cache results', async () => {
const result1 = await service.getData();
const result2 = await service.getData();
expect(repository.query).toHaveBeenCalledTimes(1); // Only once
});
});Check:
- CacheModule imported in module
- CacheService injected correctly
- Cache key format consistency
- TTL values (milliseconds vs seconds)
Solutions:
- Reduce TTL values
- Switch to Redis cache
- Implement cache eviction policy
- Monitor cache size with
/cache/stats
Check:
- Redis server running:
redis-cli ping - Connection credentials in
.env - Network connectivity
- Redis logs:
redis-cli MONITOR
Solutions:
- Review invalidation logic
- Reduce TTL for volatile data
- Add cache versioning for deployments
- Manual cache clear:
DELETE /cache/clear
-
Use Meaningful Cache Keys: Include entity type and ID
// ✅ Good const key = `${CACHE_KEYS.QUEST_DETAIL}:${id}`; // ❌ Poor const key = `data_${id}`;
-
Set Appropriate TTLs: Match data volatility
// Stable data: longer TTL CACHE_TTL.VERY_LONG // 24 hours // Volatile data: shorter TTL CACHE_TTL.SHORT // 1 minute
-
Invalidate Strategically: Invalidate only affected keys
// ✅ Specific await this.cacheService.delete(`${CACHE_KEYS.QUEST_DETAIL}:${id}`); // ❌ Overkill await this.cacheService.clear(); // Clears everything
-
Handle Cache Failures Gracefully: Fall back to database
try { return await this.cacheService.get(key); } catch (error) { console.warn('Cache error, falling back to DB', error); return await this.database.query(); }
-
Monitor Cache Performance: Check stats regularly
curl /cache/stats | jq '.[] | select(.hitRate < 50)'
- Cache Warming: Pre-load frequently used data on startup
- Cache Tagging: Tag related cache entries for grouped invalidation
- Cache Compression: Compress large objects before caching
- Distributed Caching: Use Redis with multiple instances
- Cache Analytics: Detailed hit/miss analysis by endpoint
- Automatic Invalidation: Event-driven cache invalidation