Performance & Caching
PBS Knowledge uses a multi-layer caching strategy to ensure fast response times while maintaining data freshness.
Caching Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Request Flow │
└─────────────────────────────────────────────────────────────────┘
Client Request
│
▼
┌─────────────┐
│ NGINX │ ──→ Static assets cached (1 year)
└─────────────┘
│
▼
┌─────────────┐ ┌─────────────┐
│ Backend │ ──→ │ Redis │ API response cache
│ Service │ │ Cache │ (24-48 hour TTL)
└─────────────┘ └─────────────┘
│
▼ (cache miss)
┌─────────────┐
│ TerminusDB │ Graph database query
└─────────────┘
Redis Caching Layer
Overview
Redis provides API response caching with a long TTL + explicit invalidation strategy:
- Default TTL: 24 hours
- Invalidation: Triggered on create/update/delete operations
- Fallback: Graceful degradation if Redis is unavailable
Cache Configuration
Located in backend/src/utils/redisCache.ts:
export const REDIS_CACHE_TTLS = {
SHORT: 5 * 60, // 5 minutes - rapidly changing data
MEDIUM: 60 * 60, // 1 hour
DEFAULT: 24 * 60 * 60, // 24 hours - most API responses
LONG: 48 * 60 * 60, // 48 hours - dropdown/reference data
WEEK: 7 * 24 * 60 * 60, // 7 days
};
Cached Endpoints
| Endpoint | Cache Key Pattern | TTL |
|---|---|---|
GET /api/people/:id | pbs:cache:person:{id} | 24 hours |
GET /api/people | pbs:cache:people:list:{type}:{limit}:{offset} | 24 hours |
GET /api/courses/:id | pbs:cache:course:{id} | 24 hours |
GET /api/courses | pbs:cache:courses:list:{filters}:{limit}:{offset} | 24 hours |
GET /api/people/:id/publications | pbs:cache:person:publications:{id} | 24 hours |
| Faculty dropdown data | pbs:cache:faculty:all | 48 hours |
Cache Invalidation
Caches are automatically invalidated when data changes:
// PersonService - automatically invalidates on mutations
async updatePerson(id, updates) {
const result = await this.db.updateDocument(...);
await invalidatePersonCache(id); // Clears person + list caches
return result;
}
Invalidation triggers:
- Person create/update/delete → Clears person cache + all list caches
- Course create/update/delete → Clears course cache + all list caches
- Publication sync completion → Clears all data caches
Admin Cache Management
Admins can manage caches via API:
# View cache statistics
GET /api/admin/cache/stats
# Response: { "keys": 42, "memory": "1.2M" }
# Clear all caches
POST /api/admin/cache/clear
# Response: { "message": "All caches cleared", "keysDeleted": 42 }
# Clear specific cache type
POST /api/admin/cache/clear/people
POST /api/admin/cache/clear/courses
POST /api/admin/cache/clear/all
GraphQL Query Optimization
Field Selection Strategy
Use minimal fields for list views and full fields for detail views:
// ❌ BAD: Fetching all fields for a list view
query ListPeople {
Faculty(limit: 50) {
_id
first_name
last_name
email
bio // Large text field - not needed in list
education { ... } // Nested array - expensive
research_interests
website
// ... 15+ fields
}
}
// ✅ GOOD: Minimal fields for list view
query ListPeople {
Faculty(limit: 50) {
_id
first_name
last_name
email
profile_image
rank
area
}
}
Impact: 60-80% reduction in response size for list endpoints.
Query Builders
The codebase uses query builder functions that provide optimized queries:
// backend/src/services/person/queryBuilders.ts
// For list views - minimal fields
buildListPeopleByTypeQueryMinimal(type, limit, offset)
// For detail views - full fields
buildGetPersonQuery(typeName)
Avoiding N+1 Queries
// ❌ BAD: N+1 query pattern
for (const publication of publications) {
const existing = await findExistingPublication(publication.doi);
// This queries ALL publications for each check!
}
// ✅ GOOD: Batch query with cached index
const existingMap = await getPublicationIndex();
for (const publication of publications) {
const existing = existingMap.get(publication.doi);
// O(1) lookup from cached index
}
Publication Index Cache
For sync operations, an in-memory publication index provides O(1) existence checks:
// Cached in memory with 5-minute TTL
const publicationIndex = await getPublicationIndex();
// Returns Map<doi|openalexId, { id, citations_count, last_updated }>
Connection Pooling
Singleton Pattern
The TerminusDB client uses a singleton pattern for connection reuse:
// backend/src/lib/terminusdb-graphql-client.ts
let defaultClientInstance: TerminusDBGraphQLClient | null = null;
export function getDefaultClient(): TerminusDBGraphQLClient {
if (!defaultClientInstance) {
defaultClientInstance = new TerminusDBGraphQLClient(getDefaultConfig());
}
return defaultClientInstance;
}
Benefits:
- Reuses HTTP connections (keep-alive)
- Reduces TCP handshake overhead
- Single auth header computation
Background Job Optimization
Publication Sync Strategy
Publication syncs run weekly and use these optimizations:
- Batch processing - Faculty processed in parallel batches
- Cached publication index - O(1) existence checks
- Rate limiting - Respects OpenAlex API limits
- Cache invalidation - Clears all caches after sync completes
// After sync completes
invalidatePublicationIndex(); // Clear in-memory index
await invalidateAllDataCaches(); // Clear Redis caches
Performance Monitoring
Cache Statistics
Monitor cache effectiveness:
# Check cache stats
curl -H "Authorization: Bearer $TOKEN" \
https://your-domain/api/admin/cache/stats
Response:
{
"keys": 156,
"memory": "2.4M"
}
Key Metrics to Watch
| Metric | Good | Warning | Action |
|---|---|---|---|
| Cache keys | < 1000 | > 5000 | Review TTLs |
| Redis memory | < 50MB | > 200MB | Monitor payloads |
| API response time | < 100ms | > 500ms | Check cache hits |
Best Practices
When Adding New Endpoints
- Consider caching - Most read endpoints benefit from caching
- Use minimal queries - Only fetch fields needed for the response
- Add invalidation - Clear cache on related mutations
Cache Key Design
// Use consistent key patterns
buildCacheKey(REDIS_CACHE_KEYS.PERSON, personId)
// → "pbs:cache:person:Faculty/john_doe"
buildCacheKey(REDIS_CACHE_KEYS.PEOPLE_LIST, type, limit, offset)
// → "pbs:cache:people:list:Faculty:50:0"
Error Handling
The cache layer gracefully handles failures:
export async function cacheGet<T>(key: string): Promise<T | null> {
if (!isRedisAvailable()) {
return null; // Graceful fallback - just skip cache
}
try {
const data = await redisConnection.get(key);
return data ? JSON.parse(data) : null;
} catch (error) {
log.error('Redis get error', { key, error });
return null; // Don't crash - just proceed without cache
}
}
Troubleshooting
Cache Not Working
- Check Redis connection:
docker compose logs redis - Verify Redis status in backend logs
- Check if TTL is appropriate for your use case
Stale Data After Updates
- Verify invalidation is called in the service method
- Check for multiple ID formats (with/without
terminusdb:///data/prefix) - Use admin endpoint to manually clear:
POST /api/admin/cache/clear
High Memory Usage
- Check cache stats:
GET /api/admin/cache/stats - Review large payload caching (publications with many authors)
- Consider shorter TTL for large collections