mnemonic-proxy 0.3.2__py3-none-any.whl

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.
ctxproxy/kvarchive.py ADDED
@@ -0,0 +1,855 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Archivio freddo dei salvataggi di Strata (.kva): blocchi indirizzati per contenuto + zstd (card t_6c016d94).
3
+
4
+ Solo libreria standard (Python >= 3.14: `compression.zstd`). Non modifica Strata: un file archiviato torna un `.bin`
5
+ byte-identico (sha256 verificato) prima di un restore.
6
+
7
+ Formato
8
+ -------
9
+ <root>/blocks/<h[:2]>/<h>.kvb blocco: magic "KVB1", codec u8, typesize u16, livello u8, raw_len u64, dati zstd.
10
+ h = blake2b-256 dei byte ORIGINALI del blocco (indipendente dal codec): due file con
11
+ lo stesso contenuto in quella posizione condividono il blocco (deduplica).
12
+ <root>/manifests/<nome>.kva JSON: versione, nome, dimensione, sha256 del file intero, mtime, elenco ordinato dei
13
+ blocchi [hash, raw_len], statistiche. Ricostruzione = concatenazione dei blocchi.
14
+ <root>/.lock flock: archiviazione, rimozione e pulizia dei blocchi sono serializzate.
15
+
16
+ Taglio dei blocchi: allineato alle parti del format v1 di Strata (vedi `regions`). Ogni parte KV (k, v, scale, pooled)
17
+ è tagliata a multipli di 2048 token dall'inizio della parte, quindi il prefisso identico fra due salvataggi della
18
+ stessa conversazione (bit per bit, KV-COMPRESSION-RESULT §4) dà blocchi identici. Lo stato DeltaNet e il resto a
19
+ blocchi da 1 MiB; i pezzi piccoli fra le parti (contatori, geometria, header) sono blocchi a sé. Un file che non è
20
+ format v1 si taglia a blocchi fissi da 1 MiB (funziona lo stesso, senza allineamento).
21
+
22
+ Riordino (codec 1): prima di zstd i byte del blocco sono raggruppati per posizione modulo `typesize` (byte shuffle,
23
+ come Blosc): scale fp16 typesize 2, pooled/GDN fp32 e ids i32 typesize 4, draft K/V typesize head_dim (= trasposizione
24
+ per canale). Reversibile esatto. Le K/V principali restano zstd semplice (vedi DEFAULT_CODECS).
25
+
26
+ Conteggio dei riferimenti: un blocco vive finché un manifest lo elenca (`refcounts`); `gc` cancella solo i blocchi
27
+ che nessun manifest usa, sotto lock. Un archivio interrotto lascia al più blocchi orfani (li toglie `gc`), mai un
28
+ manifest senza blocchi, e l'originale si cancella solo dopo la ricostruzione verificata (sha256) dal disco.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import struct
36
+ import threading
37
+ import time
38
+ from concurrent.futures import ThreadPoolExecutor
39
+
40
+ from . import compat
41
+
42
+ try:
43
+ from compression import zstd
44
+ except ImportError: # pragma: no cover - Python < 3.14
45
+ zstd = None
46
+
47
+ VERSION = 1
48
+ BLOCK_MAGIC = b"KVB1"
49
+ BLOCK_HDR = struct.Struct("<4sBHBQ") # magic, codec, typesize, level, raw_len (16 byte)
50
+ CODEC_ZSTD, CODEC_SHUF_ZSTD, CODEC_RAW = 0, 1, 2
51
+ BIG = 1 << 20 # blocco standard (GDN, file non v1)
52
+ TOKENS_PER_BLOCK = 2048 # parti KV: blocchi da 2048 token (512 pagine da 4)
53
+ KV_PARTS = ("k", "v", "k_scale", "v_scale", "pooled")
54
+ CK_VECS = ("gdn", "ple", "tails", "dead", "block_pos")
55
+
56
+ # codec per tipo di regione. CODEC_SHUF_ZSTD usa il typesize della regione: K/V int8 = head_dim (raggruppa i byte
57
+ # per canale: trasposizione per canale), scale fp16 = 2, pooled/GDN fp32 = 4, ids i32 = 4. Scelta misurata in
58
+ # KV-ARCHIVE-RESULT.md §2: sulle K/V principali il riordino vale 0,7 punti di spazio ma raddoppia archivio e
59
+ # ricostruzione (shuffle in Python puro), quindi di serie K/V principali vanno con zstd semplice ("mixed").
60
+ DEFAULT_CODECS = {
61
+ "kv": CODEC_ZSTD,
62
+ "draft_kv": CODEC_SHUF_ZSTD,
63
+ "scale": CODEC_SHUF_ZSTD,
64
+ "pooled": CODEC_SHUF_ZSTD,
65
+ "gdn": CODEC_SHUF_ZSTD,
66
+ "ids": CODEC_SHUF_ZSTD,
67
+ "misc": CODEC_ZSTD,
68
+ }
69
+ FULL_CODECS = {**DEFAULT_CODECS, "kv": CODEC_SHUF_ZSTD} # riordino ovunque (-0,7 punti, ~2x più lento)
70
+ PLAIN_CODECS = {k: CODEC_ZSTD for k in DEFAULT_CODECS} # zstd semplice ovunque (confronto)
71
+ CODEC_SETS = {"mixed": DEFAULT_CODECS, "full": FULL_CODECS, "plain": PLAIN_CODECS}
72
+
73
+
74
+ class KvaError(Exception):
75
+ pass
76
+
77
+
78
+ class MissingBlock(KvaError):
79
+ pass
80
+
81
+
82
+ class CorruptArchive(KvaError):
83
+ pass
84
+
85
+
86
+ class Aborted(KvaError):
87
+ pass
88
+
89
+
90
+ # --------------------------------------------------------------------------- parser (sola lettura) del format v1
91
+ def regions(buf) -> list[tuple[int, int, str, int, int]]:
92
+ """-> [(offset, nbytes, kind, block_bytes, typesize)] che coprono il file per intero, in ordine.
93
+ Se il file non è un salvataggio Strata v1 integro: blocchi fissi da 1 MiB (kind 'misc')."""
94
+ n = len(buf)
95
+ try:
96
+ return _regions_v1(buf)
97
+ except (KvaError, struct.error, ValueError, IndexError, AssertionError):
98
+ return [(0, n, "misc", BIG, 1)] if n else []
99
+
100
+
101
+ def _regions_v1(buf):
102
+ n = len(buf)
103
+ if n < 96 or bytes(buf[:8]) != b"STRSESS\x01" or bytes(buf[n - 8:]) != b"STRSEND\x01":
104
+ raise KvaError("non v1")
105
+ hs = struct.unpack_from("<I", buf, 12)[0]
106
+ out, p = [], [hs + 18 * 8 + 24]
107
+ misc_start = [0]
108
+
109
+ def u64():
110
+ v = struct.unpack_from("<Q", buf, p[0])[0]
111
+ p[0] += 8
112
+ return v
113
+
114
+ def part(nbytes, kind, block, ts=1):
115
+ if p[0] + nbytes > n:
116
+ raise KvaError("parte oltre la fine")
117
+ if p[0] > misc_start[0]:
118
+ out.append((misc_start[0], p[0] - misc_start[0], "misc", BIG, 1))
119
+ if nbytes:
120
+ out.append((p[0], nbytes, kind, max(1, block), ts))
121
+ p[0] += nbytes
122
+ misc_start[0] = p[0]
123
+
124
+ def ckpt():
125
+ part(u64() * 4, "ids", BIG, 4)
126
+ nimg = u64()
127
+ p[0] += nimg * 16
128
+ for v in CK_VECS:
129
+ part(u64(), "gdn" if v == "gdn" else "misc", BIG, 4 if v == "gdn" else 1)
130
+ p[0] += 8
131
+
132
+ ckpt()
133
+ for _ in range(u64()):
134
+ ckpt()
135
+ nkv = u64()
136
+ if nkv > 4096:
137
+ raise KvaError("troppi strati")
138
+ for i in range(nkv):
139
+ fmt, cells, heads, hd, ps, prow, idim = struct.unpack_from("<7q", buf, p[0])
140
+ p[0] += 56
141
+ draft = i == nkv - 1
142
+ pages = max(1, -(-cells // max(ps, 1)))
143
+ for name in KV_PARTS:
144
+ nb = u64()
145
+ if name == "pooled":
146
+ unit = nb // prow if prow > 0 else 0 # byte per riga (= page_size token)
147
+ kind, ts = "pooled", 4
148
+ else:
149
+ unit = nb // pages # byte per pagina
150
+ if name.endswith("scale"):
151
+ kind, ts = "scale", 2
152
+ else:
153
+ kind, ts = ("draft_kv" if draft else "kv"), (hd if 0 < hd <= 4096 else 1)
154
+ block = unit * (TOKENS_PER_BLOCK // max(ps, 1)) if unit else BIG
155
+ part(nb, kind, block if 0 < block <= 8 * BIG else BIG, ts)
156
+ if p[0] + 16 != n:
157
+ raise KvaError("lunghezza incoerente")
158
+ out.append((misc_start[0], n - misc_start[0], "misc", BIG, 1))
159
+ return out
160
+
161
+
162
+ def split_blocks(buf, max_misc: int = BIG) -> list[tuple[int, int, str, int]]:
163
+ """-> [(offset, nbytes, kind, typesize)] blocchi in ordine di file."""
164
+ blocks = []
165
+ for off, nb, kind, bsz, ts in regions(buf):
166
+ bsz = bsz if kind != "misc" else max_misc
167
+ o, end = off, off + nb
168
+ while o < end:
169
+ ln = min(bsz, end - o)
170
+ blocks.append((o, ln, kind, ts))
171
+ o += ln
172
+ return blocks
173
+
174
+
175
+ # --------------------------------------------------------------------------- codec dei blocchi
176
+ def shuffle(data, ts: int) -> bytes:
177
+ if ts <= 1 or len(data) % ts:
178
+ return bytes(data)
179
+ mv = memoryview(data)
180
+ return b"".join(bytes(mv[i::ts]) for i in range(ts))
181
+
182
+
183
+ def unshuffle(data, ts: int) -> bytes:
184
+ n = len(data)
185
+ if ts <= 1 or n % ts:
186
+ return bytes(data)
187
+ out = bytearray(n)
188
+ m = n // ts
189
+ mv = memoryview(data)
190
+ for i in range(ts):
191
+ out[i::ts] = mv[i * m:(i + 1) * m]
192
+ return bytes(out)
193
+
194
+
195
+ def encode_block(raw, codec: int, ts: int, level: int) -> bytes:
196
+ if codec == CODEC_SHUF_ZSTD and (ts <= 1 or len(raw) % ts):
197
+ codec, ts = CODEC_ZSTD, 1
198
+ if codec == CODEC_ZSTD:
199
+ body = zstd.compress(raw, level)
200
+ elif codec == CODEC_SHUF_ZSTD:
201
+ body = zstd.compress(shuffle(raw, ts), level)
202
+ else:
203
+ body = bytes(raw)
204
+ if codec != CODEC_RAW and len(body) >= len(raw):
205
+ codec, ts, body = CODEC_RAW, 1, bytes(raw) # incomprimibile: tenuto com'è
206
+ return BLOCK_HDR.pack(BLOCK_MAGIC, codec, ts, level, len(raw)) + body
207
+
208
+
209
+ def decode_block(blob: bytes) -> bytes:
210
+ if len(blob) < BLOCK_HDR.size:
211
+ raise CorruptArchive("blocco troncato")
212
+ magic, codec, ts, _lvl, raw_len = BLOCK_HDR.unpack_from(blob)
213
+ if magic != BLOCK_MAGIC:
214
+ raise CorruptArchive("wrong block magic")
215
+ body = memoryview(blob)[BLOCK_HDR.size:]
216
+ if codec == CODEC_RAW:
217
+ raw = bytes(body)
218
+ elif codec == CODEC_ZSTD:
219
+ raw = zstd.decompress(body)
220
+ elif codec == CODEC_SHUF_ZSTD:
221
+ raw = unshuffle(zstd.decompress(body), ts)
222
+ else:
223
+ raise CorruptArchive("codec sconosciuto %d" % codec)
224
+ if len(raw) != raw_len:
225
+ raise CorruptArchive("wrong block length")
226
+ return raw
227
+
228
+
229
+ def bhash(data) -> str:
230
+ return hashlib.blake2b(data, digest_size=32).hexdigest()
231
+
232
+
233
+ def _lower_priority():
234
+ """Thread a priorità bassa: nice 19 (per thread, Linux) e I/O idle (ioprio_set), best effort."""
235
+ try:
236
+ os.setpriority(os.PRIO_PROCESS, threading.get_native_id(), 19)
237
+ except (OSError, AttributeError):
238
+ pass
239
+ try:
240
+ import ctypes
241
+ libc = ctypes.CDLL(None, use_errno=True)
242
+ # ioprio_set(IOPRIO_WHO_PROCESS=1, tid, IOPRIO_CLASS_IDLE<<13); x86_64 syscall 251, aarch64 30
243
+ nr = {"x86_64": 251, "aarch64": 30}.get(os.uname().machine)
244
+ if nr:
245
+ libc.syscall(nr, 1, threading.get_native_id(), 3 << 13)
246
+ except Exception: # noqa: BLE001
247
+ pass
248
+
249
+
250
+ # --------------------------------------------------------------------------- archivio
251
+ class KvArchive:
252
+ def __init__(self, root: str, level: int = 3, threads: int = 4, codecs: dict | None = None,
253
+ low_priority: bool = False):
254
+ if zstd is None:
255
+ raise KvaError("serve Python >= 3.14 (compression.zstd)")
256
+ self.root = os.path.abspath(root)
257
+ self.level, self.threads = level, max(1, threads)
258
+ self.codecs = {**DEFAULT_CODECS, **(codecs or {})}
259
+ self.low_priority = low_priority
260
+ os.makedirs(os.path.join(self.root, "blocks"), exist_ok=True)
261
+ os.makedirs(os.path.join(self.root, "manifests"), exist_ok=True)
262
+ self._tlock = threading.RLock()
263
+
264
+ # ---------- percorsi / lock ----------
265
+ def block_path(self, h: str) -> str:
266
+ return os.path.join(self.root, "blocks", h[:2], h + ".kvb")
267
+
268
+ def manifest_path(self, name: str) -> str:
269
+ if compat.bad_name(name):
270
+ raise KvaError("nome non valido: %r" % name)
271
+ return os.path.join(self.root, "manifests", name + ".kva")
272
+
273
+ class _Lock:
274
+ def __init__(self, arc):
275
+ self.arc = arc
276
+
277
+ def __enter__(self):
278
+ self.arc._tlock.acquire()
279
+ self.fd = os.open(os.path.join(self.arc.root, ".lock"), os.O_RDWR | os.O_CREAT | compat.O_BINARY, 0o600)
280
+ compat.lock_fd(self.fd)
281
+ return self
282
+
283
+ def __exit__(self, *a):
284
+ compat.unlock_fd(self.fd)
285
+ os.close(self.fd)
286
+ self.arc._tlock.release()
287
+
288
+ def lock(self):
289
+ return KvArchive._Lock(self)
290
+
291
+ def _pool(self):
292
+ return ThreadPoolExecutor(self.threads, initializer=_lower_priority if self.low_priority else None)
293
+
294
+ # ---------- interrogazioni ----------
295
+ def names(self) -> list[str]:
296
+ d = os.path.join(self.root, "manifests")
297
+ return sorted(f[:-4] for f in os.listdir(d) if f.endswith(".kva"))
298
+
299
+ def has(self, name: str) -> bool:
300
+ return os.path.exists(self.manifest_path(name))
301
+
302
+ def manifest(self, name: str) -> dict:
303
+ try:
304
+ with open(self.manifest_path(name), encoding="utf-8") as f:
305
+ m = json.load(f)
306
+ except FileNotFoundError:
307
+ raise KvaError("no archive for %r" % name) from None
308
+ except ValueError as e:
309
+ raise CorruptArchive("manifest illeggibile per %r: %s" % (name, e)) from None
310
+ if m.get("version") != VERSION:
311
+ raise CorruptArchive("versione manifest %r" % m.get("version"))
312
+ return m
313
+
314
+ def refcounts(self) -> dict[str, int]:
315
+ """hash -> numero di riferimenti (somma su tutti i manifest; un blocco ripetuto nello stesso file conta più
316
+ volte)."""
317
+ rc: dict[str, int] = {}
318
+ for nm in self.names():
319
+ for h, _ in self.manifest(nm)["blocks"]:
320
+ rc[h] = rc.get(h, 0) + 1
321
+ return rc
322
+
323
+ def stored_blocks(self) -> dict[str, int]:
324
+ out = {}
325
+ bd = os.path.join(self.root, "blocks")
326
+ for sub in os.listdir(bd):
327
+ sp = os.path.join(bd, sub)
328
+ if not os.path.isdir(sp):
329
+ continue
330
+ for f in os.listdir(sp):
331
+ if f.endswith(".kvb"):
332
+ out[f[:-4]] = os.path.getsize(os.path.join(sp, f))
333
+ return out
334
+
335
+ def usage(self) -> dict:
336
+ sb = self.stored_blocks()
337
+ logical = sum(self.manifest(n)["size"] for n in self.names())
338
+ stored = sum(sb.values())
339
+ return {"manifests": len(self.names()), "blocks": len(sb), "stored_bytes": stored,
340
+ "logical_bytes": logical, "ratio": (stored / logical) if logical else None}
341
+
342
+ # ---------- scrittura ----------
343
+ def _write_block(self, h: str, blob: bytes) -> int:
344
+ """Scrive (tmp + fsync + rename) se assente. -> byte nuovi scritti (0 se già presente)."""
345
+ path = self.block_path(h)
346
+ if os.path.exists(path):
347
+ return 0
348
+ os.makedirs(os.path.dirname(path), exist_ok=True)
349
+ tmp = "%s.tmp-%d-%d" % (path, os.getpid(), threading.get_ident())
350
+ with open(tmp, "wb") as f:
351
+ f.write(blob)
352
+ f.flush()
353
+ os.fsync(f.fileno())
354
+ compat.replace(tmp, path)
355
+ return len(blob)
356
+
357
+ def archive(self, src: str, name: str | None = None, delete_original: bool = False,
358
+ should_abort=None, before_delete=None) -> dict:
359
+ """Archivia `src`. Ordine (sicuro a interruzioni): blocchi (fsync) -> manifest temporaneo -> ricostruzione dal
360
+ disco con sha256 == originale -> rename del manifest -> (se richiesto) cancellazione dell'originale.
361
+ `should_abort()` è chiamata fra i blocchi (True = interrompi, originale intatto). `before_delete(st)` (stat
362
+ dell'originale) può vietare la cancellazione restituendo False (es. Strata occupato)."""
363
+ t0 = time.time()
364
+ name = name or os.path.basename(src)
365
+ self.manifest_path(name)
366
+ st0 = os.stat(src)
367
+ # il lock copre blocchi + verifica + manifest: gc non può togliere blocchi non ancora referenziati
368
+ with self.lock():
369
+ r = self._archive_locked(src, name, st0, t0, should_abort)
370
+ deleted = False
371
+ if delete_original:
372
+ st1 = os.stat(src)
373
+ same = (st1.st_size, st1.st_mtime_ns, st1.st_ino) == (st0.st_size, st0.st_mtime_ns, st0.st_ino)
374
+ if same and (before_delete is None or before_delete(st1) is not False):
375
+ os.remove(src)
376
+ self._fsync_dir(os.path.dirname(os.path.abspath(src)))
377
+ deleted = True
378
+ r["deleted_original"] = deleted
379
+ r["ms"] = round((time.time() - t0) * 1000)
380
+ return r
381
+
382
+ def _archive_locked(self, src, name, st0, t0, should_abort) -> dict:
383
+ with open(src, "rb") as f:
384
+ import mmap
385
+ size = st0.st_size
386
+ mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) if size else b""
387
+ try:
388
+ mv = memoryview(mm)
389
+ blocks = split_blocks(mv)
390
+ sha = hashlib.sha256()
391
+ entries, new_bytes, new_blocks, dedup_bytes = [], 0, 0, 0
392
+ kinds: dict[str, list[int]] = {}
393
+
394
+ def work(b):
395
+ o, ln, kind, ts = b
396
+ raw = mv[o:o + ln]
397
+ h = bhash(raw)
398
+ with claim_mu:
399
+ if h in claimed or os.path.exists(self.block_path(h)):
400
+ return h, ln, kind, 0
401
+ claimed.add(h) # stesso blocco due volte nel file: scritto una volta sola
402
+ codec = self.codecs.get(kind, CODEC_ZSTD)
403
+ return h, ln, kind, self._write_block(h, encode_block(raw, codec, ts, self.level))
404
+
405
+ claimed, claim_mu = set(), threading.Lock()
406
+ with self._pool() as ex:
407
+ W = self.threads * 4
408
+ futs = []
409
+ i = 0
410
+ while i < len(blocks) or futs:
411
+ while i < len(blocks) and len(futs) < W:
412
+ futs.append(ex.submit(work, blocks[i]))
413
+ i += 1
414
+ h, ln, kind, wrote = futs.pop(0).result()
415
+ o = blocks[len(entries)][0]
416
+ sha.update(mv[o:o + ln]) # sha256 dell'originale, in ordine
417
+ entries.append([h, ln])
418
+ k = kinds.setdefault(kind, [0, 0, 0])
419
+ k[0] += ln
420
+ k[1] += wrote
421
+ if wrote:
422
+ new_bytes += wrote
423
+ new_blocks += 1
424
+ else:
425
+ dedup_bytes += ln
426
+ k[2] += ln
427
+ if should_abort and should_abort():
428
+ for fu in futs:
429
+ fu.cancel()
430
+ raise Aborted("interrotto dopo %d blocchi su %d" % (len(entries), len(blocks)))
431
+ del mv
432
+ finally:
433
+ if size:
434
+ try:
435
+ mm.close()
436
+ except BufferError:
437
+ pass # viste ancora vive dopo un'interruzione: la chiude il GC
438
+ t_arch = time.time()
439
+ man = {"version": VERSION, "name": name, "size": size, "sha256": sha.hexdigest(),
440
+ "mtime": st0.st_mtime, "created": time.time(), "level": self.level,
441
+ "blocks": entries,
442
+ "stats": {"n_blocks": len(entries), "new_blocks": new_blocks, "new_stored_bytes": new_bytes,
443
+ "dedup_bytes": dedup_bytes,
444
+ "kinds": {k: {"raw": v[0], "new_stored": v[1], "dedup_raw": v[2]} for k, v in kinds.items()}}}
445
+ mp = self.manifest_path(name)
446
+ tmp = mp + ".tmp-%d" % os.getpid()
447
+ with open(tmp, "w", encoding="utf-8") as f:
448
+ json.dump(man, f, separators=(",", ":"))
449
+ f.flush()
450
+ os.fsync(f.fileno())
451
+ try:
452
+ vsha, vsize = self._verify_entries(entries)
453
+ if vsha != man["sha256"] or vsize != size:
454
+ raise CorruptArchive("verification failed for %s: sha256 of the reconstruction differs" % name)
455
+ compat.replace(tmp, mp)
456
+ self._fsync_dir(os.path.dirname(mp))
457
+ except BaseException:
458
+ try:
459
+ os.remove(tmp)
460
+ except OSError:
461
+ pass
462
+ raise
463
+ t_ver = time.time()
464
+ return {"name": name, "bytes": size, "new_stored_bytes": new_bytes, "dedup_bytes": dedup_bytes,
465
+ "blocks": len(entries), "new_blocks": new_blocks, "sha256": man["sha256"],
466
+ "archive_ms": round((t_arch - t0) * 1000), "verify_ms": round((t_ver - t_arch) * 1000)}
467
+
468
+ @staticmethod
469
+ def _fsync_dir(d):
470
+ try:
471
+ fd = os.open(d, os.O_RDONLY)
472
+ try:
473
+ os.fsync(fd)
474
+ finally:
475
+ os.close(fd)
476
+ except OSError:
477
+ pass
478
+
479
+ # ---------- lettura ----------
480
+ def _read_block(self, idx: int, h: str, ln: int) -> bytes:
481
+ try:
482
+ with open(self.block_path(h), "rb") as f:
483
+ blob = f.read()
484
+ except FileNotFoundError:
485
+ raise MissingBlock("block %d missing: %s (incomplete archive, reconstruction impossible)"
486
+ % (idx, h)) from None
487
+ raw = decode_block(blob)
488
+ if len(raw) != ln:
489
+ raise CorruptArchive("block %d (%s): %d bytes instead of %d" % (idx, h, len(raw), ln))
490
+ return raw
491
+
492
+ def _iter_raw(self, entries, check_hash: bool = True):
493
+ def rd(a):
494
+ i, (h, ln) = a
495
+ raw = self._read_block(i, h, ln)
496
+ if check_hash and bhash(raw) != h:
497
+ raise CorruptArchive("blocco %d: contenuto diverso dal suo hash %s" % (i, h))
498
+ return raw
499
+ with self._pool() as ex:
500
+ W = self.threads * 4
501
+ it = iter(enumerate(entries))
502
+ futs = []
503
+ for a in it:
504
+ futs.append(ex.submit(rd, a))
505
+ if len(futs) >= W:
506
+ break
507
+ while futs:
508
+ raw = futs.pop(0).result()
509
+ a = next(it, None)
510
+ if a is not None:
511
+ futs.append(ex.submit(rd, a))
512
+ yield raw
513
+
514
+ def _verify_entries(self, entries) -> tuple[str, int]:
515
+ sha, n = hashlib.sha256(), 0
516
+ for raw in self._iter_raw(entries):
517
+ sha.update(raw)
518
+ n += len(raw)
519
+ return sha.hexdigest(), n
520
+
521
+ def verify(self, name: str) -> bool:
522
+ m = self.manifest(name)
523
+ sha, n = self._verify_entries(m["blocks"])
524
+ return sha == m["sha256"] and n == m["size"]
525
+
526
+ def restore(self, name: str, dest: str) -> dict:
527
+ """Ricostruisce `name` in `dest`: file temporaneo nella stessa cartella, sha256 verificato, fsync, rename
528
+ atomico. Se manca un blocco: MissingBlock e nessun file lasciato."""
529
+ t0 = time.time()
530
+ m = self.manifest(name)
531
+ d = os.path.dirname(os.path.abspath(dest)) or "."
532
+ tmp = os.path.join(d, ".%s.kva-tmp-%d" % (os.path.basename(dest), os.getpid()))
533
+ sha, n = hashlib.sha256(), 0
534
+ try:
535
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | compat.O_BINARY, 0o600)
536
+ with os.fdopen(fd, "wb") as f:
537
+ for raw in self._iter_raw(m["blocks"], check_hash=False):
538
+ sha.update(raw)
539
+ f.write(raw)
540
+ n += len(raw)
541
+ f.flush()
542
+ if sha.hexdigest() != m["sha256"] or n != m["size"]:
543
+ raise CorruptArchive("ricostruzione di %s: sha256/dimensione diversi dall'originale" % name)
544
+ os.fsync(f.fileno())
545
+ compat.replace(tmp, dest)
546
+ self._fsync_dir(d)
547
+ except BaseException:
548
+ try:
549
+ os.remove(tmp)
550
+ except OSError:
551
+ pass
552
+ raise
553
+ return {"name": name, "bytes": n, "ms": round((time.time() - t0) * 1000), "sha256": m["sha256"]}
554
+
555
+ # ---------- rimozione / pulizia ----------
556
+ def remove(self, name: str, gc: bool = True) -> dict:
557
+ """Toglie il manifest; con gc, cancella i blocchi rimasti senza riferimenti."""
558
+ with self.lock():
559
+ try:
560
+ os.remove(self.manifest_path(name))
561
+ except FileNotFoundError:
562
+ return {"name": name, "removed": False, "freed_bytes": 0, "freed_blocks": 0}
563
+ r = self._gc_locked() if gc else {"freed_bytes": 0, "freed_blocks": 0}
564
+ return {"name": name, "removed": True, **r}
565
+
566
+ def gc(self) -> dict:
567
+ with self.lock():
568
+ return self._gc_locked()
569
+
570
+ def _gc_locked(self) -> dict:
571
+ live = self.refcounts()
572
+ freed, nb = 0, 0
573
+ bd = os.path.join(self.root, "blocks")
574
+ for sub in os.listdir(bd):
575
+ sp = os.path.join(bd, sub)
576
+ if not os.path.isdir(sp):
577
+ continue
578
+ for f in os.listdir(sp):
579
+ h = f.split(".", 1)[0]
580
+ if f.endswith(".kvb") and h in live:
581
+ continue
582
+ p = os.path.join(sp, f)
583
+ try:
584
+ freed += os.path.getsize(p)
585
+ os.remove(p)
586
+ nb += 1
587
+ except OSError:
588
+ pass
589
+ return {"freed_bytes": freed, "freed_blocks": nb}
590
+
591
+
592
+ # --------------------------------------------------------------------------- integrazione nel proxy
593
+ def default_root(cfg) -> str:
594
+ if getattr(cfg, "kv_archive_dir", ""):
595
+ return cfg.kv_archive_dir
596
+ if getattr(cfg, "slot_dir", ""):
597
+ return os.path.join(os.path.dirname(os.path.abspath(cfg.slot_dir)), "kv-archive")
598
+ return ""
599
+
600
+
601
+ def discard(cfg, name: str, journal=None) -> None:
602
+ """Il proxy ha cancellato (o sostituito) il salvataggio `name`: anche la sua copia archiviata non serve più."""
603
+ if not getattr(cfg, "kv_archive", False):
604
+ return
605
+ root = default_root(cfg)
606
+ if not root or not os.path.exists(os.path.join(root, "manifests", name + ".kva")):
607
+ return
608
+ try:
609
+ r = KvArchive(root, threads=1).remove(name)
610
+ if journal is not None:
611
+ journal.log("kv_archive_drop", file=name, freed_bytes=r.get("freed_bytes"))
612
+ except Exception as e: # noqa: BLE001
613
+ if journal is not None:
614
+ journal.log("kv_archive_error", step="drop", file=name, error=str(e)[:300])
615
+
616
+
617
+ class ArchivingUpstream:
618
+ """Avvolge l'Upstream di base: prima di un restore di un file archiviato (assente nella slot_dir) lo ricostruisce
619
+ nella slot_dir; dopo un save con lo stesso nome di un archivio, l'archivio vecchio si scarta."""
620
+
621
+ def __init__(self, up, cfg, journal, arc: KvArchive):
622
+ self.up, self.cfg, self.journal, self.arc = up, cfg, journal, arc
623
+
624
+ def __getattr__(self, k):
625
+ return getattr(self.up, k)
626
+
627
+ def raw(self, *a, **kw):
628
+ return self.up.raw(*a, **kw)
629
+
630
+ def ensure_local(self, filename: str) -> dict | None:
631
+ dest = os.path.join(self.cfg.slot_dir, filename)
632
+ if os.path.exists(dest) or not self.arc.has(filename):
633
+ return None
634
+ try:
635
+ r = self.arc.restore(filename, dest)
636
+ except Exception as e: # noqa: BLE001
637
+ self.journal.log("kv_archive_error", step="unarchive", file=filename, error=str(e)[:300])
638
+ raise
639
+ st = self.arc.manifest(filename)["stats"]
640
+ ev = self.journal.log("kv_unarchive", file=filename, bytes=r["bytes"], ms=r["ms"],
641
+ blocks=st.get("n_blocks"))
642
+ return ev
643
+
644
+ def slot(self, action: str, filename: str) -> dict:
645
+ if action == "restore" and self.cfg.slot_dir:
646
+ self.ensure_local(filename)
647
+ r = self.up.slot(action, filename)
648
+ if action == "save" and self.arc.has(filename):
649
+ discard(self.cfg, filename, self.journal)
650
+ return r
651
+
652
+
653
+ class Archiver:
654
+ """Thread a priorità bassa: quando Strata è inattivo da `kv_archive_idle_s` archivia i salvataggi freddi della
655
+ slot_dir (non toccati da `kv_archive_min_age_s`, non quello che Strata tiene ora, non l'ultimo autosalvataggio
656
+ di una conversazione, non l'ancora corrente) e cancella l'originale solo dopo la verifica sha256."""
657
+
658
+ def __init__(self, proxy, arc: KvArchive):
659
+ self.proxy, self.arc = proxy, arc
660
+ self.cfg, self.store, self.journal = proxy.cfg, proxy.store, proxy.journal
661
+ self.stop_ev = threading.Event()
662
+ self.th = None
663
+ self.failed: dict[str, float] = {}
664
+
665
+ def start(self):
666
+ self.th = threading.Thread(target=self._loop, daemon=True, name="kv-archive")
667
+ self.th.start()
668
+ return self
669
+
670
+ def stop(self):
671
+ self.stop_ev.set()
672
+
673
+ def _loop(self):
674
+ _lower_priority()
675
+ while not self.stop_ev.wait(self.cfg.kv_archive_poll_s):
676
+ try:
677
+ self.tick()
678
+ except Exception as e: # noqa: BLE001
679
+ self.journal.log("kv_archive_error", step="tick", error=repr(e)[:300])
680
+
681
+ # ---------- politica ----------
682
+ def _status(self):
683
+ from .engines import normalize_status
684
+ try:
685
+ up = self.proxy.up
686
+ return normalize_status(getattr(up, "engine", None), up)
687
+ except Exception: # noqa: BLE001
688
+ return None
689
+
690
+ def _strata_idle(self, now) -> bool:
691
+ """Inattivo = lock del proxy libero, /v1/status senza richieste in corso e contatore `requests` fermo da
692
+ almeno kv_archive_idle_s (vale anche per client che non passano dal proxy)."""
693
+ if self.proxy.lock.locked():
694
+ self._seen = None
695
+ return False
696
+ s = self._status()
697
+ if s is None or (s.get("activity") or {}).get("in_flight"):
698
+ self._seen = None
699
+ return False
700
+ req = (s.get("activity") or {}).get("requests")
701
+ seen = getattr(self, "_seen", None)
702
+ if seen is None or seen[0] != req:
703
+ self._seen = (req, now)
704
+ return False
705
+ return now - seen[1] >= self.cfg.kv_archive_idle_s
706
+
707
+ def _protected(self) -> set[str]:
708
+ """File che restano in chiaro: l'autosalvataggio più recente di ogni conversazione, l'ancora corrente di ogni
709
+ (conv, seg) e il file che corrisponde a ciò che Strata tiene ora."""
710
+ keep = set()
711
+ try:
712
+ seen = set()
713
+ for r in self.store.autosaves(active_only=True): # più recenti prima
714
+ if r["conv"] not in seen:
715
+ seen.add(r["conv"])
716
+ keep.add(r["file"])
717
+ except Exception: # noqa: BLE001
718
+ pass
719
+ st = getattr(self.proxy.up, "state", None) or {}
720
+ if st.get("conv"):
721
+ pre = "%s-seg%d-" % (st["conv"], st.get("seg") or 0)
722
+ try:
723
+ keep |= {f for f in os.listdir(self.cfg.slot_dir) if f.startswith(pre)}
724
+ except OSError:
725
+ pass
726
+ return keep
727
+
728
+ def candidates(self, now: float | None = None) -> list[str]:
729
+ now = now or time.time()
730
+ sd = self.cfg.slot_dir
731
+ if not sd or not os.path.isdir(sd):
732
+ return []
733
+ keep = self._protected()
734
+ out = []
735
+ for f in sorted(os.listdir(sd)):
736
+ p = os.path.join(sd, f)
737
+ if not f.endswith(".bin") or f.startswith(".") or f in keep:
738
+ continue
739
+ if now - self.failed.get(f, 0) < 3600:
740
+ continue
741
+ try:
742
+ stt = os.stat(p)
743
+ except OSError:
744
+ continue
745
+ if now - stt.st_mtime < self.cfg.kv_archive_min_age_s or stt.st_size < self.cfg.kv_archive_min_bytes:
746
+ continue
747
+ out.append(f)
748
+ return out
749
+
750
+ def tick(self, now: float | None = None):
751
+ """Al massimo un file per giro. -> evento kv_archive o None."""
752
+ now = now or time.time()
753
+ if not self._strata_idle(now):
754
+ return None
755
+ cands = self.candidates(now)
756
+ if not cands:
757
+ return None
758
+ f = cands[0]
759
+ src = os.path.join(self.cfg.slot_dir, f)
760
+
761
+ def abort():
762
+ return self.stop_ev.is_set() or self.proxy.lock.locked()
763
+
764
+ def before_delete(_st):
765
+ # cancellazione solo a proxy fermo: nessuna richiesta (né restore) in corso in questo istante
766
+ if not self.proxy.lock.acquire(blocking=False):
767
+ return False
768
+ self._held = True
769
+ return True
770
+
771
+ self._held = False
772
+ try:
773
+ r = self.arc.archive(src, f, delete_original=True, should_abort=abort, before_delete=before_delete)
774
+ except Aborted as e:
775
+ self.journal.log("kv_archive_skip", file=f, reason="busy", detail=str(e)[:200])
776
+ return None
777
+ except Exception as e: # noqa: BLE001
778
+ self.failed[f] = now
779
+ self.journal.log("kv_archive_error", step="archive", file=f, error=str(e)[:300])
780
+ return None
781
+ finally:
782
+ if self._held:
783
+ self.proxy.lock.release()
784
+ self._held = False
785
+ return self.journal.log("kv_archive", file=f, bytes_before=r["bytes"], bytes_new=r["new_stored_bytes"],
786
+ bytes_dedup=r["dedup_bytes"], blocks=r["blocks"], new_blocks=r["new_blocks"],
787
+ ms=r["ms"], archive_ms=r["archive_ms"], verify_ms=r["verify_ms"],
788
+ deleted_original=r["deleted_original"])
789
+
790
+
791
+ def enable(proxy, start: bool = True):
792
+ """Chiamata dal server quando cfg.kv_archive: avvolge l'upstream di base e avvia il thread."""
793
+ cfg = proxy.cfg
794
+ root = default_root(cfg)
795
+ if not cfg.slot_dir or not root:
796
+ proxy.journal.log("kv_archive_error", step="enable", error="serve slot_dir")
797
+ return None
798
+ arc = KvArchive(root, level=cfg.kv_archive_level, threads=cfg.kv_archive_threads, low_priority=True)
799
+ up = proxy.up
800
+ inner = getattr(up, "up", None) if up.__class__.__name__ == "Tracker" else None
801
+ if inner is not None and not isinstance(inner, ArchivingUpstream):
802
+ up.up = ArchivingUpstream(inner, cfg, proxy.journal, arc)
803
+ elif inner is None and not isinstance(up, ArchivingUpstream):
804
+ proxy.up = ArchivingUpstream(up, cfg, proxy.journal, arc)
805
+ proxy.archiver = Archiver(proxy, arc)
806
+ if start:
807
+ proxy.archiver.start()
808
+ return proxy.archiver
809
+
810
+
811
+ def _main(argv=None):
812
+ """CLI: archive / restore / verify / ls / gc / usage (prove e manutenzione a mano)."""
813
+ import argparse
814
+ ap = argparse.ArgumentParser(prog="python -m ctxproxy.kvarchive")
815
+ ap.add_argument("--root", required=True)
816
+ ap.add_argument("--threads", type=int, default=4)
817
+ ap.add_argument("--level", type=int, default=3)
818
+ ap.add_argument("--codecs", choices=sorted(CODEC_SETS), default="mixed",
819
+ help="mixed (di serie) / full (riordino anche K/V) / plain (zstd semplice)")
820
+ sub = ap.add_subparsers(dest="cmd", required=True)
821
+ a1 = sub.add_parser("archive")
822
+ a1.add_argument("files", nargs="+")
823
+ a1.add_argument("--delete", action="store_true")
824
+ a2 = sub.add_parser("restore")
825
+ a2.add_argument("name")
826
+ a2.add_argument("dest")
827
+ a3 = sub.add_parser("verify")
828
+ a3.add_argument("names", nargs="*")
829
+ sub.add_parser("ls")
830
+ sub.add_parser("gc")
831
+ sub.add_parser("usage")
832
+ a = ap.parse_args(argv)
833
+ arc = KvArchive(a.root, level=a.level, threads=a.threads, codecs=CODEC_SETS[a.codecs])
834
+ if a.cmd == "archive":
835
+ for f in a.files:
836
+ print(json.dumps(arc.archive(f, delete_original=a.delete)), flush=True)
837
+ elif a.cmd == "restore":
838
+ print(json.dumps(arc.restore(a.name, a.dest)))
839
+ elif a.cmd == "verify":
840
+ for n in a.names or arc.names():
841
+ t = time.time()
842
+ print(json.dumps({"name": n, "ok": arc.verify(n), "ms": round((time.time() - t) * 1000)}), flush=True)
843
+ elif a.cmd == "ls":
844
+ for n in arc.names():
845
+ m = arc.manifest(n)
846
+ print(json.dumps({"name": n, "size": m["size"], **{k: m["stats"][k] for k in
847
+ ("n_blocks", "new_blocks", "new_stored_bytes")}}))
848
+ elif a.cmd == "gc":
849
+ print(json.dumps(arc.gc()))
850
+ else:
851
+ print(json.dumps(arc.usage()))
852
+
853
+
854
+ if __name__ == "__main__":
855
+ _main()