UNPKG

@inso_web/els-mcp

Version:

MCP-сервер поверх INSO Error Logs Service. Read-only tools (search, analytics, fingerprinting, correlations) для подключения Claude Desktop/Code и ChatGPT к логам ошибок. Streamable HTTP transport + stdio для npx-запуска.

109 lines 4.59 kB
import { gzipSync, gunzipSync } from 'node:zlib'; import { recordCacheHit, recordCacheMiss } from '../observability/metrics.js'; /** * Lookup-aside cache wrapper. * * Алгоритм: * 1. GET key → если HIT, парсим JSON (с gzip-распаковкой если нужно), отдаём. * 2. Если MISS — вызываем loader(), сохраняем результат с TTL через SETEX. * 3. На любых ошибках Redis — graceful degradation: loader() вызывается всегда. * * Compression: значения > `COMPRESSION_THRESHOLD_BYTES` (10 KB) — gzip + base64. * Префикс `gz:` помечает сжатые значения. * * Negative cache: НЕ реализован (loader-исключения не кэшируются). * * Tenant-isolation: cache key обязан содержать appSlug/keyPrefix. * Это обеспечивает `tenantKey()` в `policies.ts`. */ const COMPRESSION_THRESHOLD_BYTES = 10 * 1024; const COMPRESSION_PREFIX = 'gz:'; export class CacheService { deps; constructor(deps) { this.deps = deps; } /** * Lookup-aside fetch. * * Loader НЕ вызывается, если был cache hit. Ошибки loader'а — пробрасываются * (не кэшируем negative result). */ async cached(key, ttlSec, loader, opts = {}) { const toolClass = opts.toolClass ?? this.deps.toolClass ?? inferToolClass(key); if (opts.bypass || this.deps.redis.unavailable) { const value = await loader(); recordCacheMiss(toolClass); return { value, cached: false }; } // 1. Lookup let raw = null; try { raw = await this.deps.redis.get(key); } catch (err) { this.deps.log?.warn?.({ err: err.message, key }, 'cache GET failed'); } if (raw !== null) { try { const parsed = decodeCacheValue(raw); recordCacheHit(toolClass); return { value: parsed, cached: true }; } catch (err) { // Поломанная запись (битый JSON / битый gzip) — игнорируем, идём в loader. this.deps.log?.warn?.({ err: err.message, key }, 'cache value decode failed, falling back to loader'); } } // 2. Miss → loader recordCacheMiss(toolClass); const value = await loader(); // 3. Store (best-effort, не блокируем при ошибке) if (value !== undefined && ttlSec > 0) { try { const encoded = encodeCacheValue(value); await this.deps.redis.setex(key, ttlSec, encoded); } catch (err) { this.deps.log?.warn?.({ err: err.message, key }, 'cache SETEX failed (continuing)'); } } return { value, cached: false }; } } /** * Static convenience helper (для случаев без CacheService instance). * * НЕ рекомендуется в production коде — используйте `CacheService.cached()`. */ export async function cached(redis, key, ttlSec, loader, log) { const svc = new CacheService({ redis, ...(log !== undefined ? { log } : {}) }); return svc.cached(key, ttlSec, loader); } // ─── Serialization helpers ────────────────────────────────────────────────── function encodeCacheValue(value) { const json = JSON.stringify(value); if (json.length <= COMPRESSION_THRESHOLD_BYTES) return json; // gzip + base64 для значений > 10KB const compressed = gzipSync(Buffer.from(json, 'utf-8')); return COMPRESSION_PREFIX + compressed.toString('base64'); } function decodeCacheValue(raw) { if (raw.startsWith(COMPRESSION_PREFIX)) { const buf = Buffer.from(raw.slice(COMPRESSION_PREFIX.length), 'base64'); const json = gunzipSync(buf).toString('utf-8'); return JSON.parse(json); } return JSON.parse(raw); } /** * Извлекает tool-class из cache key `mcp:cache:{class}:{tenant}:...`. * Используется как fallback для метрик, если caller не указал явно. */ function inferToolClass(key) { const parts = key.split(':'); // mcp:cache:{class}:... return parts[2] ?? 'unknown'; } //# sourceMappingURL=wrapper.js.map