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).
This commit is contained in:
+23
-2
@@ -10,6 +10,7 @@
|
||||
* 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]
|
||||
*
|
||||
@@ -27,7 +28,7 @@ process.emitWarning = (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 { loadConfig } = await import("../extensions/shared.ts");
|
||||
const { breakerInfo, loadConfig, resetBreaker } = await import("../extensions/shared.ts");
|
||||
process.emitWarning = originalEmitWarning;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -65,6 +66,10 @@ if (cmd === "status") {
|
||||
}
|
||||
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(", ")}`);
|
||||
@@ -178,6 +183,22 @@ if (cmd === "status") {
|
||||
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 });
|
||||
@@ -187,7 +208,7 @@ if (cmd === "status") {
|
||||
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 | enrich | pull`);
|
||||
console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | breaker | enrich | pull`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user