@noeva/analytics-core 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  Le versioni del pacchetto che i repository dei progetti installano (ADR 0005). Prima di
4
4
  `analytics upgrade` si legge cosa cambia fra la versione installata e la nuova.
5
5
 
6
+ ## 0.5.1 — 12 novembre 2026
7
+
8
+ - Il servizio MCP dei PC (`noeva-<id>.exe`) non si blocca piu' su una query lenta
9
+ (11/11/2026). Gli strumenti di Wren erano sincroni e giravano nell'event loop:
10
+ una query lunga fermava tutte le altre richieste e l'annullamento. Ora run_sql,
11
+ dry_run e query_cube girano in piu' thread, ognuno con la sua connessione
12
+ (`connessioni` in noeva.json, 3 per il vivo), con un tempo massimo per query
13
+ (`limite_query_s`, 120 s) fatto rispettare dal driver ODBC, che annulla la query su
14
+ SQL Server, e dal servizio stesso se il driver non basta. Claude riceve un errore
15
+ che dice di ridurre il periodo, non di riprovare in parallelo. Quando Claude annulla
16
+ la richiesta MCP, la query si ferma anche sul database (`server/esecuzione.py`).
17
+ - `noeva-<id>.exe --verifica` dice se il servizio aperto in Claude ha una query in
18
+ corso oltre il tempo massimo (il file di stato nella cartella temporanea), e in quel
19
+ caso chiude con un problema.
20
+ - Le regole delle query sul vivo (un mese poi l'anno, mai query pesanti in parallelo,
21
+ aggregare prima di unire, ridurre il periodo dopo un timeout) stanno in
22
+ `lib/modello/istruzioni.json` (`regole_query`): nelle istruzioni di initialize, in
23
+ locale e dal gateway, e nella scheda `00-progetto`, che arriva col modello
24
+ pubblicato anche ai servizi gia' installati. Anche nella descrizione di run_sql.
25
+ - Per le nuove regole nella scheda il progetto ripubblica il modello
26
+ (`analytics modello`, il job `pubblica` della CI); per il tempo massimo e le
27
+ connessioni serve un rilascio dell'eseguibile (`rilascio-windows.yml`).
28
+
6
29
  ## 0.5.0 — 12 novembre 2026
7
30
 
8
31
  - **L'AI passa solo da NOEVA.** I moduli di pagina non offrono piu' la CLI di Claude:
@@ -261,6 +261,10 @@ def argomenti_pyinstaller(nome: str, build: pathlib.Path) -> list:
261
261
  # e dal repository si compila con le trasformazioni di lib/modello/consegna.py.
262
262
  "--paths", QUI / "server", "--paths", QUI.parent / "modello",
263
263
  "--hidden-import", "modello_locale", "--hidden-import", "consegna", "--hidden-import", "scheda",
264
+ # Le query in thread, col tempo massimo e l'annullamento (server/esecuzione.py);
265
+ # la scheda legge le regole delle query da istruzioni.json, che sta accanto al modulo.
266
+ "--hidden-import", "esecuzione", "--hidden-import", "istruzioni",
267
+ "--add-data", f"{QUI.parent / 'modello' / 'istruzioni.json'}{os.pathsep}.",
264
268
  QUI / "server" / "noeva_mcp.py",
265
269
  ]
266
270
 
@@ -0,0 +1,405 @@
1
+ """Come il servizio locale esegue le query: piu' connessioni, un tempo massimo, l'annullamento.
2
+
3
+ L'11/11/2026 il connettore di un cliente e' rimasto fermo mezz'ora: quattro query pesanti
4
+ lanciate in parallelo, nessuna finita, e nel frattempo nessuna risposta, nemmeno
5
+ alle richieste piu' semplici. Il motivo: nel server MCP di Wren (wrenai 0.15) gli
6
+ strumenti sono funzioni sincrone, e FastMCP (mcp 1.30) le chiama dentro l'event
7
+ loop. Una query lunga fermava tutto il processo: le altre richieste, i ping e
8
+ l'annullamento di Claude. Da fuori sembrava spento.
9
+
10
+ Qui gli strumenti che toccano il database (run_sql, dry_run, query_cube) diventano
11
+ asincroni e girano in un gruppo di thread, ognuno con il suo motore e la sua
12
+ connessione:
13
+
14
+ - una query lenta occupa un thread, le altre passano sugli altri (`connessioni`,
15
+ 3 per il database vivo);
16
+ - ogni query ha un tempo massimo (`limite_query_s`, 120 s): lo fa rispettare il
17
+ driver ODBC (il timeout della connessione, che su SQL Server annulla davvero la
18
+ query sul server); se non bastasse, allo scadere piu' un margine il servizio
19
+ annulla il cursore lui stesso e risponde comunque;
20
+ - quando Claude annulla la richiesta MCP (notifications/cancelled) il servizio
21
+ annulla il cursore: la query si ferma anche sul database;
22
+ - l'errore del tempo scaduto dice a Claude cosa fare: ridurre il periodo, non
23
+ rilanciare la stessa query ne' spezzarla in parallelo.
24
+
25
+ Le query in corso stanno in un file di stato nella cartella temporanea, riscritto
26
+ ogni pochi secondi da un thread a parte: `--verifica` lo legge e dice se una query
27
+ e' in corso da troppo tempo (stato_in_corso).
28
+
29
+ Niente qui importa mcp o wren: si prova con un motore finto (test/test_esecuzione.py).
30
+ Niente stampa su stdout: e' il canale MCP.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import json
36
+ import os
37
+ import pathlib
38
+ import tempfile
39
+ import threading
40
+ import time
41
+ from concurrent.futures import ThreadPoolExecutor
42
+ from typing import Any, Callable
43
+
44
+ LIMITE_S = 120
45
+ CONNESSIONI = 3
46
+ # Oltre il limite del driver: il tempo che il driver ha per annullare e rispondere
47
+ # prima che il servizio annulli il cursore da se'.
48
+ MARGINE_S = 15
49
+ # Ogni quanto il file di stato si riscrive; oltre STATO_VECCHIO_S il servizio che lo
50
+ # scriveva non c'e' piu'.
51
+ BATTITO_S = 5
52
+ STATO_VECCHIO_S = 60
53
+
54
+ # Gli strumenti di Wren che vanno sul database.
55
+ STRUMENTI_DB = ("run_sql", "dry_run", "query_cube")
56
+
57
+ REGOLE = (
58
+ "Il database e' quello di produzione, che usa anche il gestionale: ogni query ha un tempo "
59
+ "massimo di {minuti}. Prima prova la query su un mese, poi allarga all'anno. Una query pesante "
60
+ "alla volta, mai in parallelo. Aggrega ogni tabella (GROUP BY) prima di unirla alle altre: mai "
61
+ "un join riga per riga tra tabelle grandi (per esempio listino e fatture). Se una query va in "
62
+ "timeout riduci il periodo o le colonne, non spezzarla in piu' query in parallelo."
63
+ )
64
+
65
+
66
+ def minuti(secondi: float) -> str:
67
+ if secondi % 60 == 0:
68
+ m = int(secondi // 60)
69
+ return "1 minuto" if m == 1 else f"{m} minuti"
70
+ return f"{int(secondi)} secondi"
71
+
72
+
73
+ def regole_testo(limite_s: float = LIMITE_S) -> str:
74
+ return REGOLE.format(minuti=minuti(limite_s))
75
+
76
+
77
+ class TempoScaduto(Exception):
78
+ """La query ha superato il tempo massimo ed e' stata annullata."""
79
+
80
+ def __init__(self, limite_s: float):
81
+ super().__init__(
82
+ f"NOEVA ha fermato la query dopo {minuti(limite_s)}, il tempo massimo, e l'ha annullata anche "
83
+ "sul database. Non rilanciarla uguale e non dividerla in piu' query da lanciare in parallelo: "
84
+ "finirebbero in coda e scadrebbero anche quelle. Riducila: prova su un solo mese, poi allarga; "
85
+ "aggrega ogni tabella (GROUP BY) prima di unirla alle altre, mai un join riga per riga "
86
+ "(per esempio listino con fatture); togli le colonne che non servono."
87
+ )
88
+
89
+
90
+ def e_un_timeout(e: BaseException | None) -> bool:
91
+ """Il timeout del driver, dentro gli errori che Wren avvolge (WrenError, DatabaseTimeoutError)."""
92
+ visti = 0
93
+ while e is not None and visti < 10:
94
+ if isinstance(e, (TimeoutError, TempoScaduto)) or type(e).__name__ == "DatabaseTimeoutError":
95
+ return True
96
+ testo = str(e)
97
+ if "HYT00" in testo or "timeout expired" in testo.lower():
98
+ return True
99
+ e = e.__cause__ or e.__context__
100
+ visti += 1
101
+ return False
102
+
103
+
104
+ def connessione_persa(e: BaseException | None) -> bool:
105
+ """Errori dopo i quali la connessione non serve piu' (rete, server riavviato)."""
106
+ visti = 0
107
+ while e is not None and visti < 10:
108
+ testo = str(e)
109
+ if any(s in testo for s in ("08S01", "08003", "08001", "Communication link failure")):
110
+ return True
111
+ e = e.__cause__ or e.__context__
112
+ visti += 1
113
+ return False
114
+
115
+
116
+ class Annullabile:
117
+ """Una connessione pyodbc che ricorda il cursore in uso, per annullarlo da un altro thread.
118
+
119
+ Il connettore MSSQL di Wren apre un cursore per query e non lo espone;
120
+ pyodbc permette Cursor.cancel() da un altro thread, ed e' il modo di fermare
121
+ la query sul server."""
122
+
123
+ def __init__(self, connessione: Any):
124
+ object.__setattr__(self, "_connessione", connessione)
125
+ object.__setattr__(self, "corrente", None)
126
+
127
+ def cursor(self, *a, **k):
128
+ c = self._connessione.cursor(*a, **k)
129
+ object.__setattr__(self, "corrente", c)
130
+ return c
131
+
132
+ def annulla(self) -> None:
133
+ c = self.corrente
134
+ if c is not None:
135
+ try:
136
+ c.cancel()
137
+ except Exception: # noqa: BLE001 - cursore gia' chiuso: niente da annullare
138
+ pass
139
+
140
+ def __getattr__(self, nome: str):
141
+ return getattr(self._connessione, nome)
142
+
143
+ def __setattr__(self, nome: str, valore: Any) -> None:
144
+ setattr(self._connessione, nome, valore)
145
+
146
+
147
+ def annulla_motore(motore: Any) -> None:
148
+ """Ferma la query in corso sul motore: il cursore su SQL Server, interrupt() su DuckDB."""
149
+ connettore = getattr(motore, "_connector", None)
150
+ conn = getattr(connettore, "connection", None)
151
+ if isinstance(conn, Annullabile):
152
+ conn.annulla()
153
+ elif conn is not None and hasattr(conn, "interrupt"):
154
+ try:
155
+ conn.interrupt()
156
+ except Exception: # noqa: BLE001
157
+ pass
158
+
159
+
160
+ def rendi_annullabile(motore: Any) -> Any:
161
+ """Avvolge la connessione del motore appena si apre (il connettore di Wren e' pigro)."""
162
+ originale = motore._get_connector
163
+
164
+ def apri():
165
+ c = originale()
166
+ conn = getattr(c, "connection", None)
167
+ if conn is not None and hasattr(conn, "cursor") and not hasattr(conn, "interrupt") \
168
+ and not isinstance(conn, Annullabile):
169
+ c.connection = Annullabile(conn)
170
+ return c
171
+
172
+ motore._get_connector = apri
173
+ return motore
174
+
175
+
176
+ class Lavoro:
177
+ def __init__(self, descrizione: str):
178
+ self.descrizione = descrizione
179
+ self.creato = time.time()
180
+ self.inizio: float | None = None # time.monotonic() quando un thread lo prende
181
+ self.inizio_epoch: float | None = None
182
+ self.annullato = False
183
+ self.motore: Any = None
184
+
185
+ def annulla(self) -> None:
186
+ self.annullato = True
187
+ if self.motore is not None:
188
+ annulla_motore(self.motore)
189
+
190
+
191
+ def _lascia(futuro: asyncio.Future) -> None:
192
+ """Un lavoro che non si attende piu': il suo errore (l'annullamento) non va nel log."""
193
+ futuro.add_done_callback(lambda f: f.cancelled() or f.exception())
194
+
195
+
196
+ class Postazioni:
197
+ """I thread che eseguono le query, ognuno col suo motore e la sua connessione.
198
+
199
+ `crea_motore()` fa un motore nuovo (senza aprire la connessione: Wren la apre
200
+ alla prima query). `motore()` e' quello del thread corrente: nei thread delle
201
+ query il suo, altrove quello di base, che serve agli strumenti senza database
202
+ (dry_plan, list_functions) e non apre mai una connessione."""
203
+
204
+ def __init__(self, crea_motore: Callable[[], Any], connessioni: int = CONNESSIONI,
205
+ limite_s: float = LIMITE_S, margine_s: float = MARGINE_S,
206
+ stato: pathlib.Path | None = None, server: str = ""):
207
+ self._crea = crea_motore
208
+ self.connessioni = max(1, int(connessioni))
209
+ self.limite_s = float(limite_s)
210
+ self.margine_s = float(margine_s)
211
+ self._locale = threading.local()
212
+ self._motori: list[Any] = []
213
+ self._blocco = threading.Lock()
214
+ self._lavori: set[Lavoro] = set()
215
+ self._pool = ThreadPoolExecutor(max_workers=self.connessioni, thread_name_prefix="noeva-query")
216
+ self.base = crea_motore()
217
+ self.server = server
218
+ self.stato = stato
219
+ self._fermo = threading.Event()
220
+ if stato is not None:
221
+ threading.Thread(target=self._battito, name="noeva-stato", daemon=True).start()
222
+
223
+ # --- i motori ---------------------------------------------------------------
224
+ def motore(self) -> Any:
225
+ m = getattr(self._locale, "motore", None)
226
+ return m if m is not None else self.base
227
+
228
+ def _motore_del_thread(self) -> Any:
229
+ m = getattr(self._locale, "motore", None)
230
+ if m is None:
231
+ m = rendi_annullabile(self._crea())
232
+ self._locale.motore = m
233
+ with self._blocco:
234
+ self._motori.append(m)
235
+ return m
236
+
237
+ # --- l'esecuzione -------------------------------------------------------------
238
+ def _lavora(self, lavoro: Lavoro, fn: Callable[[], Any]) -> Any:
239
+ if lavoro.annullato:
240
+ raise asyncio.CancelledError("annullata prima di partire")
241
+ m = self._motore_del_thread()
242
+ lavoro.motore = m
243
+ lavoro.inizio = time.monotonic()
244
+ lavoro.inizio_epoch = time.time()
245
+ try:
246
+ return fn()
247
+ except Exception as e:
248
+ if connessione_persa(e):
249
+ # La prossima query riapre la connessione invece di fallire per sempre.
250
+ try:
251
+ m.close()
252
+ except Exception: # noqa: BLE001
253
+ pass
254
+ if e_un_timeout(e):
255
+ raise TempoScaduto(self.limite_s) from e
256
+ raise
257
+ finally:
258
+ lavoro.motore = None
259
+
260
+ async def esegui(self, fn: Callable[[], Any], descrizione: str = "") -> Any:
261
+ """Esegue fn in un thread delle query; fn usa motore(), cioe' il motore di quel thread.
262
+
263
+ Il tempo si conta da quando un thread prende il lavoro, non dalla coda: in
264
+ coda si aspetta che una query finisca o scada."""
265
+ lavoro = Lavoro(descrizione)
266
+ with self._blocco:
267
+ self._lavori.add(lavoro)
268
+ futuro = asyncio.wrap_future(self._pool.submit(self._lavora, lavoro, fn))
269
+ try:
270
+ while True:
271
+ fatti, _ = await asyncio.wait({futuro}, timeout=0.5)
272
+ if fatti:
273
+ return futuro.result()
274
+ if lavoro.inizio is not None and time.monotonic() - lavoro.inizio > self.limite_s + self.margine_s:
275
+ # Il driver non l'ha fermata (la lettura delle righe, un driver
276
+ # senza timeout): la si annulla da qui e si risponde comunque.
277
+ lavoro.annulla()
278
+ _lascia(futuro)
279
+ raise TempoScaduto(self.limite_s)
280
+ except asyncio.CancelledError:
281
+ # Claude ha annullato la richiesta: la query si ferma anche sul database.
282
+ lavoro.annulla()
283
+ futuro.cancel()
284
+ _lascia(futuro)
285
+ raise
286
+ finally:
287
+ with self._blocco:
288
+ self._lavori.discard(lavoro)
289
+
290
+ def chiudi(self) -> None:
291
+ self._fermo.set()
292
+ with self._blocco:
293
+ lavori = list(self._lavori)
294
+ motori = list(self._motori)
295
+ for lv in lavori:
296
+ lv.annulla()
297
+ self._pool.shutdown(wait=False, cancel_futures=True)
298
+ for m in [*motori, self.base]:
299
+ try:
300
+ m.close()
301
+ except Exception: # noqa: BLE001
302
+ pass
303
+ if self.stato is not None:
304
+ try:
305
+ self.stato.unlink()
306
+ except OSError:
307
+ pass
308
+
309
+ # --- lo stato per --verifica ----------------------------------------------------
310
+ def fotografia(self) -> dict:
311
+ with self._blocco:
312
+ lavori = list(self._lavori)
313
+ return {
314
+ "server": self.server,
315
+ "pid": os.getpid(),
316
+ "aggiornato": time.time(),
317
+ "limite_s": self.limite_s,
318
+ "connessioni": self.connessioni,
319
+ "lavori": [
320
+ {"in_coda": lv.inizio_epoch is None, "dal": lv.inizio_epoch or lv.creato,
321
+ "query": lv.descrizione[:300]}
322
+ for lv in sorted(lavori, key=lambda x: x.creato)
323
+ ],
324
+ }
325
+
326
+ def _battito(self) -> None:
327
+ while not self._fermo.is_set():
328
+ scrivi_stato(self.stato, self.fotografia())
329
+ self._fermo.wait(BATTITO_S)
330
+
331
+
332
+ class MotoreCorrente:
333
+ """Al posto di ctx.engine negli strumenti di Wren: il motore del thread che li esegue."""
334
+
335
+ def __init__(self, postazioni: Postazioni):
336
+ object.__setattr__(self, "_postazioni", postazioni)
337
+
338
+ def __getattr__(self, nome: str):
339
+ return getattr(self._postazioni.motore(), nome)
340
+
341
+
342
+ def rendi_asincroni(server: Any, postazioni: Postazioni, regole: bool = True) -> list[str]:
343
+ """Gli strumenti di Wren che vanno sul database, eseguiti nei thread delle query.
344
+
345
+ FastMCP chiama `fn(**argomenti)` e la attende se `is_async`: la funzione
346
+ sincrona di Wren resta quella, con lo stesso schema degli argomenti, e gira in
347
+ Postazioni.esegui. A run_sql si aggiungono le regole delle query."""
348
+ strumenti = server._tool_manager._tools
349
+ fatti = []
350
+ for nome in STRUMENTI_DB:
351
+ t = strumenti.get(nome)
352
+ if t is None:
353
+ continue
354
+
355
+ def avvolgi(sincrona: Callable[..., Any]) -> Callable[..., Any]:
356
+ async def asincrona(**argomenti):
357
+ descrizione = argomenti.get("sql") or json.dumps(argomenti, ensure_ascii=False, default=str)
358
+ return await postazioni.esegui(lambda: sincrona(**argomenti), str(descrizione))
359
+ return asincrona
360
+
361
+ t.fn = avvolgi(t.fn)
362
+ t.is_async = True
363
+ if nome == "run_sql" and regole:
364
+ t.description = f"{t.description.rstrip()}\n\n{regole_testo(postazioni.limite_s)}"
365
+ fatti.append(nome)
366
+ return fatti
367
+
368
+
369
+ def cartella_stato() -> pathlib.Path:
370
+ return pathlib.Path(os.environ.get("NOEVA_STATO") or tempfile.gettempdir()) / "noeva-stato"
371
+
372
+
373
+ def file_stato(server: str, pid: int | None = None) -> pathlib.Path:
374
+ return cartella_stato() / f"{server}-{pid or os.getpid()}.json"
375
+
376
+
377
+ def scrivi_stato(percorso: pathlib.Path, dati: dict) -> None:
378
+ try:
379
+ percorso.parent.mkdir(parents=True, exist_ok=True)
380
+ tmp = percorso.with_suffix(".tmp")
381
+ tmp.write_text(json.dumps(dati, ensure_ascii=False), encoding="utf-8")
382
+ os.replace(tmp, percorso)
383
+ except OSError:
384
+ pass # lo stato serve a --verifica, non al servizio
385
+
386
+
387
+ def stato_in_corso(server: str, ora: float | None = None) -> list[dict]:
388
+ """Gli stati dei servizi vivi di questo server; quelli fermi da tempo si cancellano."""
389
+ ora = ora or time.time()
390
+ vivi = []
391
+ for f in sorted(cartella_stato().glob(f"{server}-*.json")):
392
+ try:
393
+ dati = json.loads(f.read_text(encoding="utf-8"))
394
+ except (OSError, ValueError):
395
+ continue
396
+ if dati.get("server") != server:
397
+ continue # un altro server con un nome che comincia allo stesso modo
398
+ if ora - float(dati.get("aggiornato") or 0) > STATO_VECCHIO_S:
399
+ try:
400
+ f.unlink()
401
+ except OSError:
402
+ pass
403
+ continue
404
+ vivi.append(dati)
405
+ return vivi
@@ -18,7 +18,14 @@ dashboard su DuckDB come ripiego offline.
18
18
  noeva-<id> --sorgente erp serve MCP su stdio (lo lancia Claude Code)
19
19
  noeva-<id> --verifica controlla driver, password e connessione di
20
20
  tutte le sorgenti e dice cosa manca: si lancia
21
- a mano dal PowerShell quando /mcp dice "failed"
21
+ a mano dal PowerShell quando /mcp dice "failed";
22
+ dice anche se il servizio aperto in Claude ha una
23
+ query in corso da troppo tempo
24
+
25
+ Le query girano in piu' thread, ognuno con la sua connessione, con un tempo
26
+ massimo e l'annullamento sul database (esecuzione.py): una query lenta non ferma
27
+ piu' le altre. In noeva.json, per sorgente: `limite_query_s` (default 120) e
28
+ `connessioni` (default 3; 1 per lo snapshot). NOEVA_LIMITE_QUERY_S forza il limite.
22
29
 
23
30
  Su stdio lo stdout e' il canale del protocollo: fuori da --verifica qui non si
24
31
  stampa niente, i log vanno su stderr.
@@ -31,6 +38,7 @@ import json
31
38
  import os
32
39
  import pathlib
33
40
  import sys
41
+ import time
34
42
 
35
43
  # Congelato da PyInstaller la cartella e' quella dell'eseguibile; da sorgente,
36
44
  # quella sopra server/. NOEVA_DIR la forza (per provare da un'altra macchina).
@@ -53,6 +61,13 @@ class Problema(Exception):
53
61
  """Un errore di configurazione da spiegare a chi installa, non uno stack."""
54
62
 
55
63
 
64
+ def limite_query(s: dict) -> float:
65
+ """Il tempo massimo di una query, in secondi."""
66
+ import esecuzione
67
+
68
+ return float(os.environ.get("NOEVA_LIMITE_QUERY_S") or s.get("limite_query_s") or esecuzione.LIMITE_S)
69
+
70
+
56
71
  def configurazione() -> dict:
57
72
  p = QUI / "noeva.json"
58
73
  if not p.is_file():
@@ -95,11 +110,20 @@ def connessione_mssql(s: dict) -> dict:
95
110
  "user": utente or None,
96
111
  "password": password or None,
97
112
  "driver": driver,
98
- "kwargs": {"TrustServerCertificate": "yes", **(s.get("opzioni") or {})},
113
+ # statement_timeout diventa il timeout della connessione pyodbc: allo
114
+ # scadere il driver annulla la query sul server (esecuzione.py).
115
+ "kwargs": {"TrustServerCertificate": "yes", "statement_timeout": str(int(limite_query(s))),
116
+ **(s.get("opzioni") or {})},
99
117
  }
100
118
 
101
119
 
102
120
  def motore(s: dict):
121
+ progetto, crea = fabbrica(s)
122
+ return progetto, crea()
123
+
124
+
125
+ def fabbrica(s: dict):
126
+ """(progetto, crea_motore): il modello si cerca una volta, i motori si fanno uguali."""
103
127
  from wren.engine import WrenEngine
104
128
 
105
129
  import modello_locale
@@ -119,27 +143,36 @@ def motore(s: dict):
119
143
  raise Problema(f"nessun .duckdb in {dati}")
120
144
  # Il connettore DuckDB attacca in sola lettura ogni .duckdb della
121
145
  # cartella, con alias = nome del file: e' il catalogo del modello.
122
- eng = WrenEngine(manifest_str=manifest, data_source="duckdb",
123
- connection_info={"url": str(dati), "format": "duckdb"})
124
- else:
125
- eng = WrenEngine(manifest_str=manifest, data_source="mssql", connection_info=connessione_mssql(s))
126
- return progetto, eng
146
+ return progetto, lambda: WrenEngine(manifest_str=manifest, data_source="duckdb",
147
+ connection_info={"url": str(dati), "format": "duckdb"})
148
+ info = connessione_mssql(s)
149
+ return progetto, lambda: WrenEngine(manifest_str=manifest, data_source="mssql", connection_info=info)
127
150
 
128
151
 
129
152
  def servi(conf: dict, nome: str) -> None:
130
153
  s = conf["sorgenti"][nome]
131
- progetto, eng = motore(s)
154
+ progetto, crea = fabbrica(s)
155
+ import esecuzione
156
+ import modello_locale
132
157
  from wren.mcp_server import ServeContext, build_server
133
158
 
134
- server = build_server(ServeContext(project=progetto, engine=eng, allow_write=False, no_connect=False))
159
+ limite = limite_query(s)
160
+ postazioni = esecuzione.Postazioni(
161
+ crea, connessioni=s.get("connessioni") or (esecuzione.CONNESSIONI if s["tipo"] == "mssql" else 1),
162
+ limite_s=limite, stato=esecuzione.file_stato(s["server"]), server=s["server"])
163
+ # Gli strumenti di Wren leggono ctx.engine: il motore del thread che li esegue.
164
+ server = build_server(ServeContext(project=progetto, engine=esecuzione.MotoreCorrente(postazioni),
165
+ allow_write=False, no_connect=False))
166
+ esecuzione.rendi_asincroni(server, postazioni, regole=s["tipo"] == "mssql")
135
167
  # FastMCP non espone un setter: nome e istruzioni stanno sul server di basso
136
168
  # livello, ed e' quello che il client legge all'initialize.
137
169
  server._mcp_server.name = s["server"]
138
170
  server._mcp_server.instructions = s["istruzioni"]
171
+ modello_locale.log(f"{s['server']}: {postazioni.connessioni} connessioni, query fermate dopo {int(limite)} s")
139
172
  try:
140
173
  server.run(transport="stdio")
141
174
  finally:
142
- eng.close()
175
+ postazioni.chiudi()
143
176
 
144
177
 
145
178
  def verifica(conf: dict, nomi: list[str]) -> int:
@@ -170,10 +203,48 @@ def verifica(conf: dict, nomi: list[str]) -> int:
170
203
  falliti += 1
171
204
  break
172
205
  eng.close()
206
+ if not query_in_corso(s):
207
+ falliti += 1
173
208
  print("\ntutto a posto" if not falliti else f"\n{falliti} sorgente/i con problemi")
174
209
  return 1 if falliti else 0
175
210
 
176
211
 
212
+ def query_in_corso(s: dict) -> bool:
213
+ """Le query del servizio aperto in Claude (il file di stato di esecuzione.py).
214
+
215
+ Falso se una e' in corso da oltre il tempo massimo: il servizio dovrebbe
216
+ averla gia' fermata, quindi e' bloccato."""
217
+ import esecuzione
218
+
219
+ vivi = esecuzione.stato_in_corso(s["server"])
220
+ if not vivi:
221
+ print(" .. servizio in Claude: non aperto, o di una versione che non lo dice")
222
+ return True
223
+ ora = time.time()
224
+ bene = True
225
+ for st in vivi:
226
+ lavori = st.get("lavori") or []
227
+ limite = float(st.get("limite_s") or esecuzione.LIMITE_S)
228
+ if not lavori:
229
+ print(f" ok servizio in Claude (processo {st.get('pid')}): nessuna query in corso")
230
+ continue
231
+ in_coda = sum(1 for lv in lavori if lv.get("in_coda"))
232
+ for lv in lavori:
233
+ if lv.get("in_coda"):
234
+ continue
235
+ durata = ora - float(lv.get("dal") or ora)
236
+ testo = " ".join(str(lv.get("query") or "").split())[:160]
237
+ if durata > limite + esecuzione.MARGINE_S + esecuzione.BATTITO_S:
238
+ bene = False
239
+ print(f" NO query in corso da {int(durata // 60)} min {int(durata % 60)} s, oltre il massimo "
240
+ f"di {esecuzione.minuti(limite)}: il servizio e' bloccato, chiudi e riapri Claude. Query: {testo}")
241
+ else:
242
+ print(f" .. query in corso da {int(durata)} s: {testo}")
243
+ if in_coda:
244
+ print(f" .. {in_coda} query in coda (connessioni: {st.get('connessioni')})")
245
+ return bene
246
+
247
+
177
248
  def main(argv: list[str] | None = None) -> int:
178
249
  ap = argparse.ArgumentParser(prog=pathlib.Path(sys.argv[0]).stem)
179
250
  ap.add_argument("--sorgente", help="quale database servire (default: la prima di noeva.json)")
@@ -6,9 +6,12 @@
6
6
  "istruzioni.js: i segnaposto sono {progetto} (l'id del registro e di analytics.json) e,",
7
7
  "per lo snapshot, {data} (la data del dato). Cio' che e' vero del progetto (cliente,",
8
8
  "cosa coprono i dati, dashboard, contatto) sta nella scheda knowledge/rules/00-progetto.md,",
9
- "non qui (NW-1261)."
9
+ "non qui (NW-1261).",
10
+ "regole_query: come si interroga un database vivo senza bloccarlo (11/11/2026); si",
11
+ "aggiunge alle istruzioni del vivo e alla scheda 00-progetto, che arriva col modello pubblicato."
10
12
  ],
11
13
  "nome": "noeva-{progetto}",
12
14
  "vivo": "NOEVA Analytics, progetto {progetto}: i dati del cliente dal vivo, in sola lettura. Prima di scrivere SQL leggi get_instructions: la regola 00-progetto dice di chi sono i dati, cosa coprono, dove stanno le dashboard e chi e' il contatto in 4D; le altre regole e describe_model dicono cosa significano i numeri. Valida con dry_run, poi run_sql con un limit; aggrega nel database invece di scaricare righe.",
13
- "snapshot": "NOEVA Analytics, progetto {progetto}: SNAPSHOT delle dashboard (dato al {data}), il ripiego offline, non il database vivo. Prima di scrivere SQL leggi get_instructions: la regola 00-snapshot dice quali tabelle esistono e a che data e' il dato, la 00-progetto di chi sono i dati e dove stanno le dashboard. Dillo sempre nella risposta: e' il ripiego offline."
15
+ "snapshot": "NOEVA Analytics, progetto {progetto}: SNAPSHOT delle dashboard (dato al {data}), il ripiego offline, non il database vivo. Prima di scrivere SQL leggi get_instructions: la regola 00-snapshot dice quali tabelle esistono e a che data e' il dato, la 00-progetto di chi sono i dati e dove stanno le dashboard. Dillo sempre nella risposta: e' il ripiego offline.",
16
+ "regole_query": "Il database vivo e' quello di produzione, che usa anche il gestionale, e ogni query ha un tempo massimo di 2 minuti: prova la query su un mese, poi sull'anno; una query pesante alla volta, mai in parallelo; aggrega ogni tabella (GROUP BY) prima di unirla alle altre, mai un join riga per riga tra tabelle grandi (per esempio listino e fatture); se una query va in timeout riduci il periodo invece di spezzarla in piu' query in parallelo."
14
17
  }
@@ -27,8 +27,13 @@ def nome_server(progetto: str) -> str:
27
27
  return _riempi(TESTI["nome"], progetto=progetto)
28
28
 
29
29
 
30
+ def regole_query() -> str:
31
+ """Come si interroga un database vivo senza bloccarlo: anche nella scheda 00-progetto."""
32
+ return TESTI["regole_query"]
33
+
34
+
30
35
  def istruzioni_vivo(progetto: str) -> str:
31
- return _riempi(TESTI["vivo"], progetto=progetto)
36
+ return _riempi(TESTI["vivo"], progetto=progetto) + " " + regole_query()
32
37
 
33
38
 
34
39
  def istruzioni_snapshot(progetto: str, data: str | None) -> str:
@@ -21,6 +21,7 @@ Legge, tutti facoltativi tranne analytics.json:
21
21
  models/ quanti modelli
22
22
  apps/index.html il titolo del progetto, che e' quello dell'indice
23
23
  apps/*/index.html le dashboard, col titolo della pagina
24
+ istruzioni.json le regole delle query sul vivo (regole_query), se ci sono sorgenti vive
24
25
 
25
26
  Il registro dei progetti (lib/rilascio/progetti.json) sta nel monorepo di 4D e il
26
27
  repository di un cliente non lo vede (il pacchetto non nomina clienti, ADR 0005):
@@ -41,6 +42,11 @@ import sys
41
42
 
42
43
  import yaml
43
44
 
45
+ try:
46
+ from istruzioni import regole_query
47
+ except (ImportError, OSError): # un eseguibile costruito senza istruzioni.json: la scheda resta senza
48
+ regole_query = None
49
+
44
50
  FILE = "00-progetto.md"
45
51
  CONTENITORE = "https://analytics.noeva.ai"
46
52
  # Le cartelle di apps/ che non sono dashboard: il motore e i moduli di pagina.
@@ -119,6 +125,11 @@ def scheda(sorgente: pathlib.Path, conf: dict | None = None) -> str:
119
125
  r.append("- La copertura per sorgente non e' descritta (`cosa` in consegna.yml): la dicono "
120
126
  "le regole e la descrizione dei modelli.")
121
127
 
128
+ if conf.get("sorgenti") and regole_query is not None:
129
+ # Arriva col modello pubblicato anche ai servizi gia' installati, prima del
130
+ # loro aggiornamento (11/11/2026: query pesanti in parallelo, connettore fermo).
131
+ r += ["", "## Le query sul database vivo", "", regole_query()]
132
+
122
133
  r += ["", "## Le dashboard", "",
123
134
  "Dietro il login NOEVA: le apre chi ha il progetto nel suo workspace.", "",
124
135
  f"- Indice: {base}"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noeva/analytics-core",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Gli strumenti di un progetto NOEVA Analytics nel suo repository: moduli di pagina, snapshot, verifica dello standard e kit dell'agente (ADR 0005).",
5
5
  "type": "module",
6
6
  "bin": {