UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

118 lines (117 loc) • 4.41 kB
import { AsyncLocalStorage } from "node:async_hooks"; /** Keep only the handles each in-flight reader actually obtained. */ export function indexReaders(current, close) { const calls = new AsyncLocalStorage(); const readers = new Map(); const retired = new Set(); return { get() { const handle = current(); const call = calls.getStore(); if (call?.active && !call.handles.has(handle)) { call.handles.add(handle); readers.set(handle, (readers.get(handle) ?? 0) + 1); } return handle; }, retire(handle) { if (readers.has(handle)) retired.add(handle); else close(handle); }, run(fn) { const call = { active: true, handles: new Set() }; return calls.run(call, async () => { try { return await fn(); } finally { call.active = false; for (const handle of call.handles) { const count = readers.get(handle) - 1; if (count) readers.set(handle, count); else { readers.delete(handle); if (retired.delete(handle)) close(handle); } } } }); }, }; } /** PHREN_INDEX_BUSY_WAIT_MS, default 5000, capped at 30000; 0 disables the wait. */ export function indexBusyWaitMs(env = process.env) { const parsed = Number.parseInt(env.PHREN_INDEX_BUSY_WAIT_MS ?? "", 10); if (!Number.isFinite(parsed) || parsed < 0) return 5000; return Math.min(parsed, 30_000); } const sleepMs = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * Run `fn`, and while it fails only because another process holds the rebuild * lock, retry with jittered backoff until `maxWaitMs` is spent. Any other * error, or the last busy error once the budget is gone, propagates. * Another process's rebuild usually leaves a cache entry for the same store * state, so the retry after it is a cheap cache load rather than a second build. */ export async function retryWhileBusy(fn, wait) { const now = wait.now ?? Date.now; const sleep = wait.sleep ?? sleepMs; const deadline = now() + Math.max(0, wait.maxWaitMs); let delay = 50; for (;;) { try { return await fn(); } catch (error) { const remaining = deadline - now(); if (!wait.isBusy(error) || remaining <= 0) throw error; // Jitter so a crowd of waiting servers does not retry in lockstep. await sleep(Math.min(remaining, delay + Math.floor(Math.random() * delay))); delay = Math.min(delay * 2, 500); } } } /** Coalesce local freshness checks without a timer or any remote polling. */ export function localIndexRefresh(options) { const now = options.now ?? Date.now; const interval = options.intervalMs ?? 1000; let checkedAt = -Infinity; let pending; let failure; return { async ensureFresh() { if (pending) return pending; if (now() - checkedAt < interval) { if (failure) throw failure; return; } checkedAt = now(); try { if (options.isFresh()) { failure = undefined; return; } } catch (error) { failure = error; throw error; } // The wait happens outside runExclusive so queued writes keep flowing // between attempts; every concurrent caller shares this one pending run. const attempt = () => options.runExclusive(options.refresh); const run = options.busyWait ? retryWhileBusy(attempt, options.busyWait) : attempt(); pending = run.then(() => { failure = undefined; }) .catch((error) => { failure = error; throw error; }) .finally(() => { checkedAt = now(); pending = undefined; }); return pending; }, }; }