Files
pi-qmem/scripts/qmem-sqlite.mjs
T
Matteo Benedetto 9dd24298cc perf(fallback): connect timeout breve + circuit breaker persistente (fast-fail)
Il gateway irraggiungibile costava ~30s x4 tentativi (fino a ~2 minuti) per ogni
chiamata: ora si distinguono i due casi e le chiamate successive sono immediate.

- due timeout separati: `connectTimeoutMs` (default 2500, connect+headers) e
  `timeoutMs` (default 30000, budget per il body). Nessuna risposta entro il
  primo = "gateway non raggiungibile"; body lento = "elaborazione lunga"
- circuit breaker persistente in ~/.local/share/pi-qmem/breaker.json
  (env QMEM_BREAKER_FILE): fallimento definitivo (connessione rifiutata/DNS/
  connect timeout) → nessun retry e apertura immediata per `breakerBaseMs`
  (default 120000 = 2 min) con escalation fino a `breakerMaxMs` (10 min);
  5xx/body lento sono ambigui → retry con Retry-After e apertura dopo
  `breakerTripAfter` (default 2). Un successo lo richiude; cambiando `url` lo
  stato riparte chiuso (endpoint-aware)
- con breaker aperto gatewayRequest ritorna in ~0 ms senza rete
  (`gateway_unreachable`, `breaker_open`, `retry_in_ms`): i tool passano subito
  al fallback locale e l'outbox accoda
- fix di due bug scoperti durante i test:
  * `res.json().catch(() => ({}))` trasformava un body non completato in
    "successo con dati vuoti" → l'agente vedeva "nessun risultato" invece del
    fallback locale. Ora è `timeout_body` (fallimento, ambiguo)
  * `submitOrQueue` passava un AbortSignal esterno, che con la nuova semantica
    sarebbe stato letto come annullamento utente (eccezione invece di coda)
- messaggi dei tool con lo stato del breaker e come forzare un tentativo;
  `details.breaker` per l'osservabilità
- comandi: `/qmem:local breaker [reset]` e `qmem-sqlite breaker [--reset]`;
  lo stato compare in `/qmem:local status` e nella CLI
- budget interni per enrich/pull/flush (niente AbortSignal esterni)

Misure: connessione rifiutata → 4-8 ms (prima: 4 x 30 s); front che risponde
503 dopo ~40 s → 3,5 s alla prima chiamata, poi 0 ms di rete a breaker aperto;
server che accetta e non risponde → 708 ms (connect timeout); body lento →
1,2 s senza aprire il breaker; persistenza verificata fra processi distinti.

Test: scripts/test-local.mjs 38/38 (nuova fase dedicata al breaker).
2026-09-13 18:09:50 +02:00

216 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* qmem-sqlite — CLI per l'indice locale di pi-qmem (SQLite + FTS5).
*
* Uso:
* node scripts/qmem-sqlite.mjs status
* node scripts/qmem-sqlite.mjs import [--db FILE]
* node scripts/qmem-sqlite.mjs find "query" [--kind K] [--project P] [--scope S]
* [--top N] [--all] [--private] [--exact] [--json]
* node scripts/qmem-sqlite.mjs store --project P [--kind K] [--text "..."] [--queue-only]
* node scripts/qmem-sqlite.mjs queue [--status queued|synced|duplicate|failed]
* node scripts/qmem-sqlite.mjs flush [--limit N]
* node scripts/qmem-sqlite.mjs breaker [--reset]
* node scripts/qmem-sqlite.mjs enrich [--all] [--limit N] [--pace MS]
* node scripts/qmem-sqlite.mjs pull [--limit N]
*
* Il DB di default è ~/.local/share/pi-qmem/qmem.sqlite (override: --db,
* env QMEM_SQLITE, oppure `localDbPath` in ~/.config/pi-qmem/config.json).
*/
// import dinamico: permette di sopprimere il warning MODULE_TYPELESS_PACKAGE_JSON
// (il package non dichiara "type":"module" per non cambiare la semantica del
// manifest pi) e di degradare con grazia se node:sqlite non è disponibile.
const originalEmitWarning = process.emitWarning;
process.emitWarning = (warning, ...rest) => {
const code = rest[0]?.code ?? (typeof rest[0] === "string" ? rest[0] : undefined) ?? warning?.code;
if (code === "MODULE_TYPELESS_PACKAGE_JSON") return;
return originalEmitWarning.call(process, warning, ...rest);
};
const localDb = await import("../extensions/local-db.ts");
const { DEFAULT_DB_FILE, enrichFromGateway, flushQueue, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, queueList, queueStats, queueStore, sessionRoots, submitOrQueue } = localDb;
const { breakerInfo, loadConfig, resetBreaker } = await import("../extensions/shared.ts");
process.emitWarning = originalEmitWarning;
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "status";
function opt(name, fallback) {
const i = argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = argv[i + 1];
return v && !v.startsWith("--") ? v : true;
}
const has = (name) => argv.includes(`--${name}`);
const dbFile = typeof opt("db", null) === "string" ? opt("db", null) : localDbPath(loadConfig());
const json = has("json");
function out(obj) {
if (json) console.log(JSON.stringify(obj, null, 2));
else console.log(obj);
}
if (cmd === "status") {
const r = await localDbReport({ dbFile });
if (json) {
out(r);
} else {
console.log(`DB locale : ${r.path}${r.exists ? "" : " (assente — esegui: import)"}`);
if (r.exists) {
console.log(` dimensione : ${r.sizeKb} KB`);
console.log(` record : ${r.total} (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati)`);
console.log(` con project_id: ${r.withProject}/${r.total}`);
console.log(` ultimo import : ${r.lastImport ?? "-"} enrich: ${r.lastEnrich ?? "-"} export: ${r.lastExport ?? "-"} flush: ${r.lastFlush ?? "-"}`);
if (r.queued || r.syncedQueue || r.failedQueue || r.duplicateQueue) {
console.log(` coda offline : ${r.queued} in attesa, ${r.syncedQueue} sincronizzati, ${r.duplicateQueue} duplicati, ${r.failedQueue} falliti${r.oldestQueued ? ` (più vecchio: ${r.oldestQueued})` : ""}`);
if (r.queueLastError) console.log(` ultimo errore : ${r.queueLastError.slice(0, 120)}`);
}
if (r.pendingInIndex) console.log(` in indice : ${r.pendingInIndex} record marcati ⏳ (creati offline, non ancora sul gateway)`);
if (r.deleted) console.log(` tombstone : ${r.deleted} record cancellati sul gateway (soft delete)`);
{
const br = breakerInfo();
console.log(` breaker : ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"} | fallimenti ${br.failures} | aperture ${br.trips}${br.lastError ? ` | ultimo errore: ${String(br.lastError).slice(0, 70)}` : ""}`);
}
console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`);
if (r.duplicates.length) {
console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`);
}
}
}
} else if (cmd === "import") {
const roots = sessionRoots();
const stats = await importFromSessions({ dbFile });
const r = await localDbReport({ dbFile });
if (json) out({ stats, report: r });
else {
console.log(`Import dalle sessioni (${roots.join(", ")})`);
console.log(` file letti : ${stats.files} (${stats.lines} righe)`);
console.log(` eventi : store=${stats.store} correct=${stats.correct} get=${stats.get} search_hit=${stats.searchHits} non_interpretati=${stats.unparsed}`);
console.log(` record unici : ${stats.records} (scritti/aggiornati: ${stats.written})`);
console.log(` DB : ${r.path}${r.total} record, ${r.sizeKb} KB, ${r.withText} con testo`);
if (r.duplicates.length) console.log(` duplicati : ${r.duplicates.length} gruppi con testo identico`);
}
} else if (cmd === "find") {
const query = argv[1] && !argv[1].startsWith("--") ? argv[1] : "";
if (!query) {
console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--deleted] [--exact] [--json]');
process.exit(2);
}
const hits = await localSearch(
{
query,
kind: typeof opt("kind", null) === "string" ? opt("kind", null) : undefined,
project_id: typeof opt("project", null) === "string" ? opt("project", null) : undefined,
scope: typeof opt("scope", null) === "string" ? opt("scope", null) : undefined,
level: typeof opt("level", null) === "string" ? opt("level", null) : undefined,
topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined,
include_superseded: has("all"),
include_private: has("private"),
include_deleted: has("deleted"),
exact: has("exact"),
top_k: Number(opt("top", 5)) || 5,
},
{ dbFile },
);
if (json) {
out(hits);
} else {
if (!hits.length) console.log(`Nessun risultato locale per "${query}" (indice: ${dbFile}).`);
hits.forEach((h, i) => {
console.log(`${i + 1}. [${h.kind ?? "?"}/${h.scope ?? "?"}${h.project_id ? ` project=${h.project_id}` : ""} rank=${Number(h.rank).toFixed(2)}${h.superseded_by ? " superseduto" : ""}] ${h.snippet}`);
console.log(` (id: ${h.memory_id}, creato: ${h.created_at ?? "?"}, agente: ${h.agent_id ?? "?"}, fonti: ${h.sources ?? "?"})`);
});
}
} else if (cmd === "enrich") {
const cfg = loadConfig();
const stats = await enrichFromGateway(cfg, {
dbFile,
onlyIncomplete: !has("all"),
limit: Number(opt("limit", 1000)) || 1000,
paceMs: Number(opt("pace", 600)),
});
if (json) out(stats);
else {
console.log(`Arricchimento dal gateway (${cfg.url})`);
console.log(` richiesti: ${stats.requested} ok: ${stats.ok} falliti: ${stats.failed} aggiornati: ${stats.updated}`);
if (stats.errors.length) console.log(` errori : ${stats.errors.join(" | ")}`);
if (stats.failed && !stats.ok) console.log(" (gateway non raggiungibile: riprova quando torna online)");
}
} else if (cmd === "store") {
// store con fallback offline: prova il gateway, altrimenti accoda
const cfg = loadConfig();
const text = typeof opt("text", null) === "string" ? opt("text", null) : fs.readFileSync(String(opt("file", "/dev/stdin")), "utf8").trim();
const project = typeof opt("project", null) === "string" ? opt("project", null) : "";
if (!text || !project) {
console.error('Uso: store --project P [--kind K] [--scope S] [--text "..."] | --file FILE [--queue-only]');
process.exit(2);
}
const payload = {
text,
project_id: project,
kind: typeof opt("kind", null) === "string" ? opt("kind", null) : "fact",
scope: typeof opt("scope", null) === "string" ? opt("scope", null) : "agent",
agent_id: typeof opt("agent", null) === "string" ? opt("agent", null) : undefined,
topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined,
};
if (has("queue-only")) {
const q = await queueStore(payload, { dbFile });
out(json ? q : `Accodato localmente: ${q.local_id} (in coda: ${q.queue_size})`);
} else {
const res = await submitOrQueue(cfg, payload, { dbFile });
if (json) out(res);
else if (res.queued) out(`Gateway non raggiungibile (HTTP ${res.status}): accodato localmente ${res.local_id} (in coda: ${res.queue_size}). Flush: qmem-sqlite flush`);
else if (res.remote_id) out(`Salvato sul gateway: ${res.remote_id}`);
else out(`Errore HTTP ${res.status}: ${JSON.stringify(res.data)}`);
}
} else if (cmd === "queue") {
const stats = await queueStats({ dbFile });
const items = await queueList({ dbFile, status: typeof opt("status", null) === "string" ? opt("status", null) : undefined, limit: Number(opt("limit", 20)) || 20 });
if (json) out({ stats, items });
else {
console.log(`Coda offline (${dbFile})`);
console.log(` in attesa: ${stats.queued} sincronizzati: ${stats.synced} duplicati: ${stats.duplicate} falliti: ${stats.failed}`);
if (stats.oldestQueued) console.log(` più vecchio: ${stats.oldestQueued}`);
if (stats.lastError) console.log(` ultimo errore: ${stats.lastError.slice(0, 140)}`);
items.forEach((q, i) => console.log(` ${i + 1}. [${q.status}] ${q.local_id.slice(0, 8)} [${q.payload.kind ?? "fact"}/${q.payload.project_id}] ${String(q.payload.text).slice(0, 70)}`));
}
} else if (cmd === "flush") {
const cfg = loadConfig();
const res = await flushQueue(cfg, { dbFile, limit: Number(opt("limit", 200)) || 200, paceMs: Number(opt("pace", 300)) });
if (json) out(res);
else {
console.log(`Flush outbox verso ${cfg.url}`);
console.log(` sincronizzati: ${res.synced} duplicati: ${res.duplicates} falliti: ${res.failed} ancora in coda: ${res.remaining}`);
if (res.stopped) console.log(` fermato: ${res.stopped}`);
if (res.errors.length) console.log(` errori: ${res.errors.join(" | ")}`);
}
} else if (cmd === "breaker") {
if (has("reset")) {
const br = resetBreaker();
out(json ? br : `Circuit breaker: chiuso (fallimenti ${br.failures})`);
} else {
const br = breakerInfo();
if (json) out(br);
else {
console.log(`Circuit breaker qmem (${br.file})`);
console.log(` stato: ${br.open ? `APERTO — riprova tra ${Math.ceil(br.remainingMs / 1000)}s` : "chiuso"}`);
console.log(` fallimenti consecutivi: ${br.failures} | aperture totali: ${br.trips}`);
if (br.lastError) console.log(` ultimo errore: ${br.lastError}`);
if (br.lastChange) console.log(` ultimo cambio: ${br.lastChange}`);
console.log(" reset: qmem-sqlite breaker --reset");
}
}
} else if (cmd === "pull") {
const cfg = loadConfig();
const res = await pullFromGatewayExport(cfg, { dbFile, limit: Number(opt("limit", 500)) || 500 });
if (json) out(res);
else {
console.log(`Pull export dal gateway (${cfg.url})`);
console.log(` supportato: ${res.supported} pagine: ${res.pages} record: ${res.fetched}${res.message ? ` — ${res.message}` : ""}`);
}
} else {
console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | breaker | enrich | pull`);
process.exit(2);
}
void DEFAULT_DB_FILE;