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
sdk/mkdb_client.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MkDB high-level SDK client.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from sdk.mkdb_client import MkDBClient
|
|
6
|
+
|
|
7
|
+
client = MkDBClient(host="127.0.0.1", port=9001)
|
|
8
|
+
client.connect()
|
|
9
|
+
|
|
10
|
+
client.set("products_1v", "p001", {"name": "Widget", "price": 9.99})
|
|
11
|
+
record = client.get("products_1v", "p001")
|
|
12
|
+
print(record)
|
|
13
|
+
|
|
14
|
+
results = client.query("products_1v", {"price": {"gte": 5, "lte": 20}})
|
|
15
|
+
print(results)
|
|
16
|
+
|
|
17
|
+
client.on_update("products_1v", lambda event: print("Change:", event))
|
|
18
|
+
client.close()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hmac
|
|
22
|
+
import hashlib
|
|
23
|
+
import secrets
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
from sdk.connection import Connection
|
|
27
|
+
from sdk.http_connection import HttpConnection
|
|
28
|
+
from sdk.delta import flatten
|
|
29
|
+
from sdk.responses import (
|
|
30
|
+
GetResponse,
|
|
31
|
+
WriteResponse,
|
|
32
|
+
DeleteResponse,
|
|
33
|
+
QueryResponse,
|
|
34
|
+
GenerateIdResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MkDBClient:
|
|
39
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 9001,
|
|
40
|
+
access: str = "R",
|
|
41
|
+
username: str = "",
|
|
42
|
+
password: str = "mk_db",
|
|
43
|
+
recv_timeout: float = 30.0,
|
|
44
|
+
transport: str = "socket"):
|
|
45
|
+
"""
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
host, port : server address
|
|
49
|
+
access : "R", "W", or "RW"
|
|
50
|
+
username : username (required if server has users configured)
|
|
51
|
+
password : password / write key
|
|
52
|
+
recv_timeout : seconds to wait for a response
|
|
53
|
+
transport : "socket" (default) or "http"
|
|
54
|
+
HTTP transport does not support pub-sub (on_update).
|
|
55
|
+
"""
|
|
56
|
+
t = transport.lower()
|
|
57
|
+
if t == "http":
|
|
58
|
+
self._conn = HttpConnection(host=host, port=port, recv_timeout=recv_timeout,
|
|
59
|
+
access=access, username=username, password=password)
|
|
60
|
+
else:
|
|
61
|
+
self._conn = Connection(host=host, port=port, recv_timeout=recv_timeout,
|
|
62
|
+
access=access, username=username, password=password)
|
|
63
|
+
self.auth = AuthManager(self)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Lifecycle
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def connect(self) -> None:
|
|
70
|
+
self._conn.connect()
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._conn.close()
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Data operations
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def get(self, store: str, record_id: str) -> GetResponse:
|
|
80
|
+
"""Read a record. Returns a GetResponse (check .found before using .data)."""
|
|
81
|
+
resp = self._conn.send({
|
|
82
|
+
"action": "read",
|
|
83
|
+
"store": store,
|
|
84
|
+
"record_id": record_id,
|
|
85
|
+
})
|
|
86
|
+
self._raise_if_error(resp)
|
|
87
|
+
data = resp.get("data")
|
|
88
|
+
return GetResponse(record_id=record_id, data=data, found=data is not None)
|
|
89
|
+
|
|
90
|
+
def set(self, store: str, record_id: str, delta: dict,
|
|
91
|
+
flatten_nested: bool = True) -> WriteResponse:
|
|
92
|
+
"""Write / update a record. Returns a WriteResponse."""
|
|
93
|
+
flat = flatten(delta) if flatten_nested else delta
|
|
94
|
+
resp = self._conn.send({
|
|
95
|
+
"action": "write",
|
|
96
|
+
"store": store,
|
|
97
|
+
"record_id": record_id,
|
|
98
|
+
"delta": flat,
|
|
99
|
+
})
|
|
100
|
+
self._raise_if_error(resp)
|
|
101
|
+
rid = (resp.get("data") or {}).get("record_id", record_id)
|
|
102
|
+
return WriteResponse(record_id=rid, store=store)
|
|
103
|
+
|
|
104
|
+
def insert(self, store: str, delta: dict,
|
|
105
|
+
flatten_nested: bool = True) -> WriteResponse:
|
|
106
|
+
"""Write a new record with a server-generated ID. Returns a WriteResponse."""
|
|
107
|
+
flat = flatten(delta) if flatten_nested else delta
|
|
108
|
+
resp = self._conn.send({
|
|
109
|
+
"action": "write",
|
|
110
|
+
"store": store,
|
|
111
|
+
"delta": flat,
|
|
112
|
+
})
|
|
113
|
+
self._raise_if_error(resp)
|
|
114
|
+
rid = (resp.get("data") or {}).get("record_id", "")
|
|
115
|
+
return WriteResponse(record_id=rid, store=store)
|
|
116
|
+
|
|
117
|
+
def generate_id(self, store: str) -> GenerateIdResponse:
|
|
118
|
+
"""Ask the server to generate a unique ID for the store without writing anything."""
|
|
119
|
+
resp = self._conn.send({
|
|
120
|
+
"action": "generate_id",
|
|
121
|
+
"store": store,
|
|
122
|
+
})
|
|
123
|
+
self._raise_if_error(resp)
|
|
124
|
+
rid = (resp.get("data") or {}).get("record_id", "")
|
|
125
|
+
return GenerateIdResponse(record_id=rid, store=store)
|
|
126
|
+
|
|
127
|
+
def delete(self, store: str, record_id: str) -> DeleteResponse:
|
|
128
|
+
"""Delete a record. Returns a DeleteResponse."""
|
|
129
|
+
resp = self._conn.send({
|
|
130
|
+
"action": "delete",
|
|
131
|
+
"store": store,
|
|
132
|
+
"record_id": record_id,
|
|
133
|
+
})
|
|
134
|
+
self._raise_if_error(resp)
|
|
135
|
+
return DeleteResponse(record_id=record_id, store=store)
|
|
136
|
+
|
|
137
|
+
def query(self, store: str, filter_dict: dict,
|
|
138
|
+
hydrate: bool = False) -> QueryResponse:
|
|
139
|
+
"""Query a store. Returns a QueryResponse."""
|
|
140
|
+
resp = self._conn.send({
|
|
141
|
+
"action": "query",
|
|
142
|
+
"store": store,
|
|
143
|
+
"filter": filter_dict,
|
|
144
|
+
"hydrate": hydrate,
|
|
145
|
+
})
|
|
146
|
+
self._raise_if_error(resp)
|
|
147
|
+
data = resp.get("data") or {}
|
|
148
|
+
return QueryResponse(
|
|
149
|
+
count=data.get("count", 0),
|
|
150
|
+
ids=data.get("ids", []),
|
|
151
|
+
records=data.get("records"),
|
|
152
|
+
store=store,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Pub-sub
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def on_update(self, store: str, callback: Callable[[dict], None]) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Subscribe to updates for a store and register a callback for push events.
|
|
162
|
+
The callback receives the full server-push event dict.
|
|
163
|
+
"""
|
|
164
|
+
def _filter(event: dict) -> None:
|
|
165
|
+
if event.get("store") == store:
|
|
166
|
+
callback(event)
|
|
167
|
+
self._conn.register_push_handler(_filter)
|
|
168
|
+
self._conn.send_raw({"type": "subscribe", "store": store})
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Internal helpers
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _raise_if_error(resp: dict) -> None:
|
|
176
|
+
if resp.get("status") != "ok":
|
|
177
|
+
raise RuntimeError(resp.get("error", "Unknown error"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AuthManager:
|
|
181
|
+
"""
|
|
182
|
+
Simple credential store using MkDB itself as the backing store.
|
|
183
|
+
|
|
184
|
+
Passwords are never stored in plaintext.
|
|
185
|
+
Storage format (flat dict in MkDB):
|
|
186
|
+
{"auth.salt": "<hex>", "auth.hash": "<sha256 hex>"}
|
|
187
|
+
Stored under store="__auth__", record_id=username.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
AUTH_STORE = "__auth__"
|
|
191
|
+
|
|
192
|
+
def __init__(self, client: MkDBClient):
|
|
193
|
+
self._client = client
|
|
194
|
+
|
|
195
|
+
def register(self, username: str, password: str) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Register a new user. Raises RuntimeError if username already exists
|
|
198
|
+
or if the __auth__ store is unavailable.
|
|
199
|
+
"""
|
|
200
|
+
salt = secrets.token_hex(32)
|
|
201
|
+
pw_hash = hashlib.sha256(
|
|
202
|
+
(password + salt).encode("utf-8")
|
|
203
|
+
).hexdigest()
|
|
204
|
+
self._client.set(
|
|
205
|
+
self.AUTH_STORE,
|
|
206
|
+
username,
|
|
207
|
+
{"auth.salt": salt, "auth.hash": pw_hash},
|
|
208
|
+
flatten_nested=False,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def validate(self, username: str, password: str) -> bool:
|
|
212
|
+
"""
|
|
213
|
+
Validate credentials. Returns True if valid, False otherwise.
|
|
214
|
+
Uses hmac.compare_digest to prevent timing attacks.
|
|
215
|
+
"""
|
|
216
|
+
record = self._client.get(self.AUTH_STORE, username)
|
|
217
|
+
if record is None:
|
|
218
|
+
# Use compare_digest anyway to avoid timing oracle
|
|
219
|
+
hmac.compare_digest("x", "y")
|
|
220
|
+
return False
|
|
221
|
+
stored_salt = record.get("auth.salt", "")
|
|
222
|
+
stored_hash = record.get("auth.hash", "")
|
|
223
|
+
computed = hashlib.sha256(
|
|
224
|
+
(password + stored_salt).encode("utf-8")
|
|
225
|
+
).hexdigest()
|
|
226
|
+
return hmac.compare_digest(computed, stored_hash)
|
sdk/responses.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MkDB SDK response objects.
|
|
3
|
+
|
|
4
|
+
Every public client method returns one of these instead of a raw dict,
|
|
5
|
+
giving callers full IDE autocompletion and type-checking.
|
|
6
|
+
|
|
7
|
+
Entity data (the actual record fields) is intentionally left as plain
|
|
8
|
+
``dict`` — no generated model classes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from typing import Optional, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Minimal base_object — copied from src/objects so the SDK stays standalone.
|
|
17
|
+
# Provides .json and .update() exactly as the server-side version does.
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _format_items(input: Union[dict, list]) -> Union[dict, list]:
|
|
21
|
+
if type(input) == list:
|
|
22
|
+
data = []
|
|
23
|
+
for item in input:
|
|
24
|
+
if isinstance(item, base_object):
|
|
25
|
+
data.append(item.json)
|
|
26
|
+
elif type(item) in [list, dict]:
|
|
27
|
+
data.append(_format_items(item))
|
|
28
|
+
elif type(item) in [tuple, int, float, str, bool]:
|
|
29
|
+
data.append(item)
|
|
30
|
+
return data
|
|
31
|
+
elif type(input) == dict:
|
|
32
|
+
data = {}
|
|
33
|
+
for key in input:
|
|
34
|
+
if "__" in str(key):
|
|
35
|
+
continue
|
|
36
|
+
item = input[key]
|
|
37
|
+
if isinstance(item, base_object):
|
|
38
|
+
data[key] = item.json
|
|
39
|
+
elif type(item) in [list, dict]:
|
|
40
|
+
data[key] = _format_items(item)
|
|
41
|
+
elif type(item) in [tuple, int, float, str, bool]:
|
|
42
|
+
data[key] = item
|
|
43
|
+
return data
|
|
44
|
+
return input
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class base_object:
|
|
48
|
+
def __init__(self, data: dict = {}):
|
|
49
|
+
self.update(data)
|
|
50
|
+
|
|
51
|
+
def get(self, name, default=None):
|
|
52
|
+
return self.__dict__.get(name, default)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def json(self) -> dict:
|
|
56
|
+
data = {}
|
|
57
|
+
for name in self.__dict__:
|
|
58
|
+
if "__" in name:
|
|
59
|
+
continue
|
|
60
|
+
item = self.__dict__[name]
|
|
61
|
+
if isinstance(item, base_object):
|
|
62
|
+
data[name] = item.json
|
|
63
|
+
elif type(item) in [list, dict]:
|
|
64
|
+
data[name] = _format_items(item)
|
|
65
|
+
elif type(item) in [tuple, int, float, str, bool]:
|
|
66
|
+
data[name] = item
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
def update(self, data: dict):
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
return
|
|
72
|
+
for name, item in data.items():
|
|
73
|
+
if name in self.__dict__:
|
|
74
|
+
if isinstance(self.__dict__[name], base_object):
|
|
75
|
+
self.__dict__[name].update(item)
|
|
76
|
+
elif type(item) in [list, int, float, str, dict, bool]:
|
|
77
|
+
setattr(self, name, item)
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return str(self.json)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Response classes
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GetResponse(base_object):
|
|
89
|
+
"""Returned by ``client.get()``."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, record_id: str, data: Optional[dict], found: bool):
|
|
92
|
+
self.record_id: str = record_id
|
|
93
|
+
self.data: Optional[dict] = data # flat field dict, or None
|
|
94
|
+
self.found: bool = found
|
|
95
|
+
super().__init__({})
|
|
96
|
+
|
|
97
|
+
def __bool__(self) -> bool:
|
|
98
|
+
return self.found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WriteResponse(base_object):
|
|
102
|
+
"""Returned by ``client.set()`` and ``client.insert()``."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, record_id: str, store: str):
|
|
105
|
+
self.record_id: str = record_id
|
|
106
|
+
self.store: str = store
|
|
107
|
+
super().__init__({})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DeleteResponse(base_object):
|
|
111
|
+
"""Returned by ``client.delete()``."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, record_id: str, store: str):
|
|
114
|
+
self.record_id: str = record_id
|
|
115
|
+
self.store: str = store
|
|
116
|
+
super().__init__({})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class QueryResponse(base_object):
|
|
120
|
+
"""Returned by ``client.query()``."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, count: int, ids: list,
|
|
123
|
+
records: Optional[list] = None,
|
|
124
|
+
store: str = ""):
|
|
125
|
+
self.count: int = count
|
|
126
|
+
self.ids: list = ids
|
|
127
|
+
# Only populated when hydrate=True
|
|
128
|
+
self.records: Optional[list] = records
|
|
129
|
+
self.store: str = store
|
|
130
|
+
super().__init__({})
|
|
131
|
+
|
|
132
|
+
def __len__(self) -> int:
|
|
133
|
+
return self.count
|
|
134
|
+
|
|
135
|
+
def __bool__(self) -> bool:
|
|
136
|
+
return self.count > 0
|
|
137
|
+
|
|
138
|
+
def __iter__(self):
|
|
139
|
+
"""Iterate over records if hydrated, otherwise over IDs."""
|
|
140
|
+
if self.records is not None:
|
|
141
|
+
return iter(self.records)
|
|
142
|
+
return iter(self.ids)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class GenerateIdResponse(base_object):
|
|
146
|
+
"""Returned by ``client.generate_id()``."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, record_id: str, store: str):
|
|
149
|
+
self.record_id: str = record_id
|
|
150
|
+
self.store: str = store
|
|
151
|
+
super().__init__({})
|
|
152
|
+
|
|
153
|
+
def __str__(self) -> str:
|
|
154
|
+
return self.record_id
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# MkDB core engine package
|
src/config/db.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from src.filing import write_json
|
|
4
|
+
from src.config.server import control_server, socket_server, http_server
|
|
5
|
+
from src.objects import base_object
|
|
6
|
+
|
|
7
|
+
class query_worker_config(base_object):
|
|
8
|
+
def __init__(self, data:dict={}):
|
|
9
|
+
self.parallel_enabled = False # set True to spin up worker processes
|
|
10
|
+
self.worker_count = 0 # 0 = auto (os.cpu_count())
|
|
11
|
+
self.worker_cache_size = 500 # max records in each worker's local cache
|
|
12
|
+
self.worker_cache_ttl = 30 # seconds before a worker cache entry expires
|
|
13
|
+
self.task_timeout = 30.0 # seconds before submit() raises TimeoutError
|
|
14
|
+
super().__init__(data)
|
|
15
|
+
|
|
16
|
+
class ram_config(base_object):
|
|
17
|
+
def __init__(self, data:dict={}):
|
|
18
|
+
self.max_size = 1000
|
|
19
|
+
self.ttl = 3600
|
|
20
|
+
self.cleanup_interval = 600
|
|
21
|
+
self.clean_type = "lru"
|
|
22
|
+
self.update_on_access = True
|
|
23
|
+
self.index = True
|
|
24
|
+
self.index_cache_ttl = 3600
|
|
25
|
+
self.query_fields = True
|
|
26
|
+
self.query_fields_cache_ttl = 3600
|
|
27
|
+
# 0 = keep all query indexes in RAM.
|
|
28
|
+
# >0 = if an index file on disk exceeds this many bytes, don't load
|
|
29
|
+
# it into RAM; query and write directly from/to the file instead.
|
|
30
|
+
self.index_ram_threshold_bytes = 0
|
|
31
|
+
super().__init__(data)
|
|
32
|
+
|
|
33
|
+
TOKEN_SOFT_LIMIT = 28 # health = "low" when auto_expand is on and length >= this
|
|
34
|
+
TOKEN_HARD_LIMIT = 32 # health = "degraded" regardless; performance warning threshold
|
|
35
|
+
|
|
36
|
+
class entity_config(base_object):
|
|
37
|
+
def __init__(self, data:dict={}):
|
|
38
|
+
self.token_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
39
|
+
self.token_length = 16
|
|
40
|
+
self.auto_expand = False
|
|
41
|
+
super().__init__(data)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def health(self) -> dict:
|
|
45
|
+
"""Return entity-config health status and an explanatory message."""
|
|
46
|
+
if self.token_length > TOKEN_HARD_LIMIT:
|
|
47
|
+
return {
|
|
48
|
+
"status": "degraded",
|
|
49
|
+
"message": (
|
|
50
|
+
f"Token length {self.token_length} exceeds the recommended "
|
|
51
|
+
f"maximum of {TOKEN_HARD_LIMIT} characters. "
|
|
52
|
+
"Performance may degrade — consider reducing it."
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
if self.auto_expand and self.token_length >= TOKEN_SOFT_LIMIT:
|
|
56
|
+
return {
|
|
57
|
+
"status": "low",
|
|
58
|
+
"message": (
|
|
59
|
+
f"Token length {self.token_length} is approaching the "
|
|
60
|
+
f"{TOKEN_HARD_LIMIT}-character performance limit. "
|
|
61
|
+
"With auto expand enabled this may increase further, "
|
|
62
|
+
"degrading database performance."
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
return {"status": "ok", "message": ""}
|
|
66
|
+
|
|
67
|
+
class file_config(base_object):
|
|
68
|
+
def __init__(self, data:dict={}):
|
|
69
|
+
self.file_size_split_trigger = 20000000
|
|
70
|
+
self.type = "rolling_log"
|
|
71
|
+
self.blob_threshold = 5 * 1024 * 1024 # 5 MB
|
|
72
|
+
self.segment_threshold = 50 * 1024 * 1024 # 50 MB
|
|
73
|
+
self.parity_nsym = 10
|
|
74
|
+
self.compaction_dead_ratio = 0.3
|
|
75
|
+
self.compaction_interval = 60
|
|
76
|
+
super().__init__(data)
|
|
77
|
+
|
|
78
|
+
class field_schema(base_object):
|
|
79
|
+
def __init__(self, data={}):
|
|
80
|
+
self.name = ""
|
|
81
|
+
self.queryable = False # False | "exact" | "full-text" | "numeric"
|
|
82
|
+
self.indexed = False
|
|
83
|
+
super().__init__(data)
|
|
84
|
+
|
|
85
|
+
class schema_config(base_object):
|
|
86
|
+
def __init__(self, data={}):
|
|
87
|
+
self.fields: dict = {}
|
|
88
|
+
super().__init__(data)
|
|
89
|
+
self._normalise_fields()
|
|
90
|
+
|
|
91
|
+
def _normalise_fields(self):
|
|
92
|
+
self.fields = {
|
|
93
|
+
k: field_schema(v) if isinstance(v, dict) else v
|
|
94
|
+
for k, v in self.fields.items()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def update(self, data: dict):
|
|
98
|
+
super().update(data)
|
|
99
|
+
self._normalise_fields()
|
|
100
|
+
|
|
101
|
+
class write_queue_config(base_object):
|
|
102
|
+
def __init__(self, data={}):
|
|
103
|
+
self.debounce_window = 5.0
|
|
104
|
+
self.max_pending = 10000
|
|
105
|
+
super().__init__(data)
|
|
106
|
+
|
|
107
|
+
class store_rate_limit(base_object):
|
|
108
|
+
"""Per-store IP rate limit for the HTTP data plane."""
|
|
109
|
+
def __init__(self, data: dict = {}):
|
|
110
|
+
self.enabled = False
|
|
111
|
+
self.max_requests_per_second = 100
|
|
112
|
+
super().__init__(data)
|
|
113
|
+
|
|
114
|
+
class store_config(base_object):
|
|
115
|
+
def __init__(self, data:dict={}):
|
|
116
|
+
self.name = ""
|
|
117
|
+
self.description = ""
|
|
118
|
+
self.protect_reads = False # when True, read/query also require auth
|
|
119
|
+
self.slow_query_threshold_ms = 0.0 # 0 = disabled; >0 = log queries slower than X ms
|
|
120
|
+
self.client_id_header = "" # HTTP header to use as client identifier; "" = remote_addr
|
|
121
|
+
self.file_config = file_config(data.get("file_config", {}))
|
|
122
|
+
self.entity_config = entity_config(data.get("entity_config", {}))
|
|
123
|
+
self.ram_config = ram_config(data.get("ram_config", {}))
|
|
124
|
+
self.query_worker_config = query_worker_config(data.get("query_worker_config", {}))
|
|
125
|
+
self.schema_config = schema_config(data.get("schema_config", {}))
|
|
126
|
+
self.write_queue_config = write_queue_config(data.get("write_queue_config", {}))
|
|
127
|
+
self.rate_limit = store_rate_limit(data.get("rate_limit", {}))
|
|
128
|
+
super().__init__(data)
|
|
129
|
+
|
|
130
|
+
class backup_config(base_object):
|
|
131
|
+
def __init__(self, data:dict={}):
|
|
132
|
+
self.enabled = False
|
|
133
|
+
self.interval = 3600
|
|
134
|
+
self.directory = "backups"
|
|
135
|
+
super().__init__(data)
|
|
136
|
+
|
|
137
|
+
class store_permission(base_object):
|
|
138
|
+
"""Read/write permissions for a user on a specific store."""
|
|
139
|
+
def __init__(self, data: dict = {}):
|
|
140
|
+
self.read = True
|
|
141
|
+
self.write = False
|
|
142
|
+
super().__init__(data)
|
|
143
|
+
|
|
144
|
+
class db_user(base_object):
|
|
145
|
+
"""A database user who can authenticate against the data plane."""
|
|
146
|
+
def __init__(self, data: dict = {}):
|
|
147
|
+
self.username = ""
|
|
148
|
+
self.password_hash = ""
|
|
149
|
+
self.stores: dict = {} # store_name -> store_permission
|
|
150
|
+
super().__init__(data)
|
|
151
|
+
self.stores = {
|
|
152
|
+
k: store_permission(v) if isinstance(v, dict) else v
|
|
153
|
+
for k, v in self.stores.items()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class data_security_config(base_object):
|
|
157
|
+
"""Security settings for the data plane (HTTP + Socket)."""
|
|
158
|
+
def __init__(self, data: dict = {}):
|
|
159
|
+
# When True, GET/read/query requests also require authentication.
|
|
160
|
+
# When False (default), only write/delete requests require auth.
|
|
161
|
+
self.protect_reads = False
|
|
162
|
+
super().__init__(data)
|
|
163
|
+
|
|
164
|
+
# ── Control-plane RBAC ───────────────────────────────────────────────────────
|
|
165
|
+
# Roles (least → most privileged):
|
|
166
|
+
# viewer – read-only: list stores, get configs, view logs
|
|
167
|
+
# operator – viewer + create/delete stores, update store configs
|
|
168
|
+
# admin – operator + all control settings (auth, users, server config)
|
|
169
|
+
|
|
170
|
+
CONTROL_ROLES = ("viewer", "operator", "admin")
|
|
171
|
+
|
|
172
|
+
class control_user(base_object):
|
|
173
|
+
"""A control-plane SDK user with a role."""
|
|
174
|
+
def __init__(self, data: dict = {}):
|
|
175
|
+
self.username = ""
|
|
176
|
+
self.password_hash = ""
|
|
177
|
+
self.role = "viewer" # "viewer" | "operator" | "admin"
|
|
178
|
+
super().__init__(data)
|
|
179
|
+
|
|
180
|
+
class mkdb_servers_config(base_object):
|
|
181
|
+
def __init__(self, data:dict={}):
|
|
182
|
+
self.control_server = control_server(data.get("control_server", {}))
|
|
183
|
+
self.socket_server = socket_server(data.get("socket_server", {}))
|
|
184
|
+
self.http_server = http_server(data.get("http_server", {}))
|
|
185
|
+
super().__init__(data)
|
|
186
|
+
|
|
187
|
+
class mkdb_config(base_object):
|
|
188
|
+
def __init__(self, data:dict={}):
|
|
189
|
+
self.name = ""
|
|
190
|
+
self.storage_nodes: list = []
|
|
191
|
+
self.stores:dict[str, store_config] = {}
|
|
192
|
+
super().__init__(data)
|
|
193
|
+
self.servers = mkdb_servers_config(data.get("servers", {}))
|
|
194
|
+
self.backup = backup_config(data.get("backup", {}))
|
|
195
|
+
for store_name, store_data in data.get("stores", {}).items():
|
|
196
|
+
self.stores[store_name] = store_config(store_data)
|
|
197
|
+
self.users: dict = {} # username -> db_user
|
|
198
|
+
for uname, udata in data.get("users", {}).items():
|
|
199
|
+
self.users[uname] = db_user(udata)
|
|
200
|
+
self.data_security = data_security_config(data.get("data_security", {}))
|
|
201
|
+
self.control_users: dict = {} # username -> control_user
|
|
202
|
+
for uname, udata in data.get("control_users", {}).items():
|
|
203
|
+
self.control_users[uname] = control_user(udata)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def base_path(self):
|
|
207
|
+
if not hasattr(self, "__base_path__"):
|
|
208
|
+
self.__base_path__ = os.getcwd()
|
|
209
|
+
return self.__base_path__
|
|
210
|
+
|
|
211
|
+
def new_store(self, store_name:str, description:str=""):
|
|
212
|
+
"""Add a new store to the configuration."""
|
|
213
|
+
if store_name in self.stores:
|
|
214
|
+
raise ValueError(f"Store '{store_name}' already exists in the configuration.")
|
|
215
|
+
|
|
216
|
+
new_store_config = store_config()
|
|
217
|
+
new_store_config.name = store_name
|
|
218
|
+
new_store_config.description = description
|
|
219
|
+
self.stores[store_name] = new_store_config
|
|
220
|
+
print(f"Store '{store_name}' added to configuration.")
|
|
221
|
+
self.save()
|
|
222
|
+
|
|
223
|
+
def save(self):
|
|
224
|
+
"""Save the current configuration to a file."""
|
|
225
|
+
config_path = os.path.join(self.base_path, "config.json")
|
|
226
|
+
write_json(config_path, self.json)
|
|
227
|
+
print(f"Configuration saved to {config_path}")
|
src/config/server.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from src.objects import base_object
|
|
2
|
+
|
|
3
|
+
class server_address_config(base_object):
|
|
4
|
+
def __init__(self, data:dict={}):
|
|
5
|
+
self.host = ""
|
|
6
|
+
self.port = 0
|
|
7
|
+
super().__init__(data)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class auth_config(base_object):
|
|
11
|
+
"""Authentication configuration shared by all server types."""
|
|
12
|
+
def __init__(self, data:dict={}):
|
|
13
|
+
self.enabled = False
|
|
14
|
+
self.password_hash = "" # empty = no password set; format: pbkdf2:sha256:{iters}:{salt}:{hash}
|
|
15
|
+
self.session_ttl = 3600 # seconds until a session token expires
|
|
16
|
+
super().__init__(data)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class control_server(base_object):
|
|
20
|
+
def __init__(self, data:dict={}):
|
|
21
|
+
self.enabled = True
|
|
22
|
+
self.address = server_address_config(data.get("address", {}))
|
|
23
|
+
self.auth = auth_config(data.get("auth", {}))
|
|
24
|
+
super().__init__(data)
|
|
25
|
+
if self.enabled and self.address.host in [None, ""]:
|
|
26
|
+
print("Control server enabled but no host specified. Defaulting to localhost.")
|
|
27
|
+
self.address.host = "localhost"
|
|
28
|
+
if self.enabled and self.address.port == 0:
|
|
29
|
+
print("Control server enabled but no port specified. Defaulting to port 80.")
|
|
30
|
+
self.address.port = 80
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class socket_server(base_object):
|
|
34
|
+
def __init__(self, data:dict={}):
|
|
35
|
+
self.enabled = True
|
|
36
|
+
self.address = server_address_config(data.get("address", {}))
|
|
37
|
+
self.auth = auth_config(data.get("auth", {}))
|
|
38
|
+
self.heartbeat_interval = 5.0
|
|
39
|
+
self.max_clients = 100
|
|
40
|
+
self.recv_timeout = 30.0
|
|
41
|
+
super().__init__(data)
|
|
42
|
+
|
|
43
|
+
class http_server(base_object):
|
|
44
|
+
def __init__(self, data:dict={}):
|
|
45
|
+
self.enabled = False
|
|
46
|
+
self.address = server_address_config(data.get("address", {}))
|
|
47
|
+
self.auth = auth_config(data.get("auth", {}))
|
|
48
|
+
self.max_body_size = 10 * 1024 * 1024
|
|
49
|
+
self.cors_enabled = False
|
|
50
|
+
self.cors_origins: list = ["*"]
|
|
51
|
+
self.max_requests_per_second = 100
|
|
52
|
+
super().__init__(data)
|