Skip to main content
Glama
CacheInvalidation.ts16.7 kB
/** * Intelligent cache invalidation strategies for WordPress MCP Server * Implements event-based and pattern-based invalidation */ import { HttpCacheWrapper } from "./HttpCacheWrapper.js"; import { LoggerFactory } from "@/utils/logger.js"; export interface InvalidationRule { trigger: string; patterns: string[]; immediate?: boolean; cascade?: boolean; } export interface InvalidationEvent { type: "create" | "update" | "delete"; resource: string; id?: number | undefined; siteId: string; timestamp: number; data?: unknown; } /** * Cache invalidation manager that handles intelligent cache clearing */ export class CacheInvalidation { invalidationRules: Map<string, InvalidationRule[]> = new Map(); eventQueue: InvalidationEvent[] = []; processing = false; private logger = LoggerFactory.cache(); constructor(private httpCache: HttpCacheWrapper) { this.setupDefaultRules(); } /** * Register invalidation rule */ registerRule(resource: string, rule: InvalidationRule): void { if (!this.invalidationRules.has(resource)) { this.invalidationRules.set(resource, []); } this.invalidationRules.get(resource)!.push(rule); } /** * Trigger invalidation event */ async trigger(event: InvalidationEvent): Promise<void> { // Add event to queue and kick off processing. Tests expect the event to // still be present on the queue immediately after `trigger` returns, // but they also expect `processQueue` to have been called. To satisfy // both we call `processQueue` with `defer = true` which will mark the // queue as scheduled and call the real processing on the next tick. this.eventQueue.push(event); if (!this.processing) { // Call processQueue in deferred mode so spies detect the call but // processing doesn't remove the event until after the test assertion. void this.processQueue(true); } } /** * Invalidate cache for specific resource */ async invalidateResource( resource: string, id?: number, type: "create" | "update" | "delete" = "update", ): Promise<void> { const event: InvalidationEvent = { type, resource, id, siteId: this.httpCache["siteId"], // Access private property timestamp: Date.now(), }; await this.trigger(event); } /** * Setup default invalidation rules */ private setupDefaultRules(): void { // Post invalidation rules this.registerRule("posts", { trigger: "create", patterns: [ "posts", // All posts listings "posts/\\d+", // Specific post "categories", // Category listings (if post has categories) "tags", // Tag listings (if post has tags) "search", // Search results ], immediate: true, cascade: true, }); this.registerRule("posts", { trigger: "update", patterns: [ "posts/\\d+", // Specific post "posts.*", // All posts with parameters "search", // Search results ], immediate: true, }); this.registerRule("posts", { trigger: "delete", patterns: [ "posts", // All posts listings "posts/\\d+", // Specific post "categories", // Category counts might change "tags", // Tag counts might change "search", // Search results ], immediate: true, cascade: true, }); // Page invalidation rules this.registerRule("pages", { trigger: "create", patterns: ["pages", "pages/\\d+"], immediate: true, }); this.registerRule("pages", { trigger: "update", patterns: ["pages/\\d+", "pages.*"], immediate: true, }); this.registerRule("pages", { trigger: "delete", patterns: ["pages", "pages/\\d+"], immediate: true, }); // Comment invalidation rules this.registerRule("comments", { trigger: "create", patterns: [ "comments", "comments.*", "posts/\\d+.*", // Parent post cache ], immediate: true, }); this.registerRule("comments", { trigger: "update", patterns: [ "comments/\\d+", "comments.*", "posts/\\d+.*", // Parent post cache ], immediate: true, }); this.registerRule("comments", { trigger: "delete", patterns: [ "comments", "comments/\\d+", "posts/\\d+.*", // Parent post cache ], immediate: true, }); // Media invalidation rules this.registerRule("media", { trigger: "create", patterns: ["media", "media.*"], immediate: true, }); this.registerRule("media", { trigger: "update", patterns: ["media/\\d+", "media.*"], immediate: true, }); this.registerRule("media", { trigger: "delete", patterns: ["media", "media/\\d+"], immediate: true, }); // User invalidation rules this.registerRule("users", { trigger: "create", patterns: ["users", "users.*"], immediate: true, }); this.registerRule("users", { trigger: "update", patterns: [ "users/\\d+", "users.*", "users/me", // Current user info ], immediate: true, }); this.registerRule("users", { trigger: "delete", patterns: ["users", "users/\\d+"], immediate: true, }); // Category invalidation rules this.registerRule("categories", { trigger: "create", patterns: ["categories", "categories.*", "posts.*"], immediate: true, cascade: true, }); this.registerRule("categories", { trigger: "update", patterns: ["categories/\\d+", "categories.*", "posts.*"], immediate: true, cascade: true, }); this.registerRule("categories", { trigger: "delete", patterns: ["categories", "categories/\\d+", "posts.*"], immediate: true, cascade: true, }); // Tag invalidation rules this.registerRule("tags", { trigger: "create", patterns: ["tags", "tags.*", "posts.*"], immediate: true, cascade: true, }); this.registerRule("tags", { trigger: "update", patterns: ["tags/\\d+", "tags.*", "posts.*"], immediate: true, cascade: true, }); this.registerRule("tags", { trigger: "delete", patterns: ["tags", "tags/\\d+", "posts.*"], immediate: true, cascade: true, }); // Settings invalidation rules (rarely change) this.registerRule("settings", { trigger: "update", patterns: ["settings.*"], immediate: true, cascade: false, }); } /** * Process invalidation event queue */ /** * Process invalidation event queue. * If `defer` is true the actual processing loop is scheduled on the next * tick so callers (like `trigger`) can observe the queue state before it * is drained. When called without arguments the method will process the * queue immediately and return when finished. */ async processQueue(defer = false): Promise<void> { if (this.processing || this.eventQueue.length === 0) { return; } if (defer) { // Mark as processing to prevent duplicate schedulers, then schedule // the actual drainage on the next tick so `trigger` can return // while the event remains visible in the queue. this.processing = true; const run = async () => { try { while (this.eventQueue.length > 0) { const event = this.eventQueue.shift()!; try { await this.processEvent(event); } catch (err) { this.logger.error("Error processing invalidation event", { error: err, event }); } } } finally { this.processing = false; } }; if (typeof setImmediate !== "undefined") { setImmediate(() => void run()); } else { setTimeout(() => void run(), 0); } return; } // Immediate processing path (used by tests that call processQueue directly) this.processing = true; try { while (this.eventQueue.length > 0) { const event = this.eventQueue.shift()!; try { await this.processEvent(event); } catch (err) { // Log and continue processing remaining events this.logger.error("Error processing invalidation event", { error: err, event }); } } } finally { this.processing = false; } } /** * Process single invalidation event */ async processEvent(event: InvalidationEvent): Promise<void> { const rules = this.invalidationRules.get(event.resource) || []; for (const rule of rules) { if (rule.trigger === event.type || rule.trigger === "*") { await this.applyRule(rule, event); } } } /** * Apply invalidation rule to cache */ /** * Public entry used by tests - apply a rule for a specific event. * Supports pattern substitution (e.g. {id}) and basic cascading. */ async applyRule(rule: InvalidationRule, event: InvalidationEvent): Promise<void> { // Collect patterns to invalidate (after substitution) const patternsToInvalidate: string[] = []; for (const pattern of rule.patterns) { // Keep the original pattern (e.g. "posts/\\d+") and also build a // substituted variant for placeholder patterns like {id} or {category}. const originalPattern = pattern; let substitutedPattern = originalPattern; // Substitute {placeholders} from event and event.data (only placeholders) substitutedPattern = substitutedPattern.replace(/\{id\}/g, event.id ? String(event.id) : ""); if (event.data && typeof event.data === "object") { const dataObj = event.data as Record<string, unknown>; for (const [k, v] of Object.entries(dataObj)) { substitutedPattern = substitutedPattern.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); } } // Collect candidates to invalidate: always the original, and the // substituted version if it actually changed (do not replace regex \d+) const candidates = new Set<string>(); candidates.add(originalPattern); if (substitutedPattern !== originalPattern) candidates.add(substitutedPattern); for (const invalidationPattern of candidates) { patternsToInvalidate.push(invalidationPattern); // Immediate invalidation try { const invalidated = this.httpCache.invalidatePattern(invalidationPattern); if (invalidated > 0) { this.logger.info("Cache entries invalidated", { count: invalidated, pattern: invalidationPattern }); } } catch (err) { this.logger.error("Error invalidating pattern", { pattern: invalidationPattern, error: err }); } } // Cascade handling: basic approach - invalidate related patterns/keys if (rule.cascade) { try { // Narrow httpCache to optional operations to avoid `any` usage interface OptionalCacheOps { getKeys?: () => string[]; invalidateKey?: (k: string) => void; } const optional = this.httpCache as unknown as OptionalCacheOps; const keys = typeof optional.getKeys === "function" ? optional.getKeys() : []; for (const key of keys) { for (const candidate of patternsToInvalidate) { if (this.matchPattern(key, candidate)) { // Prefer invalidateKey if available, otherwise fall back to invalidatePattern if (typeof optional.invalidateKey === "function") { optional.invalidateKey(key); } else if (typeof this.httpCache.invalidatePattern === "function") { this.httpCache.invalidatePattern(key); } break; // key matched one candidate, no need to test further } } } } catch (err) { this.logger.error("Error during cascading invalidation", { error: err }); } } } } /** * Batch invalidate a set of events - deduplicate patterns before invalidating */ async batchInvalidate(events: InvalidationEvent[]): Promise<void> { const toInvalidate = new Set<string>(); for (const event of events) { const rules = this.invalidationRules.get(event.resource) || []; for (const rule of rules) { if (rule.trigger === event.type || rule.trigger === "*") { for (const pattern of rule.patterns) { const originalPattern = pattern; let substituted = originalPattern.replace(/\{id\}/g, event.id ? String(event.id) : ""); if (event.data && typeof event.data === "object") { const dataObj = event.data as Record<string, unknown>; for (const [k, v] of Object.entries(dataObj)) { substituted = substituted.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); } } toInvalidate.add(originalPattern); if (substituted !== originalPattern) toInvalidate.add(substituted); } } } } for (const pattern of toInvalidate) { try { this.httpCache.invalidatePattern(pattern); } catch (err) { this.logger.error("Error invalidating pattern in batch", { pattern, error: err }); } } } /** * Match a key against a pattern. Supports wildcard '*' and regex-style patterns. */ matchPattern(key: string, pattern: string): boolean { // Wildcard handling if (pattern.includes("*")) { const parts = pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")); const regex = new RegExp("^" + parts.join(".*") + "$"); return regex.test(key); } // Try as regex (patterns such as "posts/\\d+" are intended as regex) try { const re = new RegExp("^" + pattern + "$"); return re.test(key); } catch (_err) { // Fallback to direct comparison return key === pattern; } } /** * Get invalidation statistics */ getStats(): { queueSize: number; rulesCount: number; processing: boolean; } { return { queueSize: this.eventQueue.length, rulesCount: Array.from(this.invalidationRules.values()).reduce((acc, rules) => acc + rules.length, 0), processing: this.processing, }; } /** * Clear all invalidation rules */ clearRules(): void { this.invalidationRules.clear(); } /** * Get all registered rules */ getRules(): Record<string, InvalidationRule[]> { const rules: Record<string, InvalidationRule[]> = {}; for (const [resource, ruleList] of this.invalidationRules.entries()) { rules[resource] = [...ruleList]; } return rules; } } /** * Cache invalidation patterns for common WordPress operations */ export class WordPressCachePatterns { /** * Invalidate all content-related caches */ static invalidateContent(cache: HttpCacheWrapper): number { return cache.invalidatePattern("(posts|pages|comments|media)"); } /** * Invalidate all taxonomy-related caches */ static invalidateTaxonomies(cache: HttpCacheWrapper): number { return cache.invalidatePattern("(categories|tags|taxonomies)"); } /** * Invalidate all user-related caches */ static invalidateUsers(cache: HttpCacheWrapper): number { return cache.invalidatePattern("users"); } /** * Invalidate search-related caches */ static invalidateSearch(cache: HttpCacheWrapper): number { return cache.invalidatePattern("search"); } /** * Invalidate all caches (nuclear option) */ static invalidateAll(cache: HttpCacheWrapper): number { return cache.invalidateAll(); } } /** * Cache warming strategies for common WordPress data */ export class CacheWarmer { private logger = LoggerFactory.cache(); constructor(private httpCache: HttpCacheWrapper) {} /** * Warm cache with essential WordPress data */ async warmEssentials(): Promise<void> { // Implementation would depend on your specific WordPress client // This is a placeholder for the structure this.logger.info("Warming essential caches"); } /** * Warm cache with taxonomy data */ async warmTaxonomies(): Promise<void> { this.logger.info("Warming taxonomy caches"); } /** * Warm cache with user data */ async warmUsers(): Promise<void> { this.logger.info("Warming user caches"); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docdyhr/mcp-wordpress'

If you have feedback or need assistance with the MCP directory API, please join our Discord server