fix(rectification): 请求内 Case 档案只读投影写即失效缓存
Independent Staging Quality Gate / validate (push) Canceled after 2m18s
Independent Staging Quality Gate / publish (push) Canceled after 0s

同一请求里 dossier/compute 按 (fn, userId, caseId) 合并重复读,其它 RPC 与 .from 立即失效。路由入口各包一处,零调用点改动。
This commit is contained in:
jesse-ux
2026-09-16 07:32:34 +08:00
parent e4788dfc00
commit b9c053778a
10 changed files with 440 additions and 8 deletions
@@ -30,6 +30,7 @@ import { isProductEnabled } from "@/lib/product-access";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
@@ -150,7 +151,7 @@ export async function POST(request: Request) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { isProductEnabled } from "@/lib/product-access";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
@@ -59,7 +60,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
@@ -32,7 +33,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
@@ -29,7 +30,7 @@ export async function GET(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
}
@@ -9,6 +9,7 @@ import { resolveExactSkillPackage } from "@/lib/skill-package-registry";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
@@ -64,7 +65,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate");
}
@@ -0,0 +1,76 @@
/**
* Request-scoped cache for Case read projections.
*
* `get_agentic_rectification_case_dossier` and
* `get_agentic_rectification_case_compute` are unchanged for the life of a
* request until this client performs any other RPC or table access. Call sites
* keep calling `loadV9CaseDossier` as before; the wrapper sits on the client
* object so direct `accounting.rpc(...)` paths are covered too.
*
* The cache is a closure over a Map created by each `withRectificationRequestCache`
* call. It is not stored on `globalThis` or at module scope.
*
* Other properties are forwarded through a Proxy so `.from(table).update(...)`
* (used via `as never` in a few call sites) still works, and accessing them
* invalidates the same way a non-projection RPC does.
*/
import type { RectificationRpcClient } from "./tool-service";
export const RECTIFICATION_CACHED_PROJECTION_RPCS = [
"get_agentic_rectification_case_dossier",
"get_agentic_rectification_case_compute",
] as const;
type CachedProjectionRpc = (typeof RECTIFICATION_CACHED_PROJECTION_RPCS)[number];
const cachedProjectionRpcSet = new Set<string>(RECTIFICATION_CACHED_PROJECTION_RPCS);
function isCachedProjectionRpc(fn: string): fn is CachedProjectionRpc {
return cachedProjectionRpcSet.has(fn);
}
function projectionCacheKey(fn: string, args: Record<string, unknown>): string | null {
const userId = args.p_user_id;
const caseId = args.p_case_id;
if (typeof userId !== "string" || typeof caseId !== "string" || !userId || !caseId) {
return null;
}
return `${fn}\0${userId}\0${caseId}`;
}
type RpcResult = Awaited<ReturnType<RectificationRpcClient["rpc"]>>;
export function withRectificationRequestCache<T extends RectificationRpcClient>(accounting: T): T {
const cache = new Map<string, PromiseLike<RpcResult>>();
const originalRpc = accounting.rpc.bind(accounting);
function invalidate(): void {
cache.clear();
}
function cachedRpc(fn: string, args: Record<string, unknown>): PromiseLike<RpcResult> {
if (!isCachedProjectionRpc(fn)) {
invalidate();
return originalRpc(fn, args);
}
const key = projectionCacheKey(fn, args);
if (!key) return originalRpc(fn, args);
const hit = cache.get(key);
if (hit) return hit;
const pending = originalRpc(fn, args);
cache.set(key, pending);
return pending;
}
return new Proxy(accounting, {
get(target, property) {
if (property === "rpc") return cachedRpc;
invalidate();
const value = Reflect.get(target, property);
if (typeof value === "function") {
return value.bind(target);
}
return value;
},
}) as T;
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
import { loadV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_COMPUTE_RPC,
CASE_DOSSIER_RPC,
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
countCachedProjectionRpcs,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
/** Measured after wrapping: route-style prefetch + one evidence turn. */
const CACHED_AGENT_TURN_DOSSIER_BUDGET = 4;
const WRAPPED_ADMIN_CLIENT = /accounting = withRectificationRequestCache\(createAdminSupabaseClient\(\)\)/;
const BARE_ADMIN_CLIENT = /accounting = createAdminSupabaseClient\(\)/;
const CACHED_ROUTES = [
"../src/app/api/rectification/agent/route.ts",
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
"../src/app/api/rectification/cases/[caseId]/route.ts",
"../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts",
"../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts",
] as const;
function projectionArgs(caseId = CASE_ID) {
return { p_user_id: USER_ID, p_case_id: caseId };
}
function delayedClient(innerCalls: string[], delayMs = 20) {
return {
rpc(fn: string, args: Record<string, unknown>) {
void args;
innerCalls.push(fn);
return new Promise<{ data: unknown; error: null }>((resolve) => {
setTimeout(() => resolve({ data: { ok: fn }, error: null }), delayMs);
});
},
};
}
test("repeated case_dossier reads of the same user and case hit the in-flight cache once", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
const first = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
const second = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
assert.equal(first.case.caseId, CASE_ID);
assert.equal(second.case.caseId, CASE_ID);
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 0 });
});
test("a non-projection rpc invalidates so the next dossier read hits the backing client", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc("append_agentic_rectification_turn", projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
});
test("two caseIds do not share a cached projection", async () => {
const otherCaseId = "99999999-9999-4999-8999-999999999999";
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(otherCaseId));
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
assert.equal(
accounting.calls.filter((call) => call.fn === CASE_DOSSIER_RPC).length,
2,
);
});
test("concurrent duplicate reads share one backing rpc", async () => {
const innerCalls: string[] = [];
const wrapped = withRectificationRequestCache(delayedClient(innerCalls));
const args = projectionArgs();
const [first, second] = await Promise.all([
wrapped.rpc(CASE_DOSSIER_RPC, args),
wrapped.rpc(CASE_DOSSIER_RPC, args),
]);
assert.deepEqual(first, { data: { ok: CASE_DOSSIER_RPC }, error: null });
assert.deepEqual(second, { data: { ok: CASE_DOSSIER_RPC }, error: null });
assert.deepEqual(innerCalls, [CASE_DOSSIER_RPC]);
});
test("dossier and compute projections cache independently", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
[CASE_COMPUTE_RPC]: () => ({ case_id: CASE_ID }),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 1 });
});
test(".from passthrough invalidates the projection cache", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.from("profiles").update({ birth_time_source: "approximate" }).eq("id", USER_ID);
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
assert.equal(accounting.profilePatches.length, 1);
});
test("rectification routes wrap createAdminSupabaseClient once and never use the bare client", () => {
for (const relative of CACHED_ROUTES) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
const wraps = source.match(new RegExp(WRAPPED_ADMIN_CLIENT.source, "g")) ?? [];
assert.equal(wraps.length, 1, relative);
assert.equal(BARE_ADMIN_CLIENT.test(source), false, relative);
}
});
test("a cached evidence turn stays within the measured case_dossier budget", async () => {
const handlers = {
...receiptHandlers,
[CASE_DOSSIER_RPC]: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
};
const unwrapped = fakeAccounting(handlers);
const wrapped = fakeAccounting(handlers, { requestCache: true });
const stream = {
stream: async () => ({
fullStream: (async function* () {
yield { type: "start" };
yield { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } };
yield { type: "tool-result", payload: { toolName: "skill" } };
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
yield { type: "text-delta", payload: { text: "记下了。" } };
yield { type: "finish" };
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
const base = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence" as const,
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: () => {},
buildAgent: async () => stream as never,
};
await loadV9CaseDossier(unwrapped.client, USER_ID, CASE_ID);
await runV9AgentTurn({ ...base, accounting: unwrapped.client });
await loadV9CaseDossier(wrapped.client, USER_ID, CASE_ID);
await runV9AgentTurn({ ...base, accounting: wrapped.client });
const before = countCachedProjectionRpcs(unwrapped.calls);
const after = countCachedProjectionRpcs(wrapped.calls);
assert.ok(after.dossier < before.dossier);
assert.ok(
after.dossier <= CACHED_AGENT_TURN_DOSSIER_BUDGET,
`cached dossier rpc ${after.dossier} exceeded budget ${CACHED_AGENT_TURN_DOSSIER_BUDGET} (uncached ${before.dossier})`,
);
});
@@ -1,4 +1,8 @@
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";
export type FakeRpcHandler = (
fn: string,
@@ -17,13 +21,81 @@ export type FakeAccounting = {
};
};
export type FakeAccountingOptions = {
fallback?: FakeRpcHandler;
/**
* Default off. When true, wrap `client` with the request-scoped projection
* cache. Also enabled by RECTIFICATION_REQUEST_CACHE=1 for before/after counts.
*/
requestCache?: boolean;
/**
* Default off. When true, this instance is included in takeFakeAccountingRpcCounts().
* Also enabled by RECTIFICATION_RPC_COUNT=1.
*/
countRpc?: boolean;
};
export type CachedProjectionRpcCounts = {
dossier: number;
compute: number;
};
export type FakeAccountingRpcCountSummary = {
scenes: number;
dossier: number;
compute: number;
dossierMean: number;
computeMean: number;
};
const countedAccounting: FakeAccounting[] = [];
let rpcCountEnabled = process.env.RECTIFICATION_RPC_COUNT === "1";
export function countCachedProjectionRpcs(
calls: Array<{ fn: string }>,
): CachedProjectionRpcCounts {
let dossier = 0;
let compute = 0;
for (const call of calls) {
if (call.fn === CASE_DOSSIER_RPC) dossier += 1;
else if (call.fn === CASE_COMPUTE_RPC) compute += 1;
}
return { dossier, compute };
}
export function enableFakeAccountingRpcCount(enabled = true): void {
rpcCountEnabled = enabled;
if (!enabled) countedAccounting.length = 0;
}
export function takeFakeAccountingRpcCounts(): FakeAccountingRpcCountSummary {
const scenes = countedAccounting
.map((item) => countCachedProjectionRpcs(item.calls))
.filter((row) => row.dossier > 0 || row.compute > 0);
const dossier = scenes.reduce((sum, row) => sum + row.dossier, 0);
const compute = scenes.reduce((sum, row) => sum + row.compute, 0);
return {
scenes: scenes.length,
dossier,
compute,
dossierMean: scenes.length === 0 ? 0 : dossier / scenes.length,
computeMean: scenes.length === 0 ? 0 : compute / scenes.length,
};
}
if (process.env.RECTIFICATION_RPC_COUNT === "1") {
process.on("beforeExit", () => {
process.stderr.write(`RECTIFICATION_RPC_COUNT ${JSON.stringify(takeFakeAccountingRpcCounts())}\n`);
});
}
export function fakeAccounting(
handlers: Partial<Record<string, FakeRpcHandler>>,
options: { fallback?: FakeRpcHandler } = {},
options: FakeAccountingOptions = {},
): FakeAccounting {
const calls: Array<{ fn: string; args: Record<string, unknown> }> = [];
const profilePatches: Array<Record<string, unknown>> = [];
const client: FakeAccounting["client"] = {
const rawClient: FakeAccounting["client"] = {
rpc(fn, args) {
calls.push({ fn, args });
const handler = handlers[fn] ?? options.fallback;
@@ -57,7 +129,12 @@ export function fakeAccounting(
};
},
};
return { calls, profilePatches, client };
const requestCache = options.requestCache === true
|| process.env.RECTIFICATION_REQUEST_CACHE === "1";
const client = requestCache ? withRectificationRequestCache(rawClient) : rawClient;
const accounting: FakeAccounting = { calls, profilePatches, client };
if (options.countRpc === true || rpcCountEnabled) countedAccounting.push(accounting);
return accounting;
}
export const CASE_ID = "11111111-1111-4111-8111-111111111111";