Skip to main content

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​

EndpointCache Key PatternTTL
GET /api/people/:idpbs:cache:person:{id}24 hours
GET /api/peoplepbs:cache:people:list:{type}:{limit}:{offset}24 hours
GET /api/courses/:idpbs:cache:course:{id}24 hours
GET /api/coursespbs:cache:courses:list:{filters}:{limit}:{offset}24 hours
GET /api/people/:id/publicationspbs:cache:person:publications:{id}24 hours
Faculty dropdown datapbs:cache:faculty:all48 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:

  1. Batch processing - Faculty processed in parallel batches
  2. Cached publication index - O(1) existence checks
  3. Rate limiting - Respects OpenAlex API limits
  4. 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​

MetricGoodWarningAction
Cache keys< 1000> 5000Review TTLs
Redis memory< 50MB> 200MBMonitor payloads
API response time< 100ms> 500msCheck cache hits

Best Practices​

When Adding New Endpoints​

  1. Consider caching - Most read endpoints benefit from caching
  2. Use minimal queries - Only fetch fields needed for the response
  3. 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​

  1. Check Redis connection: docker compose logs redis
  2. Verify Redis status in backend logs
  3. Check if TTL is appropriate for your use case

Stale Data After Updates​

  1. Verify invalidation is called in the service method
  2. Check for multiple ID formats (with/without terminusdb:///data/ prefix)
  3. Use admin endpoint to manually clear: POST /api/admin/cache/clear

High Memory Usage​

  1. Check cache stats: GET /api/admin/cache/stats
  2. Review large payload caching (publications with many authors)
  3. Consider shorter TTL for large collections