UNPKG

schemantic

Version:

A fully typed, extensible TypeScript type generator for FastAPI OpenAPI schemas

650 lines (572 loc) 18.4 kB
"use strict"; /** * Advanced Request Deduplication Plugin * * Implements intelligent request deduplication at the client level with * sophisticated caching strategies, memory management, and performance optimization. * * Key Features: * - Request fingerprinting with content-based hashing * - Intelligent cache invalidation strategies * - Memory-efficient pending request management * - Configurable TTL and size limits * - Request coalescing for identical concurrent requests * - Stale-while-revalidate patterns * * Architecture: * - Non-blocking request deduplication * - LRU cache with TTL support * - WeakMap-based cleanup for memory efficiency * - Configurable cache policies per endpoint */ Object.defineProperty(exports, "__esModule", { value: true }); exports.requestDeduplicationPlugin = void 0; /** * Advanced Request Deduplication Plugin Implementation */ exports.requestDeduplicationPlugin = { name: "request-deduplication", version: "0.1.0", description: "Advanced request deduplication with intelligent caching and memory management", /** * Enhance API client with request deduplication capabilities */ afterClientGeneration: async (generatedClient, context) => { const options = getPluginOptions(context); if (!options.enabled) { return; } try { // Add deduplication middleware to client const deduplicationCode = generateDeduplicationCode(generatedClient, options); generatedClient.content += deduplicationCode; // Update dependencies generatedClient.dependencies.push("crypto-js"); // For hashing // Add deduplication exports generatedClient.exports.push("RequestDeduplicator", "CacheManager", "RequestCoalescer", "clearRequestCache", "getCacheStats"); } catch (error) { console.warn("Request deduplication plugin afterClientGeneration failed:", error); } }, }; /** * Get plugin options from generation context */ function getPluginOptions(context) { const pluginConfig = context?.config?.plugins?.find((p) => p.name === "request-deduplication"); return { enabled: true, defaultTTL: 5 * 60 * 1000, // 5 minutes maxCacheSize: 1000, maxPendingRequests: 100, enableStaleWhileRevalidate: true, enableCoalescing: true, enableDebugLogging: false, endpointPolicies: {}, ...(pluginConfig?.options || {}), }; } /** * Generate request deduplication code for API client */ function generateDeduplicationCode(_generatedClient, options) { return ` /** * Advanced Request Deduplication System */ /** * LRU Cache implementation with TTL support */ class LRUCacheWithTTL<K, V> { private readonly maxSize: number; private readonly cache = new Map<K, { value: V; timestamp: number; ttl: number }>(); private readonly accessOrder = new Map<K, number>(); private accessCounter = 0; constructor(maxSize: number = ${options.maxCacheSize || 1000}) { this.maxSize = maxSize; } set(key: K, value: V, ttl: number = ${options.defaultTTL || 5 * 60 * 1000}): void { const now = Date.now(); // Remove expired entries before adding new one this.cleanup(); // If at capacity, remove LRU item if (this.cache.size >= this.maxSize && !this.cache.has(key)) { this.evictLRU(); } this.cache.set(key, { value, timestamp: now, ttl }); this.accessOrder.set(key, ++this.accessCounter); } get(key: K): V | undefined { const entry = this.cache.get(key); if (!entry) { return undefined; } const now = Date.now(); if (now - entry.timestamp > entry.ttl) { // Entry is expired this.cache.delete(key); this.accessOrder.delete(key); return undefined; } // Update access order this.accessOrder.set(key, ++this.accessCounter); return entry.value; } has(key: K): boolean { return this.get(key) !== undefined; } delete(key: K): boolean { const result = this.cache.delete(key); this.accessOrder.delete(key); return result; } clear(): void { this.cache.clear(); this.accessOrder.clear(); this.accessCounter = 0; } size(): number { this.cleanup(); return this.cache.size; } private cleanup(): void { const now = Date.now(); const expiredKeys: K[] = []; for (const [key, entry] of this.cache) { if (now - entry.timestamp > entry.ttl) { expiredKeys.push(key); } } for (const key of expiredKeys) { this.cache.delete(key); this.accessOrder.delete(key); } } private evictLRU(): void { let oldestKey: K | undefined; let oldestAccess = Infinity; for (const [key, accessTime] of this.accessOrder) { if (accessTime < oldestAccess) { oldestAccess = accessTime; oldestKey = key; } } if (oldestKey !== undefined) { this.cache.delete(oldestKey); this.accessOrder.delete(oldestKey); } } getStats(): { size: number; hitRate: number; evictions: number } { return { size: this.cache.size, hitRate: 0, // Would need to track hits/misses for this evictions: 0 // Would need to track evictions for this }; } } /** * Request fingerprint generator */ class RequestFingerprintGenerator { /** * Generate a unique fingerprint for a request */ static generate( method: string, url: string, headers: Record<string, string> = {}, body?: unknown, params?: Record<string, unknown> ): string { const fingerprint = { method: method.toUpperCase(), url: this.normalizeURL(url), headers: this.normalizeHeaders(headers), body: body ? JSON.stringify(body) : undefined, params: params ? JSON.stringify(params) : undefined }; // Simple hash generation (in production, use crypto-js or similar) return this.simpleHash(JSON.stringify(fingerprint)); } private static normalizeURL(url: string): string { try { const urlObj = new URL(url); // Sort query parameters for consistent fingerprinting const params = new URLSearchParams(urlObj.search); const sortedParams = new URLSearchParams(); Array.from(params.keys()).sort().forEach(key => { params.getAll(key).forEach(value => sortedParams.append(key, value)); }); urlObj.search = sortedParams.toString(); return urlObj.toString(); } catch { return url; } } private static normalizeHeaders(headers: Record<string, string>): Record<string, string> { const normalized: Record<string, string> = {}; // Only include cache-relevant headers and normalize case const relevantHeaders = ['authorization', 'content-type', 'accept', 'cache-control']; for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); if (relevantHeaders.includes(lowerKey)) { normalized[lowerKey] = value; } } return normalized; } private static simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); } } /** * Request coalescing manager */ class RequestCoalescer { private readonly pendingRequests = new Map<string, Promise<unknown>>(); private readonly maxPending: number; constructor(maxPending = ${options.maxPendingRequests || 100}) { this.maxPending = maxPending; } /** * Coalesce identical requests */ async coalesce<T>( fingerprint: string, requestFactory: () => Promise<T> ): Promise<T> { // Check if identical request is already pending const existingRequest = this.pendingRequests.get(fingerprint); if (existingRequest) { ${options.enableDebugLogging ? "console.debug(`Request coalesced: ${fingerprint}`);" : ""} return existingRequest as Promise<T>; } // Check if we're at capacity if (this.pendingRequests.size >= this.maxPending) { ${options.enableDebugLogging ? "console.warn(`Request coalescing at capacity, executing directly`);" : ""} return requestFactory(); } // Create new request and track it const requestPromise = requestFactory() .finally(() => { // Clean up when request completes this.pendingRequests.delete(fingerprint); }); this.pendingRequests.set(fingerprint, requestPromise); return requestPromise; } /** * Clear all pending requests */ clear(): void { this.pendingRequests.clear(); } /** * Get current pending request count */ getPendingCount(): number { return this.pendingRequests.size; } } /** * Cache manager with stale-while-revalidate support */ class CacheManager<T = unknown> { private readonly cache = new LRUCacheWithTTL<string, CacheEntry<T>>(); private readonly staleRevalidatePromises = new Map<string, Promise<T>>(); constructor(private readonly options: { defaultTTL: number; enableStaleWhileRevalidate: boolean; debugLogging: boolean; }) {} /** * Get cached response or undefined if not cached/expired */ get(fingerprint: string): T | undefined { const entry = this.cache.get(fingerprint); if (!entry) { return undefined; } const now = Date.now(); const age = now - entry.timestamp; if (age > entry.ttl) { // Entry is expired if (this.options.enableStaleWhileRevalidate && !entry.stale) { // Mark as stale but return it entry.stale = true; this.cache.set(fingerprint, entry, entry.ttl); ${options.enableDebugLogging ? "this.options.debugLogging && console.debug(`Serving stale data: ${fingerprint}`);" : ""} return entry.data; } // Entry is too old even for stale-while-revalidate this.cache.delete(fingerprint); return undefined; } ${options.enableDebugLogging ? "this.options.debugLogging && console.debug(`Cache hit: ${fingerprint}`);" : ""} return entry.data; } /** * Set cached response */ set(fingerprint: string, data: T, ttl?: number): void { const entry: CacheEntry<T> = { data, timestamp: Date.now(), ttl: ttl || this.options.defaultTTL, stale: false }; this.cache.set(fingerprint, entry, entry.ttl); ${options.enableDebugLogging ? "this.options.debugLogging && console.debug(`Cache set: ${fingerprint}`);" : ""} } /** * Check if entry exists and is fresh */ hasFresh(fingerprint: string): boolean { const entry = this.cache.get(fingerprint); if (!entry) return false; const age = Date.now() - entry.timestamp; return age <= entry.ttl && !entry.stale; } /** * Check if entry exists (including stale) */ has(fingerprint: string): boolean { return this.cache.has(fingerprint); } /** * Invalidate cache entry */ invalidate(fingerprint: string): void { this.cache.delete(fingerprint); this.staleRevalidatePromises.delete(fingerprint); ${options.enableDebugLogging ? "this.options.debugLogging && console.debug(`Cache invalidated: ${fingerprint}`);" : ""} } /** * Clear entire cache */ clear(): void { this.cache.clear(); this.staleRevalidatePromises.clear(); } /** * Get cache statistics */ getStats(): { size: number; hitRate: number } { return { size: this.cache.size(), hitRate: 0 // Would need hit/miss tracking }; } /** * Revalidate stale entry in background */ revalidateInBackground<U extends T>( fingerprint: string, revalidator: () => Promise<U> ): void { if (this.staleRevalidatePromises.has(fingerprint)) { return; // Already revalidating } const revalidationPromise = revalidator() .then(freshData => { this.set(fingerprint, freshData); return freshData; }) .catch(error => { ${options.enableDebugLogging ? "this.options.debugLogging && console.warn(`Background revalidation failed: ${fingerprint}`, error);" : ""} throw error; }) .finally(() => { this.staleRevalidatePromises.delete(fingerprint); }); this.staleRevalidatePromises.set(fingerprint, revalidationPromise); } } /** * Main request deduplicator */ export class RequestDeduplicator { private readonly cacheManager: CacheManager; private readonly coalescer: RequestCoalescer; private readonly endpointPolicies: Record<string, any>; constructor() { this.cacheManager = new CacheManager({ defaultTTL: ${options.defaultTTL || 5 * 60 * 1000}, enableStaleWhileRevalidate: ${options.enableStaleWhileRevalidate || true}, debugLogging: ${options.enableDebugLogging || false} }); this.coalescer = new RequestCoalescer(${options.maxPendingRequests || 100}); this.endpointPolicies = ${JSON.stringify(options.endpointPolicies || {})}; } /** * Execute request with deduplication */ async execute<T>( method: string, url: string, requestFactory: () => Promise<T>, options: { headers?: Record<string, string>; body?: unknown; params?: Record<string, unknown>; skipCache?: boolean; customTTL?: number; } = {} ): Promise<T> { if (options.skipCache) { return requestFactory(); } // Generate request fingerprint const fingerprint = RequestFingerprintGenerator.generate( method, url, options.headers, options.body, options.params ); // Check endpoint-specific policy const policy = this.getEndpointPolicy(url); if (!policy.enabled) { return requestFactory(); } // Try cache first const cachedResult = this.cacheManager.get(fingerprint); if (cachedResult !== undefined) { // If stale and stale-while-revalidate is enabled, revalidate in background if (policy.staleWhileRevalidate && !this.cacheManager.hasFresh(fingerprint)) { this.cacheManager.revalidateInBackground(fingerprint, requestFactory); } return cachedResult as T; } // No cache hit, execute request with coalescing const result = await this.coalescer.coalesce(fingerprint, requestFactory); // Cache the result const ttl = options.customTTL || policy.ttl || ${options.defaultTTL || 5 * 60 * 1000}; this.cacheManager.set(fingerprint, result, ttl); return result; } /** * Invalidate cache for specific pattern */ invalidate(pattern: string | RegExp): void { // In a real implementation, this would iterate through cache keys // and invalidate matching entries if (typeof pattern === 'string') { const fingerprint = RequestFingerprintGenerator.generate('GET', pattern); this.cacheManager.invalidate(fingerprint); } } /** * Clear all caches */ clearCache(): void { this.cacheManager.clear(); this.coalescer.clear(); } /** * Get cache statistics */ getStats(): { cacheSize: number; pendingRequests: number; cacheHitRate: number; } { return { cacheSize: this.cacheManager.getStats().size, pendingRequests: this.coalescer.getPendingCount(), cacheHitRate: this.cacheManager.getStats().hitRate }; } private getEndpointPolicy(url: string): { enabled: boolean; ttl: number; staleWhileRevalidate: boolean; } { // Simple URL matching - in production, use more sophisticated matching for (const [pattern, policy] of Object.entries(this.endpointPolicies)) { if (url.includes(pattern)) { return { enabled: policy.enabled ?? true, ttl: policy.ttl ?? ${options.defaultTTL || 5 * 60 * 1000}, staleWhileRevalidate: policy.staleWhileRevalidate ?? ${options.enableStaleWhileRevalidate || true} }; } } return { enabled: true, ttl: ${options.defaultTTL || 5 * 60 * 1000}, staleWhileRevalidate: ${options.enableStaleWhileRevalidate || true} }; } } /** * Global deduplicator instance */ const globalDeduplicator = new RequestDeduplicator(); /** * Utility functions */ export function clearRequestCache(): void { globalDeduplicator.clearCache(); } export function getCacheStats(): ReturnType<RequestDeduplicator['getStats']> { return globalDeduplicator.getStats(); } export function invalidateCache(pattern: string | RegExp): void { globalDeduplicator.invalidate(pattern); } /** * Request wrapper with deduplication */ export function withDeduplication<T extends (...args: any[]) => Promise<any>>( requestFn: T, options: { method: string; urlExtractor: (...args: Parameters<T>) => string; headersExtractor?: (...args: Parameters<T>) => Record<string, string>; bodyExtractor?: (...args: Parameters<T>) => unknown; paramsExtractor?: (...args: Parameters<T>) => Record<string, unknown>; } ): T { return ((...args: Parameters<T>) => { const url = options.urlExtractor(...args); const headers = options.headersExtractor?.(...args); const body = options.bodyExtractor?.(...args); const params = options.paramsExtractor?.(...args); return globalDeduplicator.execute( options.method, url, () => requestFn(...args), { headers, body, params } ); }) as T; }`; } // Export plugin for registration exports.default = exports.requestDeduplicationPlugin; //# sourceMappingURL=request-deduplication.js.map