PyMkDB 0.1.0__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.
- pymkdb/__init__.py +6 -0
- pymkdb/cli.py +57 -0
- pymkdb-0.1.0.dist-info/METADATA +86 -0
- pymkdb-0.1.0.dist-info/RECORD +54 -0
- pymkdb-0.1.0.dist-info/WHEEL +5 -0
- pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
- pymkdb-0.1.0.dist-info/top_level.txt +3 -0
- sdk/__init__.py +1 -0
- sdk/connection.py +225 -0
- sdk/delta.py +19 -0
- sdk/http_connection.py +180 -0
- sdk/mkdb_client.py +226 -0
- sdk/responses.py +154 -0
- src/__init__.py +1 -0
- src/config/db.py +227 -0
- src/config/server.py +52 -0
- src/db/__init__.py +207 -0
- src/db/cache/__init__.py +1 -0
- src/db/cache/ram_cache.py +144 -0
- src/db/cache/write_queue.py +156 -0
- src/db/maintenance/__init__.py +0 -0
- src/db/maintenance/compactor.py +118 -0
- src/db/maintenance/task_scheduler.py +73 -0
- src/db/objects/store.py +283 -0
- src/db/parity/__init__.py +0 -0
- src/db/parity/parity_manager.py +196 -0
- src/db/query/__init__.py +1 -0
- src/db/query/full_text_index.py +168 -0
- src/db/query/numeric_index.py +196 -0
- src/db/query/query_engine.py +308 -0
- src/db/query/tokenizer.py +48 -0
- src/db/query_workers/__init__.py +16 -0
- src/db/query_workers/dispatcher.py +339 -0
- src/db/query_workers/task.py +78 -0
- src/db/query_workers/worker.py +292 -0
- src/db/requesting/main.py +0 -0
- src/db/storage/__init__.py +1 -0
- src/db/storage/blob_store.py +47 -0
- src/db/storage/index_manager.py +92 -0
- src/db/storage/log_manager.py +119 -0
- src/db/storage/serializer.py +38 -0
- src/filing/__init__.py +31 -0
- src/objects/__init__.py +190 -0
- src/runtime/__init__.py +15 -0
- src/server/__init__.py +0 -0
- src/server/coms/actions.py +209 -0
- src/server/coms/http.py +46 -0
- src/server/coms/http_handlers.py +445 -0
- src/server/coms/metrics.py +231 -0
- src/server/coms/socket.py +461 -0
- src/server/coms/socket_protocol.py +54 -0
- src/server/control/api/actions.py +1001 -0
- src/server/control/server.py +404 -0
- src/server/event_log.py +58 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TaskScheduler — periodic daemon thread that checks segments and enqueues
|
|
3
|
+
compaction tasks via WriteQueue.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskScheduler:
|
|
14
|
+
def __init__(self, store, interval: float = None):
|
|
15
|
+
"""
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
store : src.db.objects.store.store
|
|
19
|
+
interval : check interval in seconds (default from file_config or 60)
|
|
20
|
+
"""
|
|
21
|
+
self._store = store
|
|
22
|
+
if interval is None:
|
|
23
|
+
interval = float(getattr(store.config.file_config, "compaction_interval", 60))
|
|
24
|
+
self.interval = interval
|
|
25
|
+
self._stop_event = threading.Event()
|
|
26
|
+
self._thread = threading.Thread(
|
|
27
|
+
target=self._loop, daemon=True, name=f"TaskScheduler-{store.config.name}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def start(self) -> None:
|
|
31
|
+
self._thread.start()
|
|
32
|
+
|
|
33
|
+
def stop(self) -> None:
|
|
34
|
+
self._stop_event.set()
|
|
35
|
+
|
|
36
|
+
def trigger_now(self, task_type: str = "compact") -> None:
|
|
37
|
+
"""Immediately enqueue a compaction task for all eligible segments."""
|
|
38
|
+
self._run_checks(task_type)
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Internal loop
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _loop(self) -> None:
|
|
45
|
+
while not self._stop_event.wait(timeout=self.interval):
|
|
46
|
+
self._run_checks("compact")
|
|
47
|
+
|
|
48
|
+
def _run_checks(self, task_type: str) -> None:
|
|
49
|
+
store = self._store
|
|
50
|
+
if store.log_manager is None or store._write_queue is None:
|
|
51
|
+
return
|
|
52
|
+
from src.db.maintenance.compactor import Compactor
|
|
53
|
+
compactor = Compactor(store)
|
|
54
|
+
|
|
55
|
+
segments = store.log_manager.list_segments()
|
|
56
|
+
# Skip the active segment
|
|
57
|
+
active = store.log_manager.active_segment
|
|
58
|
+
for seq_int in segments:
|
|
59
|
+
if seq_int == active:
|
|
60
|
+
continue
|
|
61
|
+
seq_str = f"{seq_int:03d}"
|
|
62
|
+
try:
|
|
63
|
+
if task_type == "compact" and compactor.needs_compaction(seq_str):
|
|
64
|
+
logger.info("TaskScheduler: enqueuing compact for segment %s", seq_str)
|
|
65
|
+
store._write_queue.enqueue({
|
|
66
|
+
"op": "compact",
|
|
67
|
+
"store": store.config.name,
|
|
68
|
+
"record_id": None,
|
|
69
|
+
"delta": {"segment": seq_int},
|
|
70
|
+
"ts": time.time(),
|
|
71
|
+
})
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
logger.warning("TaskScheduler: error checking segment %s: %s", seq_str, exc)
|
src/db/objects/store.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from src.db import mkdb
|
|
4
|
+
from src.config.db import store_config as _store_config
|
|
5
|
+
from src.db.query_workers import QueryDispatcher
|
|
6
|
+
from src.db.storage.log_manager import LogManager
|
|
7
|
+
from src.db.storage.index_manager import IndexManager
|
|
8
|
+
from src.db.storage import blob_store
|
|
9
|
+
from src.db.storage import serializer as _serializer
|
|
10
|
+
from src.db.cache.ram_cache import RamCache
|
|
11
|
+
from src.db.cache.write_queue import WriteQueue
|
|
12
|
+
from src.db.query.query_engine import QueryEngine, QuerySyntaxError
|
|
13
|
+
from src.db.maintenance.compactor import Compactor
|
|
14
|
+
from src.db.maintenance.task_scheduler import TaskScheduler
|
|
15
|
+
from src.db.parity.parity_manager import ParityManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class store:
|
|
19
|
+
def __init__(self, db: mkdb, store_config: _store_config):
|
|
20
|
+
self.db = db
|
|
21
|
+
self.config = store_config
|
|
22
|
+
self._dispatcher: QueryDispatcher | None = None
|
|
23
|
+
self.log_manager: LogManager | None = None
|
|
24
|
+
self.index_manager: IndexManager | None = None
|
|
25
|
+
self._ram_cache: RamCache | None = None
|
|
26
|
+
self._write_queue: WriteQueue | None = None
|
|
27
|
+
self.query_engine: QueryEngine | None = None
|
|
28
|
+
self._compactor: Compactor | None = None
|
|
29
|
+
self._task_scheduler: TaskScheduler | None = None
|
|
30
|
+
self._parity_manager: ParityManager | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def store_path(self):
|
|
34
|
+
return os.path.join(self.db.file_path, "stores", self.config.name)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def dispatcher(self) -> QueryDispatcher:
|
|
38
|
+
"""Lazy-initialised query dispatcher for this store."""
|
|
39
|
+
if self._dispatcher is None:
|
|
40
|
+
self._dispatcher = QueryDispatcher(
|
|
41
|
+
store_name=self.config.name,
|
|
42
|
+
base_path=self.store_path,
|
|
43
|
+
config=self.config.query_worker_config,
|
|
44
|
+
)
|
|
45
|
+
self._dispatcher.start()
|
|
46
|
+
return self._dispatcher
|
|
47
|
+
|
|
48
|
+
def setup(self):
|
|
49
|
+
"""Setup the store based on the configuration."""
|
|
50
|
+
os.makedirs(self.store_path, exist_ok=True)
|
|
51
|
+
seg_threshold = getattr(self.config.file_config, "segment_threshold",
|
|
52
|
+
self.config.file_config.file_size_split_trigger)
|
|
53
|
+
self.log_manager = LogManager(
|
|
54
|
+
store_path=self.store_path,
|
|
55
|
+
service=self.config.name,
|
|
56
|
+
segment_threshold=seg_threshold,
|
|
57
|
+
)
|
|
58
|
+
self.index_manager = IndexManager(
|
|
59
|
+
store_path=self.store_path,
|
|
60
|
+
service=self.config.name,
|
|
61
|
+
)
|
|
62
|
+
print(f"Store '{self.config.name}' initialized at {self.store_path}")
|
|
63
|
+
from src.server.event_log import emit as _emit
|
|
64
|
+
_emit("info", f"store:{self.config.name}", f"Store loaded from {self.store_path}")
|
|
65
|
+
|
|
66
|
+
# Parity manager — boot verification runs at startup
|
|
67
|
+
nsym = getattr(self.config.file_config, "parity_nsym", 10)
|
|
68
|
+
self._parity_manager = ParityManager(
|
|
69
|
+
store_path=self.store_path,
|
|
70
|
+
service=self.config.name,
|
|
71
|
+
nsym=nsym,
|
|
72
|
+
)
|
|
73
|
+
self._parity_manager.boot_verify_all(self.log_manager)
|
|
74
|
+
|
|
75
|
+
# RAM cache
|
|
76
|
+
self._ram_cache = RamCache(
|
|
77
|
+
max_size=self.config.ram_config.max_size,
|
|
78
|
+
ttl=self.config.ram_config.ttl,
|
|
79
|
+
clean_type=self.config.ram_config.clean_type,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Write queue
|
|
83
|
+
debounce = getattr(self.config, "write_queue_config",
|
|
84
|
+
None)
|
|
85
|
+
debounce_window = debounce.debounce_window if debounce else 5.0
|
|
86
|
+
max_pending = debounce.max_pending if debounce else 10_000
|
|
87
|
+
self._write_queue = WriteQueue(
|
|
88
|
+
debounce_window=debounce_window,
|
|
89
|
+
max_pending=max_pending,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _handle_write(task: dict) -> None:
|
|
93
|
+
"""Flush a debounced write task to the log/index layer."""
|
|
94
|
+
rid = task["record_id"]
|
|
95
|
+
delta = task["delta"]
|
|
96
|
+
# Capture the active segment before write so we can detect rollover
|
|
97
|
+
seg_before = self.log_manager.active_segment if self.log_manager else None
|
|
98
|
+
# Call the low-level write (defined by WS-1); it writes to log + index
|
|
99
|
+
self._write_to_storage(rid, delta)
|
|
100
|
+
if self.query_engine is not None:
|
|
101
|
+
self.query_engine.on_write(rid, delta)
|
|
102
|
+
self.invalidate_cache(rid)
|
|
103
|
+
# On segment rollover: seal the old segment with parity + flush indexes
|
|
104
|
+
if self.log_manager and seg_before is not None and self.log_manager.active_segment != seg_before:
|
|
105
|
+
if self.query_engine is not None:
|
|
106
|
+
self.query_engine.save_all()
|
|
107
|
+
if self._parity_manager is not None:
|
|
108
|
+
try:
|
|
109
|
+
self._parity_manager.generate(seg_before)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # parity failure must never block writes
|
|
112
|
+
|
|
113
|
+
self._write_queue.register_handler("write", _handle_write)
|
|
114
|
+
self._write_queue.start()
|
|
115
|
+
|
|
116
|
+
# Query engine
|
|
117
|
+
self.query_engine = QueryEngine(self)
|
|
118
|
+
self.query_engine.build_indexes()
|
|
119
|
+
|
|
120
|
+
# Compaction
|
|
121
|
+
self._compactor = Compactor(self)
|
|
122
|
+
|
|
123
|
+
# Register compact handler on write queue
|
|
124
|
+
def _handle_compact(task: dict) -> None:
|
|
125
|
+
seq_int = task.get("delta", {}).get("segment")
|
|
126
|
+
if seq_int is not None:
|
|
127
|
+
self._compactor.compact_segment(int(seq_int))
|
|
128
|
+
if self._parity_manager is not None:
|
|
129
|
+
try:
|
|
130
|
+
self._parity_manager.generate(int(seq_int))
|
|
131
|
+
except ImportError:
|
|
132
|
+
pass # reedsolo not installed; silently skip
|
|
133
|
+
|
|
134
|
+
self._write_queue.register_handler("compact", _handle_compact)
|
|
135
|
+
|
|
136
|
+
# Task scheduler
|
|
137
|
+
self._task_scheduler = TaskScheduler(self)
|
|
138
|
+
self._task_scheduler.start()
|
|
139
|
+
|
|
140
|
+
def query(self, operation: str, params: dict, timeout: float | None = None):
|
|
141
|
+
"""
|
|
142
|
+
Submit a query task to the worker pool and return the result.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
operation : str "read" | "query" | "count" | "exists" | "multi_read"
|
|
147
|
+
params : dict Operation-specific parameters.
|
|
148
|
+
timeout : float Optional per-call timeout override.
|
|
149
|
+
"""
|
|
150
|
+
return self.dispatcher.submit(operation, params, timeout=timeout)
|
|
151
|
+
|
|
152
|
+
def invalidate_cache(self, record_id: str) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Broadcast a cache-invalidation for record_id to all query workers.
|
|
155
|
+
Called by the write queue after a record is flushed to disk.
|
|
156
|
+
"""
|
|
157
|
+
if self._dispatcher is not None:
|
|
158
|
+
self._dispatcher.invalidate(record_id)
|
|
159
|
+
|
|
160
|
+
def stop(self) -> None:
|
|
161
|
+
"""Gracefully stop the query worker pool for this store."""
|
|
162
|
+
if self._dispatcher is not None:
|
|
163
|
+
self._dispatcher.stop()
|
|
164
|
+
self._dispatcher = None
|
|
165
|
+
|
|
166
|
+
def generate_id(self) -> str:
|
|
167
|
+
"""Generate a unique record ID using this store's entity_config.
|
|
168
|
+
|
|
169
|
+
Samples random characters from ``entity_config.token_chars`` at
|
|
170
|
+
``entity_config.token_length``. Collision-checks against the live
|
|
171
|
+
index. If every attempt at the current length fails AND
|
|
172
|
+
``entity_config.auto_expand`` is True, the token_length is incremented
|
|
173
|
+
by 1, the config is saved, and generation retries at the new length.
|
|
174
|
+
"""
|
|
175
|
+
import random
|
|
176
|
+
ec = self.config.entity_config
|
|
177
|
+
chars = ec.token_chars or "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
178
|
+
max_attempts_per_length = 200
|
|
179
|
+
|
|
180
|
+
while True:
|
|
181
|
+
length = ec.token_length
|
|
182
|
+
for _ in range(max_attempts_per_length):
|
|
183
|
+
candidate = "".join(random.choices(chars, k=length))
|
|
184
|
+
# Accept immediately if the index says it doesn't exist
|
|
185
|
+
if self.index_manager is None or self.index_manager.get(candidate) is None:
|
|
186
|
+
return candidate
|
|
187
|
+
|
|
188
|
+
# Exhausted attempts — keyspace is likely saturated at this length
|
|
189
|
+
if ec.auto_expand:
|
|
190
|
+
ec.token_length += 1
|
|
191
|
+
self.db.config.save()
|
|
192
|
+
# Loop again with the expanded length
|
|
193
|
+
else:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
f"Could not generate a unique ID for store '{self.config.name}' "
|
|
196
|
+
f"after {max_attempts_per_length} attempts at token_length={length}. "
|
|
197
|
+
"Enable auto_expand in the store's entity config, or increase token_length."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def write(self, record_id: str, flat_dict: dict) -> None:
|
|
201
|
+
"""Write a record — updates cache immediately, flushes to disk via queue."""
|
|
202
|
+
if self._ram_cache is not None:
|
|
203
|
+
self._ram_cache.apply_delta(record_id, flat_dict)
|
|
204
|
+
if self._write_queue is not None:
|
|
205
|
+
self._write_queue.enqueue({
|
|
206
|
+
"op": "write",
|
|
207
|
+
"store": self.config.name,
|
|
208
|
+
"record_id": record_id,
|
|
209
|
+
"delta": flat_dict,
|
|
210
|
+
"ts": __import__("time").time(),
|
|
211
|
+
})
|
|
212
|
+
else:
|
|
213
|
+
# Fallback: direct write if queue not initialised
|
|
214
|
+
self._write_to_storage(record_id, flat_dict)
|
|
215
|
+
|
|
216
|
+
def _write_to_storage(self, record_id: str, flat_dict: dict) -> None:
|
|
217
|
+
"""Write directly to log + index (bypasses cache and queue)."""
|
|
218
|
+
if self.log_manager is None or self.index_manager is None:
|
|
219
|
+
raise RuntimeError("Store is not set up. Call setup() first.")
|
|
220
|
+
blob_threshold = getattr(self.config.file_config, "blob_threshold", 5 * 1024 * 1024)
|
|
221
|
+
from src.db.storage import serializer as _serializer
|
|
222
|
+
from src.db.storage import blob_store
|
|
223
|
+
line_str = _serializer.serialize_record(record_id, flat_dict)
|
|
224
|
+
if len(line_str.encode("utf-8")) > blob_threshold:
|
|
225
|
+
seg, offset, size = blob_store.write_blob(self.store_path, record_id, line_str)
|
|
226
|
+
else:
|
|
227
|
+
seg, offset, size = self.log_manager.append(record_id, line_str)
|
|
228
|
+
self.index_manager.set(record_id, seg, offset, size)
|
|
229
|
+
|
|
230
|
+
def read(self, record_id: str) -> dict | None:
|
|
231
|
+
"""Read a record — checks RAM cache first, falls back to disk."""
|
|
232
|
+
if self._ram_cache is not None:
|
|
233
|
+
cached = self._ram_cache.get(record_id)
|
|
234
|
+
if cached is not None:
|
|
235
|
+
return cached
|
|
236
|
+
if self.index_manager is None:
|
|
237
|
+
raise RuntimeError("Store is not set up. Call setup() first.")
|
|
238
|
+
entry = self.index_manager.get(record_id)
|
|
239
|
+
if entry is None:
|
|
240
|
+
return None
|
|
241
|
+
seg, offset, size = entry
|
|
242
|
+
if self.log_manager is None:
|
|
243
|
+
raise RuntimeError("Store is not set up. Call setup() first.")
|
|
244
|
+
from src.db.storage import serializer as _serializer
|
|
245
|
+
line_str = self.log_manager.read(seg, offset, size)
|
|
246
|
+
_, flat_dict = _serializer.deserialize_record(line_str)
|
|
247
|
+
if self._ram_cache is not None:
|
|
248
|
+
self._ram_cache.set(record_id, flat_dict)
|
|
249
|
+
return flat_dict
|
|
250
|
+
|
|
251
|
+
def delete(self, record_id: str) -> None:
|
|
252
|
+
"""Soft-delete a record."""
|
|
253
|
+
if self._ram_cache is not None:
|
|
254
|
+
self._ram_cache.delete(record_id)
|
|
255
|
+
if self.index_manager is None:
|
|
256
|
+
raise RuntimeError("Store is not set up. Call setup() first.")
|
|
257
|
+
self.index_manager.delete(record_id)
|
|
258
|
+
# Update query indexes
|
|
259
|
+
if self.query_engine is not None:
|
|
260
|
+
# Read old values for index removal — if not in cache, skip (index will be stale until rebuild)
|
|
261
|
+
old = self._ram_cache.get(record_id) if self._ram_cache else None
|
|
262
|
+
if old:
|
|
263
|
+
self.query_engine.on_delete(record_id, old)
|
|
264
|
+
|
|
265
|
+
def teardown(self) -> None:
|
|
266
|
+
"""Flush and close storage handles. Called during server shutdown."""
|
|
267
|
+
from src.server.event_log import emit as _emit
|
|
268
|
+
_emit("info", f"store:{self.config.name}", "Store shutting down — flushing writes")
|
|
269
|
+
if self._task_scheduler is not None:
|
|
270
|
+
self._task_scheduler.stop()
|
|
271
|
+
# Flush the write queue FIRST so all pending writes hit the log and
|
|
272
|
+
# update the query engine's in-memory indexes before we save them.
|
|
273
|
+
if self._write_queue is not None:
|
|
274
|
+
self._write_queue.flush_all()
|
|
275
|
+
self._write_queue.stop()
|
|
276
|
+
if self.query_engine is not None:
|
|
277
|
+
self.query_engine.save_all()
|
|
278
|
+
print(f"[{self.config.name}] Query indexes saved.")
|
|
279
|
+
if self.index_manager is not None and self.index_manager._dirty:
|
|
280
|
+
self.index_manager.save_full()
|
|
281
|
+
if self.log_manager is not None:
|
|
282
|
+
self.log_manager.close()
|
|
283
|
+
self.stop() # stops query dispatcher
|
|
File without changes
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ParityManager — Reed-Solomon parity generation and verification for log segments.
|
|
3
|
+
|
|
4
|
+
Parity files: {store_path}/{service}_{NNN}.parity
|
|
5
|
+
These files contain the Reed-Solomon parity symbols for the corresponding .log segment.
|
|
6
|
+
|
|
7
|
+
External dependency: reedsolo >= 1.7 (pip install reedsolo)
|
|
8
|
+
Imports are deferred to operation methods so the module loads cleanly even
|
|
9
|
+
if reedsolo is not installed; a clear ImportError with install instructions
|
|
10
|
+
is raised only when a parity operation is invoked.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_REEDSOLO_MISSING = (
|
|
19
|
+
"reedsolo is required for parity operations. "
|
|
20
|
+
"Install it with: pip install reedsolo"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParityManager:
|
|
25
|
+
def __init__(self, store_path: str, service: str, nsym: int = 10):
|
|
26
|
+
"""
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
store_path : absolute path to the store directory
|
|
30
|
+
service : store name (file prefix)
|
|
31
|
+
nsym : Reed-Solomon parity symbol count (default 10)
|
|
32
|
+
"""
|
|
33
|
+
self.store_path = store_path
|
|
34
|
+
self.service = service
|
|
35
|
+
self.nsym = nsym
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
# Path helpers
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _log_path(self, seq_int: int) -> str:
|
|
42
|
+
return os.path.join(self.store_path, f"{self.service}_{seq_int:03d}.log")
|
|
43
|
+
|
|
44
|
+
def _parity_path(self, seq_int: int) -> str:
|
|
45
|
+
return os.path.join(self.store_path, f"{self.service}_{seq_int:03d}.parity")
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Public API
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def generate(self, seq_int: int) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Read the .log segment, encode via Reed-Solomon, write parity to .parity file.
|
|
54
|
+
Overwrites any existing .parity file.
|
|
55
|
+
|
|
56
|
+
Parity file format: concatenated ECC symbols for each chunk
|
|
57
|
+
(nsym bytes per chunk of chunk_size = nsize-nsym data bytes).
|
|
58
|
+
This avoids the single-chunk limitation of GF(2^8) (max 255 bytes)
|
|
59
|
+
and keeps parity files small (~nsym/chunk_size ≈ 4% of log size).
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
import reedsolo
|
|
63
|
+
except ImportError:
|
|
64
|
+
raise ImportError(_REEDSOLO_MISSING)
|
|
65
|
+
|
|
66
|
+
log_path = self._log_path(seq_int)
|
|
67
|
+
if not os.path.exists(log_path):
|
|
68
|
+
logger.warning("generate: log segment %03d not found, skipping", seq_int)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
with open(log_path, "rb") as fh:
|
|
72
|
+
data = fh.read()
|
|
73
|
+
|
|
74
|
+
codec = reedsolo.RSCodec(self.nsym)
|
|
75
|
+
chunk_size = codec.nsize - codec.nsym # data bytes per chunk (default 245)
|
|
76
|
+
ecc_parts = []
|
|
77
|
+
for i in range(0, len(data), chunk_size):
|
|
78
|
+
chunk = data[i:i + chunk_size]
|
|
79
|
+
enc_chunk = bytearray(codec.encode(chunk))
|
|
80
|
+
ecc_parts.append(enc_chunk[len(chunk):]) # only the nsym ECC bytes
|
|
81
|
+
parity_bytes = b"".join(ecc_parts)
|
|
82
|
+
|
|
83
|
+
parity_path = self._parity_path(seq_int)
|
|
84
|
+
tmp_path = parity_path + ".tmp"
|
|
85
|
+
with open(tmp_path, "wb") as fh:
|
|
86
|
+
fh.write(parity_bytes)
|
|
87
|
+
os.replace(tmp_path, parity_path)
|
|
88
|
+
logger.debug("generate: parity written for segment %03d (%d bytes)", seq_int, len(parity_bytes))
|
|
89
|
+
|
|
90
|
+
def verify_and_repair(self, seq_int: int) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Verify and optionally repair a log segment using its parity file.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
True — segment is clean or was repaired successfully
|
|
97
|
+
False — segment is corrupt beyond repair
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
import reedsolo
|
|
101
|
+
except ImportError:
|
|
102
|
+
raise ImportError(_REEDSOLO_MISSING)
|
|
103
|
+
|
|
104
|
+
log_path = self._log_path(seq_int)
|
|
105
|
+
parity_path = self._parity_path(seq_int)
|
|
106
|
+
|
|
107
|
+
if not os.path.exists(log_path):
|
|
108
|
+
logger.warning("verify_and_repair: log segment %03d not found", seq_int)
|
|
109
|
+
return False
|
|
110
|
+
if not os.path.exists(parity_path):
|
|
111
|
+
logger.warning("verify_and_repair: parity file for segment %03d not found; generating now", seq_int)
|
|
112
|
+
self.generate(seq_int)
|
|
113
|
+
return True # freshly generated == clean by definition
|
|
114
|
+
|
|
115
|
+
with open(log_path, "rb") as fh:
|
|
116
|
+
log_bytes = fh.read()
|
|
117
|
+
with open(parity_path, "rb") as fh:
|
|
118
|
+
parity_bytes = fh.read()
|
|
119
|
+
|
|
120
|
+
codec = reedsolo.RSCodec(self.nsym)
|
|
121
|
+
chunk_size = codec.nsize - codec.nsym # data bytes per chunk (default 245)
|
|
122
|
+
repaired = bytearray()
|
|
123
|
+
ecc_offset = 0
|
|
124
|
+
try:
|
|
125
|
+
for i in range(0, len(log_bytes), chunk_size):
|
|
126
|
+
chunk = log_bytes[i:i + chunk_size]
|
|
127
|
+
ecc = parity_bytes[ecc_offset:ecc_offset + codec.nsym]
|
|
128
|
+
dec_tuple = codec.decode(bytes(chunk) + bytes(ecc))
|
|
129
|
+
repaired.extend(dec_tuple[0])
|
|
130
|
+
ecc_offset += codec.nsym
|
|
131
|
+
except reedsolo.ReedSolomonError as exc:
|
|
132
|
+
logger.error(
|
|
133
|
+
"verify_and_repair: segment %03d is corrupt beyond repair: %s",
|
|
134
|
+
seq_int, exc,
|
|
135
|
+
)
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
decoded_bytes = bytes(repaired)
|
|
139
|
+
if decoded_bytes != log_bytes:
|
|
140
|
+
# Corruption detected but repairable
|
|
141
|
+
logger.warning(
|
|
142
|
+
"verify_and_repair: segment %03d had corruption — repaired and rewritten",
|
|
143
|
+
seq_int,
|
|
144
|
+
)
|
|
145
|
+
from src.server.event_log import emit as _emit
|
|
146
|
+
_emit("recovery", f"store:{self.service}",
|
|
147
|
+
f"Segment {self.service}_{seq_int:03d}.log had corruption — repaired via Reed-Solomon")
|
|
148
|
+
tmp_path = log_path + ".tmp"
|
|
149
|
+
with open(tmp_path, "wb") as fh:
|
|
150
|
+
fh.write(decoded_bytes)
|
|
151
|
+
os.replace(tmp_path, log_path)
|
|
152
|
+
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def boot_verify_all(self, log_manager) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Called from store.setup() after LogManager is initialised.
|
|
158
|
+
Iterates all sealed segments and calls verify_and_repair on each.
|
|
159
|
+
The active (highest-numbered) segment is excluded from verification
|
|
160
|
+
because it is still being appended to — its parity is always regenerated
|
|
161
|
+
fresh instead so subsequent rollover-based parity generation is correct.
|
|
162
|
+
"""
|
|
163
|
+
from src.server.event_log import emit as _emit
|
|
164
|
+
all_segs = log_manager.list_segments()
|
|
165
|
+
active = log_manager.active_segment
|
|
166
|
+
for seq_int in all_segs:
|
|
167
|
+
if seq_int == active:
|
|
168
|
+
# Always regenerate parity for the active segment — it may have
|
|
169
|
+
# grown since the last time parity was written.
|
|
170
|
+
try:
|
|
171
|
+
self.generate(seq_int)
|
|
172
|
+
logger.debug("boot_verify_all: regenerated parity for active segment %03d", seq_int)
|
|
173
|
+
except ImportError:
|
|
174
|
+
logger.info(
|
|
175
|
+
"boot_verify_all: reedsolo not installed — parity skipped. "
|
|
176
|
+
"Install with: pip install reedsolo"
|
|
177
|
+
)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("boot_verify_all: could not generate parity for active segment %03d: %s", seq_int, exc)
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
ok = self.verify_and_repair(seq_int)
|
|
183
|
+
if not ok:
|
|
184
|
+
logger.error("boot_verify_all: segment %03d could not be repaired", seq_int)
|
|
185
|
+
_emit("error", f"store:{self.service}",
|
|
186
|
+
f"Segment {self.service}_{seq_int:03d}.log is corrupt beyond repair")
|
|
187
|
+
except ImportError:
|
|
188
|
+
logger.info(
|
|
189
|
+
"boot_verify_all: reedsolo not installed — parity verification skipped. "
|
|
190
|
+
"Install with: pip install reedsolo"
|
|
191
|
+
)
|
|
192
|
+
return # skip all remaining segments
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.warning("boot_verify_all: error on segment %03d: %s", seq_int, exc)
|
|
195
|
+
_emit("warning", f"store:{self.service}",
|
|
196
|
+
f"Parity check error on segment {self.service}_{seq_int:03d}.log: {exc}")
|
src/db/query/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|