mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
feat: release v1.0.0
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
Reference in New Issue
Block a user