UNPKG

@tencentdb-agent-memory/memory-tencentdb

Version:

Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)

290 lines (248 loc) 10.5 kB
/** * TimerScanner — Sharded Timer Scanner (Scheme D + Mode 1) * * Architecture: * - Timers are stored in 16 sharded global ZSETs: `{prefix}:timers:shard_{0..15}` * - Member format: `{instanceId}\x00{sessionId}:{timerType}`, score = fireAtMs * - ALL pods run the scanner (no leader election needed) * - Each pod scans all 16 shards using Lua atomic claim (ZRANGEBYSCORE + ZREM) * - Lua atomicity guarantees no duplicate consumption across pods * * Performance: * - Fixed number of shard-claim calls per scan interval * - O(1) per scan regardless of instance count * - Each shard keeps bounded member counts to avoid hot keys * * Usage: * Embedded: const scanner = new TimerScanner(backend, config); scanner.start(); */ import type { IStateBackend, TaskPayload, TimerEntry } from "../core/state/types.js"; interface Logger { debug?: (message: string) => void; info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; } export interface TimerScannerConfig { /** 扫描间隔 ms (default: 2000) */ scanIntervalMs?: number; /** 每个 shard 每次最多取出的 timer 数 (default: 1000) */ claimBatchSize?: number; /** 节点 ID (用于日志标识) */ nodeId?: string; /** Legacy: 实例列表(Scheme D 下不再需要,保留兼容) */ instances?: string[] | (() => Promise<string[]>); /** Legacy: leader 相关配置(Scheme D 下忽略) */ leaderLockKey?: string; leaderLockTtlMs?: number; leaderRenewIntervalMs?: number; } const TAG = "[timer-scanner]"; // ============================ // TimerScanner // ============================ export class TimerScanner { private backend: IStateBackend; private config: { scanIntervalMs: number; claimBatchSize: number; nodeId: string; }; private logger: Logger; private scanTimer: ReturnType<typeof setInterval> | null = null; private destroyed = false; // Metrics private metrics = { scansCompleted: 0, tasksEnqueued: 0, scanErrors: 0, lastScanMs: 0, lastScanAt: 0, isLeader: true, // Always true in Scheme D (all pods are "leaders") }; constructor(backend: IStateBackend, config: TimerScannerConfig, logger?: Logger) { this.backend = backend; this.logger = logger ?? { info: console.log, warn: console.warn, error: console.error }; this.config = { scanIntervalMs: config.scanIntervalMs ?? 2000, claimBatchSize: config.claimBatchSize ?? 1000, nodeId: config.nodeId ?? `scanner-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, }; } // ============================ // Lifecycle // ============================ async start(): Promise<void> { if (this.destroyed) return; this.logger.info(`${TAG} Starting (nodeId=${this.config.nodeId}, interval=${this.config.scanIntervalMs}ms, shards=${this.getShardCount()})`); this.startScanLoop(); this.logger.info(`${TAG} Started (leaderless mode, all pods scan)`); } async stop(): Promise<void> { if (this.destroyed) return; this.destroyed = true; this.stopScanLoop(); this.logger.info(`${TAG} Stopped (scans=${this.metrics.scansCompleted}, enqueued=${this.metrics.tasksEnqueued})`); } getMetrics() { return { ...this.metrics, nodeId: this.config.nodeId }; } // ============================ // Scan Loop // ============================ private startScanLoop(): void { if (this.scanTimer) return; this.scanTimer = setInterval(() => this.scan(), this.config.scanIntervalMs); } private stopScanLoop(): void { if (this.scanTimer) { clearInterval(this.scanTimer); this.scanTimer = null; } } private getShardCount(): number { // Use backend-specific shard count if available const rb = this.backend as any; if (typeof rb.timerShardCount === "number") return rb.timerShardCount; return 16; // default } private getShardKey(shard: number): string { const rb = this.backend as any; if (typeof rb.getTimerShardKeyByIndex === "function") return rb.getTimerShardKeyByIndex(shard); return `tdai_memory:timers:shard_${shard}`; } private async scan(): Promise<void> { if (this.destroyed) return; const startMs = Date.now(); try { const shardCount = this.getShardCount(); const now = Date.now(); let totalEnqueued = 0; for (let shard = 0; shard < shardCount; shard++) { const rb = this.backend as any; let expired: TimerEntry[]; if (typeof rb.claimExpiredFromShard === "function") { // Scheme D: atomic claim from shard expired = await rb.claimExpiredFromShard(this.getShardKey(shard), now, this.config.claimBatchSize); } else { // Fallback for LocalStateBackend: use legacy getExpiredTimers break; // LocalStateBackend handles timers internally via setTimeout } for (const entry of expired) { const { instanceId, sessionId, taskType, priority, timerType } = this.parseShardMember(entry.member); const task: TaskPayload = { id: `${taskType}-${instanceId.slice(-8)}-${sessionId.slice(-8)}-${now}`, type: taskType, instanceId, sessionId, priority, createdAt: now, data: { triggeredBy: "timer_scanner", timerMember: `${sessionId}:${timerType}`, instanceId }, }; await this.backend.enqueueTask(task); totalEnqueued++; this.logger?.debug?.( `${TAG} [${instanceId}] Timer expired: ${sessionId}:${taskType} → enqueued ${taskType} task`, ); } } this.metrics.scansCompleted++; this.metrics.tasksEnqueued += totalEnqueued; this.metrics.lastScanMs = Date.now() - startMs; this.metrics.lastScanAt = Date.now(); if (totalEnqueued > 0) { this.logger.info(`${TAG} Scan complete: enqueued ${totalEnqueued} task(s) in ${this.metrics.lastScanMs}ms`); } } catch (err) { this.metrics.scanErrors++; this.logger.error(`${TAG} Scan error: ${err instanceof Error ? err.message : String(err)}`); } } /** * Parse shard member format: "{instanceId}\x00{sessionId}:{timerType}" * Example: "mem-j4wjesud\x00sess_001:L1_idle" → { instanceId: "mem-j4wjesud", sessionId: "sess_001", taskType: "L1" } */ private parseShardMember(member: string): { instanceId: string; sessionId: string; taskType: TaskPayload["type"]; priority: number; timerType: string } { const sep = member.indexOf("\x00"); let instanceId: string; let rest: string; if (sep >= 0) { instanceId = member.slice(0, sep); rest = member.slice(sep + 1); } else { // Fallback: try colon-separated (legacy format "instanceId:sessionId:type") const firstColon = member.indexOf(":"); instanceId = member.slice(0, firstColon); rest = member.slice(firstColon + 1); } // rest = timer member after instanceId separator // New unified format: "offload-{type}:{embeddedInstanceId}:{sessionId}[:{extra}]" (prefix-based) // Legacy format: "sessionId:L1_idle" or "sessionId:L2_schedule" // Check for offload prefix format first if (rest.startsWith("offload-l15:")) { // Skip embedded instanceId: "offload-l15:{instanceId}:{sessionId}" const afterPrefix = rest.slice("offload-l15:".length); const colonIdx = afterPrefix.indexOf(":"); const sessionId = colonIdx > 0 ? afterPrefix.slice(colonIdx + 1) : afterPrefix; return { instanceId, sessionId, taskType: "offload-l15", priority: 0, timerType: rest }; } if (rest.startsWith("offload-l2:")) { // Skip embedded instanceId: "offload-l2:{instanceId}:{sessionId}[:{mmdFile}]" const afterPrefix = rest.slice("offload-l2:".length); const colonIdx = afterPrefix.indexOf(":"); let sessionId = colonIdx > 0 ? afterPrefix.slice(colonIdx + 1) : afterPrefix; // Strip trailing ":{mmdFile}" from sessionId if (sessionId.endsWith(".mmd")) { const lastColon = sessionId.lastIndexOf(":"); if (lastColon > 0) sessionId = sessionId.slice(0, lastColon); } return { instanceId, sessionId, taskType: "offload-l2", priority: 1, timerType: rest }; } if (rest.startsWith("offload-l1:")) { // Skip embedded instanceId: "offload-l1:{instanceId}:{sessionId}" const afterPrefix = rest.slice("offload-l1:".length); const colonIdx = afterPrefix.indexOf(":"); const sessionId = colonIdx > 0 ? afterPrefix.slice(colonIdx + 1) : afterPrefix; return { instanceId, sessionId, taskType: "offload-l1", priority: 0, timerType: rest }; } // For non-offload types, use lastColon (original logic) const lastColon = rest.lastIndexOf(":"); if (lastColon <= 0) { return { instanceId, sessionId: rest, taskType: "L1", priority: 0, timerType: "L1_idle" }; } const sessionId = rest.slice(0, lastColon); const timerType = rest.slice(lastColon + 1); if (timerType.startsWith("L1")) return { instanceId, sessionId, taskType: "L1", priority: 0, timerType }; if (timerType.startsWith("L2")) return { instanceId, sessionId, taskType: "L2", priority: 1, timerType }; if (timerType.startsWith("L3")) return { instanceId, sessionId, taskType: "L3", priority: 2, timerType }; return { instanceId, sessionId, taskType: "flush", priority: 0, timerType }; } } // ============================ // Standalone entry point // ============================ export async function startTimerScanner(): Promise<TimerScanner> { const { createStateBackend } = await import("../core/state/index.js"); const backend = await createStateBackend({ type: (process.env.STATE_BACKEND as "redis" | "local") || "redis", redis: { host: process.env.REDIS_HOST || "127.0.0.1", port: parseInt(process.env.REDIS_PORT || "6379", 10), password: process.env.REDIS_PASSWORD || undefined, keyPrefix: process.env.REDIS_KEY_PREFIX || "tdai_memory", }, }); const scanner = new TimerScanner(backend, { scanIntervalMs: parseInt(process.env.SCANNER_INTERVAL_MS || "2000", 10), nodeId: process.env.SCANNER_NODE_ID, }); const shutdown = async () => { await scanner.stop(); await backend.destroy?.(); process.exit(0); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); await scanner.start(); return scanner; }