mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9494fd6c69 | ||
|
|
5ff30ac9b6 |
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v3.13.0 — 2026-05-07
|
||||
|
||||
### Features (cache layer hardening)
|
||||
|
||||
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
|
||||
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
|
||||
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
|
||||
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
|
||||
|
||||
### Governance
|
||||
|
||||
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
|
||||
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
|
||||
|
||||
### No new env vars / no public API surface change
|
||||
|
||||
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
|
||||
|
||||
## v3.12.0 — 2026-04-25
|
||||
|
||||
### Features
|
||||
|
||||
@@ -472,17 +472,20 @@ ocp settings cacheTTL 300000
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
||||
- Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
|
||||
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
|
||||
- Cache hits return instantly — no Claude CLI process spawned
|
||||
- Works for both streaming and non-streaming requests
|
||||
- **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta — incremental render preserved
|
||||
- **`cache_control` bypass** — if a request carries an Anthropic `cache_control` annotation (top-level or nested in `content[]`), OCP skips its own cache entirely so it doesn't interfere with Anthropic-side prompt caching
|
||||
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses to the leader's call. Non-streaming path only (streaming-path singleflight is a known TODO)
|
||||
- Multi-turn conversations (with `session_id`) are never cached
|
||||
- Expired entries are cleaned up automatically every 10 minutes
|
||||
|
||||
**Management:**
|
||||
```bash
|
||||
# View cache stats
|
||||
# View cache stats (now includes singleflight in-flight counts)
|
||||
curl http://127.0.0.1:3456/cache/stats
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
|
||||
|
||||
# Clear all cached responses
|
||||
curl -X DELETE http://127.0.0.1:3456/cache
|
||||
@@ -493,6 +496,8 @@ ocp settings cacheTTL 0
|
||||
|
||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
||||
|
||||
**Hash format upgrade in v3.13.0:** legacy `v1` cache rows from earlier versions don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window. No migration script required.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
|
||||
@@ -371,6 +371,36 @@ export function getCacheStats() {
|
||||
return { entries: total, totalHits, sizeBytes };
|
||||
}
|
||||
|
||||
// ── Singleflight stampede protection ──
|
||||
|
||||
// In-memory singleflight Map: hash → { promise, requesters }
|
||||
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
|
||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||
const inflightMap = new Map();
|
||||
|
||||
export function singleflight(hash, fn) {
|
||||
const existing = inflightMap.get(hash);
|
||||
if (existing) {
|
||||
existing.requesters++;
|
||||
return existing.promise;
|
||||
}
|
||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||
const promise = Promise.resolve().then(fn).finally(() => {
|
||||
inflightMap.delete(hash);
|
||||
});
|
||||
inflightMap.set(hash, { promise, requesters: 1 });
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function getInflightStats() {
|
||||
let totalRequesters = 0;
|
||||
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
|
||||
return {
|
||||
inflight: inflightMap.size,
|
||||
requesters: totalRequesters,
|
||||
};
|
||||
}
|
||||
|
||||
// Find a key by id or name (returns { id, name } or null)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.12.0",
|
||||
"version": "3.13.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -10,8 +10,7 @@
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"setup": "node setup.mjs",
|
||||
"test": "node test-features.mjs",
|
||||
"traffic": "node scripts/github-traffic.mjs"
|
||||
"test": "node test-features.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// github-traffic.mjs — fetch GitHub Traffic Insights for this repo.
|
||||
//
|
||||
// Usage:
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs # pretty report
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --json # raw JSON
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --save # write snapshot file
|
||||
//
|
||||
// Options:
|
||||
// --owner=<user> Override repo owner (default: dtzp555-max)
|
||||
// --repo=<name> Override repo name (default: ocp)
|
||||
// --json Print raw JSON instead of a formatted report
|
||||
// --save[=path] Append snapshot as JSONL to path (default: ./traffic-history.jsonl)
|
||||
//
|
||||
// Requires a token with push access to the repository. Traffic endpoints
|
||||
// return the last 14 days of data — run daily to build a longer history.
|
||||
|
||||
const API = "https://api.github.com";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { owner: "dtzp555-max", repo: "ocp", json: false, save: null };
|
||||
for (const a of argv.slice(2)) {
|
||||
if (a === "--json") args.json = true;
|
||||
else if (a === "--save") args.save = "traffic-history.jsonl";
|
||||
else if (a.startsWith("--save=")) args.save = a.slice(7);
|
||||
else if (a.startsWith("--owner=")) args.owner = a.slice(8);
|
||||
else if (a.startsWith("--repo=")) args.repo = a.slice(7);
|
||||
else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
|
||||
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`github-traffic.mjs — fetch GitHub Traffic Insights
|
||||
|
||||
Usage:
|
||||
GITHUB_TOKEN=<token> node scripts/github-traffic.mjs [options]
|
||||
|
||||
Options:
|
||||
--owner=<user> Repo owner (default: dtzp555-max)
|
||||
--repo=<name> Repo name (default: ocp)
|
||||
--json Print raw JSON (for piping to jq)
|
||||
--save[=path] Append snapshot as JSONL (default: ./traffic-history.jsonl)
|
||||
-h, --help Show this help
|
||||
|
||||
Requires GITHUB_TOKEN with push access to the repository.`);
|
||||
}
|
||||
|
||||
async function gh(path, token) {
|
||||
const res = await fetch(API + path, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ocp-traffic-script",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`GET ${path} → ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchAll(owner, repo, token) {
|
||||
const base = `/repos/${owner}/${repo}`;
|
||||
const [repoInfo, views, clones, referrers, paths] = await Promise.all([
|
||||
gh(base, token),
|
||||
gh(`${base}/traffic/views`, token),
|
||||
gh(`${base}/traffic/clones`, token),
|
||||
gh(`${base}/traffic/popular/referrers`, token),
|
||||
gh(`${base}/traffic/popular/paths`, token),
|
||||
]);
|
||||
return {
|
||||
fetched_at: new Date().toISOString(),
|
||||
repo: {
|
||||
full_name: repoInfo.full_name,
|
||||
stars: repoInfo.stargazers_count,
|
||||
watchers: repoInfo.subscribers_count,
|
||||
forks: repoInfo.forks_count,
|
||||
open_issues: repoInfo.open_issues_count,
|
||||
pushed_at: repoInfo.pushed_at,
|
||||
},
|
||||
views, clones, referrers, paths,
|
||||
};
|
||||
}
|
||||
|
||||
function bar(value, max, width = 20) {
|
||||
if (!max) return "";
|
||||
const n = Math.round((value / max) * width);
|
||||
return "█".repeat(n) + "░".repeat(width - n);
|
||||
}
|
||||
|
||||
function formatReport(data) {
|
||||
const { repo, views, clones, referrers, paths } = data;
|
||||
const lines = [];
|
||||
lines.push(`\n📊 GitHub Traffic — ${repo.full_name}`);
|
||||
lines.push(` ⭐ ${repo.stars} 👁 ${repo.watchers ?? "?"} 🍴 ${repo.forks} 🐛 ${repo.open_issues} open issues`);
|
||||
lines.push(` Last push: ${repo.pushed_at}`);
|
||||
lines.push(` Fetched: ${data.fetched_at}\n`);
|
||||
|
||||
lines.push(`── Views (last 14 days) ────────────────────────────────────`);
|
||||
lines.push(` Total: ${views.count} Unique: ${views.uniques}`);
|
||||
const maxV = Math.max(1, ...views.views.map(v => v.count));
|
||||
for (const v of views.views) {
|
||||
const day = v.timestamp.slice(0, 10);
|
||||
lines.push(` ${day} ${String(v.count).padStart(4)} (${String(v.uniques).padStart(3)} uniq) ${bar(v.count, maxV)}`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Clones (last 14 days) ───────────────────────────────────`);
|
||||
lines.push(` Total: ${clones.count} Unique: ${clones.uniques}`);
|
||||
if (clones.clones.length) {
|
||||
const maxC = Math.max(1, ...clones.clones.map(c => c.count));
|
||||
for (const c of clones.clones) {
|
||||
const day = c.timestamp.slice(0, 10);
|
||||
lines.push(` ${day} ${String(c.count).padStart(4)} (${String(c.uniques).padStart(3)} uniq) ${bar(c.count, maxC)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no clones recorded)`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Top Referrers ───────────────────────────────────────────`);
|
||||
if (referrers.length) {
|
||||
const maxR = Math.max(1, ...referrers.map(r => r.count));
|
||||
for (const r of referrers) {
|
||||
lines.push(` ${r.referrer.padEnd(28)} ${String(r.count).padStart(5)} views (${r.uniques} uniq) ${bar(r.count, maxR, 15)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no referrer data)`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Popular Content ─────────────────────────────────────────`);
|
||||
if (paths.length) {
|
||||
const maxP = Math.max(1, ...paths.map(p => p.count));
|
||||
for (const p of paths) {
|
||||
lines.push(` ${String(p.count).padStart(5)} views ${String(p.uniques).padStart(4)} uniq ${p.path}`);
|
||||
lines.push(` ${bar(p.count, maxP, 40)} ${p.title.slice(0, 60)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no popular content data)`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token) {
|
||||
console.error("Error: GITHUB_TOKEN environment variable is required.");
|
||||
console.error("Create a token with 'repo' scope: https://github.com/settings/tokens");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await fetchAll(args.owner, args.repo, token);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch traffic: ${err.message}`);
|
||||
if (err.message.includes("403")) {
|
||||
console.error("Note: traffic endpoints require push access to the repository.");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.save) {
|
||||
const { writeFileSync, appendFileSync, existsSync } = await import("node:fs");
|
||||
const line = JSON.stringify(data) + "\n";
|
||||
if (existsSync(args.save)) appendFileSync(args.save, line);
|
||||
else writeFileSync(args.save, line);
|
||||
console.error(`Snapshot appended to ${args.save}`);
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log(formatReport(data));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+39
-7
@@ -34,7 +34,7 @@ import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl } from "./keys.mjs";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -590,6 +590,7 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
||||
// TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat.
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
@@ -1265,14 +1266,45 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
|
||||
// Non-streaming path with stampede protection: wrap the upstream call in singleflight
|
||||
// when cache is enabled and a hash is present. Concurrent identical requests share
|
||||
// one upstream spawn; followers receive the same promise. Streaming-path dedup is
|
||||
// explicitly out of scope (see TODO comment above callClaudeStreaming).
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try {
|
||||
const content = await singleflight(req._cacheHash, async () => {
|
||||
// Re-check cache inside the singleflight: a follower that enters before the
|
||||
// leader finishes will wait on the shared promise (not reach here), but a
|
||||
// request that races in just after the previous singleflight cleared the map
|
||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||
if (recheck) return recheck.response;
|
||||
const c = await callClaude(model, messages, conversationId);
|
||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
return c;
|
||||
});
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
return;
|
||||
} catch (err) {
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
console.error(`[proxy] error: ${err.message}`);
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
// Write to cache
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
} catch (err) {
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
@@ -1551,10 +1583,10 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cache/stats — cache statistics
|
||||
// GET /cache/stats — cache statistics (entries, hits, size, inflight singleflight count)
|
||||
if (pathname === "/cache/stats" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
return jsonResponse(res, 200, getCacheStats());
|
||||
return jsonResponse(res, 200, { ...getCacheStats(), ...getInflightStats() });
|
||||
}
|
||||
|
||||
// DELETE /cache — clear cache
|
||||
|
||||
+101
-1
@@ -3,7 +3,7 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl } from "./keys.mjs";
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
@@ -354,6 +354,106 @@ test("D3: chunked replay uses Array.from — multibyte codepoints stay intact",
|
||||
}
|
||||
});
|
||||
|
||||
// ── PR-B Singleflight tests (async) ──
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ✗ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSingleflightTests() {
|
||||
console.log("\nPR-B Singleflight:");
|
||||
|
||||
// 1. Basic dedup: 10 concurrent calls with same hash execute fn only once.
|
||||
await asyncTest("basic dedup: 10 concurrent callers execute fn only once", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise(resolve => {
|
||||
callCount++;
|
||||
setTimeout(() => resolve("result-A"), 20);
|
||||
});
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => singleflight("sf-dedup-1", fn)));
|
||||
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
||||
assert.ok(results.every(r => r === "result-A"), "all 10 callers should receive the same return value");
|
||||
});
|
||||
|
||||
// 2. Failure fan-out: all followers reject when leader rejects.
|
||||
await asyncTest("failure fan-out: all followers reject with leader error", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise((_, reject) => {
|
||||
callCount++;
|
||||
setTimeout(() => reject(new Error("upstream-fail")), 20);
|
||||
});
|
||||
const promises = Array.from({ length: 10 }, () => singleflight("sf-fail-1", fn));
|
||||
const results = await Promise.allSettled(promises);
|
||||
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
||||
assert.ok(results.every(r => r.status === "rejected"), "all 10 should be rejected");
|
||||
assert.ok(results.every(r => r.reason?.message === "upstream-fail"), "all should share the same error message");
|
||||
});
|
||||
|
||||
// 3a. Map cleanup after success: inflight count returns to 0 after promise resolves.
|
||||
await asyncTest("map cleanup after success: inflight=0 after promise settles", async () => {
|
||||
const fn = () => new Promise(resolve => setTimeout(() => resolve("done"), 10));
|
||||
await singleflight("sf-cleanup-ok", fn);
|
||||
const stats = getInflightStats();
|
||||
assert.equal(stats.inflight, 0, `expected inflight=0 after settlement, got ${stats.inflight}`);
|
||||
});
|
||||
|
||||
// 3b. Map cleanup after failure: inflight count returns to 0 after promise rejects.
|
||||
await asyncTest("map cleanup after failure: inflight=0 after promise rejects", async () => {
|
||||
const fn = () => new Promise((_, reject) => setTimeout(() => reject(new Error("fail")), 10));
|
||||
try { await singleflight("sf-cleanup-fail", fn); } catch {}
|
||||
const stats = getInflightStats();
|
||||
assert.equal(stats.inflight, 0, `expected inflight=0 after rejection, got ${stats.inflight}`);
|
||||
});
|
||||
|
||||
// 4. Different hashes don't share: two parallel calls with distinct hashes both execute.
|
||||
await asyncTest("different hashes do not share a singleflight entry", async () => {
|
||||
let countA = 0;
|
||||
let countB = 0;
|
||||
const fnA = () => new Promise(resolve => { countA++; setTimeout(() => resolve("A"), 20); });
|
||||
const fnB = () => new Promise(resolve => { countB++; setTimeout(() => resolve("B"), 20); });
|
||||
const [rA, rB] = await Promise.all([singleflight("sf-hash-A", fnA), singleflight("sf-hash-B", fnB)]);
|
||||
assert.equal(countA, 1);
|
||||
assert.equal(countB, 1);
|
||||
assert.equal(rA, "A");
|
||||
assert.equal(rB, "B");
|
||||
});
|
||||
|
||||
// 5. getInflightStats shape: returns { inflight: number, requesters: number }.
|
||||
await asyncTest("getInflightStats returns correct shape", async () => {
|
||||
// Verify shape against a settled state (inflight=0 is still the right shape).
|
||||
const stats = getInflightStats();
|
||||
assert.equal(typeof stats.inflight, "number", "inflight should be a number");
|
||||
assert.equal(typeof stats.requesters, "number", "requesters should be a number");
|
||||
// Also verify live counts: start a pending fn, check inflight>0, then resolve.
|
||||
const { promise: blocker, resolve: resolveBlocker } = Promise.withResolvers();
|
||||
const fn = () => blocker;
|
||||
const p = singleflight("sf-stats-shape", fn);
|
||||
const liveStats = getInflightStats();
|
||||
assert.ok(liveStats.inflight >= 1, `expected inflight>=1, got ${liveStats.inflight}`);
|
||||
resolveBlocker("ok");
|
||||
await p;
|
||||
});
|
||||
|
||||
// 6. Sequential calls don't share: singleflight is for concurrent dedup only.
|
||||
await asyncTest("sequential calls with same hash each execute fn independently", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise(resolve => { callCount++; setTimeout(() => resolve(callCount), 10); });
|
||||
const r1 = await singleflight("sf-sequential", fn);
|
||||
const r2 = await singleflight("sf-sequential", fn);
|
||||
assert.equal(callCount, 2, `fn should have been called twice, got ${callCount}`);
|
||||
assert.equal(r1, 1);
|
||||
assert.equal(r2, 2);
|
||||
});
|
||||
}
|
||||
|
||||
await runSingleflightTests();
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user