Masalah: Concurrency Spikes pada MCP Tool Calls
Server Model Context Protocol (MCP) yang melayani kebutuhan dokumentasi (seperti MDN documentation server) kerap menghadapi lonjakan konkurensi tak terduga. Ketika agentic loop (misalnya Claude Desktop, Cursor, atau AutoGPT) mengeksekusi multi-step reasoning, runtime LLM dapat memicu puluhan sub-agent atau task paralel yang meminta referensi API yang sama secara bersamaan.
Jika cache lokal kosong atau kedaluwarsa, seluruh worker instance yang terdistribusi akan mengeksekusi HTTP request identik ke server upstream (developer.mozilla.org). Masalah ini memicu dua kegagalan fatal:
- Cache Stampede (Thundering Herd): Lonjakan komputasi dan bandwidth internal akibat puluhan worker memproses dan mem-parsing respons upstream yang sama secara serentak.
- Upstream HTTP 429 (Too Many Requests): Server dokumentasi upstream membatasi kuota request melalui Cloudflare atau edge rate-limiter, memblokir IP worker, dan menyebabkan kegagalan berantai pada seluruh sesi agent.
Solusi standar seperti in-memory local lock (misal async-mutex) hanya efektif pada lingkup satu proses Node.js. Pada infrastruktur terdistribusi (multi-container atau serverless worker), diperlukan koordinasi global menggunakan Redis distributed lock.
Arsitektur Solusi: Redis Lock + Multi-Tier Cache
Untuk menekan duplicate fetch ke nol tanpa membebani latensi user, gunakan kombinasi tiga lapis pertahanan:
- In-Memory LRU Cache: Menyimpan respons valid dengan TTL pendek/menengah untuk akses cepat tanpa round-trip jaringan ke Redis.
- Redis Distributed Lock (SET NX PX): Memastikan hanya satu worker yang mengeksekusi HTTP fetch ke upstream saat terjadi cache-miss.
- Stale-While-Revalidate Fallback: Jika lock gagal diperoleh atau upstream timeout, sajikan data lama (stale data) alih-alih melempar error HTTP 500 ke LLM client.
Implementasi Distributed Lock di TypeScript
Pola distributed lock yang aman memerlukan tiga elemen utama: flag atomik NX (not exists), durasi sewa atomik PX (milliseconds), serta token unik (UUID) untuk memastikan release lock dilakukan oleh pemilik aslinya via Lua script.
1. Core Locking Mechanism
import { randomUUID } from "node:crypto";
import type { Redis } from "ioredis";
const RELEASE_LOCK_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
export class DistributedLock {
constructor(private readonly redis: Redis) {}
async acquire(key: string, ttlMs: number): Promise<string | null> {
const token = randomUUID();
// SET resource_name my_random_value NX PX 30000
const result = await this.redis.set(`lock:${key}`, token, "PX", ttlMs, "NX");
return result === "OK" ? token : null;
}
async release(key: string, token: string): Promise<boolean> {
const result = await this.redis.eval(
RELEASE_LOCK_LUA,
1,
`lock:${key}`,
token
);
return result === 1;
}
}
2. Fetch Coordinator dengan Fallback Stale Cache
Koordinasikan lock dengan in-memory cache. Worker yang gagal mendapatkan lock akan menunggu proses worker pemenang selesai alih-alih ikut menembak upstream.
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
export class McpDocFetcher<T> {
private localCache = new Map<string, CacheEntry<T>>();
constructor(
private readonly lock: DistributedLock,
private readonly defaultTtlMs = 60_000
) {}
async getOrFetch(
key: string,
fetcher: () => Promise<T>,
lockTtlMs = 5000
): Promise<T> {
const cached = this.localCache.get(key);
const now = Date.now();
// 1. Return fresh data if available
if (cached && cached.expiresAt > now) {
return cached.data;
}
// 2. Try acquire lock for upstream fetch
const token = await this.lock.acquire(key, lockTtlMs);
if (token) {
try {
const freshData = await fetcher();
this.localCache.set(key, {
data: freshData,
expiresAt: Date.now() + this.defaultTtlMs,
});
return freshData;
} finally {
// Safe release via Lua
await this.lock.release(key, token);
}
}
// 3. Fallback: If lock contention high, serve stale if exists
if (cached) {
return cached.data;
}
// 4. Wait & poll briefly for the winner to populate cache
return this.pollCache(key, 5, 200, fetcher);
}
private async pollCache(
key: string,
retries: number,
intervalMs: number,
fetcher: () => Promise<T>
): Promise<T> {
for (let i = 0; i < retries; i++) {
await new Promise((res) => setTimeout(res, intervalMs));
const entry = this.localCache.get(key);
if (entry) return entry.data;
}
// Final defensive fallback: run fetcher directly if coordination timed out
return fetcher();
}
}
Verifikasi: Runnable Deduplication Test
Script pengujian di bawah memverifikasi bahwa ketika 10 request datang secara simultan saat cache kosong, upstream fetcher hanya dipanggil tepat satu kali.
import assert from "node:assert/strict";
// In-memory mock Redis for direct execution without external infra
class MockRedis {
private store = new Map<string, { val: string; exp: number }>();
async set(key: string, val: string, _px: string, ttl: number, nx: string) {
const now = Date.now();
const curr = this.store.get(key);
if (nx === "NX" && curr && curr.exp > now) {
return null;
}
this.store.set(key, { val, exp: now + ttl });
return "OK";
}
async eval(_script: string, _numkeys: number, key: string, token: string) {
const curr = this.store.get(key);
if (curr && curr.val === token) {
this.store.delete(key);
return 1;
}
return 0;
}
}
async function runTest() {
const mockRedis = new MockRedis();
const lock = new DistributedLock(mockRedis as unknown as Redis);
const coordinator = new McpDocFetcher<string>(lock, 10_000);
let upstreamCallCount = 0;
const mockUpstreamFetch = async () => {
upstreamCallCount++;
await new Promise((r) => setTimeout(r, 100)); // Simulate HTTP latency
return "MDN doc payload: Array.prototype.map";
};
const KEY = "mdn:js:array:map";
// Fire 10 parallel tool invocations from AI agents
const results = await Promise.all(
Array.from({ length: 10 }).map(() =>
coordinator.getOrFetch(KEY, mockUpstreamFetch)
)
);
// Assertions
assert.equal(
upstreamCallCount,
1,
`Upstream called ${upstreamCallCount} times, expected exactly 1`
);
assert.equal(results.length, 10);
assert.equal(results[0], "MDN doc payload: Array.prototype.map");
console.log("PASS: Deduplication verified. Upstream called only once.");
}
runTest().catch((err) => {
console.error("FAIL:", err);
process.exit(1);
});
Edge Cases & Trade-offs
- Lock TTL Expiration: Jika upstream latency melebihi nilai
PXlock, lock akan kedaluwarsa sebelum request selesai. Worker lain akan menganggap lock kosong dan melakukan fetch kedua. Solusi: Gunakan background lock extender (heartbeat/watchdog) jika upstream response time tidak menentu. - Clock Drift: Penggunaan TTL berbasis milidetik bergantung pada konsistensi clock server. Pastikan NTP tersinkronisasi di seluruh cluster node.
- Fail-Open vs Fail-Closed: Pada konteks MCP server, pilih fail-open dengan fallback stale cache saat Redis cluster down, agar AI agent tetap mendapatkan respons parsial daripada exception yang membatalkan seluruh task pipeline.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!