CachingService.tsβ’21.2 kB
/**
* CachingService - Centralized caching utilities for universal handlers
*
* Extracted from shared-handlers.ts as part of Issue #489 Phase 2.
* Provides task caching, 404 response caching, and attribute discovery caching
* functionality with configurable TTLs and automatic cleanup.
*/
import { AttioRecord } from '../types/attio.js';
import { enhancedPerformanceTracker } from '../middleware/performance-enhanced.js';
import { generateIdCacheKey } from '../utils/validation/id-validation.js';
import { UniversalResourceType } from '../handlers/tool-configs/universal/types.js';
import {
DEFAULT_TASKS_CACHE_TTL,
DEFAULT_404_CACHE_TTL,
DEFAULT_ATTRIBUTES_CACHE_TTL,
MAX_CACHE_ENTRIES,
} from '../constants/universal.constants.js';
/**
* Cache entry structure for storing cached data with timestamps
*/
interface CacheEntry {
data: AttioRecord[];
timestamp: number;
}
/**
* Attribute cache entry structure for storing attribute discovery results
*/
interface AttributeCacheEntry {
data: Record<string, unknown>;
timestamp: number;
resourceType: UniversalResourceType;
objectSlug?: string;
}
/**
* 404 cache entry structure for storing failed lookup results
*/
interface NotFoundCacheEntry {
timestamp: number;
resourceType: string;
recordId: string;
}
/**
* Cache statistics for monitoring and debugging
*/
interface CacheStats {
tasks: {
hits: number;
misses: number;
entries: number;
};
notes: {
hits: number;
misses: number;
entries: number;
};
attributes: {
hits: number;
misses: number;
entries: number;
};
notFound: {
hits: number;
misses: number;
entries: number;
};
}
/**
* CachingService provides centralized caching functionality for universal handlers
*/
export class CachingService {
// Simple in-memory cache for tasks pagination performance optimization
private static tasksCache = new Map<string, CacheEntry>();
// Simple in-memory cache for notes pagination performance optimization
private static notesCache = new Map<string, CacheEntry>();
// Attribute discovery cache for performance optimization
private static attributesCache = new Map<string, AttributeCacheEntry>();
// 404 response cache to prevent repeated failed lookups
private static notFoundCache = new Map<string, NotFoundCacheEntry>();
// Cache statistics for monitoring
private static stats: CacheStats = {
tasks: { hits: 0, misses: 0, entries: 0 },
notes: { hits: 0, misses: 0, entries: 0 },
attributes: { hits: 0, misses: 0, entries: 0 },
notFound: { hits: 0, misses: 0, entries: 0 },
};
/**
* Get cached tasks with automatic TTL management
*
* @param cacheKey - Cache key for the tasks
* @param ttl - Time to live in milliseconds (default: 30 seconds)
* @returns Cached tasks if available and valid, undefined otherwise
*/
static getCachedTasks(
cacheKey: string,
ttl: number = DEFAULT_TASKS_CACHE_TTL
): AttioRecord[] | undefined {
const now = Date.now();
if (this.tasksCache.has(cacheKey)) {
const cached = this.tasksCache.get(cacheKey)!;
if (now - cached.timestamp < ttl) {
this.stats.tasks.hits++;
return cached.data;
}
// Remove expired cache entry
this.tasksCache.delete(cacheKey);
this.stats.tasks.entries--;
}
return undefined;
}
/**
* Cache tasks data with timestamp
*
* @param cacheKey - Cache key for the tasks
* @param data - Tasks data to cache
*/
static setCachedTasks(cacheKey: string, data: AttioRecord[]): void {
const now = Date.now();
this.tasksCache.set(cacheKey, { data, timestamp: now });
this.stats.tasks.entries++;
this.performCacheCleanup();
}
/**
* Generate cache key for tasks list
*
* @returns Standard cache key for tasks list
*/
static getTasksListCacheKey(): string {
return 'tasks_list_all';
}
/**
* Check if a 404 response is cached for a given resource
*
* @param resourceType - Type of resource (e.g., 'companies', 'people')
* @param recordId - ID of the record
* @returns True if 404 is cached, false otherwise
*/
static isCached404(resourceType: string, recordId: string): boolean {
const cacheKey = generateIdCacheKey(resourceType, recordId);
const cached404 = enhancedPerformanceTracker.getCached404(cacheKey);
return !!cached404;
}
/**
* Cache a 404 response for a resource
*
* @param resourceType - Type of resource (e.g., 'companies', 'people')
* @param recordId - ID of the record
* @param ttl - Time to live in milliseconds (default: 60 seconds)
*/
static cache404Response(
resourceType: string,
recordId: string,
ttl: number = DEFAULT_404_CACHE_TTL
): void {
const cacheKey = generateIdCacheKey(resourceType, recordId);
const now = Date.now();
this.notFoundCache.set(cacheKey, {
timestamp: now,
resourceType,
recordId,
});
this.stats.notFound.entries++;
enhancedPerformanceTracker.cache404Response(
cacheKey,
{ error: 'Not found' },
ttl
);
this.performCacheCleanup();
}
// ========================================
// Attribute Discovery Caching
// ========================================
/**
* Generate cache key for attribute discovery
*
* @param resourceType - The resource type
* @param objectSlug - Optional object slug for records
* @returns Cache key string
*/
private static getAttributeCacheKey(
resourceType: UniversalResourceType,
objectSlug?: string
): string {
return objectSlug
? `attributes:${resourceType}:${objectSlug}`
: `attributes:${resourceType}`;
}
/**
* Get cached attribute discovery results
*
* @param resourceType - The resource type
* @param objectSlug - Optional object slug for records
* @param ttl - Time to live in milliseconds
* @returns Cached attributes if available and valid, undefined otherwise
*/
static getCachedAttributes(
resourceType: UniversalResourceType,
objectSlug?: string,
ttl: number = DEFAULT_ATTRIBUTES_CACHE_TTL
): Record<string, unknown> | undefined {
const cacheKey = this.getAttributeCacheKey(resourceType, objectSlug);
const now = Date.now();
if (this.attributesCache.has(cacheKey)) {
const cached = this.attributesCache.get(cacheKey)!;
if (now - cached.timestamp < ttl) {
this.stats.attributes.hits++;
return cached.data;
}
// Remove expired cache entry
this.attributesCache.delete(cacheKey);
this.stats.attributes.entries--;
}
this.stats.attributes.misses++;
return undefined;
}
/**
* Cache attribute discovery results
*
* @param resourceType - The resource type
* @param data - Attribute discovery results to cache
* @param objectSlug - Optional object slug for records
*/
static setCachedAttributes(
resourceType: UniversalResourceType,
data: Record<string, unknown>,
objectSlug?: string
): void {
const cacheKey = this.getAttributeCacheKey(resourceType, objectSlug);
const now = Date.now();
this.attributesCache.set(cacheKey, {
data,
timestamp: now,
resourceType,
objectSlug,
});
this.stats.attributes.entries++;
this.performCacheCleanup();
}
/**
* Invalidate attribute cache for a specific resource type
*
* @param resourceType - The resource type to invalidate
* @param objectSlug - Optional object slug to invalidate specific records cache
*/
static invalidateAttributeCache(
resourceType: UniversalResourceType,
objectSlug?: string
): void {
if (objectSlug) {
const cacheKey = this.getAttributeCacheKey(resourceType, objectSlug);
if (this.attributesCache.delete(cacheKey)) {
this.stats.attributes.entries--;
}
} else {
// Invalidate all cache entries for this resource type
const keysToDelete: string[] = [];
for (const [key, entry] of Array.from(this.attributesCache.entries())) {
if (entry.resourceType === resourceType) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.attributesCache.delete(key);
this.stats.attributes.entries--;
}
}
}
/**
* Manage attribute caching with automatic data loading
*
* @param dataLoader - Function to load attribute data when cache miss occurs
* @param resourceType - The resource type
* @param objectSlug - Optional object slug for records
* @param ttl - Time to live in milliseconds
* @returns Cached or freshly loaded attribute data
*/
static async getOrLoadAttributes(
dataLoader: () => Promise<Record<string, unknown>>,
resourceType: UniversalResourceType,
objectSlug?: string,
ttl: number = DEFAULT_ATTRIBUTES_CACHE_TTL
): Promise<{ data: Record<string, unknown>; fromCache: boolean }> {
// Check cache first
const cachedAttributes = this.getCachedAttributes(
resourceType,
objectSlug,
ttl
);
if (cachedAttributes) {
return { data: cachedAttributes, fromCache: true };
}
// Load fresh data
const freshData = await dataLoader();
// Cache the fresh data
this.setCachedAttributes(resourceType, freshData, objectSlug);
return { data: freshData, fromCache: false };
}
// ========================================
// Cache Management & Cleanup
// ========================================
/**
* Clear all tasks cache entries
*/
static clearTasksCache(): void {
this.tasksCache.clear();
this.stats.tasks.entries = 0;
}
/**
* Clear all attributes cache entries
*/
static clearAttributesCache(): void {
this.attributesCache.clear();
this.stats.attributes.entries = 0;
}
/**
* Clear all 404 cache entries
*/
static clearNotFoundCache(): void {
this.notFoundCache.clear();
this.stats.notFound.entries = 0;
}
/**
* Clear all notes cache entries
*/
static clearNotesCache(): void {
this.notesCache.clear();
this.stats.notes = { hits: 0, misses: 0, entries: 0 };
}
/**
* Clear all cache entries
*/
static clearAllCache(): void {
this.clearTasksCache();
this.clearNotesCache();
this.clearAttributesCache();
this.clearNotFoundCache();
// Reset stats
this.stats = {
tasks: { hits: 0, misses: 0, entries: 0 },
notes: { hits: 0, misses: 0, entries: 0 },
attributes: { hits: 0, misses: 0, entries: 0 },
notFound: { hits: 0, misses: 0, entries: 0 },
};
}
/**
* Clear expired cache entries for all cache types
*/
static clearExpiredCache(): void {
this.clearExpiredTasksCache();
this.clearExpiredAttributesCache();
this.clearExpiredNotFoundCache();
}
/**
* Clear expired tasks cache entries
*
* @param ttl - Time to live in milliseconds
*/
static clearExpiredTasksCache(ttl: number = DEFAULT_TASKS_CACHE_TTL): void {
const now = Date.now();
let deletedCount = 0;
for (const [key, entry] of Array.from(this.tasksCache.entries())) {
if (now - entry.timestamp >= ttl) {
this.tasksCache.delete(key);
deletedCount++;
}
}
this.stats.tasks.entries -= deletedCount;
}
/**
* Clear expired attributes cache entries
*
* @param ttl - Time to live in milliseconds
*/
static clearExpiredAttributesCache(
ttl: number = DEFAULT_ATTRIBUTES_CACHE_TTL
): void {
const now = Date.now();
let deletedCount = 0;
for (const [key, entry] of Array.from(this.attributesCache.entries())) {
if (now - entry.timestamp >= ttl) {
this.attributesCache.delete(key);
deletedCount++;
}
}
this.stats.attributes.entries -= deletedCount;
}
/**
* Clear expired 404 cache entries
*
* @param ttl - Time to live in milliseconds
*/
static clearExpiredNotFoundCache(ttl: number = DEFAULT_404_CACHE_TTL): void {
const now = Date.now();
let deletedCount = 0;
for (const [key, entry] of Array.from(this.notFoundCache.entries())) {
if (now - entry.timestamp >= ttl) {
this.notFoundCache.delete(key);
deletedCount++;
}
}
this.stats.notFound.entries -= deletedCount;
}
/**
* Perform automatic cache cleanup when cache size exceeds limits
*/
private static performCacheCleanup(): void {
const totalEntries =
this.tasksCache.size +
this.attributesCache.size +
this.notFoundCache.size;
if (totalEntries > MAX_CACHE_ENTRIES) {
// Clear expired entries first
this.clearExpiredCache();
// If still too large, clear oldest entries
const remainingEntries =
this.tasksCache.size +
this.attributesCache.size +
this.notFoundCache.size;
if (remainingEntries > MAX_CACHE_ENTRIES) {
this.clearOldestEntries();
}
}
}
/**
* Clear oldest cache entries to maintain cache size limits
*/
private static clearOldestEntries(): void {
// Collect all entries with timestamps
const allEntries: Array<{
key: string;
timestamp: number;
type: 'tasks' | 'attributes' | 'notFound';
}> = [];
// Collect tasks entries
for (const [key, entry] of Array.from(this.tasksCache.entries())) {
allEntries.push({ key, timestamp: entry.timestamp, type: 'tasks' });
}
// Collect attributes entries
for (const [key, entry] of Array.from(this.attributesCache.entries())) {
allEntries.push({ key, timestamp: entry.timestamp, type: 'attributes' });
}
// Collect 404 entries
for (const [key, entry] of Array.from(this.notFoundCache.entries())) {
allEntries.push({ key, timestamp: entry.timestamp, type: 'notFound' });
}
// Sort by timestamp (oldest first)
allEntries.sort((a, b) => a.timestamp - b.timestamp);
// Remove oldest entries until under limit
const entriesToRemove =
allEntries.length - Math.floor(MAX_CACHE_ENTRIES * 0.8); // Keep 80% of max
for (let i = 0; i < entriesToRemove && i < allEntries.length; i++) {
const entry = allEntries[i];
switch (entry.type) {
case 'tasks':
this.tasksCache.delete(entry.key);
this.stats.tasks.entries--;
break;
case 'attributes':
this.attributesCache.delete(entry.key);
this.stats.attributes.entries--;
break;
case 'notFound':
this.notFoundCache.delete(entry.key);
this.stats.notFound.entries--;
break;
}
}
}
/**
* Get comprehensive cache statistics for monitoring
*
* @returns Detailed cache statistics
*/
static getCacheStats(): CacheStats & {
totalEntries: number;
tasksCacheSize: number;
tasksCacheEntries: string[];
notesCacheSize: number;
notesCacheEntries: string[];
cacheEfficiency: {
tasks: number;
notes: number;
attributes: number;
notFound: number;
overall: number;
};
} {
const totalHits =
this.stats.tasks.hits +
this.stats.notes.hits +
this.stats.attributes.hits +
this.stats.notFound.hits;
const totalRequests =
totalHits +
this.stats.tasks.misses +
this.stats.notes.misses +
this.stats.attributes.misses +
this.stats.notFound.misses;
return {
...this.stats,
totalEntries:
this.tasksCache.size +
this.notesCache.size +
this.attributesCache.size +
this.notFoundCache.size,
tasksCacheSize: this.tasksCache.size,
tasksCacheEntries: Array.from(this.tasksCache.keys()),
notesCacheSize: this.notesCache.size,
notesCacheEntries: Array.from(this.notesCache.keys()),
cacheEfficiency: {
tasks:
this.stats.tasks.hits + this.stats.tasks.misses > 0
? this.stats.tasks.hits /
(this.stats.tasks.hits + this.stats.tasks.misses)
: 0,
notes:
this.stats.notes.hits + this.stats.notes.misses > 0
? this.stats.notes.hits /
(this.stats.notes.hits + this.stats.notes.misses)
: 0,
attributes:
this.stats.attributes.hits + this.stats.attributes.misses > 0
? this.stats.attributes.hits /
(this.stats.attributes.hits + this.stats.attributes.misses)
: 0,
notFound:
this.stats.notFound.hits + this.stats.notFound.misses > 0
? this.stats.notFound.hits /
(this.stats.notFound.hits + this.stats.notFound.misses)
: 0,
overall: totalRequests > 0 ? totalHits / totalRequests : 0,
},
};
}
/**
* Manage task caching with automatic data loading
*
* This method encapsulates the common pattern of:
* 1. Check cache for valid data
* 2. Load data if cache miss
* 3. Cache the loaded data
*
* @param dataLoader - Function to load data when cache miss occurs
* @param cacheKey - Cache key (default: standard tasks list key)
* @param ttl - Time to live in milliseconds
* @returns Cached or freshly loaded data
*/
static async getOrLoadTasks(
dataLoader: () => Promise<AttioRecord[]>,
cacheKey: string = this.getTasksListCacheKey(),
ttl: number = DEFAULT_TASKS_CACHE_TTL
): Promise<{ data: AttioRecord[]; fromCache: boolean }> {
// Check cache first
const cachedTasks = this.getCachedTasks(cacheKey, ttl);
if (cachedTasks) {
return { data: cachedTasks, fromCache: true };
}
// Load fresh data
const freshData = await dataLoader();
// Cache the fresh data
this.setCachedTasks(cacheKey, freshData);
return { data: freshData, fromCache: false };
}
/**
* Get cached notes with automatic TTL management
*/
static getCachedNotes(
cacheKey: string,
ttl: number = DEFAULT_TASKS_CACHE_TTL
): AttioRecord[] | undefined {
const entry = this.notesCache.get(cacheKey);
if (!entry) {
this.stats.notes.misses++;
return undefined;
}
const now = Date.now();
if (now - entry.timestamp > ttl) {
this.notesCache.delete(cacheKey);
this.stats.notes.misses++;
return undefined;
}
this.stats.notes.hits++;
return entry.data;
}
/**
* Set cached notes with timestamp
*/
static setCachedNotes(cacheKey: string, data: AttioRecord[]): void {
// Enforce maximum cache size
if (this.notesCache.size >= MAX_CACHE_ENTRIES) {
const firstKey = this.notesCache.keys().next().value;
if (firstKey) {
this.notesCache.delete(firstKey);
}
}
this.notesCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
this.stats.notes.entries = this.notesCache.size;
}
/**
* Generate cache key for notes list
* Includes parent filter context to prevent cache collisions between different parent records.
*
* IMPORTANT: Unlike tasks (which use static 'tasks_list_all' key), notes MUST use
* context-specific keys because Attio API requires parent filters to return any notes.
* Without parent context in the cache key, we'd incorrectly return cached notes
* from one parent when searching another parent's notes.
*
* Examples:
* - Workspace-wide: 'notes:list:all:all'
* - Company-specific: 'notes:list:companies:abc-123'
* - Deal-specific: 'notes:list:deals:xyz-789'
*/
static getNotesListCacheKey(filters?: Record<string, unknown>): string {
const parentObject = filters?.parent_object || 'all';
const parentRecordId = filters?.parent_record_id || 'all';
return `notes:list:${parentObject}:${parentRecordId}`;
}
/**
* Clear expired notes cache entries
*/
static clearExpiredNotesCache(ttl: number = DEFAULT_TASKS_CACHE_TTL): void {
const now = Date.now();
for (const [key, entry] of this.notesCache.entries()) {
if (now - entry.timestamp > ttl) {
this.notesCache.delete(key);
}
}
this.stats.notes.entries = this.notesCache.size;
}
/**
* Manage note caching with automatic data loading
*
* This method encapsulates the common pattern of:
* 1. Check cache for valid data
* 2. Load data if cache miss
* 3. Cache the loaded data
*
* @param dataLoader - Function to load data when cache miss occurs
* @param cacheKey - Cache key (default: standard notes list key)
* @param ttl - Time to live in milliseconds
* @returns Cached or freshly loaded data
*/
static async getOrLoadNotes(
dataLoader: () => Promise<AttioRecord[]>,
cacheKey: string = this.getNotesListCacheKey(),
ttl: number = DEFAULT_TASKS_CACHE_TTL
): Promise<{ data: AttioRecord[]; fromCache: boolean }> {
// Check cache first
const cachedNotes = this.getCachedNotes(cacheKey, ttl);
if (cachedNotes) {
return { data: cachedNotes, fromCache: true };
}
// Load fresh data
const freshData = await dataLoader();
// Cache the fresh data
this.setCachedNotes(cacheKey, freshData);
return { data: freshData, fromCache: false };
}
}