autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
174 lines (173 loc) • 5.55 kB
JavaScript
/**
* HitRecorder — 批量使用信号采集器
*
* Phase 0 核心服务。将高频使用事件(Guard 命中、搜索命中、采用等)
* 先写入内存 buffer,定时批量持久化到 Stats JSON,同时发射 SignalBus 信号。
*
* 设计要点:
* - 即时 emit Signal(信号不延迟)
* - buffer → 30s flush → 批量 UPDATE(减少 SQLite 写)
* - shutdown hook 保证进程退出前数据落盘
*
* @module service/signal/HitRecorder
*/
import { unwrapRawDb } from '../../repository/search/SearchRepoAdapter.js';
/** 事件类型 → Stats JSON 字段 映射 */
const EVENT_TO_STATS_FIELD = {
guardHit: 'guardHits',
searchHit: 'searchHits',
view: 'views',
adoption: 'adoptions',
application: 'applications',
};
/** 事件类型 → SignalBus 信号类型 映射 */
const EVENT_TO_SIGNAL_TYPE = {
guardHit: 'guard',
searchHit: 'search',
view: 'usage',
adoption: 'usage',
application: 'usage',
};
// ── HitRecorder ─────────────────────────────────────
export class HitRecorder {
#bus;
#db;
#buffer = new Map();
#flushIntervalMs;
#maxBufferSize;
#timer = null;
#totalRecorded = 0;
#totalFlushed = 0;
constructor(bus, db, config = {}) {
this.#bus = bus;
this.#db = unwrapRawDb(db);
this.#flushIntervalMs = config.flushIntervalMs ?? 30_000;
this.#maxBufferSize = config.maxBufferSize ?? 100;
}
/**
* 启动定时 flush。通常在服务初始化时调用。
*/
start() {
if (this.#timer) {
return;
}
this.#timer = setInterval(() => {
void this.flush();
}, this.#flushIntervalMs);
this.#timer.unref(); // 不阻止进程退出
}
/**
* 停止定时 flush 并执行最后一次 flush。
* 供 shutdown hook 调用。
*/
async stop() {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
await this.flush();
}
/**
* 记录一次命中事件。
*
* 1. 立即通过 SignalBus 发射信号(信号不延迟)
* 2. 事件写入内存 buffer,等待 flush 批量持久化
*
* @param recipeId 关联的知识条目 ID
* @param eventType 事件类型
* @param value 信号强度 0-1(默认 1)
* @param metadata 附加元数据
*/
record(recipeId, eventType, value = 1, metadata = {}) {
this.#totalRecorded++;
// 1. 即时发射信号
this.#bus.send(EVENT_TO_SIGNAL_TYPE[eventType], `HitRecorder.${eventType}`, value, {
target: recipeId,
metadata: { ...metadata, eventType },
});
// 2. 聚合进 buffer
const key = `${recipeId}:${eventType}`;
const now = Date.now();
const existing = this.#buffer.get(key);
if (existing) {
existing.count++;
existing.lastAt = now;
}
else {
this.#buffer.set(key, {
recipeId,
eventType,
count: 1,
firstAt: now,
lastAt: now,
});
}
// 3. buffer 满时立即 flush
if (this.#buffer.size >= this.#maxBufferSize) {
void this.flush();
}
}
/**
* 批量持久化 buffer 到数据库。
* 使用 json_set 原子更新 Stats JSON 中对应字段。
*/
async flush() {
if (this.#buffer.size === 0) {
return 0;
}
// 取出当前 buffer 并清空(后续 record 写入新 buffer)
const entries = [...this.#buffer.values()];
this.#buffer.clear();
let flushed = 0;
const now = Math.floor(Date.now() / 1000);
try {
const stmt = this.#db.prepare(
// @escape-hatch(permanent) — json_set() not expressible in Drizzle
`UPDATE knowledge_entries
SET stats = json_set(
COALESCE(stats, '{}'),
'$.' || ?,
COALESCE(json_extract(stats, '$.' || ?), 0) + ?
),
updatedAt = ?
WHERE id = ?`);
for (const entry of entries) {
const field = EVENT_TO_STATS_FIELD[entry.eventType];
try {
stmt.run(field, field, entry.count, now, entry.recipeId);
flushed += entry.count;
}
catch {
// Recipe 可能已被删除,静默忽略
}
}
}
catch {
// DB statement prepare 失败(表可能不存在),回填 buffer
for (const entry of entries) {
const key = `${entry.recipeId}:${entry.eventType}`;
const existing = this.#buffer.get(key);
if (existing) {
existing.count += entry.count;
}
else {
this.#buffer.set(key, entry);
}
}
}
this.#totalFlushed += flushed;
return flushed;
}
/** 当前 buffer 中的条目数(诊断用) */
get bufferSize() {
return this.#buffer.size;
}
/** 累计记录次数 */
get totalRecorded() {
return this.#totalRecorded;
}
/** 累计已持久化次数 */
get totalFlushed() {
return this.#totalFlushed;
}
}