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