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:
@@ -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**:
|
||||
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
queueList,
|
||||
queueStats,
|
||||
} from "./local-db.ts";
|
||||
import { loadConfig } from "./shared.ts";
|
||||
import { breakerInfo, loadConfig, resetBreaker } from "./shared.ts";
|
||||
|
||||
export function registerQmemLocal(pi: ExtensionAPI) {
|
||||
pi.registerCommand("qmem:local", {
|
||||
description: "Indice locale SQLite/FTS5: status | import | find <query> | enrich [--all] | pull",
|
||||
description:
|
||||
"Indice locale SQLite/FTS5: status | import | find <query> | queue | flush | breaker [reset] | enrich [--all] | pull",
|
||||
handler: async (args, ctx) => {
|
||||
const cfg = loadConfig();
|
||||
const dbFile = localDbPath(cfg);
|
||||
@@ -37,8 +38,10 @@ export function registerQmemLocal(pi: ExtensionAPI) {
|
||||
}
|
||||
const dup = r.duplicates.length ? ` | duplicati: ${r.duplicates.length} gruppi` : "";
|
||||
const coda = r.queued || r.failedQueue || r.syncedQueue ? ` | coda: ${r.queued} in attesa, ${r.syncedQueue} sincronizzati${r.failedQueue ? `, ${r.failedQueue} falliti` : ""}${r.duplicateQueue ? `, ${r.duplicateQueue} duplicati` : ""}` : "";
|
||||
const br = breakerInfo();
|
||||
ctx.ui.notify(
|
||||
`Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati, ${r.pendingInIndex} in coda) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}${coda}\n` +
|
||||
`Circuit breaker: ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"} | fallimenti: ${br.failures} | aperture: ${br.trips}\n` +
|
||||
`Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati, ${r.pendingInIndex} in coda) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}${coda}\n` +
|
||||
`ultimo import: ${r.lastImport ?? "-"} | enrich: ${r.lastEnrich ?? "-"} | export: ${r.lastExport ?? "-"} | flush: ${r.lastFlush ?? "-"}\n` +
|
||||
`DB: ${r.path}`,
|
||||
"info",
|
||||
@@ -134,6 +137,21 @@ export function registerQmemLocal(pi: ExtensionAPI) {
|
||||
ctx.ui.notify(msg, res.synced || res.duplicates ? "info" : "warning");
|
||||
return;
|
||||
}
|
||||
if (sub === "breaker") {
|
||||
if (rest.includes("reset")) {
|
||||
resetBreaker();
|
||||
ctx.ui.notify("Circuit breaker qmem: chiuso — il prossimo accesso al gateway sarà immediato", "info");
|
||||
return;
|
||||
}
|
||||
const br = breakerInfo();
|
||||
ctx.ui.notify(
|
||||
`Circuit breaker qmem: ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"}\n` +
|
||||
`fallimenti consecutivi: ${br.failures} | aperture totali: ${br.trips}${br.lastError ? `\nultimo errore: ${br.lastError}` : ""}\n` +
|
||||
`file: ${br.file}\nUso: /qmem:local breaker reset`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "pull") {
|
||||
ctx.ui.setStatus("pi-qmem", "Pull export dal gateway...");
|
||||
const res = await pullFromGatewayExport(cfg, { dbFile });
|
||||
|
||||
+26
-18
@@ -25,7 +25,7 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { gatewayRequest, loadConfig, type MemoryConfig } from "./shared.ts";
|
||||
import { breakerInfo, gatewayRequest, loadConfig, type MemoryConfig } from "./shared.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Percorsi e apertura
|
||||
@@ -610,7 +610,7 @@ export async function enrichFromGateway(
|
||||
stats.requested++;
|
||||
let res: { ok: boolean; status: number; data: any };
|
||||
try {
|
||||
res = await gatewayRequest(cfg, "GET", `/v1/memories/${id}`, undefined, AbortSignal.timeout(perRequestMs));
|
||||
res = await gatewayRequest(cfg, "GET", `/v1/memories/${id}`, undefined, undefined, undefined, { timeoutMs: perRequestMs });
|
||||
} catch (e) {
|
||||
// timeout/abort: gateway non raggiungibile → inutile insistere sui record successivi
|
||||
stats.failed++;
|
||||
@@ -674,17 +674,17 @@ export async function pullFromGatewayExport(
|
||||
let fetched = 0;
|
||||
for (; pages < maxPages; pages++) {
|
||||
const route = `/v1/memories:export?limit=${limit}&include_deleted=true${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
||||
const { ok, status, data } = await gatewayRequest(
|
||||
cfg,
|
||||
"GET",
|
||||
route,
|
||||
undefined,
|
||||
AbortSignal.timeout(Math.min(cfg.timeoutMs ?? 30_000, 10_000)),
|
||||
);
|
||||
const { ok, status, data } = await gatewayRequest(cfg, "GET", route, undefined, undefined, undefined, {
|
||||
timeoutMs: Math.min(cfg.timeoutMs ?? 30_000, 10_000),
|
||||
});
|
||||
if (status === 404 || status === 405 || status === 400) {
|
||||
return { supported: false, pages, fetched, message: `endpoint di export non disponibile sul gateway (HTTP ${status})` };
|
||||
}
|
||||
if (!ok) return { supported: false, pages, fetched, message: `export fallito: HTTP ${status}` };
|
||||
if (!ok) {
|
||||
const br = breakerInfo();
|
||||
const why = br.open ? `gateway non raggiungibile (breaker aperto: ritenta tra ${Math.ceil(br.remainingMs / 1000)}s)` : `export fallito: HTTP ${status}`;
|
||||
return { supported: false, pages, fetched, message: why };
|
||||
}
|
||||
const items = data?.results ?? data?.records ?? [];
|
||||
if (Array.isArray(items) && items.length) {
|
||||
upsertRecords(
|
||||
@@ -925,11 +925,12 @@ export async function flushQueue(
|
||||
"POST",
|
||||
"/v1/memories",
|
||||
body,
|
||||
AbortSignal.timeout(perRequestMs),
|
||||
undefined,
|
||||
item.local_id, // Idempotency-Key: retry sicuri, nessun duplicato
|
||||
{ timeoutMs: perRequestMs },
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = `${e instanceof Error ? e.message : String(e)} (timeout ${perRequestMs}ms)`;
|
||||
const msg = `${e instanceof Error ? e.message : String(e)} (budget ${perRequestMs}ms)`;
|
||||
db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
|
||||
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
|
||||
stats.stopped = msg;
|
||||
@@ -995,7 +996,12 @@ export async function flushQueue(
|
||||
}
|
||||
} else {
|
||||
// 0/429/5xx: gateway non utilizzabile, resta in coda
|
||||
const msg = res.status === 429 ? "rate limit del gateway (HTTP 429): ripreso al prossimo flush" : `HTTP ${res.status}`;
|
||||
const br = breakerInfo();
|
||||
const msg = br.open
|
||||
? `gateway non raggiungibile (breaker aperto: ritenta tra ${Math.ceil(br.remainingMs / 1000)}s)`
|
||||
: res.status === 429
|
||||
? "rate limit del gateway (HTTP 429): ripreso al prossimo flush"
|
||||
: `HTTP ${res.status}`;
|
||||
db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
|
||||
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
|
||||
stats.stopped = msg;
|
||||
@@ -1019,11 +1025,13 @@ export async function submitOrQueue(
|
||||
cfg: MemoryConfig,
|
||||
payload: QueuePayload,
|
||||
opts?: { dbFile?: string; queue?: boolean },
|
||||
): Promise<{ queued: boolean; remote_id?: string; local_id?: string; queue_size?: number; status: number; data?: any }> {
|
||||
): Promise<{ queued: boolean; remote_id?: string; local_id?: string; queue_size?: number; status: number; data?: any; breaker?: ReturnType<typeof breakerInfo> }> {
|
||||
const queueAllowed = opts?.queue ?? cfg.offlineQueue !== false;
|
||||
const body = stripEmpty(payload as unknown as Record<string, unknown>);
|
||||
const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 15_000);
|
||||
const res = await gatewayRequest(cfg, "POST", "/v1/memories", body, AbortSignal.timeout(perRequestMs), randomUUID());
|
||||
// NB: niente AbortSignal esterno (verrebbe letto come annullamento utente):
|
||||
// il budget si passa via opts, e i timeout interni gestiscono connect/body.
|
||||
const res = await gatewayRequest(cfg, "POST", "/v1/memories", body, undefined, randomUUID(), { timeoutMs: perRequestMs });
|
||||
if (res.ok && res.data?.memory_id) {
|
||||
// indicizza subito in locale (senza attendere enrich/export)
|
||||
try {
|
||||
@@ -1053,14 +1061,14 @@ export async function submitOrQueue(
|
||||
/* l'indice locale è best-effort sul percorso online */
|
||||
}
|
||||
maybeBackgroundFlush(cfg, opts?.dbFile);
|
||||
return { queued: false, remote_id: res.data.memory_id, status: res.status, data: res.data };
|
||||
return { queued: false, remote_id: res.data.memory_id, status: res.status, data: res.data, breaker: res.breaker };
|
||||
}
|
||||
const down = res.status === 0 || res.status >= 500 || res.status === 429;
|
||||
if (down && queueAllowed) {
|
||||
const q = await queueStore(payload, { dbFile: opts?.dbFile });
|
||||
return { queued: true, local_id: q.local_id, queue_size: q.queue_size, status: res.status, data: res.data };
|
||||
return { queued: true, local_id: q.local_id, queue_size: q.queue_size, status: res.status, data: res.data, breaker: res.breaker };
|
||||
}
|
||||
return { queued: false, status: res.status, data: res.data };
|
||||
return { queued: false, status: res.status, data: res.data, breaker: res.breaker };
|
||||
}
|
||||
|
||||
let flushInFlight: Promise<unknown> | null = null;
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ Procedures (hierarchy L1/L2, scores, supersede, reflexion, consolidation): skill
|
||||
- Quando il gateway non risponde, qmem_search degrada all'INDICE LOCALE SQLite/FTS5 (ricerca testuale, NON neurale: nessuno score 0.45/0.60). Il risultato è etichettato 'fallback: local_sqlite'.
|
||||
- I risultati locali sono osservazioni più vecchie del gateway: verificali prima dell'uso e non applicare le soglie di score del gateway.
|
||||
- OUTBOX: se il gateway è giù, qmem_store accoda il record in locale (non lo perde). Il record è subito ricercabile (marcato ⏳ in coda) e viene inviato automaticamente al ritorno della connessione (flush su session_start). Finché non è sincronizzato NON è nella memoria condivisa: trattalo come non condiviso.
|
||||
- Gestione: /qmem:local status | import | find <query> | queue | flush | enrich | pull.
|
||||
- Gestione: /qmem:local status | import | find <query> | queue | flush | breaker [reset] | enrich | pull.
|
||||
- CIRCUIT BREAKER: se il gateway è irraggiungibile (o non risponde entro connectTimeoutMs) qmem NON lo ritenta per ~2 minuti: le chiamate passano subito al fallback locale e l'outbox accoda. Non insistere con qmem_search/qmem_store sperando in un esito diverso; per forzare un tentativo quando sai che il gateway è tornato: /qmem:local breaker reset.
|
||||
### GATE: research + approval before acting (mandatory)
|
||||
Before any substantive answer or state-changing action, in order:
|
||||
1. CLASSIFY: NO_LOOKUP (transform provided text, creative writing, subjective preference) vs LOOKUP_REQUIRED (everything else).
|
||||
|
||||
+232
-24
@@ -29,7 +29,16 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
||||
export interface MemoryConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
/** Budget (ms) per la risposta DOPO gli header: distingue l'elaborazione lunga (default 30000). */
|
||||
timeoutMs?: number;
|
||||
/** Timeout (ms) per connect+headers: oltre questo il gateway è "non raggiungibile" (default 2500). */
|
||||
connectTimeoutMs?: number;
|
||||
/** Attesa base del circuit breaker dopo un fallimento definitivo (default 120000 = 2 min). */
|
||||
breakerBaseMs?: number;
|
||||
/** Tetto dell'escalation del breaker (default 600000 = 10 min). */
|
||||
breakerMaxMs?: number;
|
||||
/** Fallimenti ambigui consecutivi (body lento, 5xx) prima di aprire il breaker (default 2). */
|
||||
breakerTripAfter?: number;
|
||||
correctMinScore?: number;
|
||||
/** Percorso del DB SQLite locale (default ~/.local/share/pi-qmem/qmem.sqlite). */
|
||||
localDbPath?: string;
|
||||
@@ -43,12 +52,17 @@ const CONFIG_DEFAULTS: MemoryConfig = {
|
||||
url: "https://qmem.enne2.net",
|
||||
apiKey: "",
|
||||
timeoutMs: 30_000,
|
||||
connectTimeoutMs: 2500,
|
||||
breakerBaseMs: 120_000,
|
||||
breakerMaxMs: 600_000,
|
||||
breakerTripAfter: 2,
|
||||
correctMinScore: 0.6,
|
||||
localFallback: true,
|
||||
offlineQueue: true,
|
||||
};
|
||||
|
||||
// Retry su errori transitori (429/5xx/timeout): backoff esponenziale + jitter
|
||||
// Retry su errori transitori del SERVER (429/5xx/timeout del body): backoff + jitter.
|
||||
// Nessun retry sulle connessioni fallite: il breaker copre l'intervallo successivo.
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_MS = 500;
|
||||
|
||||
@@ -56,6 +70,108 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circuit breaker persistente (fast-fail quando il gateway è irraggiungibile)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stato in un file JSON dedicato (non nella config, non nel DB): sopravvive a
|
||||
// /reload, a nuove sessioni e ai processi CLI, e non richiede node:sqlite.
|
||||
// - fallimento DEFINITIVO (connessione rifiutata/DNS/timeout di connect) → apre
|
||||
// subito per `breakerBaseMs` (default 2 min), con escalation esponenziale;
|
||||
// - fallimento AMBIGUO (body lento, HTTP 5xx) → apre dopo `breakerTripAfter`;
|
||||
// - un successo (o `resetBreaker()`) lo richiude.
|
||||
// Mentre è aperto nessuna richiesta tocca la rete: i tool passano direttamente
|
||||
// al fallback locale (SQLite/FTS5) e l'outbox accoda senza attese.
|
||||
const BREAKER_FILE = process.env.QMEM_BREAKER_FILE ?? path.join(os.homedir(), ".local", "share", "pi-qmem", "breaker.json");
|
||||
|
||||
interface BreakerState {
|
||||
openUntil: number;
|
||||
failures: number;
|
||||
trips?: number;
|
||||
lastError?: string;
|
||||
lastChange?: string;
|
||||
/** Endpoint a cui si riferisce lo stato: cambiando URL il breaker riparte chiuso. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
let breakerCache: BreakerState | null = null;
|
||||
|
||||
function loadBreaker(): BreakerState {
|
||||
if (breakerCache) return breakerCache;
|
||||
try {
|
||||
breakerCache = { openUntil: 0, failures: 0, ...JSON.parse(fs.readFileSync(BREAKER_FILE, "utf8")) };
|
||||
} catch {
|
||||
breakerCache = { openUntil: 0, failures: 0 };
|
||||
}
|
||||
return breakerCache;
|
||||
}
|
||||
|
||||
function saveBreaker(state: BreakerState): void {
|
||||
breakerCache = state;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(BREAKER_FILE), { recursive: true });
|
||||
fs.writeFileSync(BREAKER_FILE, JSON.stringify(state, null, 2));
|
||||
} catch {
|
||||
/* stato solo in memoria */
|
||||
}
|
||||
}
|
||||
|
||||
export interface BreakerInfo {
|
||||
open: boolean;
|
||||
remainingMs: number;
|
||||
failures: number;
|
||||
trips: number;
|
||||
lastError?: string;
|
||||
lastChange?: string;
|
||||
url?: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export function breakerInfo(): BreakerInfo {
|
||||
const s = loadBreaker();
|
||||
return {
|
||||
open: Math.max(0, s.openUntil - Date.now()) > 0,
|
||||
remainingMs: Math.max(0, s.openUntil - Date.now()),
|
||||
failures: s.failures ?? 0,
|
||||
trips: s.trips ?? 0,
|
||||
lastError: s.lastError,
|
||||
lastChange: s.lastChange,
|
||||
url: s.url,
|
||||
file: BREAKER_FILE,
|
||||
};
|
||||
}
|
||||
|
||||
export function breakerIsOpen(): boolean {
|
||||
return breakerInfo().open;
|
||||
}
|
||||
|
||||
/** Registra un fallimento; con `definitive` (connessione) apre immediatamente. */
|
||||
export function tripBreaker(reason: string, definitive: boolean, cfg?: MemoryConfig): BreakerInfo {
|
||||
const s = loadBreaker();
|
||||
const tripAfter = Math.max(1, cfg?.breakerTripAfter ?? CONFIG_DEFAULTS.breakerTripAfter ?? 2);
|
||||
s.failures = definitive ? Math.max((s.failures ?? 0) + 1, tripAfter) : (s.failures ?? 0) + 1;
|
||||
s.lastError = reason;
|
||||
s.lastChange = new Date().toISOString();
|
||||
if (cfg?.url) s.url = cfg.url;
|
||||
if (s.failures >= tripAfter) {
|
||||
const base = cfg?.breakerBaseMs ?? CONFIG_DEFAULTS.breakerBaseMs ?? 120_000;
|
||||
const max = cfg?.breakerMaxMs ?? CONFIG_DEFAULTS.breakerMaxMs ?? 600_000;
|
||||
const step = Math.max(0, s.failures - tripAfter);
|
||||
const wait = Math.min(base * 2 ** step, max);
|
||||
s.openUntil = Date.now() + wait;
|
||||
s.trips = (s.trips ?? 0) + 1;
|
||||
}
|
||||
saveBreaker(s);
|
||||
return breakerInfo();
|
||||
}
|
||||
|
||||
/** Richiude il breaker (successo, o reset manuale). */
|
||||
export function resetBreaker(): BreakerInfo {
|
||||
const s = loadBreaker();
|
||||
if (!s.failures && !s.openUntil && !s.trips) return breakerInfo();
|
||||
saveBreaker({ openUntil: 0, failures: 0, trips: 0, lastChange: new Date().toISOString(), url: s.url });
|
||||
return breakerInfo();
|
||||
}
|
||||
|
||||
export function loadConfig(): MemoryConfig {
|
||||
try {
|
||||
return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) };
|
||||
@@ -98,47 +214,139 @@ export async function gatewayRequest(
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
idempotencyKey?: string,
|
||||
): Promise<{ ok: boolean; status: number; data: any }> {
|
||||
opts?: { timeoutMs?: number; connectTimeoutMs?: number },
|
||||
): Promise<{ ok: boolean; status: number; data: any; breaker?: BreakerInfo }> {
|
||||
// Cambio di endpoint (es. da front a nodo diretto): lo stato non è più valido
|
||||
// per questo gateway → si riparte chiusi.
|
||||
{
|
||||
const st = loadBreaker();
|
||||
if (st.url && st.url !== cfg.url) {
|
||||
saveBreaker({ openUntil: 0, failures: 0, trips: st.trips ?? 0, lastChange: new Date().toISOString(), url: cfg.url });
|
||||
} else if (!st.url) {
|
||||
saveBreaker({ ...st, url: cfg.url });
|
||||
}
|
||||
}
|
||||
// Fast-fail: con il breaker aperto nessuna richiesta di rete (tempo ~0).
|
||||
const open = breakerInfo();
|
||||
if (open.open) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: {
|
||||
error: "gateway_unreachable",
|
||||
breaker_open: true,
|
||||
retry_in_ms: Math.round(open.remainingMs),
|
||||
detail: open.lastError ?? "errore precedente",
|
||||
hint: "fallback locale attivo; /qmem:local breaker reset per forzare un tentativo",
|
||||
},
|
||||
breaker: open,
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": cfg.apiKey,
|
||||
};
|
||||
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
||||
|
||||
const connectMs = Math.max(200, opts?.connectTimeoutMs ?? cfg.connectTimeoutMs ?? 2500);
|
||||
const bodyMs = Math.max(500, opts?.timeoutMs ?? cfg.timeoutMs ?? 30_000);
|
||||
const url = `${cfg.url}${route}`;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
// timeout fallback: se pi non fornisce un signal, usa timeoutMs dalla config
|
||||
const timeoutSignal = signal ?? AbortSignal.timeout(cfg.timeoutMs ?? 30_000);
|
||||
const t0 = Date.now();
|
||||
const ac = new AbortController();
|
||||
let phase: "connect" | "body" = "connect";
|
||||
const onAbort = () => ac.abort(signal?.reason ?? new Error("aborted"));
|
||||
if (signal) {
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Fase 1: nessuna risposta (header) entro connectMs → gateway non raggiungibile
|
||||
let timer = setTimeout(() => ac.abort(new Error(`nessuna risposta entro ${connectMs}ms`)), connectMs);
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}${route}`, {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
signal: timeoutSignal,
|
||||
signal: ac.signal,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// retry solo su errori transitori (429/5xx), rispettando Retry-After
|
||||
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
|
||||
const retryAfter = res.headers.get("retry-after");
|
||||
const delay = retryAfter
|
||||
? Math.min(Number(retryAfter) * 1000 || RETRY_BASE_MS, 10_000)
|
||||
: RETRY_BASE_MS * 2 ** attempt + Math.random() * 200;
|
||||
await sleep(delay);
|
||||
continue;
|
||||
// Header ricevuti: da qui il tempo è "elaborazione", con un budget separato
|
||||
phase = "body";
|
||||
clearTimeout(timer);
|
||||
const remaining = Math.max(1000, bodyMs - (Date.now() - t0));
|
||||
timer = setTimeout(() => ac.abort(new Error(`risposta non completata entro ${remaining}ms`)), remaining);
|
||||
let data: any = {};
|
||||
let parseError: string | undefined;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
parseError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
clearTimeout(timer);
|
||||
if (parseError && ac.signal.aborted) {
|
||||
// header arrivati ma body non completato entro il budget: NON è un
|
||||
// successo vuoto (prima veniva restituito {ok:true, data:{}}), è un
|
||||
// fallimento di elaborazione → il chiamante passa al fallback locale.
|
||||
const br = tripBreaker(`risposta non completata: ${parseError}`, false, cfg);
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: { error: "timeout_body", detail: parseError, budget_ms: remaining, url },
|
||||
breaker: br,
|
||||
};
|
||||
}
|
||||
if (res.ok) {
|
||||
resetBreaker();
|
||||
return { ok: true, status: res.status, data, breaker: breakerInfo() };
|
||||
}
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
// Server raggiungibile ma in difficoltà: fallimento ambiguo, retry con Retry-After
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const retryAfter = res.headers.get("retry-after");
|
||||
const delay = retryAfter
|
||||
? Math.min(Number(retryAfter) * 1000 || RETRY_BASE_MS, 10_000)
|
||||
: RETRY_BASE_MS * 2 ** attempt + Math.random() * 200;
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
const br = tripBreaker(`HTTP ${res.status} da ${url}`, false, cfg);
|
||||
return { ok: false, status: res.status, data, breaker: br };
|
||||
}
|
||||
// 4xx applicativo (401/404/409/422): il server risponde → breaker chiuso
|
||||
resetBreaker();
|
||||
return { ok: false, status: res.status, data, breaker: breakerInfo() };
|
||||
} catch (e) {
|
||||
// annullamento utente: propaga, non ritentare
|
||||
if (e instanceof Error && e.name === "AbortError") throw e;
|
||||
// errore di rete/timeout: retry con backoff
|
||||
lastError = e;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sleep(RETRY_BASE_MS * 2 ** attempt + Math.random() * 200);
|
||||
continue;
|
||||
clearTimeout(timer);
|
||||
if (signal?.aborted) throw e; // annullamento utente (Esc): propaga
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (phase === "connect") {
|
||||
// Connessione fallita o nessuna risposta: definitivo → breaker subito aperto,
|
||||
// nessun retry (era la causa delle attese di ~30s x4).
|
||||
const br = tripBreaker(`gateway non raggiungibile: ${msg}`, true, cfg);
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: { error: "gateway_unreachable", detail: msg, connect_timeout_ms: connectMs, url },
|
||||
breaker: br,
|
||||
};
|
||||
}
|
||||
// Header ricevuti ma body lento/interrotto: ambiguo (potrebbe essere un rerank pesante)
|
||||
lastError = msg;
|
||||
const br = tripBreaker(`risposta lenta: ${msg}`, false, cfg);
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: { error: "timeout_body", detail: msg, budget_ms: bodyMs, url },
|
||||
breaker: br,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") } };
|
||||
return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") }, breaker: breakerInfo() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { gatewayRequest, loadConfig } from "../shared.ts";
|
||||
import { localDbPath, localGet } from "../local-db.ts";
|
||||
import { breakerInfo } from "../shared.ts";
|
||||
|
||||
export function registerQmemGet(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
@@ -31,9 +32,11 @@ export function registerQmemGet(pi: ExtensionAPI) {
|
||||
try {
|
||||
const local = await localGet(p.memory_id, { dbFile: localDbPath(cfg) });
|
||||
if (local) {
|
||||
const br = breakerInfo();
|
||||
const origine = notFound
|
||||
? "non presente sul gateway (404): record dall'INDICE LOCALE"
|
||||
: `gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE`;
|
||||
: `gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE` +
|
||||
(br.open ? ` — circuit breaker aperto (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -48,6 +51,7 @@ export function registerQmemGet(pi: ExtensionAPI) {
|
||||
fallback: "local_sqlite",
|
||||
gateway_status: status,
|
||||
pending: local.pending === 1,
|
||||
breaker: { open: br.open, remaining_ms: Math.round(br.remainingMs) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { gatewayRequest, loadConfig } from "../shared.ts";
|
||||
import { breakerInfo, gatewayRequest, loadConfig } from "../shared.ts";
|
||||
import { localDbPath, localSearch, type LocalSearchHit } from "../local-db.ts";
|
||||
|
||||
export function registerQmemSearch(pi: ExtensionAPI) {
|
||||
@@ -106,6 +106,14 @@ export function registerQmemSearch(pi: ExtensionAPI) {
|
||||
},
|
||||
{ dbFile },
|
||||
);
|
||||
const br = breakerInfo();
|
||||
const motivo =
|
||||
`Motivo: ${data?.error ?? "gateway non raggiungibile"}` +
|
||||
(br.open
|
||||
? ` — circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s` +
|
||||
`${br.lastError ? ` (ultimo errore: ${br.lastError})` : ""}`
|
||||
: "") +
|
||||
`\nPer forzare un tentativo: /qmem:local breaker reset`;
|
||||
if (hits.length) {
|
||||
const lines = hits.map(
|
||||
(h: LocalSearchHit, i: number) =>
|
||||
@@ -114,7 +122,7 @@ export function registerQmemSearch(pi: ExtensionAPI) {
|
||||
const header =
|
||||
`⚠️ Gateway non raggiungibile (HTTP ${status}): risultati dall'INDICE LOCALE (SQLite/FTS5).\n` +
|
||||
`Ricerca TESTUALE, non neurale: nessuno score semantico, nessuna soglia 0.45/0.60 — verifica i risultati prima dell'uso.\n` +
|
||||
`DB: ${dbFile}`;
|
||||
`${motivo}\nDB: ${dbFile}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }],
|
||||
details: {
|
||||
@@ -122,6 +130,7 @@ export function registerQmemSearch(pi: ExtensionAPI) {
|
||||
hits: hits.length,
|
||||
gateway_status: status,
|
||||
match_mode: hits[0]?.match_mode ?? "and",
|
||||
breaker: { open: br.open, remaining_ms: Math.round(br.remainingMs), failures: br.failures, last_error: br.lastError },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -130,7 +139,7 @@ export function registerQmemSearch(pi: ExtensionAPI) {
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Gateway non raggiungibile (HTTP ${status}) e nessun risultato nell'indice locale (${dbFile}).\n` +
|
||||
`Gateway non raggiungibile (HTTP ${status}) e nessun risultato nell'indice locale (${dbFile}).\n${motivo}\n` +
|
||||
`Se il DB è assente o vecchio: /qmem:local import (ricostruisce l'indice dalle sessioni pi).`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { gatewayRequest, loadConfig } from "../shared";
|
||||
import { localDbPath, submitOrQueue } from "../local-db.ts";
|
||||
import { breakerInfo } from "../shared.ts";
|
||||
|
||||
export function registerQmemStore(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
@@ -111,6 +112,13 @@ export function registerQmemStore(pi: ExtensionAPI) {
|
||||
`⚠️ Gateway non raggiungibile (HTTP ${submitted.status}): record ACCODATO in locale (outbox).\n` +
|
||||
`id locale: ${submitted.local_id}\n` +
|
||||
`in coda: ${submitted.queue_size} record\n` +
|
||||
(() => {
|
||||
const br = breakerInfo();
|
||||
return br.open
|
||||
? `circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s ` +
|
||||
`(ultimo errore: ${br.lastError ?? "?"}; per forzare: /qmem:local breaker reset)\n`
|
||||
: "";
|
||||
})() +
|
||||
`Il contenuto è già ricercabile offline (indice locale, marcato ⏳) e verrà caricato automaticamente al ritorno della connessione ` +
|
||||
`(/qmem:local flush per forzare, /qmem:local queue per lo stato).`,
|
||||
},
|
||||
@@ -120,6 +128,9 @@ export function registerQmemStore(pi: ExtensionAPI) {
|
||||
local_id: submitted.local_id,
|
||||
queue_size: submitted.queue_size,
|
||||
gateway_status: submitted.status,
|
||||
breaker: submitted.breaker
|
||||
? { open: submitted.breaker.open, remaining_ms: Math.round(submitted.breaker.remainingMs), failures: submitted.breaker.failures }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
+101
-2
@@ -66,7 +66,7 @@ const lines = [
|
||||
fs.writeFileSync(path.join(SESS_DIR, "2026-09-01T10-00-00-000Z_test.jsonl"), lines.join("\n") + "\n");
|
||||
|
||||
// ---------------------------------------------------------------- config black-hole
|
||||
fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, localDbPath: DB }, null, 2), { mode: 0o600 });
|
||||
fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, connectTimeoutMs: 700, breakerBaseMs: 20000, breakerMaxMs: 60000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
||||
|
||||
const env = { ...process.env, HOME, QMEM_SQLITE: DB, QMEM_SESSIONS_DIR: path.join(HOME, ".pi", "agent", "sessions") };
|
||||
// isolamento anche per il processo di test: l'estensione caricata in-process
|
||||
@@ -155,6 +155,7 @@ const ctx = {
|
||||
const search = await tools.get("qmem_search").execute("t1", { query: "sqlite fts5" }, undefined, undefined, ctx);
|
||||
check("qmem_search → fallback locale con gateway giù", search.details?.fallback === "local_sqlite" && search.details?.hits > 0, `hits=${search.details?.hits} status=${search.details?.gateway_status}`);
|
||||
check("risultato locale etichettato come testuale", /INDICE LOCALE/i.test(search.content[0].text) && /non neurale/i.test(search.content[0].text) && /sqlite/i.test(search.content[0].text));
|
||||
check("il messaggio indica il circuit breaker e come forzare un tentativo", /circuit breaker aperto/i.test(search.content[0].text) && /breaker reset/i.test(search.content[0].text), `breaker=${JSON.stringify(search.details?.breaker)}`);
|
||||
const getRes = await tools.get("qmem_get").execute("t2", { memory_id: ID_E }, undefined, undefined, ctx);
|
||||
if (process.env.DEBUG_GET) console.log("GET RESULT:", JSON.stringify(getRes, null, 1).slice(0, 900));
|
||||
check("qmem_get → fallback locale per UUID", getRes.details?.fallback === "local_sqlite" && /nmap/.test(getRes.content[0].text));
|
||||
@@ -205,7 +206,7 @@ const server = createServer((req, res) => {
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const stub = `http://127.0.0.1:${server.address().port}`;
|
||||
fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
||||
fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
||||
const enrichOut = await runCliAsync(["enrich", "--json"]);
|
||||
let enrich = { stdout: enrichOut };
|
||||
let enrichJson = null;
|
||||
@@ -266,6 +267,104 @@ const dupItem = (qFinal.items ?? []).find((i) => i.local_id === dupStore.local_i
|
||||
const badItem = (qFinal.items ?? []).find((i) => i.local_id === badStore.local_id);
|
||||
check("duplicato marcato con remote_id del match", dupItem?.status === "duplicate" && dupItem?.remote_id === ID_A, `status=${dupItem?.status} remote=${String(dupItem?.remote_id).slice(0, 8)}`);
|
||||
check("422 marcato failed con errore conservato", badItem?.status === "failed" && /422/.test(badItem?.last_error ?? ""), `err=${String(badItem?.last_error).slice(0, 40)}`);
|
||||
// ---------------------------------------------------------------- 6) circuit breaker
|
||||
if (process.env.SKIP_BREAKER !== "1") {
|
||||
const origEmit = process.emitWarning;
|
||||
process.emitWarning = (w, ...r) => {
|
||||
const code = r[0]?.code ?? (typeof r[0] === "string" ? r[0] : undefined) ?? w?.code;
|
||||
if (code === "MODULE_TYPELESS_PACKAGE_JSON") return;
|
||||
return origEmit.call(process, w, ...r);
|
||||
};
|
||||
const shared = await import("../extensions/shared.ts");
|
||||
process.emitWarning = origEmit;
|
||||
const BH = { url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600, breakerBaseMs: 20000, breakerMaxMs: 60000 };
|
||||
|
||||
shared.resetBreaker();
|
||||
check("breaker inizialmente chiuso", shared.breakerInfo().open === false);
|
||||
|
||||
// connessione rifiutata → fallimento immediato, nessun retry
|
||||
let t = Date.now();
|
||||
const r1 = await shared.gatewayRequest(BH, "GET", "/v1/status");
|
||||
const ms1 = Date.now() - t;
|
||||
check("connessione rifiutata: fallimento immediato senza retry", r1.status === 0 && r1.data?.error === "gateway_unreachable" && ms1 < 1500, `${ms1}ms`);
|
||||
const br1 = shared.breakerInfo();
|
||||
check("breaker APERTO dopo il fallimento di connessione", br1.open === true, `open=${br1.open} failures=${br1.failures} trips=${br1.trips}`);
|
||||
|
||||
// fast-fail: nessuna richiesta di rete
|
||||
t = Date.now();
|
||||
const r2 = await shared.gatewayRequest(BH, "GET", "/v1/status");
|
||||
const ms2 = Date.now() - t;
|
||||
check("fast-fail con breaker aperto (nessuna rete, < 50ms)", r2.status === 0 && r2.data?.breaker_open === true && ms2 < 50, `${ms2}ms`);
|
||||
|
||||
// persistenza fra processi (nuovo processo, stesso file di stato)
|
||||
const code = `
|
||||
const p = ${JSON.stringify(path.join(REPO, "extensions/shared.ts"))};
|
||||
import(p).then(async (m) => {
|
||||
const t = Date.now();
|
||||
const r = await m.gatewayRequest({ url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600 }, "GET", "/v1/status");
|
||||
console.log(JSON.stringify({ ms: Date.now() - t, status: r.status, breaker_open: !!r.data?.breaker_open, open: m.breakerInfo().open }));
|
||||
});`;
|
||||
const child = spawnSync("node", ["-e", code], { env, encoding: "utf8" });
|
||||
let childOut = null;
|
||||
try {
|
||||
childOut = JSON.parse((child.stdout || "{}").trim().split("\n").pop());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
check(
|
||||
"breaker persistente fra processi (nuovo processo → fast-fail)",
|
||||
childOut?.breaker_open === true && childOut?.ms < 200,
|
||||
`ms=${childOut?.ms} open=${childOut?.open} stderr=${(child.stderr || "").slice(0, 60)}`,
|
||||
);
|
||||
|
||||
// server che accetta ma non risponde mai → connect timeout breve (non 30s)
|
||||
const hang = createServer(() => {});
|
||||
await new Promise((r) => hang.listen(0, "127.0.0.1", r));
|
||||
const hangUrl = `http://127.0.0.1:${hang.address().port}`;
|
||||
shared.resetBreaker();
|
||||
t = Date.now();
|
||||
const r3 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 700 }, "GET", "/v1/status");
|
||||
const ms3 = Date.now() - t;
|
||||
check("server che non risponde: connect timeout ~700ms (non 30s) e breaker aperto", r3.status === 0 && ms3 >= 600 && ms3 < 2600 && shared.breakerInfo().open === true, `${ms3}ms`);
|
||||
hang.close();
|
||||
|
||||
// header subito ma body lento: elaborazione lunga → fallimento ambiguo, breaker NON aperto
|
||||
const stall = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.write('{"status":');
|
||||
// nessun end: il body resta appeso
|
||||
});
|
||||
await new Promise((r) => stall.listen(0, "127.0.0.1", r));
|
||||
const stallUrl = `http://127.0.0.1:${stall.address().port}`;
|
||||
shared.resetBreaker();
|
||||
t = Date.now();
|
||||
const r4 = await shared.gatewayRequest({ url: stallUrl, apiKey: "k", connectTimeoutMs: 700, timeoutMs: 1200 }, "GET", "/v1/status");
|
||||
const ms4 = Date.now() - t;
|
||||
const br4 = shared.breakerInfo();
|
||||
check(
|
||||
"body lento: timeout di elaborazione (~1.2s) senza aprire il breaker",
|
||||
r4.status === 0 && r4.data?.error === "timeout_body" && ms4 < 2600 && br4.open === false,
|
||||
`${ms4}ms failures=${br4.failures} open=${br4.open}`,
|
||||
);
|
||||
|
||||
// store con gateway che accetta ma non risponde: deve accodare, non lanciare
|
||||
fs.writeFileSync(CONFIG, JSON.stringify({ url: stallUrl, apiKey: "test-key", timeoutMs: 1200, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
||||
const queuedOnStall = await tools.get("qmem_store").execute("t9", { text: "Record accodato con gateway che non risponde", project_id: "stall-proj" }, undefined, undefined, ctx);
|
||||
check(
|
||||
"gateway che accetta e non risponde: store ACCODATO (nessuna eccezione)",
|
||||
queuedOnStall.details?.queued === true && /ACCODATO/i.test(queuedOnStall.content[0].text),
|
||||
`queued=${queuedOnStall.details?.queued} status=${queuedOnStall.details?.gateway_status}`,
|
||||
);
|
||||
|
||||
// cambio di endpoint → il breaker riparte chiuso
|
||||
shared.tripBreaker("endpoint A", true, { ...BH, breakerBaseMs: 60000, url: "http://a" });
|
||||
const r5 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 400 }, "GET", "/v1/status");
|
||||
check("cambio endpoint: il breaker non blocca il nuovo gateway", r5.data?.breaker_open !== true, `error=${r5.data?.error}`);
|
||||
shared.resetBreaker();
|
||||
check("reset manuale chiude il breaker", shared.breakerInfo().open === false);
|
||||
stall.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- report
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} controlli superati — HOME di test: ${HOME}`);
|
||||
|
||||
@@ -39,8 +39,13 @@ In quel caso:
|
||||
soglia 0.45/0.60 da applicare;
|
||||
- i risultati sono **osservazioni più vecchie** del gateway (storico ricostruito
|
||||
dalle sessioni pi + ultimo enrich): verifica prima dell'uso;
|
||||
- comandi: `/qmem:local status | import | find <query> | enrich | pull`
|
||||
(`import` dalle sessioni, `enrich`/`pull` dal gateway quando torna online).
|
||||
- comandi: `/qmem:local status | import | find <query> | queue | flush | breaker [reset] | enrich | pull`
|
||||
(`import` dalle sessioni, `enrich`/`pull` dal gateway quando torna online);
|
||||
- **circuit breaker**: quando il gateway non risponde entro `connectTimeoutMs` (default 2,5 s) le
|
||||
chiamate successive falliscono in ~0 ms **senza toccare la rete** per ~2 minuti (stato in
|
||||
`~/.local/share/pi-qmem/breaker.json`): il fallback locale è immediato. Un 5xx o un body lento
|
||||
sono invece "ambigui" (retry con `Retry-After`, apertura dopo 2 fallimenti). Reset:
|
||||
`/qmem:local breaker reset`.
|
||||
|
||||
`qmem_store` accoda in locale: con il gateway giù il record entra nell'**outbox**
|
||||
locale (SQLite), è subito ricercabile (marcato ⏳) e viene inviato al gateway al
|
||||
|
||||
Reference in New Issue
Block a user