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,1001 @@
|
|
|
1
|
+
from src.db import mkdb
|
|
2
|
+
from src.config.db import mkdb_config
|
|
3
|
+
|
|
4
|
+
def api_create_store(database: mkdb, data: dict):
|
|
5
|
+
"""Create a new store in the config."""
|
|
6
|
+
store_name:str = data.get("name", "").strip()
|
|
7
|
+
description:str = data.get("description", "").strip()
|
|
8
|
+
|
|
9
|
+
if not store_name:
|
|
10
|
+
raise ValueError("Store name is required")
|
|
11
|
+
|
|
12
|
+
if database is None:
|
|
13
|
+
raise RuntimeError("Database not initialized")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
database.config.new_store(store_name, description)
|
|
17
|
+
database.setup()
|
|
18
|
+
return {
|
|
19
|
+
"store_name": store_name,
|
|
20
|
+
"message": f"Store '{store_name}' created successfully"
|
|
21
|
+
}
|
|
22
|
+
except ValueError as e:
|
|
23
|
+
raise e
|
|
24
|
+
|
|
25
|
+
def api_delete_store(database: mkdb, data: dict):
|
|
26
|
+
"""Delete a store from the config."""
|
|
27
|
+
store_name:str = data.get("name", "").strip()
|
|
28
|
+
|
|
29
|
+
if not store_name:
|
|
30
|
+
raise ValueError("Store name is required")
|
|
31
|
+
|
|
32
|
+
if database is None:
|
|
33
|
+
raise RuntimeError("Database not initialized")
|
|
34
|
+
|
|
35
|
+
if store_name not in database.config.stores:
|
|
36
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
37
|
+
|
|
38
|
+
del database.config.stores[store_name]
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"store_name": store_name,
|
|
42
|
+
"message": f"Store '{store_name}' deleted successfully"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def api_list_stores(database: mkdb, data: dict):
|
|
46
|
+
"""List all stores from the config."""
|
|
47
|
+
if database is None:
|
|
48
|
+
raise RuntimeError("Database not initialized")
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"stores": [{"name": x, "description": database.config.stores[x].description} for x in database.config.stores],
|
|
52
|
+
"count": len(database.config.stores)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def api_update_store(database: mkdb, data: dict):
|
|
56
|
+
"""Update a store's description."""
|
|
57
|
+
store_name: str = data.get("name", "").strip()
|
|
58
|
+
description: str = data.get("description", "").strip()
|
|
59
|
+
|
|
60
|
+
if not store_name:
|
|
61
|
+
raise ValueError("Store name is required")
|
|
62
|
+
|
|
63
|
+
if database is None:
|
|
64
|
+
raise RuntimeError("Database not initialized")
|
|
65
|
+
|
|
66
|
+
if store_name not in database.config.stores:
|
|
67
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
68
|
+
|
|
69
|
+
database.config.stores[store_name].description = description
|
|
70
|
+
database.config.save()
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"store_name": store_name,
|
|
74
|
+
"description": description,
|
|
75
|
+
"message": f"Store '{store_name}' updated successfully"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def api_generate_id(database: mkdb, data: dict):
|
|
79
|
+
"""Generate one or more unique record IDs for a store.
|
|
80
|
+
|
|
81
|
+
Required: name (store name)
|
|
82
|
+
Optional: count (int, 1–100, default 1)
|
|
83
|
+
|
|
84
|
+
Delegates to store.generate_id() which respects entity_config.token_chars,
|
|
85
|
+
entity_config.token_length, and entity_config.auto_expand.
|
|
86
|
+
"""
|
|
87
|
+
store_name: str = data.get("name", "").strip()
|
|
88
|
+
count: int = max(1, min(100, int(data.get("count", 1))))
|
|
89
|
+
|
|
90
|
+
if not store_name:
|
|
91
|
+
raise ValueError("Store name is required")
|
|
92
|
+
if database is None:
|
|
93
|
+
raise RuntimeError("Database not initialized")
|
|
94
|
+
if store_name not in database.stores:
|
|
95
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
96
|
+
|
|
97
|
+
store_obj = database.stores[store_name]
|
|
98
|
+
ids: list[str] = []
|
|
99
|
+
for _ in range(count):
|
|
100
|
+
ids.append(store_obj.generate_id())
|
|
101
|
+
|
|
102
|
+
return {"ids": ids, "count": len(ids)}
|
|
103
|
+
|
|
104
|
+
def api_get_store_config(database: mkdb, data: dict):
|
|
105
|
+
"""Get detailed configuration for a store."""
|
|
106
|
+
store_name: str = data.get("name", "").strip()
|
|
107
|
+
|
|
108
|
+
if not store_name:
|
|
109
|
+
raise ValueError("Store name is required")
|
|
110
|
+
|
|
111
|
+
if database is None:
|
|
112
|
+
raise RuntimeError("Database not initialized")
|
|
113
|
+
|
|
114
|
+
if store_name not in database.config.stores:
|
|
115
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
116
|
+
|
|
117
|
+
store = database.config.stores[store_name]
|
|
118
|
+
result = dict(store.json)
|
|
119
|
+
result["entity_health"] = store.entity_config.health
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
def api_update_store_config(database: mkdb, data: dict):
|
|
123
|
+
"""Update detailed configuration for a store."""
|
|
124
|
+
store_name: str = data.get("name", "").strip()
|
|
125
|
+
|
|
126
|
+
if not store_name:
|
|
127
|
+
raise ValueError("Store name is required")
|
|
128
|
+
|
|
129
|
+
if database is None:
|
|
130
|
+
raise RuntimeError("Database not initialized")
|
|
131
|
+
|
|
132
|
+
if store_name not in database.config.stores:
|
|
133
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
134
|
+
|
|
135
|
+
store = database.config.stores[store_name]
|
|
136
|
+
|
|
137
|
+
# Update sub-configs using their constructors from JSON
|
|
138
|
+
if "file_config" in data:
|
|
139
|
+
from src.config.db import file_config
|
|
140
|
+
store.file_config = file_config(data["file_config"])
|
|
141
|
+
if "entity_config" in data:
|
|
142
|
+
from src.config.db import entity_config
|
|
143
|
+
store.entity_config = entity_config(data["entity_config"])
|
|
144
|
+
if "ram_config" in data:
|
|
145
|
+
from src.config.db import ram_config
|
|
146
|
+
store.ram_config = ram_config(data["ram_config"])
|
|
147
|
+
if "rate_limit" in data:
|
|
148
|
+
from src.config.db import store_rate_limit
|
|
149
|
+
store.rate_limit = store_rate_limit(data["rate_limit"])
|
|
150
|
+
if "slow_query_threshold_ms" in data:
|
|
151
|
+
store.slow_query_threshold_ms = float(data["slow_query_threshold_ms"])
|
|
152
|
+
if "client_id_header" in data:
|
|
153
|
+
store.client_id_header = str(data["client_id_header"]).strip()
|
|
154
|
+
if "protect_reads" in data:
|
|
155
|
+
store.protect_reads = bool(data["protect_reads"])
|
|
156
|
+
if "description" in data:
|
|
157
|
+
store.description = data["description"]
|
|
158
|
+
if "schema_config" in data:
|
|
159
|
+
from src.config.db import schema_config, field_schema
|
|
160
|
+
raw_fields = data["schema_config"].get("fields", {})
|
|
161
|
+
new_schema = schema_config({})
|
|
162
|
+
new_schema.fields = {
|
|
163
|
+
k: field_schema(v) if isinstance(v, dict) else v
|
|
164
|
+
for k, v in raw_fields.items()
|
|
165
|
+
}
|
|
166
|
+
store.schema_config = new_schema
|
|
167
|
+
|
|
168
|
+
database.config.save()
|
|
169
|
+
|
|
170
|
+
# If schema changed, rebuild live query engine indexes for the running store so
|
|
171
|
+
# changes take effect immediately without needing a server restart.
|
|
172
|
+
if "schema_config" in data and store_name in database.stores:
|
|
173
|
+
import threading as _threading
|
|
174
|
+
store_obj = database.stores[store_name]
|
|
175
|
+
qe = getattr(store_obj, "query_engine", None)
|
|
176
|
+
if qe is not None:
|
|
177
|
+
def _do_rebuild():
|
|
178
|
+
for field_name, fs in store.schema_config.fields.items():
|
|
179
|
+
q = getattr(fs, "queryable", False)
|
|
180
|
+
if q:
|
|
181
|
+
try:
|
|
182
|
+
qe.rebuild_index(field_name)
|
|
183
|
+
except Exception as _exc:
|
|
184
|
+
import logging as _log
|
|
185
|
+
_log.getLogger(__name__).warning(
|
|
186
|
+
"schema update: rebuild_index('%s') failed: %s", field_name, _exc
|
|
187
|
+
)
|
|
188
|
+
_threading.Thread(target=_do_rebuild, daemon=True,
|
|
189
|
+
name=f"schema-rebuild-{store_name}").start()
|
|
190
|
+
|
|
191
|
+
response: dict = {"store_name": store_name, "message": f"Store '{store_name}' config updated successfully"}
|
|
192
|
+
if "entity_config" in data:
|
|
193
|
+
from src.config.db import TOKEN_HARD_LIMIT
|
|
194
|
+
tl = store.entity_config.token_length
|
|
195
|
+
if tl > TOKEN_HARD_LIMIT:
|
|
196
|
+
response["warning"] = (
|
|
197
|
+
f"Token length {tl} exceeds the recommended maximum of "
|
|
198
|
+
f"{TOKEN_HARD_LIMIT} characters. Performance may degrade."
|
|
199
|
+
)
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
def api_export_store_json(database: mkdb, data: dict):
|
|
203
|
+
"""Export a store's complete configuration as JSON."""
|
|
204
|
+
store_name: str = data.get("name", "").strip()
|
|
205
|
+
|
|
206
|
+
if not store_name:
|
|
207
|
+
raise ValueError("Store name is required")
|
|
208
|
+
|
|
209
|
+
if database is None:
|
|
210
|
+
raise RuntimeError("Database not initialized")
|
|
211
|
+
|
|
212
|
+
if store_name not in database.config.stores:
|
|
213
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
214
|
+
|
|
215
|
+
return database.config.stores[store_name].json
|
|
216
|
+
|
|
217
|
+
# ── Database info ─────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def api_get_db_info(database: mkdb, data: dict):
|
|
220
|
+
"""Return basic database metadata for the dashboard."""
|
|
221
|
+
if database is None:
|
|
222
|
+
raise RuntimeError("Database not initialized")
|
|
223
|
+
from src.config.db import TOKEN_HARD_LIMIT, TOKEN_SOFT_LIMIT
|
|
224
|
+
db_health = "ok"
|
|
225
|
+
for sc in database.config.stores.values():
|
|
226
|
+
h = sc.entity_config.health["status"]
|
|
227
|
+
if h == "degraded":
|
|
228
|
+
db_health = "degraded"
|
|
229
|
+
break
|
|
230
|
+
if h == "low" and db_health == "ok":
|
|
231
|
+
db_health = "low"
|
|
232
|
+
return {
|
|
233
|
+
"name": database.config.name,
|
|
234
|
+
"base_path": database.config.base_path,
|
|
235
|
+
"store_count": len(database.config.stores),
|
|
236
|
+
"health": db_health,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def api_get_dashboard(database: mkdb, data: dict):
|
|
241
|
+
"""Aggregate all dashboard data in one call: db info, server status, store metrics, event log."""
|
|
242
|
+
if database is None:
|
|
243
|
+
raise RuntimeError("Database not initialized")
|
|
244
|
+
|
|
245
|
+
import time as _time
|
|
246
|
+
from src.server.coms.metrics import get_store_metrics
|
|
247
|
+
from src.server.event_log import get_events
|
|
248
|
+
|
|
249
|
+
# ── DB info ───────────────────────────────────────────────────────────────
|
|
250
|
+
db_health = "ok"
|
|
251
|
+
for sc in database.config.stores.values():
|
|
252
|
+
h = sc.entity_config.health["status"]
|
|
253
|
+
if h == "degraded":
|
|
254
|
+
db_health = "degraded"
|
|
255
|
+
break
|
|
256
|
+
if h == "low" and db_health == "ok":
|
|
257
|
+
db_health = "low"
|
|
258
|
+
|
|
259
|
+
# ── Server status ─────────────────────────────────────────────────────────
|
|
260
|
+
srv_raw = database.get_server_status()
|
|
261
|
+
|
|
262
|
+
# ── Per-store metrics + totals ────────────────────────────────────────────
|
|
263
|
+
stores_data = []
|
|
264
|
+
total_reads = total_writes = total_queries = total_errors = total_entries = 0
|
|
265
|
+
total_bytes_in = total_bytes_out = 0
|
|
266
|
+
|
|
267
|
+
for name, store_obj in database.stores.items():
|
|
268
|
+
m = get_store_metrics(name)
|
|
269
|
+
rc = getattr(store_obj, "_ram_cache", None)
|
|
270
|
+
entries = rc.size() if rc else 0
|
|
271
|
+
est_bytes = rc.estimated_bytes() if rc else 0
|
|
272
|
+
max_size = rc.max_size if rc else 0
|
|
273
|
+
|
|
274
|
+
r = m.get("reads", 0)
|
|
275
|
+
w = m.get("writes", 0)
|
|
276
|
+
q = m.get("queries", 0)
|
|
277
|
+
e = m.get("errors", 0)
|
|
278
|
+
bi = m.get("bytes_in", 0)
|
|
279
|
+
bo = m.get("bytes_out", 0)
|
|
280
|
+
|
|
281
|
+
total_reads += r
|
|
282
|
+
total_writes += w
|
|
283
|
+
total_queries += q
|
|
284
|
+
total_errors += e
|
|
285
|
+
total_bytes_in += bi
|
|
286
|
+
total_bytes_out += bo
|
|
287
|
+
total_entries += entries
|
|
288
|
+
|
|
289
|
+
stores_data.append({
|
|
290
|
+
"name": name,
|
|
291
|
+
"reads": r,
|
|
292
|
+
"writes": w,
|
|
293
|
+
"deletes": m.get("deletes", 0),
|
|
294
|
+
"queries": m.get("queries", 0),
|
|
295
|
+
"errors": e,
|
|
296
|
+
"bytes_in": bi,
|
|
297
|
+
"bytes_out": bo,
|
|
298
|
+
"ram": {
|
|
299
|
+
"entries": entries,
|
|
300
|
+
"est_bytes": est_bytes,
|
|
301
|
+
"max_size": max_size,
|
|
302
|
+
"pct": round(entries / max_size * 100, 1) if max_size else 0,
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"db": {
|
|
308
|
+
"name": database.config.name,
|
|
309
|
+
"base_path": database.config.base_path,
|
|
310
|
+
"store_count": len(database.config.stores),
|
|
311
|
+
"health": db_health,
|
|
312
|
+
},
|
|
313
|
+
"servers": {
|
|
314
|
+
"http": {"running": bool(srv_raw.get("http"))},
|
|
315
|
+
"socket": {"running": bool(srv_raw.get("socket"))},
|
|
316
|
+
"control": {"running": bool(srv_raw.get("control"))},
|
|
317
|
+
},
|
|
318
|
+
"totals": {
|
|
319
|
+
"reads": total_reads,
|
|
320
|
+
"writes": total_writes,
|
|
321
|
+
"queries": total_queries,
|
|
322
|
+
"errors": total_errors,
|
|
323
|
+
"bytes_in": total_bytes_in,
|
|
324
|
+
"bytes_out": total_bytes_out,
|
|
325
|
+
"ram_entries": total_entries,
|
|
326
|
+
},
|
|
327
|
+
"stores": stores_data,
|
|
328
|
+
"events": get_events(limit=40),
|
|
329
|
+
"started_at": database._started_at,
|
|
330
|
+
"ts": round(_time.time(), 3),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def api_get_event_log(database: mkdb, data: dict):
|
|
335
|
+
"""Return recent system events. Optional filter: level=info|warning|error|recovery."""
|
|
336
|
+
from src.server.event_log import get_events
|
|
337
|
+
limit = max(1, min(200, int(data.get("limit", 50))))
|
|
338
|
+
level = str(data.get("level", "")).strip()
|
|
339
|
+
return {"events": get_events(limit=limit, level=level)}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── Auth management ───────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def api_get_auth_status(database: mkdb, data: dict):
|
|
345
|
+
"""Return current control-server auth configuration."""
|
|
346
|
+
if database is None:
|
|
347
|
+
raise RuntimeError("Database not initialized")
|
|
348
|
+
from src.server.control.server import active_session_count
|
|
349
|
+
auth = database.config.servers.control_server.auth
|
|
350
|
+
return {
|
|
351
|
+
"enabled": auth.enabled,
|
|
352
|
+
"has_password": bool(auth.password_hash),
|
|
353
|
+
"session_ttl": auth.session_ttl,
|
|
354
|
+
"active_sessions": active_session_count(),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
def api_set_auth_password(database: mkdb, data: dict):
|
|
358
|
+
"""Set or change the control-server password and enable auth."""
|
|
359
|
+
if database is None:
|
|
360
|
+
raise RuntimeError("Database not initialized")
|
|
361
|
+
from src.server.control.server import hash_password
|
|
362
|
+
password = data.get("password", "").strip()
|
|
363
|
+
if not password:
|
|
364
|
+
raise ValueError("Password cannot be empty")
|
|
365
|
+
auth = database.config.servers.control_server.auth
|
|
366
|
+
auth.password_hash = hash_password(password)
|
|
367
|
+
auth.enabled = True
|
|
368
|
+
database.config.save()
|
|
369
|
+
return {"message": "Password updated and authentication enabled"}
|
|
370
|
+
|
|
371
|
+
def api_enable_auth(database: mkdb, data: dict):
|
|
372
|
+
"""Enable authentication on the control server."""
|
|
373
|
+
if database is None:
|
|
374
|
+
raise RuntimeError("Database not initialized")
|
|
375
|
+
auth = database.config.servers.control_server.auth
|
|
376
|
+
if not auth.password_hash:
|
|
377
|
+
raise ValueError("Cannot enable auth: set a password first")
|
|
378
|
+
auth.enabled = True
|
|
379
|
+
database.config.save()
|
|
380
|
+
return {"message": "Authentication enabled"}
|
|
381
|
+
|
|
382
|
+
def api_disable_auth(database: mkdb, data: dict):
|
|
383
|
+
"""Disable authentication on the control server."""
|
|
384
|
+
if database is None:
|
|
385
|
+
raise RuntimeError("Database not initialized")
|
|
386
|
+
auth = database.config.servers.control_server.auth
|
|
387
|
+
auth.enabled = False
|
|
388
|
+
database.config.save()
|
|
389
|
+
return {"message": "Authentication disabled"}
|
|
390
|
+
|
|
391
|
+
def api_set_auth_session_ttl(database: mkdb, data: dict):
|
|
392
|
+
"""Set session TTL in seconds (minimum 60)."""
|
|
393
|
+
if database is None:
|
|
394
|
+
raise RuntimeError("Database not initialized")
|
|
395
|
+
ttl = data.get("ttl", 3600)
|
|
396
|
+
if not isinstance(ttl, int) or ttl < 60:
|
|
397
|
+
raise ValueError("TTL must be an integer ≥ 60 seconds")
|
|
398
|
+
auth = database.config.servers.control_server.auth
|
|
399
|
+
auth.session_ttl = ttl
|
|
400
|
+
database.config.save()
|
|
401
|
+
return {"message": f"Session TTL set to {ttl} seconds"}
|
|
402
|
+
|
|
403
|
+
def api_invalidate_all_sessions(database: mkdb, data: dict):
|
|
404
|
+
"""Force-expire every active session token."""
|
|
405
|
+
from src.server.control.server import invalidate_all_sessions
|
|
406
|
+
count = invalidate_all_sessions()
|
|
407
|
+
return {"message": f"Invalidated {count} session(s)"}
|
|
408
|
+
|
|
409
|
+
def api_query_store(database: mkdb, data: dict):
|
|
410
|
+
"""
|
|
411
|
+
Execute a query against a store's query engine.
|
|
412
|
+
|
|
413
|
+
Request data keys:
|
|
414
|
+
store (str, required) — store name
|
|
415
|
+
filter (dict, required) — query filter dict
|
|
416
|
+
hydrate (bool, optional, default False) — return full records instead of just IDs
|
|
417
|
+
"""
|
|
418
|
+
store_name = str(data.get("store", "")).strip()
|
|
419
|
+
filter_dict = data.get("filter", {})
|
|
420
|
+
hydrate = bool(data.get("hydrate", False))
|
|
421
|
+
|
|
422
|
+
if not store_name:
|
|
423
|
+
return {"success": False, "error": "'store' is required"}
|
|
424
|
+
if not isinstance(filter_dict, dict):
|
|
425
|
+
return {"success": False, "error": "'filter' must be a JSON object"}
|
|
426
|
+
|
|
427
|
+
if store_name not in database.stores:
|
|
428
|
+
return {"success": False, "error": f"Store '{store_name}' not found"}
|
|
429
|
+
|
|
430
|
+
store_obj = database.stores[store_name]
|
|
431
|
+
qe = getattr(store_obj, "query_engine", None)
|
|
432
|
+
if qe is None:
|
|
433
|
+
return {"success": False, "error": f"Store '{store_name}' has no query engine"}
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
ids = qe.query(filter_dict)
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
return {"success": False, "error": str(exc)}
|
|
439
|
+
|
|
440
|
+
if hydrate:
|
|
441
|
+
records = [store_obj.read(rid) for rid in ids]
|
|
442
|
+
return {"success": True, "count": len(records), "records": records}
|
|
443
|
+
|
|
444
|
+
return {"success": True, "count": len(ids), "ids": ids}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── Database settings ─────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
def api_get_db_settings(database: mkdb, data: dict):
|
|
450
|
+
"""Return all editable database and server settings."""
|
|
451
|
+
if database is None:
|
|
452
|
+
raise RuntimeError("Database not initialized")
|
|
453
|
+
cfg = database.config
|
|
454
|
+
return {
|
|
455
|
+
"name": cfg.name,
|
|
456
|
+
"base_path": cfg.base_path,
|
|
457
|
+
"storage_nodes": cfg.storage_nodes,
|
|
458
|
+
"store_count": len(cfg.stores),
|
|
459
|
+
"servers": {
|
|
460
|
+
"control": {
|
|
461
|
+
"enabled": cfg.servers.control_server.enabled,
|
|
462
|
+
"host": cfg.servers.control_server.address.host,
|
|
463
|
+
"port": cfg.servers.control_server.address.port,
|
|
464
|
+
},
|
|
465
|
+
"socket": {
|
|
466
|
+
"enabled": cfg.servers.socket_server.enabled,
|
|
467
|
+
"host": cfg.servers.socket_server.address.host,
|
|
468
|
+
"port": cfg.servers.socket_server.address.port,
|
|
469
|
+
"heartbeat_interval": cfg.servers.socket_server.heartbeat_interval,
|
|
470
|
+
"max_clients": cfg.servers.socket_server.max_clients,
|
|
471
|
+
"recv_timeout": cfg.servers.socket_server.recv_timeout,
|
|
472
|
+
},
|
|
473
|
+
"http": {
|
|
474
|
+
"enabled": cfg.servers.http_server.enabled,
|
|
475
|
+
"host": cfg.servers.http_server.address.host,
|
|
476
|
+
"port": cfg.servers.http_server.address.port,
|
|
477
|
+
"max_body_size": cfg.servers.http_server.max_body_size,
|
|
478
|
+
"cors_enabled": cfg.servers.http_server.cors_enabled,
|
|
479
|
+
"max_requests_per_second": cfg.servers.http_server.max_requests_per_second,
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def api_update_db_settings(database: mkdb, data: dict):
|
|
486
|
+
"""Update top-level database settings (name, storage_nodes) and save."""
|
|
487
|
+
if database is None:
|
|
488
|
+
raise RuntimeError("Database not initialized")
|
|
489
|
+
changed = False
|
|
490
|
+
if "name" in data:
|
|
491
|
+
new_name = str(data["name"]).strip()
|
|
492
|
+
if not new_name:
|
|
493
|
+
raise ValueError("Database name cannot be empty")
|
|
494
|
+
database.config.name = new_name
|
|
495
|
+
changed = True
|
|
496
|
+
if "storage_nodes" in data:
|
|
497
|
+
nodes = data["storage_nodes"]
|
|
498
|
+
if not isinstance(nodes, list):
|
|
499
|
+
raise ValueError("storage_nodes must be a list")
|
|
500
|
+
database.config.storage_nodes = [str(n) for n in nodes]
|
|
501
|
+
changed = True
|
|
502
|
+
if changed:
|
|
503
|
+
database.config.save()
|
|
504
|
+
return {"saved": changed, "message": "Settings saved." if changed else "No changes."}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ── Server lifecycle ──────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
def api_get_server_status(database: mkdb, data: dict):
|
|
510
|
+
"""Return running/stopped status for all three servers."""
|
|
511
|
+
if database is None:
|
|
512
|
+
raise RuntimeError("Database not initialized")
|
|
513
|
+
raw = database.get_server_status()
|
|
514
|
+
return {
|
|
515
|
+
"control": {"running": bool(raw.get("control"))},
|
|
516
|
+
"socket": {"running": bool(raw.get("socket"))},
|
|
517
|
+
"http": {"running": bool(raw.get("http"))},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def api_update_server_config(database: mkdb, data: dict):
|
|
522
|
+
"""Persist host/port/enabled changes for a server to config.json.
|
|
523
|
+
|
|
524
|
+
Required: server = "socket" | "http" | "control"
|
|
525
|
+
Does NOT restart — call api_server_control afterwards if needed.
|
|
526
|
+
"""
|
|
527
|
+
if database is None:
|
|
528
|
+
raise RuntimeError("Database not initialized")
|
|
529
|
+
server_name = str(data.get("server", "")).strip()
|
|
530
|
+
if server_name not in ("socket", "http", "control"):
|
|
531
|
+
raise ValueError("server must be one of: socket, http, control")
|
|
532
|
+
|
|
533
|
+
cfg_map = {
|
|
534
|
+
"socket": database.config.servers.socket_server,
|
|
535
|
+
"http": database.config.servers.http_server,
|
|
536
|
+
"control": database.config.servers.control_server,
|
|
537
|
+
}
|
|
538
|
+
srv_cfg = cfg_map[server_name]
|
|
539
|
+
|
|
540
|
+
if "host" in data:
|
|
541
|
+
srv_cfg.address.host = str(data["host"]).strip()
|
|
542
|
+
if "port" in data:
|
|
543
|
+
port = int(data["port"])
|
|
544
|
+
if not (1 <= port <= 65535):
|
|
545
|
+
raise ValueError("port must be between 1 and 65535")
|
|
546
|
+
srv_cfg.address.port = port
|
|
547
|
+
if "enabled" in data:
|
|
548
|
+
srv_cfg.enabled = bool(data["enabled"])
|
|
549
|
+
|
|
550
|
+
if server_name == "socket":
|
|
551
|
+
if "heartbeat_interval" in data:
|
|
552
|
+
srv_cfg.heartbeat_interval = float(data["heartbeat_interval"])
|
|
553
|
+
if "max_clients" in data:
|
|
554
|
+
srv_cfg.max_clients = int(data["max_clients"])
|
|
555
|
+
if "recv_timeout" in data:
|
|
556
|
+
srv_cfg.recv_timeout = float(data["recv_timeout"])
|
|
557
|
+
elif server_name == "http":
|
|
558
|
+
if "max_body_size" in data:
|
|
559
|
+
srv_cfg.max_body_size = int(data["max_body_size"])
|
|
560
|
+
if "cors_enabled" in data:
|
|
561
|
+
srv_cfg.cors_enabled = bool(data["cors_enabled"])
|
|
562
|
+
if "max_requests_per_second" in data:
|
|
563
|
+
srv_cfg.max_requests_per_second = int(data["max_requests_per_second"])
|
|
564
|
+
|
|
565
|
+
database.config.save()
|
|
566
|
+
return {"message": f"{server_name} server config saved", "server": server_name}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def api_server_control(database: mkdb, data: dict):
|
|
570
|
+
"""Start, stop, or restart a named server.
|
|
571
|
+
|
|
572
|
+
Required:
|
|
573
|
+
server = "socket" | "http" | "control"
|
|
574
|
+
action = "start" | "stop" | "restart"
|
|
575
|
+
|
|
576
|
+
Control-server stop/restart is deferred 1.5 s so the response
|
|
577
|
+
is delivered before the socket closes.
|
|
578
|
+
"""
|
|
579
|
+
import threading
|
|
580
|
+
if database is None:
|
|
581
|
+
raise RuntimeError("Database not initialized")
|
|
582
|
+
server_name = str(data.get("server", "")).strip()
|
|
583
|
+
action = str(data.get("op", "")).strip() # 'op' avoids collision with the top-level 'action' envelope key
|
|
584
|
+
if server_name not in ("socket", "http", "control"):
|
|
585
|
+
raise ValueError("server must be one of: socket, http, control")
|
|
586
|
+
if action not in ("start", "stop", "restart"):
|
|
587
|
+
raise ValueError("op must be start, stop, or restart")
|
|
588
|
+
|
|
589
|
+
def _do():
|
|
590
|
+
if action == "start":
|
|
591
|
+
database.start_server(server_name)
|
|
592
|
+
elif action == "stop":
|
|
593
|
+
database.stop_server(server_name)
|
|
594
|
+
elif action == "restart":
|
|
595
|
+
database.restart_server(server_name)
|
|
596
|
+
|
|
597
|
+
if server_name == "control" and action in ("stop", "restart"):
|
|
598
|
+
threading.Timer(1.5, _do).start()
|
|
599
|
+
return {"message": f"Control server {action} scheduled in 1.5 s", "deferred": True}
|
|
600
|
+
|
|
601
|
+
_do()
|
|
602
|
+
return {"message": f"{server_name} server {action}ed", "deferred": False}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ── User management ───────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
def api_list_users(database: mkdb, data: dict):
|
|
608
|
+
"""List all database users and their store permissions."""
|
|
609
|
+
if database is None:
|
|
610
|
+
raise RuntimeError("Database not initialized")
|
|
611
|
+
return [
|
|
612
|
+
{
|
|
613
|
+
"username": uname,
|
|
614
|
+
"has_password": bool(user.password_hash),
|
|
615
|
+
"stores": {
|
|
616
|
+
sname: {"read": perm.read, "write": perm.write}
|
|
617
|
+
for sname, perm in user.stores.items()
|
|
618
|
+
},
|
|
619
|
+
}
|
|
620
|
+
for uname, user in database.config.users.items()
|
|
621
|
+
]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def api_create_user(database: mkdb, data: dict):
|
|
625
|
+
"""Create a new data-plane user."""
|
|
626
|
+
if database is None:
|
|
627
|
+
raise RuntimeError("Database not initialized")
|
|
628
|
+
from src.server.control.server import hash_password
|
|
629
|
+
from src.config.db import db_user
|
|
630
|
+
|
|
631
|
+
username = str(data.get("username", "")).strip()
|
|
632
|
+
password = str(data.get("password", "")).strip()
|
|
633
|
+
if not username:
|
|
634
|
+
raise ValueError("Username cannot be empty")
|
|
635
|
+
if not password:
|
|
636
|
+
raise ValueError("Password cannot be empty")
|
|
637
|
+
if len(password) < 6:
|
|
638
|
+
raise ValueError("Password must be at least 6 characters")
|
|
639
|
+
if username in database.config.users:
|
|
640
|
+
raise ValueError(f"User '{username}' already exists")
|
|
641
|
+
|
|
642
|
+
u = db_user({})
|
|
643
|
+
u.username = username
|
|
644
|
+
u.password_hash = hash_password(password)
|
|
645
|
+
database.config.users[username] = u
|
|
646
|
+
database.config.save()
|
|
647
|
+
return {"message": f"User '{username}' created"}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def api_delete_user(database: mkdb, data: dict):
|
|
651
|
+
"""Delete a data-plane user."""
|
|
652
|
+
if database is None:
|
|
653
|
+
raise RuntimeError("Database not initialized")
|
|
654
|
+
username = str(data.get("username", "")).strip()
|
|
655
|
+
if not username:
|
|
656
|
+
raise ValueError("Username cannot be empty")
|
|
657
|
+
if username not in database.config.users:
|
|
658
|
+
raise ValueError(f"User '{username}' does not exist")
|
|
659
|
+
del database.config.users[username]
|
|
660
|
+
database.config.save()
|
|
661
|
+
return {"message": f"User '{username}' deleted"}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def api_set_user_password(database: mkdb, data: dict):
|
|
665
|
+
"""Set or change a user's data-plane password."""
|
|
666
|
+
if database is None:
|
|
667
|
+
raise RuntimeError("Database not initialized")
|
|
668
|
+
from src.server.control.server import hash_password
|
|
669
|
+
username = str(data.get("username", "")).strip()
|
|
670
|
+
password = str(data.get("password", "")).strip()
|
|
671
|
+
if not username:
|
|
672
|
+
raise ValueError("Username cannot be empty")
|
|
673
|
+
if not password:
|
|
674
|
+
raise ValueError("Password cannot be empty")
|
|
675
|
+
if len(password) < 6:
|
|
676
|
+
raise ValueError("Password must be at least 6 characters")
|
|
677
|
+
user = database.config.users.get(username)
|
|
678
|
+
if user is None:
|
|
679
|
+
raise ValueError(f"User '{username}' does not exist")
|
|
680
|
+
user.password_hash = hash_password(password)
|
|
681
|
+
database.config.save()
|
|
682
|
+
return {"message": f"Password updated for '{username}'"}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def api_set_user_store_access(database: mkdb, data: dict):
|
|
686
|
+
"""Grant or update a user's access to a store.
|
|
687
|
+
Required: username, store
|
|
688
|
+
Optional: read (bool, default True), write (bool, default False)
|
|
689
|
+
"""
|
|
690
|
+
if database is None:
|
|
691
|
+
raise RuntimeError("Database not initialized")
|
|
692
|
+
from src.config.db import store_permission
|
|
693
|
+
username = str(data.get("username", "")).strip()
|
|
694
|
+
store_name = str(data.get("store", "")).strip()
|
|
695
|
+
if not username:
|
|
696
|
+
raise ValueError("Username cannot be empty")
|
|
697
|
+
if not store_name:
|
|
698
|
+
raise ValueError("Store name cannot be empty")
|
|
699
|
+
user = database.config.users.get(username)
|
|
700
|
+
if user is None:
|
|
701
|
+
raise ValueError(f"User '{username}' does not exist")
|
|
702
|
+
if store_name not in database.config.stores:
|
|
703
|
+
raise ValueError(f"Store '{store_name}' does not exist")
|
|
704
|
+
perm = user.stores.get(store_name) or store_permission({})
|
|
705
|
+
perm.read = bool(data.get("read", perm.read))
|
|
706
|
+
perm.write = bool(data.get("write", perm.write))
|
|
707
|
+
user.stores[store_name] = perm
|
|
708
|
+
database.config.save()
|
|
709
|
+
return {"message": f"Access updated for '{username}' on '{store_name}'"}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def api_remove_user_store_access(database: mkdb, data: dict):
|
|
713
|
+
"""Remove a user's access to a specific store."""
|
|
714
|
+
if database is None:
|
|
715
|
+
raise RuntimeError("Database not initialized")
|
|
716
|
+
username = str(data.get("username", "")).strip()
|
|
717
|
+
store_name = str(data.get("store", "")).strip()
|
|
718
|
+
if not username or not store_name:
|
|
719
|
+
raise ValueError("Username and store name are required")
|
|
720
|
+
user = database.config.users.get(username)
|
|
721
|
+
if user is None:
|
|
722
|
+
raise ValueError(f"User '{username}' does not exist")
|
|
723
|
+
user.stores.pop(store_name, None)
|
|
724
|
+
database.config.save()
|
|
725
|
+
return {"message": f"Store access '{store_name}' removed from '{username}'"}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def api_get_rate_limit_log(database: mkdb, data: dict):
|
|
729
|
+
"""Return recent rate-limit events from logs/rate_limit.jsonl.
|
|
730
|
+
Optional: limit (int, default 200)
|
|
731
|
+
"""
|
|
732
|
+
import os, json as _json
|
|
733
|
+
if database is None:
|
|
734
|
+
raise RuntimeError("Database not initialized")
|
|
735
|
+
limit = max(1, int(data.get("limit", 200)))
|
|
736
|
+
log_path = os.path.join(database.config.base_path, "logs", "rate_limit.jsonl")
|
|
737
|
+
if not os.path.exists(log_path):
|
|
738
|
+
return {"events": [], "total": 0}
|
|
739
|
+
with open(log_path, "r", encoding="utf-8") as f:
|
|
740
|
+
lines = f.readlines()
|
|
741
|
+
events = []
|
|
742
|
+
for line in lines[-limit:]:
|
|
743
|
+
try:
|
|
744
|
+
events.append(_json.loads(line.strip()))
|
|
745
|
+
except Exception:
|
|
746
|
+
pass
|
|
747
|
+
return {"events": list(reversed(events)), "total": len(lines)}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
# ── Data security settings ────────────────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
def api_get_data_security(database: mkdb, data: dict):
|
|
753
|
+
"""Return the data-plane security settings."""
|
|
754
|
+
if database is None:
|
|
755
|
+
raise RuntimeError("Database not initialized")
|
|
756
|
+
ds = database.config.data_security
|
|
757
|
+
return {"protect_reads": ds.protect_reads}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def api_set_data_security(database: mkdb, data: dict):
|
|
761
|
+
"""Update data-plane security settings.
|
|
762
|
+
Optional: protect_reads (bool)
|
|
763
|
+
"""
|
|
764
|
+
if database is None:
|
|
765
|
+
raise RuntimeError("Database not initialized")
|
|
766
|
+
if "protect_reads" in data:
|
|
767
|
+
database.config.data_security.protect_reads = bool(data["protect_reads"])
|
|
768
|
+
database.config.save()
|
|
769
|
+
return {"protect_reads": database.config.data_security.protect_reads}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# ── Control-plane user management (RBAC) ─────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
def api_list_control_users(database: mkdb, data: dict):
|
|
775
|
+
"""List all control/SDK users and their roles."""
|
|
776
|
+
if database is None:
|
|
777
|
+
raise RuntimeError("Database not initialized")
|
|
778
|
+
from src.config.db import CONTROL_ROLES
|
|
779
|
+
return [
|
|
780
|
+
{"username": uname, "role": cu.role, "has_password": bool(cu.password_hash)}
|
|
781
|
+
for uname, cu in database.config.control_users.items()
|
|
782
|
+
]
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def api_create_control_user(database: mkdb, data: dict):
|
|
786
|
+
"""Create a control/SDK user.
|
|
787
|
+
Required: username, password, role ('viewer' | 'operator' | 'admin')
|
|
788
|
+
"""
|
|
789
|
+
if database is None:
|
|
790
|
+
raise RuntimeError("Database not initialized")
|
|
791
|
+
from src.server.control.server import hash_password
|
|
792
|
+
from src.config.db import control_user, CONTROL_ROLES
|
|
793
|
+
username = str(data.get("username", "")).strip()
|
|
794
|
+
password = str(data.get("password", "")).strip()
|
|
795
|
+
role = str(data.get("role", "viewer")).strip()
|
|
796
|
+
if not username:
|
|
797
|
+
raise ValueError("Username cannot be empty")
|
|
798
|
+
if not password or len(password) < 6:
|
|
799
|
+
raise ValueError("Password must be at least 6 characters")
|
|
800
|
+
if role not in CONTROL_ROLES:
|
|
801
|
+
raise ValueError(f"Role must be one of: {', '.join(CONTROL_ROLES)}")
|
|
802
|
+
if username in database.config.control_users:
|
|
803
|
+
raise ValueError(f"Control user '{username}' already exists")
|
|
804
|
+
cu = control_user({})
|
|
805
|
+
cu.username = username
|
|
806
|
+
cu.password_hash = hash_password(password)
|
|
807
|
+
cu.role = role
|
|
808
|
+
database.config.control_users[username] = cu
|
|
809
|
+
database.config.save()
|
|
810
|
+
return {"message": f"Control user '{username}' created with role '{role}'"}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def api_delete_control_user(database: mkdb, data: dict):
|
|
814
|
+
"""Delete a control/SDK user."""
|
|
815
|
+
if database is None:
|
|
816
|
+
raise RuntimeError("Database not initialized")
|
|
817
|
+
username = str(data.get("username", "")).strip()
|
|
818
|
+
if not username:
|
|
819
|
+
raise ValueError("Username cannot be empty")
|
|
820
|
+
if username not in database.config.control_users:
|
|
821
|
+
raise ValueError(f"Control user '{username}' does not exist")
|
|
822
|
+
del database.config.control_users[username]
|
|
823
|
+
database.config.save()
|
|
824
|
+
return {"message": f"Control user '{username}' deleted"}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def api_set_control_user_role(database: mkdb, data: dict):
|
|
828
|
+
"""Change a control user's role."""
|
|
829
|
+
if database is None:
|
|
830
|
+
raise RuntimeError("Database not initialized")
|
|
831
|
+
from src.config.db import CONTROL_ROLES
|
|
832
|
+
username = str(data.get("username", "")).strip()
|
|
833
|
+
role = str(data.get("role", "")).strip()
|
|
834
|
+
if not username:
|
|
835
|
+
raise ValueError("Username cannot be empty")
|
|
836
|
+
if role not in CONTROL_ROLES:
|
|
837
|
+
raise ValueError(f"Role must be one of: {', '.join(CONTROL_ROLES)}")
|
|
838
|
+
cu = database.config.control_users.get(username)
|
|
839
|
+
if cu is None:
|
|
840
|
+
raise ValueError(f"Control user '{username}' does not exist")
|
|
841
|
+
cu.role = role
|
|
842
|
+
database.config.save()
|
|
843
|
+
return {"message": f"Role for '{username}' updated to '{role}'"}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def api_set_control_user_password(database: mkdb, data: dict):
|
|
847
|
+
"""Set or change a control user's password."""
|
|
848
|
+
if database is None:
|
|
849
|
+
raise RuntimeError("Database not initialized")
|
|
850
|
+
from src.server.control.server import hash_password
|
|
851
|
+
username = str(data.get("username", "")).strip()
|
|
852
|
+
password = str(data.get("password", "")).strip()
|
|
853
|
+
if not username:
|
|
854
|
+
raise ValueError("Username cannot be empty")
|
|
855
|
+
if not password or len(password) < 6:
|
|
856
|
+
raise ValueError("Password must be at least 6 characters")
|
|
857
|
+
cu = database.config.control_users.get(username)
|
|
858
|
+
if cu is None:
|
|
859
|
+
raise ValueError(f"Control user '{username}' does not exist")
|
|
860
|
+
cu.password_hash = hash_password(password)
|
|
861
|
+
database.config.save()
|
|
862
|
+
return {"message": f"Password updated for control user '{username}'"}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def api_get_store_metrics(database: mkdb, data: dict):
|
|
866
|
+
"""
|
|
867
|
+
Return live request, bandwidth, RAM-cache, and per-client metrics for a store.
|
|
868
|
+
|
|
869
|
+
Request: {"name": "store_name"}
|
|
870
|
+
Response: {
|
|
871
|
+
"reads": int, "writes": int, "deletes": int, "queries": int,
|
|
872
|
+
"errors": int, "rate_limited": int,
|
|
873
|
+
"bytes_in": int, "bytes_out": int,
|
|
874
|
+
"started_at": float,
|
|
875
|
+
"transport": {"http": {...}, "socket": {...}},
|
|
876
|
+
"clients": [ {"key": str, "reads": int, ...}, ... ],
|
|
877
|
+
"rate_limited_ips": [ {"ip": str, "count": int}, ... ],
|
|
878
|
+
"ram_cache": {"entries": int, "estimated_bytes": int, "max_size": int},
|
|
879
|
+
}
|
|
880
|
+
"""
|
|
881
|
+
if database is None:
|
|
882
|
+
raise RuntimeError("Database not initialized")
|
|
883
|
+
import time
|
|
884
|
+
from src.server.coms.metrics import get_store_metrics
|
|
885
|
+
|
|
886
|
+
name = str(data.get("name", "")).strip()
|
|
887
|
+
if not name:
|
|
888
|
+
raise ValueError("'name' is required")
|
|
889
|
+
|
|
890
|
+
m = get_store_metrics(name)
|
|
891
|
+
|
|
892
|
+
# Flatten clients dict into a sorted list (most active first)
|
|
893
|
+
clients_list = sorted(
|
|
894
|
+
[{"key": k, **v} for k, v in m.get("clients", {}).items()],
|
|
895
|
+
key=lambda c: c.get("reads", 0) + c.get("writes", 0) + c.get("queries", 0),
|
|
896
|
+
reverse=True,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# Flatten rate_limited_ips into a sorted list
|
|
900
|
+
rl_ips = sorted(
|
|
901
|
+
[{"ip": ip, "count": cnt} for ip, cnt in m.get("rate_limited_ips", {}).items()],
|
|
902
|
+
key=lambda x: x["count"],
|
|
903
|
+
reverse=True,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
# RAM cache stats from the live store object
|
|
907
|
+
ram_info = {"entries": 0, "estimated_bytes": 0, "max_size": 0}
|
|
908
|
+
store_obj = database.stores.get(name)
|
|
909
|
+
if store_obj is not None:
|
|
910
|
+
rc = getattr(store_obj, "_ram_cache", None)
|
|
911
|
+
if rc is not None:
|
|
912
|
+
ram_info["entries"] = rc.size()
|
|
913
|
+
ram_info["estimated_bytes"] = rc.estimated_bytes()
|
|
914
|
+
ram_info["max_size"] = rc.max_size
|
|
915
|
+
ram_info["ttl"] = getattr(getattr(store_obj, "_ram_cache", None), "ttl", 0)
|
|
916
|
+
ram_info["clean_type"] = getattr(getattr(store_obj, "_ram_cache", None), "clean_type", "") #type: ignore
|
|
917
|
+
|
|
918
|
+
# Slow query log — newest first, convert deque to plain list for JSON
|
|
919
|
+
slow_log_raw = m.get("slow_query_log", [])
|
|
920
|
+
slow_query_log = list(reversed(list(slow_log_raw)))
|
|
921
|
+
|
|
922
|
+
# Error log — newest first
|
|
923
|
+
error_log_raw = m.get("error_log", [])
|
|
924
|
+
error_log = list(reversed(list(error_log_raw)))
|
|
925
|
+
|
|
926
|
+
# Threshold from live config (so the UI shows the current setting)
|
|
927
|
+
slow_query_threshold_ms = 0.0
|
|
928
|
+
if store_obj is not None:
|
|
929
|
+
slow_query_threshold_ms = getattr(store_obj.config, "slow_query_threshold_ms", 0.0)
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
"reads": m.get("reads", 0),
|
|
933
|
+
"writes": m.get("writes", 0),
|
|
934
|
+
"deletes": m.get("deletes", 0),
|
|
935
|
+
"queries": m.get("queries", 0),
|
|
936
|
+
"errors": m.get("errors", 0),
|
|
937
|
+
"slow_queries": m.get("slow_queries", 0),
|
|
938
|
+
"rate_limited": m.get("rate_limited", 0),
|
|
939
|
+
"bytes_in": m.get("bytes_in", 0),
|
|
940
|
+
"bytes_out": m.get("bytes_out", 0),
|
|
941
|
+
"started_at": m.get("started_at", time.time()),
|
|
942
|
+
"transport": m.get("transport", {}),
|
|
943
|
+
"clients": clients_list,
|
|
944
|
+
"rate_limited_ips": rl_ips,
|
|
945
|
+
"ram_cache": ram_info,
|
|
946
|
+
"slow_query_log": slow_query_log,
|
|
947
|
+
"slow_query_threshold_ms": slow_query_threshold_ms,
|
|
948
|
+
"error_log": error_log,
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def api_reset_store_metrics(database: mkdb, data: dict):
|
|
953
|
+
"""Clear all in-memory metrics for a store."""
|
|
954
|
+
if database is None:
|
|
955
|
+
raise RuntimeError("Database not initialized")
|
|
956
|
+
from src.server.coms.metrics import reset_store_metrics
|
|
957
|
+
name = str(data.get("name", "")).strip()
|
|
958
|
+
if not name:
|
|
959
|
+
raise ValueError("'name' is required")
|
|
960
|
+
reset_store_metrics(name)
|
|
961
|
+
return {"message": f"Metrics reset for store '{name}'"}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def api_get_all_store_metrics(database: mkdb, data: dict):
|
|
965
|
+
"""
|
|
966
|
+
Return request counts, bandwidth, and live RAM-cache stats for every store.
|
|
967
|
+
|
|
968
|
+
Response: list of {
|
|
969
|
+
name, reads, writes, deletes, queries, errors, rate_limited,
|
|
970
|
+
bytes_in, bytes_out,
|
|
971
|
+
ram_cache: {entries, max_size, estimated_bytes, ttl, clean_type}
|
|
972
|
+
}
|
|
973
|
+
"""
|
|
974
|
+
if database is None:
|
|
975
|
+
raise RuntimeError("Database not initialized")
|
|
976
|
+
from src.server.coms.metrics import get_store_metrics
|
|
977
|
+
|
|
978
|
+
result = []
|
|
979
|
+
for name, store_obj in database.stores.items():
|
|
980
|
+
m = get_store_metrics(name)
|
|
981
|
+
rc = getattr(store_obj, "_ram_cache", None)
|
|
982
|
+
ram_info = {
|
|
983
|
+
"entries": rc.size() if rc else 0,
|
|
984
|
+
"estimated_bytes": rc.estimated_bytes() if rc else 0,
|
|
985
|
+
"max_size": rc.max_size if rc else 0,
|
|
986
|
+
"ttl": rc.ttl if rc else 0,
|
|
987
|
+
"clean_type": rc.clean_type if rc else "",
|
|
988
|
+
}
|
|
989
|
+
result.append({
|
|
990
|
+
"name": name,
|
|
991
|
+
"reads": m.get("reads", 0),
|
|
992
|
+
"writes": m.get("writes", 0),
|
|
993
|
+
"deletes": m.get("deletes", 0),
|
|
994
|
+
"queries": m.get("queries", 0),
|
|
995
|
+
"errors": m.get("errors", 0),
|
|
996
|
+
"rate_limited": m.get("rate_limited", 0),
|
|
997
|
+
"bytes_in": m.get("bytes_in", 0),
|
|
998
|
+
"bytes_out": m.get("bytes_out", 0),
|
|
999
|
+
"ram_cache": ram_info,
|
|
1000
|
+
})
|
|
1001
|
+
return result
|