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/__init__.py +8 -0
- ctxproxy/api_anthropic.py +426 -0
- ctxproxy/api_responses.py +436 -0
- ctxproxy/autosave.py +350 -0
- ctxproxy/check.py +356 -0
- ctxproxy/compat.py +91 -0
- ctxproxy/core.py +2218 -0
- ctxproxy/demo.py +193 -0
- ctxproxy/engines.py +314 -0
- ctxproxy/fake_engine.py +304 -0
- ctxproxy/kvarchive.py +855 -0
- ctxproxy/lang.py +57 -0
- ctxproxy/mcp_server.py +381 -0
- ctxproxy/packtok.py +29 -0
- ctxproxy/paging.py +508 -0
- ctxproxy/recall2.py +1003 -0
- ctxproxy/render.py +274 -0
- ctxproxy/server.py +797 -0
- ctxproxy/tooldefs.py +111 -0
- ctxproxy/upstream.py +106 -0
- mnemonic_proxy-0.3.2.dist-info/METADATA +841 -0
- mnemonic_proxy-0.3.2.dist-info/RECORD +26 -0
- mnemonic_proxy-0.3.2.dist-info/WHEEL +5 -0
- mnemonic_proxy-0.3.2.dist-info/entry_points.txt +4 -0
- mnemonic_proxy-0.3.2.dist-info/licenses/LICENSE +21 -0
- mnemonic_proxy-0.3.2.dist-info/top_level.txt +1 -0
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()
|