kedis-python 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {kedis_python-0.1.0 → kedis_python-0.1.1}/PKG-INFO +4 -2
- kedis_python-0.1.1/kedis_python/__init__.py +0 -0
- kedis_python-0.1.1/kedis_python/benchmark.py +65 -0
- kedis_python-0.1.1/kedis_python/benchmark_asyncio.py +33 -0
- kedis_python-0.1.1/kedis_python/commands.py +512 -0
- kedis_python-0.1.1/kedis_python/kesp_cli.py +140 -0
- kedis_python-0.1.1/kedis_python/main.py +751 -0
- kedis_python-0.1.1/kedis_python/network.py +60 -0
- kedis_python-0.1.1/kedis_python/parser.py +132 -0
- kedis_python-0.1.1/kedis_python/replica.py +454 -0
- kedis_python-0.1.1/kedis_python/server.py +514 -0
- kedis_python-0.1.1/kedis_python/simulator.py +46 -0
- kedis_python-0.1.1/kedis_python/skiplist.py +226 -0
- kedis_python-0.1.1/kedis_python/store.py +873 -0
- kedis_python-0.1.1/kedis_python/ui.py +189 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/kedis_python.egg-info/PKG-INFO +4 -2
- kedis_python-0.1.1/kedis_python.egg-info/SOURCES.txt +28 -0
- kedis_python-0.1.1/kedis_python.egg-info/entry_points.txt +2 -0
- kedis_python-0.1.1/kedis_python.egg-info/top_level.txt +1 -0
- kedis_python-0.1.1/pyproject.toml +29 -0
- kedis_python-0.1.0/kedis_python.egg-info/SOURCES.txt +0 -13
- kedis_python-0.1.0/kedis_python.egg-info/top_level.txt +0 -1
- kedis_python-0.1.0/pyproject.toml +0 -16
- {kedis_python-0.1.0 → kedis_python-0.1.1}/LICENSE +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/README.md +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/kedis_python.egg-info/dependency_links.txt +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/setup.cfg +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_bench.py +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_kesp.py +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_kesp2.py +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_lists.py +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_lru.py +0 -0
- {kedis_python-0.1.0 → kedis_python-0.1.1}/tests/test_replica.py +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kedis-python
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: A Redis-inspired in-memory store in Python
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Redis-inspired in-memory store in Python.
|
|
5
|
+
Author: V SS Karthik
|
|
5
6
|
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Hogwarts-coder10/kedis-python
|
|
6
8
|
Requires-Python: >=3.9
|
|
7
9
|
Description-Content-Type: text/markdown
|
|
8
10
|
License-File: LICENSE
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
HOST = "127.0.0.1"
|
|
10
|
+
PORT = 6379
|
|
11
|
+
THREADS = 10
|
|
12
|
+
REQUESTS_PER_THREAD = 5000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hammer_server(thread_id):
|
|
16
|
+
"""Simulates a ruthless client hammering the database."""
|
|
17
|
+
try:
|
|
18
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
19
|
+
s.connect((HOST, PORT))
|
|
20
|
+
|
|
21
|
+
for i in range(REQUESTS_PER_THREAD):
|
|
22
|
+
# Write phase
|
|
23
|
+
cmd_set = f"SET stress_key_{thread_id}_{i} payload_data_{i}"
|
|
24
|
+
s.sendall(cmd_set.encode("utf-8"))
|
|
25
|
+
s.recv(1024) # Wait for +OK
|
|
26
|
+
|
|
27
|
+
# Read phase
|
|
28
|
+
cmd_get = f"GET stress_key_{thread_id}_{i}"
|
|
29
|
+
s.sendall(cmd_get.encode("utf-8"))
|
|
30
|
+
s.recv(1024)
|
|
31
|
+
|
|
32
|
+
s.close()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
console.print(f"[red]Thread {thread_id} crashed: {e}[/red]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_stress_test():
|
|
38
|
+
console.print(f"[bold cyan]🏎️ KEDIS WIND TUNNEL ONLINE[/bold cyan]")
|
|
39
|
+
console.print(f"Target: {HOST}:{PORT}")
|
|
40
|
+
console.print(f"Threads: {THREADS}")
|
|
41
|
+
console.print(f"Total Operations: {THREADS * REQUESTS_PER_THREAD * 2:,}\n")
|
|
42
|
+
|
|
43
|
+
threads = []
|
|
44
|
+
start_time = time.time()
|
|
45
|
+
|
|
46
|
+
# Unleash the swarm
|
|
47
|
+
for i in range(THREADS):
|
|
48
|
+
t = threading.Thread(target=hammer_server, args=(i,))
|
|
49
|
+
threads.append(t)
|
|
50
|
+
t.start()
|
|
51
|
+
|
|
52
|
+
for t in threads:
|
|
53
|
+
t.join()
|
|
54
|
+
|
|
55
|
+
duration = time.time() - start_time
|
|
56
|
+
total_ops = THREADS * REQUESTS_PER_THREAD * 2
|
|
57
|
+
rps = total_ops / duration
|
|
58
|
+
|
|
59
|
+
console.print("[bold green]🏁 STRESS TEST COMPLETE[/bold green]")
|
|
60
|
+
console.print(f"Time Elapsed: [yellow]{duration:.2f} seconds[/yellow]")
|
|
61
|
+
console.print(f"Throughput: [bold blue]{rps:,.0f} Requests/Second[/bold blue]")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
run_stress_test()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from parser import KESPEncoder # Reusing your KESP Exhaust!
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def fire_commands():
|
|
8
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", 6379)
|
|
9
|
+
|
|
10
|
+
# 1. Test the Surge Tank with a massive 50KB payload
|
|
11
|
+
massive_string = "V" * 50000
|
|
12
|
+
payload = KESPEncoder.encode(["SET", "load_test", massive_string])
|
|
13
|
+
writer.write(payload)
|
|
14
|
+
await writer.drain()
|
|
15
|
+
await reader.read(1024)
|
|
16
|
+
print("✓ 50KB Surge Tank payload swallowed successfully.")
|
|
17
|
+
|
|
18
|
+
# 2. Fire 10,000 rapid commands
|
|
19
|
+
start = time.time()
|
|
20
|
+
for i in range(10000):
|
|
21
|
+
cmd = KESPEncoder.encode(["SET", f"key_{i}", str(i)])
|
|
22
|
+
writer.write(cmd)
|
|
23
|
+
await writer.drain()
|
|
24
|
+
await reader.read(1024)
|
|
25
|
+
|
|
26
|
+
end = time.time()
|
|
27
|
+
print(f"✓ 10,000 KESP commands executed in {end - start:.2f} seconds.")
|
|
28
|
+
|
|
29
|
+
writer.close()
|
|
30
|
+
await writer.wait_closed()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
asyncio.run(fire_commands())
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .store import KedisStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommandHandler:
|
|
9
|
+
def __init__(self, store: KedisStore):
|
|
10
|
+
self.store = store
|
|
11
|
+
|
|
12
|
+
# Global Engine lock
|
|
13
|
+
self._engine_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
# Pub / Sub SwitchBoard
|
|
16
|
+
self._channels = {}
|
|
17
|
+
|
|
18
|
+
# The O(1) Dispatch Table
|
|
19
|
+
self._commands = {
|
|
20
|
+
"SET": self._handle_set,
|
|
21
|
+
"GET": self._handle_get,
|
|
22
|
+
"DEL": self._handle_del,
|
|
23
|
+
"EXISTS": self._handle_exists,
|
|
24
|
+
"EXPIRE": self._handle_expire,
|
|
25
|
+
"KEYS": self._handle_keys,
|
|
26
|
+
"TTL": self._handle_ttl,
|
|
27
|
+
"FLUSHALL": self._handle_flushall,
|
|
28
|
+
"SAVE": self._handle_save,
|
|
29
|
+
"COMPACT": self._handle_compact,
|
|
30
|
+
"LPUSH": self._handle_lpush,
|
|
31
|
+
"LRANGE": self._handle_lrange,
|
|
32
|
+
"RPUSH": self._handle_rpush,
|
|
33
|
+
"LPOP": self._handle_lpop,
|
|
34
|
+
"RPOP": self._handle_rpop,
|
|
35
|
+
"SADD": self._handle_sadd,
|
|
36
|
+
"SMEMBERS": self._handle_smembers,
|
|
37
|
+
"SREM": self._handle_srem,
|
|
38
|
+
"HSET": self._handle_hset,
|
|
39
|
+
"HGET": self._handle_hget,
|
|
40
|
+
"HGETALL": self._handle_hgetall,
|
|
41
|
+
"ZADD": self._handle_zadd,
|
|
42
|
+
"ZRANGE": self._handle_zrange,
|
|
43
|
+
"TYPE": self._handle_type,
|
|
44
|
+
"STATS": self._handle_stats,
|
|
45
|
+
"CONFIG": self._handle_config,
|
|
46
|
+
"SUBSCRIBE": self._handle_subscribe,
|
|
47
|
+
"PUBLISH": self._handle_publish,
|
|
48
|
+
"SLOWLOG": self.cmd_slowlog,
|
|
49
|
+
"LATENCY": self.cmd_latency,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def WRITE_COMMANDS(self) -> set[str]:
|
|
54
|
+
"""
|
|
55
|
+
The definitive list of all engine commands that mutate database state.
|
|
56
|
+
Used by the replication slipstream to broadcast changes and enforce read-only firewalls.
|
|
57
|
+
"""
|
|
58
|
+
return {
|
|
59
|
+
"SET",
|
|
60
|
+
"DEL",
|
|
61
|
+
"EXPIREAT",
|
|
62
|
+
"FLUSHALL",
|
|
63
|
+
"LPUSH",
|
|
64
|
+
"RPUSH",
|
|
65
|
+
"LPOP",
|
|
66
|
+
"RPOP",
|
|
67
|
+
"SADD",
|
|
68
|
+
"SREM",
|
|
69
|
+
"HSET",
|
|
70
|
+
"ZADD",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def execute(self, tokens: list[str], client_socket=None):
|
|
74
|
+
"""
|
|
75
|
+
Routes parsed tokens to the correct storage operations in O(1) time.
|
|
76
|
+
Returns raw Python types (int, list, str, None) for the KESP Encoder.
|
|
77
|
+
"""
|
|
78
|
+
if not tokens:
|
|
79
|
+
return "-ERR empty command or syntax error"
|
|
80
|
+
|
|
81
|
+
cmd = tokens[0].upper()
|
|
82
|
+
args = tokens[
|
|
83
|
+
1:
|
|
84
|
+
] # (Though your current code passes tokens, let's keep your routing exact)
|
|
85
|
+
|
|
86
|
+
# 🚀 TRACK A: Start the high-precision telemetry stopwatch (in nanoseconds)
|
|
87
|
+
start_time = time.perf_counter_ns()
|
|
88
|
+
result = None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with self._engine_lock:
|
|
92
|
+
if cmd in self._commands:
|
|
93
|
+
if cmd in ["SUBSCRIBE", "PUBLISH"]:
|
|
94
|
+
handler_function = self._commands[cmd]
|
|
95
|
+
result = handler_function(tokens, client_socket)
|
|
96
|
+
else:
|
|
97
|
+
handler_function = self._commands[cmd]
|
|
98
|
+
# Note: Depending on how your handlers are registered,
|
|
99
|
+
# they might expect 'tokens' or 'args'. Keeping your existing call:
|
|
100
|
+
result = handler_function(tokens)
|
|
101
|
+
return result
|
|
102
|
+
else:
|
|
103
|
+
result = f"-ERR unknown command '{cmd}'"
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
except TypeError as e:
|
|
107
|
+
result = f"-ERR wrong number of arguments for '{cmd}' command"
|
|
108
|
+
return result
|
|
109
|
+
except Exception as e:
|
|
110
|
+
result = f"-ERR {str(e)}"
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
finally:
|
|
114
|
+
# 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
|
|
115
|
+
end_time = time.perf_counter_ns()
|
|
116
|
+
duration_us = (end_time - start_time) // 1000
|
|
117
|
+
|
|
118
|
+
# Feed the metrics to the store's slowlog ring buffer if available
|
|
119
|
+
if (
|
|
120
|
+
hasattr(self, "store")
|
|
121
|
+
and self.store
|
|
122
|
+
and hasattr(self.store, "_log_slow_command")
|
|
123
|
+
):
|
|
124
|
+
self.store._log_slow_command(tokens, duration_us)
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------
|
|
127
|
+
# COMMAND HANDLERS (The isolated engine components)
|
|
128
|
+
# ---------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _handle_config(self, tokens: list[str]):
|
|
131
|
+
if len(tokens) < 3:
|
|
132
|
+
return "-ERR wrong number of arguments for 'CONFIG' command"
|
|
133
|
+
|
|
134
|
+
sub_cmd = tokens[1].upper()
|
|
135
|
+
param = tokens[2].lower()
|
|
136
|
+
|
|
137
|
+
if param != "appendfsync":
|
|
138
|
+
return f"-ERR unsupported CONFIG parameter '{param}'"
|
|
139
|
+
|
|
140
|
+
if sub_cmd == "SET":
|
|
141
|
+
if len(tokens) != 4:
|
|
142
|
+
return "-ERR wrong number of arguments for 'CONFIG SET'"
|
|
143
|
+
new_mode = tokens[3].lower()
|
|
144
|
+
return self.store.set_appendfsync(new_mode)
|
|
145
|
+
|
|
146
|
+
elif sub_cmd == "GET":
|
|
147
|
+
if len(tokens) != 3:
|
|
148
|
+
return "-ERR wrong number of arguments for 'CONFIG GET'"
|
|
149
|
+
return [param, self.store.appendfsync]
|
|
150
|
+
|
|
151
|
+
else:
|
|
152
|
+
return f"-ERR unknown CONFIG subcommand '{sub_cmd}'"
|
|
153
|
+
|
|
154
|
+
def _handle_set(self, tokens: list[str]):
|
|
155
|
+
if len(tokens) != 3:
|
|
156
|
+
return "-ERR wrong number of arguments for 'SET' command"
|
|
157
|
+
self.store.set(tokens[1], tokens[2])
|
|
158
|
+
return "OK"
|
|
159
|
+
|
|
160
|
+
def _handle_get(self, tokens: list[str]):
|
|
161
|
+
if len(tokens) != 2:
|
|
162
|
+
return "-ERR wrong number of arguments for 'GET' command"
|
|
163
|
+
return self.store.get(tokens[1])
|
|
164
|
+
|
|
165
|
+
def _handle_del(self, tokens: list[str]):
|
|
166
|
+
if len(tokens) != 2:
|
|
167
|
+
return "-ERR wrong number of arguments for 'DEL' command"
|
|
168
|
+
return self.store.delete(tokens[1])
|
|
169
|
+
|
|
170
|
+
def _handle_exists(self, tokens: list[str]):
|
|
171
|
+
if len(tokens) != 2:
|
|
172
|
+
return "-ERR wrong number of arguments for 'EXISTS' command"
|
|
173
|
+
return self.store.exists(tokens[1])
|
|
174
|
+
|
|
175
|
+
def _handle_expire(self, tokens: list[str]):
|
|
176
|
+
if len(tokens) != 3:
|
|
177
|
+
return "-ERR wrong number of arguments for 'EXPIRE' command"
|
|
178
|
+
try:
|
|
179
|
+
seconds = int(tokens[2])
|
|
180
|
+
except ValueError:
|
|
181
|
+
return "-ERR value is not an integer or out of range"
|
|
182
|
+
return self.store.set_expire(tokens[1], seconds)
|
|
183
|
+
|
|
184
|
+
def _handle_keys(self, tokens: list[str]):
|
|
185
|
+
if len(tokens) != 1:
|
|
186
|
+
return "-ERR wrong number of arguments for 'KEYS' command"
|
|
187
|
+
all_keys = self.store.keys()
|
|
188
|
+
if not all_keys:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
f"{k} | {data['type']} | {data['ttl']} | {data['length']}"
|
|
193
|
+
for k, data in all_keys.items()
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
def _handle_ttl(self, tokens: list[str]):
|
|
197
|
+
if len(tokens) != 2:
|
|
198
|
+
return "-ERR wrong number of arguments for 'TTL' command"
|
|
199
|
+
return self.store.ttl(tokens[1])
|
|
200
|
+
|
|
201
|
+
def _handle_flushall(self, tokens: list[str]):
|
|
202
|
+
if len(tokens) != 1:
|
|
203
|
+
return "-ERR wrong number of arguments for 'FLUSHALL' command"
|
|
204
|
+
self.store.flushall()
|
|
205
|
+
return "OK"
|
|
206
|
+
|
|
207
|
+
def _handle_save(self, tokens: list[str]):
|
|
208
|
+
if len(tokens) != 1:
|
|
209
|
+
return "-ERR wrong number of arguments for 'SAVE' command"
|
|
210
|
+
success = self.store.save()
|
|
211
|
+
return "OK" if success else "-ERR failed to save data"
|
|
212
|
+
|
|
213
|
+
def _handle_compact(self, tokens: list[str]):
|
|
214
|
+
if len(tokens) > 1:
|
|
215
|
+
return "-ERR wrong number of arguments for 'compact' command"
|
|
216
|
+
success = self.store.compact_aof()
|
|
217
|
+
return (
|
|
218
|
+
"+OK AOF log compacted successfully"
|
|
219
|
+
if success
|
|
220
|
+
else "-ERR Failed to compact AOF log"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _handle_lpush(self, tokens: list[str]):
|
|
224
|
+
if len(tokens) < 3:
|
|
225
|
+
return "-ERR wrong number of arguments for 'lpush' command"
|
|
226
|
+
try:
|
|
227
|
+
return self.store.lpush(tokens[1], *tokens[2:])
|
|
228
|
+
except TypeError as e:
|
|
229
|
+
return f"-ERR {str(e)}"
|
|
230
|
+
|
|
231
|
+
def _handle_lrange(self, tokens: list[str]):
|
|
232
|
+
if len(tokens) != 4:
|
|
233
|
+
return "-ERR wrong number of arguments for 'lrange' command"
|
|
234
|
+
try:
|
|
235
|
+
result = self.store.lrange(tokens[1], int(tokens[2]), int(tokens[3]))
|
|
236
|
+
return result if result else []
|
|
237
|
+
except (TypeError, ValueError) as e:
|
|
238
|
+
return f"-ERR {str(e)}"
|
|
239
|
+
|
|
240
|
+
def _handle_rpush(self, tokens: list[str]):
|
|
241
|
+
if len(tokens) < 3:
|
|
242
|
+
return "-ERR wrong number of arguments for 'rpush' command"
|
|
243
|
+
try:
|
|
244
|
+
return self.store.rpush(tokens[1], *tokens[2:])
|
|
245
|
+
except TypeError as e:
|
|
246
|
+
return f"-ERR {str(e)}"
|
|
247
|
+
|
|
248
|
+
def _handle_lpop(self, tokens: list[str]):
|
|
249
|
+
if len(tokens) != 2:
|
|
250
|
+
return "-ERR wrong number of arguments for 'lpop' command"
|
|
251
|
+
try:
|
|
252
|
+
return self.store.lpop(tokens[1])
|
|
253
|
+
except TypeError as e:
|
|
254
|
+
return f"-ERR {str(e)}"
|
|
255
|
+
|
|
256
|
+
def _handle_rpop(self, tokens: list[str]):
|
|
257
|
+
if len(tokens) != 2:
|
|
258
|
+
return "-ERR wrong number of arguments for 'rpop' command"
|
|
259
|
+
try:
|
|
260
|
+
return self.store.rpop(tokens[1])
|
|
261
|
+
except TypeError as e:
|
|
262
|
+
return f"-ERR {str(e)}"
|
|
263
|
+
|
|
264
|
+
def _handle_sadd(self, tokens: list[str]):
|
|
265
|
+
if len(tokens) < 3:
|
|
266
|
+
return "-ERR wrong number of arguments for 'sadd' command"
|
|
267
|
+
try:
|
|
268
|
+
return self.store.sadd(tokens[1], *tokens[2:])
|
|
269
|
+
except TypeError as e:
|
|
270
|
+
return f"-ERR {str(e)}"
|
|
271
|
+
|
|
272
|
+
def _handle_smembers(self, tokens: list[str]):
|
|
273
|
+
if len(tokens) != 2:
|
|
274
|
+
return "-ERR wrong number of arguments for 'smembers' command"
|
|
275
|
+
try:
|
|
276
|
+
members = self.store.smembers(tokens[1])
|
|
277
|
+
return members if members else []
|
|
278
|
+
except TypeError as e:
|
|
279
|
+
return f"-ERR {str(e)}"
|
|
280
|
+
|
|
281
|
+
def _handle_srem(self, tokens: list[str]):
|
|
282
|
+
if len(tokens) < 3:
|
|
283
|
+
return "-ERR wrong number of arguments for 'srem' command"
|
|
284
|
+
try:
|
|
285
|
+
return self.store.srem(tokens[1], *tokens[2:])
|
|
286
|
+
except TypeError as e:
|
|
287
|
+
return f"-ERR {str(e)}"
|
|
288
|
+
|
|
289
|
+
def _handle_hset(self, tokens: list[str]):
|
|
290
|
+
if len(tokens) < 4:
|
|
291
|
+
return "-ERR wrong number of arguments for 'hset' command"
|
|
292
|
+
try:
|
|
293
|
+
val = " ".join(tokens[3:])
|
|
294
|
+
return self.store.hset(tokens[1], tokens[2], val)
|
|
295
|
+
except TypeError as e:
|
|
296
|
+
return f"-ERR {str(e)}"
|
|
297
|
+
|
|
298
|
+
def _handle_hget(self, tokens: list[str]):
|
|
299
|
+
if len(tokens) != 3:
|
|
300
|
+
return "-ERR wrong number of arguments for 'hget' command"
|
|
301
|
+
try:
|
|
302
|
+
return self.store.hget(tokens[1], tokens[2])
|
|
303
|
+
except TypeError as e:
|
|
304
|
+
return f"-ERR {str(e)}"
|
|
305
|
+
|
|
306
|
+
def _handle_hgetall(self, tokens: list[str]):
|
|
307
|
+
if len(tokens) != 2:
|
|
308
|
+
return "-ERR wrong number of arguments for 'hgetall' command"
|
|
309
|
+
try:
|
|
310
|
+
data = self.store.hgetall(tokens[1])
|
|
311
|
+
if not data:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
# Flattens dict into [key1, val1, key2, val2]
|
|
315
|
+
flat_list = []
|
|
316
|
+
for k, v in data.items():
|
|
317
|
+
flat_list.extend([k, v])
|
|
318
|
+
return flat_list
|
|
319
|
+
except TypeError as e:
|
|
320
|
+
return f"-ERR {str(e)}"
|
|
321
|
+
|
|
322
|
+
def _handle_zadd(self, tokens: list[str]):
|
|
323
|
+
if len(tokens) < 4 or len(tokens) % 2 != 0:
|
|
324
|
+
return "-ERR wrong number of arguments for 'zadd' command"
|
|
325
|
+
key = tokens[1]
|
|
326
|
+
added = 0
|
|
327
|
+
try:
|
|
328
|
+
for i in range(2, len(tokens), 2):
|
|
329
|
+
score = float(tokens[i])
|
|
330
|
+
member = tokens[i + 1]
|
|
331
|
+
added += self.store.zadd(key, score, member)
|
|
332
|
+
return added
|
|
333
|
+
except ValueError:
|
|
334
|
+
return "-ERR value is not a valid float"
|
|
335
|
+
except TypeError as e:
|
|
336
|
+
return f"-ERR {str(e)}"
|
|
337
|
+
|
|
338
|
+
def _handle_zrange(self, tokens: list[str]):
|
|
339
|
+
if len(tokens) < 4 or len(tokens) > 5:
|
|
340
|
+
return "-ERR wrong number of arguments for 'zrange' command"
|
|
341
|
+
|
|
342
|
+
key = tokens[1]
|
|
343
|
+
withscores = False
|
|
344
|
+
|
|
345
|
+
if len(tokens) == 5:
|
|
346
|
+
if tokens[4].upper() == "WITHSCORES":
|
|
347
|
+
withscores = True
|
|
348
|
+
else:
|
|
349
|
+
return "-ERR syntax error"
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
start = int(tokens[2])
|
|
353
|
+
stop = int(tokens[3])
|
|
354
|
+
result = self.store.zrange(key, start, stop, withscores)
|
|
355
|
+
return result if result else []
|
|
356
|
+
except ValueError:
|
|
357
|
+
return "-ERR value is not an integer or out of range"
|
|
358
|
+
except TypeError as e:
|
|
359
|
+
return f"-ERR {str(e)}"
|
|
360
|
+
|
|
361
|
+
def _handle_type(self, tokens: list[str]):
|
|
362
|
+
if len(tokens) != 2:
|
|
363
|
+
return "-ERR wrong number of arguments for 'type' command"
|
|
364
|
+
return self.store.type_of(tokens[1])
|
|
365
|
+
|
|
366
|
+
def _handle_stats(self, tokens: list[str]):
|
|
367
|
+
stats = self.store.get_engine_stats()
|
|
368
|
+
lru = stats.get("lru_cache", {})
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
f"Total Keys:{stats.get('total_keys', 0)}\n"
|
|
372
|
+
f"String Chars:{stats.get('string_chars', 0)}\n"
|
|
373
|
+
f"List Items:{stats.get('list_items', 0)}\n"
|
|
374
|
+
f"Set Members:{stats.get('set_members', 0)}\n"
|
|
375
|
+
f"Hash Fields:{stats.get('hash_fields', 0)}\n"
|
|
376
|
+
f"ZSet Nodes:{stats.get('zset_nodes', 0)}\n"
|
|
377
|
+
"---\n"
|
|
378
|
+
f"LRU Hits:{lru.get('hits', 0)}\n"
|
|
379
|
+
f"LRU Misses:{lru.get('misses', 0)}\n"
|
|
380
|
+
f"Hit Rate:{lru.get('hit_rate_pct', 0.0)}%\n"
|
|
381
|
+
f"LRU Tracked:{lru.get('tracked_keys', 0)} / {lru.get('max_size', 128)}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def _handle_subscribe(self, tokens: list[str], client_socket):
|
|
385
|
+
if client_socket is None:
|
|
386
|
+
return "-ERR network socket not found"
|
|
387
|
+
if len(tokens) < 2:
|
|
388
|
+
return "-ERR wrong number of arguments for 'subscribe' command"
|
|
389
|
+
|
|
390
|
+
channel = tokens[1]
|
|
391
|
+
|
|
392
|
+
if channel not in self._channels:
|
|
393
|
+
self._channels[channel] = []
|
|
394
|
+
|
|
395
|
+
if client_socket not in self._channels[channel]:
|
|
396
|
+
self._channels[channel].append(client_socket)
|
|
397
|
+
|
|
398
|
+
return ["subscribe", channel, 1]
|
|
399
|
+
|
|
400
|
+
def _handle_publish(self, tokens: list[str], client_socket):
|
|
401
|
+
if len(tokens) < 3:
|
|
402
|
+
return "-ERR wrong number of arguments for 'publish' command"
|
|
403
|
+
|
|
404
|
+
channel = tokens[1]
|
|
405
|
+
message = " ".join(tokens[2:])
|
|
406
|
+
|
|
407
|
+
if channel not in self._channels:
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
subscribers = self._channels[channel]
|
|
411
|
+
receivers = 0
|
|
412
|
+
|
|
413
|
+
# Construct raw KESP bytes for the broadcast push
|
|
414
|
+
kesp_payload = (
|
|
415
|
+
f"A3\n"
|
|
416
|
+
f"S7\nmessage\n"
|
|
417
|
+
f"S{len(channel.encode('utf-8'))}\n{channel}\n"
|
|
418
|
+
f"S{len(message.encode('utf-8'))}\n{message}\n"
|
|
419
|
+
).encode("utf-8")
|
|
420
|
+
|
|
421
|
+
dead_sockets = []
|
|
422
|
+
for sock in subscribers:
|
|
423
|
+
try:
|
|
424
|
+
sock.sendall(kesp_payload)
|
|
425
|
+
receivers += 1
|
|
426
|
+
except Exception:
|
|
427
|
+
dead_sockets.append(sock)
|
|
428
|
+
|
|
429
|
+
for dead in dead_sockets:
|
|
430
|
+
subscribers.remove(dead)
|
|
431
|
+
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
def cmd_slowlog(self, tokens: list[str]) -> Any:
|
|
435
|
+
if len(tokens) < 2:
|
|
436
|
+
return "-ERR wrong number of arguments for 'slowlog' command"
|
|
437
|
+
|
|
438
|
+
subcmd = tokens[1].upper()
|
|
439
|
+
|
|
440
|
+
if subcmd == "GET":
|
|
441
|
+
count = None
|
|
442
|
+
if len(tokens) > 2:
|
|
443
|
+
try:
|
|
444
|
+
count = int(tokens[2])
|
|
445
|
+
except ValueError:
|
|
446
|
+
return "-ERR value is not an integer or out of range"
|
|
447
|
+
|
|
448
|
+
# Fetch raw logs
|
|
449
|
+
raw_logs = self.store.slowlog_get(count)
|
|
450
|
+
if not raw_logs:
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
# Formatting raw logs for client UI
|
|
454
|
+
formatted_logs = []
|
|
455
|
+
|
|
456
|
+
for entry in raw_logs:
|
|
457
|
+
log_id = entry[0]
|
|
458
|
+
timestamp = entry[1]
|
|
459
|
+
duration_us = entry[2]
|
|
460
|
+
cmd_string = " ".join(entry[3])
|
|
461
|
+
|
|
462
|
+
formatted_logs.append(
|
|
463
|
+
f"ID: {log_id} | Time: {timestamp} | {duration_us}µs | Cmd: {cmd_string}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return formatted_logs
|
|
467
|
+
|
|
468
|
+
elif subcmd == "LEN":
|
|
469
|
+
return self.store.slowlog_len()
|
|
470
|
+
|
|
471
|
+
elif subcmd == "RESET":
|
|
472
|
+
self.store.slowlog_reset()
|
|
473
|
+
return "+OK"
|
|
474
|
+
|
|
475
|
+
else:
|
|
476
|
+
return f"-ERR Unknown subcommand '{subcmd}'. Try SLOWLOG <GET|LEN|RESET>"
|
|
477
|
+
|
|
478
|
+
def cmd_latency(self, tokens: list[str]) -> Any:
|
|
479
|
+
if len(tokens) < 2:
|
|
480
|
+
return "-ERR wrong number of arguments for 'latency' command"
|
|
481
|
+
|
|
482
|
+
subcmd = tokens[1].upper()
|
|
483
|
+
# Safely fetch the lag, defaulting to 0.0 if in Local Mode without a heartbeat
|
|
484
|
+
lag_ms = getattr(self.store, "current_lag_ms", 0.0)
|
|
485
|
+
|
|
486
|
+
if subcmd == "LAG":
|
|
487
|
+
return f"{lag_ms}ms"
|
|
488
|
+
|
|
489
|
+
elif subcmd == "DOCTOR":
|
|
490
|
+
# The Engine Health Diagnostic Report
|
|
491
|
+
drivetrain = self.store.appendfsync.upper()
|
|
492
|
+
keys = len(getattr(self.store, "_data", {}))
|
|
493
|
+
|
|
494
|
+
# Determine health status based on lag severity
|
|
495
|
+
if lag_ms < 5.0:
|
|
496
|
+
health = "[green]EXCELLENT[/green]"
|
|
497
|
+
elif lag_ms < 20.0:
|
|
498
|
+
health = "[yellow]WARNING (Mild Loop Delay)[/yellow]"
|
|
499
|
+
else:
|
|
500
|
+
health = "[red]CRITICAL (Heavy Blocking)[/red]"
|
|
501
|
+
|
|
502
|
+
report = (
|
|
503
|
+
f"--- KEDIS ENGINE HEALTH ---\n"
|
|
504
|
+
f"Event Loop Lag : {lag_ms}ms\n"
|
|
505
|
+
f"Status : {health}\n"
|
|
506
|
+
f"I/O Drivetrain : {drivetrain}\n"
|
|
507
|
+
f"Active Keys : {keys}\n"
|
|
508
|
+
)
|
|
509
|
+
return report
|
|
510
|
+
|
|
511
|
+
else:
|
|
512
|
+
return f"-ERR Unknown subcommand '{subcmd}'. Try LATENCY <LAG|DOCTOR>"
|