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:
Matteo Benedetto
2026-09-13 18:09:50 +02:00
parent d1985514e1
commit 9dd24298cc
11 changed files with 475 additions and 58 deletions
+35 -2
View File
@@ -44,7 +44,12 @@ Config salvata in `~/.config/pi-qmem/config.json` (0600):
"apiKey": "...",
"localDbPath": "~/.local/share/pi-qmem/qmem.sqlite",
"localFallback": true,
"offlineQueue": true
"offlineQueue": true,
"connectTimeoutMs": 2500,
"timeoutMs": 30000,
"breakerBaseMs": 120000,
"breakerMaxMs": 600000,
"breakerTripAfter": 2
}
```
@@ -52,6 +57,30 @@ Config salvata in `~/.config/pi-qmem/config.json` (0600):
disabilita l'accodamento offline in scrittura (lo store torna a fallire come
prima).
## Circuit breaker (fast-fail quando il gateway è irraggiungibile)
Due timeout distinti, per non confondere "gateway giù" con "elaborazione lunga":
| Fase | Chiave | Default | Significato |
|---|---|---|---|
| **connect + headers** | `connectTimeoutMs` | **2500** | nessuna risposta entro questo tempo → **gateway non raggiungibile** (fallimento definitivo) |
| **body** (dopo gli header) | `timeoutMs` | 30000 | budget per il rerank/export/ricerca: un superamento è un fallimento **ambiguo** |
Comportamento:
- **Connessione fallita/nessuna risposta** (ECONNREFUSED, DNS, timeout di connect): **nessun retry**, il
**circuit breaker** si apre subito e resta aperto `breakerBaseMs` (**2 min**), con escalation
esponenziale fino a `breakerMaxMs` (10 min).
- **5xx o body lento**: fallimenti **ambigui** → retry con `Retry-After` e breaker solo dopo
`breakerTripAfter` (default 2) fallimenti consecutivi.
- **Breaker aperto**: le chiamate ritornano in **~0 ms senza toccare la rete** (`error:
"gateway_unreachable"`, `breaker_open: true`, `retry_in_ms`), quindi i tool passano subito al
fallback locale e l'outbox accoda senza attese.
- **Stato persistente**: `~/.local/share/pi-qmem/breaker.json` (env `QMEM_BREAKER_FILE`) → vale anche
per nuove sessioni, `/reload` e processi CLI. Cambiando `url` il breaker riparte chiuso.
- **Reset manuale**: `/qmem:local breaker reset` oppure `node scripts/qmem-sqlite.mjs breaker --reset`;
un successo lo richiude da solo. Stato: `/qmem:local breaker` o `qmem-sqlite breaker`.
## Indice locale (fallback offline)
Il gateway remoto non è sempre raggiungibile (VPN giù, nodi offline). L'estensione
@@ -99,9 +128,13 @@ Comandi (TUI) e CLI standalone:
node scripts/qmem-sqlite.mjs status|import|find "query"|enrich|pull
node scripts/qmem-sqlite.mjs store --project P --text "..." [--queue-only]
node scripts/qmem-sqlite.mjs queue|flush
node scripts/test-local.mjs # suite di test (24 controlli, HOME temporanea)
node scripts/test-local.mjs # suite di test (37 controlli, HOME temporanea)
```
Nota: un body non completato **non** viene più restituito come "successo con dati vuoti"
(prima `res.json().catch(() => ({}))` mascherava il timeout: l'agente vedeva "nessun risultato"
invece del fallback locale).
Limiti dichiarati: è uno **storico osservato** (più vecchio del gateway), la
ricerca è **lessicale** (nessuno score 0.45/0.60: non applicare le soglie
semantiche) e un record in coda (⏳) **non è ancora nella memoria condivisa**: