@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
JavaScript
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