mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|