Files
TencentDB-Agent-Memory/src/offload_server/ingest-handler.ts
T
2026-06-11 14:56:11 +08:00

263 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Offload Ingest Handler — fast path: COS write + task enqueue.
* Branch A: toolPairs only → write pending.jsonl, enqueue async L1
* Branch B: recentMessages only (no toolPairs) → L1.5 path
* Branch C: toolPairs + recentMessages → write pending + cache recentMessages, trigger L1 (skip L1.5)
*/
import type http from "node:http";
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { IStateBackend, TaskPayload } from "../core/state/types.js";
import type { OffloadExecutorConfig, OffloadState } from "./types.js";
import { defaultOffloadState } from "./types.js";
import { IngestRequestSchema } from "./schemas.js";
import { serializeJsonl } from "./parsers/json-utils.js";
import { buildOffloadBasePath } from "./session-utils.js";
// ─── Per-session in-process mutex for COS append serialization ───
// Layer 1 (local): ensures at most one inflight appendFile per session within
// the same Node.js process (no I/O overhead, zero-latency queue).
// Layer 2 (distributed): stateBackend.acquireLock across multiple offload server
// instances prevents concurrent AppendObject calls to the same COS key.
const pendingMutexes = new Map<string, Promise<void>>();
async function withSessionMutex<T>(sessionKey: string, fn: () => Promise<T>): Promise<T> {
const prev = pendingMutexes.get(sessionKey) ?? Promise.resolve();
let resolve!: () => void;
const next = new Promise<void>((r) => { resolve = r; });
pendingMutexes.set(sessionKey, next);
try {
await prev;
return await fn();
} finally {
resolve();
if (pendingMutexes.get(sessionKey) === next) {
pendingMutexes.delete(sessionKey);
}
}
}
/** Max attempts to acquire the distributed lock. */
const APPEND_LOCK_MAX_ATTEMPTS = 10;
/** TTL for the distributed append lock (ms). Short-lived since append is fast. */
const APPEND_LOCK_TTL_MS = 5000;
/** Delay between lock acquisition retries (ms). Uses exponential backoff: base * 2^attempt. */
const APPEND_LOCK_RETRY_BASE_MS = 50;
export interface IngestDeps {
storage: StorageAdapter;
stateBackend?: IStateBackend;
config: OffloadExecutorConfig;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
}
export async function handleIngest(
req: http.IncomingMessage,
res: http.ServerResponse,
auth: { serviceId: string },
deps: IngestDeps,
requestId: string,
parseJsonBody: <T>(req: http.IncomingMessage) => Promise<T>,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
successEnvelope: <T>(data: T, requestId: string) => unknown,
errorEnvelope: (code: number, message: string, requestId: string) => unknown,
): Promise<void> {
const body = await parseJsonBody(req);
const parsed = IngestRequestSchema.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, errorEnvelope(400, parsed.error.message, requestId));
return;
}
const { session_id: sessionId, tool_pairs: toolPairs, prompt, recent_messages: recentMessages } = parsed.data;
const { storage, stateBackend, config } = deps;
const basePath = buildOffloadBasePath(sessionId);
// Build context string from structured prompt + recentMessages (for L1/L1.5)
let contextText: string | undefined;
if (prompt || (recentMessages && recentMessages.length > 0)) {
const parts: string[] = [];
if (recentMessages && recentMessages.length > 0) {
parts.push("历史消息,可作为参考:");
for (const m of recentMessages) {
parts.push(`[${m.role === "user" ? "User" : "Assistant"}]: ${m.content}`);
}
}
if (prompt) {
parts.push(`\n最新user message\n[User]: ${prompt}`);
}
contextText = parts.join("\n");
}
// ─── Session skip filter (shared by L1 and L1.5) ───
const INTERNAL_SESSION_RE = /memory-.*-session-\d+/;
const shouldSkipSession = INTERNAL_SESSION_RE.test(sessionId) || sessionId.includes("subagent");
// ─── Branch L1: toolPairs non-empty → write pending + trigger L1 ───
if (toolPairs.length > 0) {
if (shouldSkipSession) {
deps.logger.info(`[offload-server] ingest: L1 skipped (session=${sessionId})`);
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// Save context for L1 executor (if available)
if (contextText) {
await storage.writeFile(`${basePath}/recent-context.txt`, contextText);
}
const pendingPath = `${basePath}/pending.jsonl`;
// Map API snake_case → internal camelCase for JSONL storage
const camelPairs = toolPairs.map((tp: Record<string, unknown>) => ({
toolName: tp.tool_name,
toolCallId: tp.tool_call_id,
params: tp.params,
result: tp.result,
error: tp.error,
timestamp: tp.timestamp,
durationMs: tp.duration_ms,
}));
const lines = serializeJsonl(camelPairs);
// Two-layer serialization to prevent COS AppendPositionErr:
// Layer 1 (local mutex): queues concurrent requests within this process.
// Layer 2 (distributed lock via stateBackend): prevents races across server instances.
// If the lock cannot be acquired, return 409 so the client retries (NOT proceed without lock).
const lockAcquired = await withSessionMutex(pendingPath, async () => {
const lockKey = `offload-pending:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
if (stateBackend) {
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
// Exponential backoff: 50, 100, 200, 400, 800, 1600... capped at 2000ms
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
deps.logger.warn(`[offload-server] ingest: append lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
return false;
}
}
try {
await storage.appendFile(pendingPath, lines);
if (stateBackend) {
const raw = await storage.readFile(pendingPath);
const lineCount = raw ? raw.split("\n").filter(Boolean).length : 0;
if (lineCount >= config.forceTriggerThreshold) {
const task: TaskPayload = {
id: `offload-l1-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l1" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, triggered=true (lines=${lineCount})`);
} else {
await stateBackend.setTimerIfEarlier(
auth.serviceId,
`offload-l1:${auth.serviceId}:${sessionId}`,
Date.now() + config.pendingMaxAgeSeconds * 1000,
);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, lines=${lineCount}/${config.forceTriggerThreshold}, timer set`);
}
}
return true;
} finally {
if (locked && stateBackend) {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
});
if (!lockAcquired) {
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// ─── Branch L1.5: toolPairs empty + prompt → task judgment ───
// Skip L1.5 for inter-session messages, internal sessions, and system prompts
const shouldSkipL15 = shouldSkipSession || !!(
prompt && (
prompt.startsWith("[Inter-session message]") ||
prompt.startsWith("Pre-compaction")
)
);
if (prompt && stateBackend && !shouldSkipL15) {
const lockKey = `offload-state:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
// Use exponential backoff, same policy as L1 append lock.
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
// Do NOT proceed without the lock: a concurrent writer would overwrite state.json
// and silently drop this boundary, causing the L1.5 executor to skip the task
// (findIndex returns -1 → return without error).
deps.logger.warn(`[offload-server] ingest: L1.5 state lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
try {
const state = await readState(storage, basePath);
const boundaryTimestamp = new Date().toISOString();
state.boundaries.push({
targetMmd: "_pending",
timestamp: boundaryTimestamp,
});
await writeState(storage, basePath, state);
const task: TaskPayload = {
id: `offload-l15-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l15" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, recentMessages: contextText, boundaryTimestamp, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: L1.5 triggered, boundaryTs=${boundaryTimestamp}, prompt=${prompt.slice(0, 200)}`);
} finally {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
// ─── Fast return ───
sendJson(res, 200, successEnvelope({}, requestId));
}
// ─── State helpers (duplicated intentionally to keep ingest-handler self-contained) ──
async function readState(storage: StorageAdapter, basePath: string): Promise<OffloadState> {
const raw = await storage.readFile(`${basePath}/state.json`);
if (!raw) return defaultOffloadState();
try {
return { ...defaultOffloadState(), ...JSON.parse(raw) };
} catch {
return defaultOffloadState();
}
}
async function writeState(storage: StorageAdapter, basePath: string, state: OffloadState): Promise<void> {
await storage.writeFile(`${basePath}/state.json`, JSON.stringify(state));
}