kedis-python 0.2.0__tar.gz → 0.3.0__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.2.0 → kedis_python-0.3.0}/PKG-INFO +7 -2
- {kedis_python-0.2.0 → kedis_python-0.3.0}/README.md +6 -1
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/commands.py +66 -41
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/main.py +1 -1
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/parser.py +147 -17
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/server.py +387 -85
- kedis_python-0.3.0/kedis_python/settings.py +238 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/ui.py +1 -1
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/PKG-INFO +7 -2
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/SOURCES.txt +17 -1
- {kedis_python-0.2.0 → kedis_python-0.3.0}/pyproject.toml +1 -1
- kedis_python-0.3.0/tests/test_chunked_reply.py +409 -0
- kedis_python-0.3.0/tests/test_doc_claims.py +72 -0
- kedis_python-0.3.0/tests/test_error_line_cap.py +132 -0
- kedis_python-0.3.0/tests/test_follower_stream.py +495 -0
- kedis_python-0.3.0/tests/test_fuzz.py +286 -0
- kedis_python-0.3.0/tests/test_fuzz_properties.py +63 -0
- kedis_python-0.3.0/tests/test_multi_limits.py +301 -0
- kedis_python-0.3.0/tests/test_multi_readonly.py +157 -0
- kedis_python-0.3.0/tests/test_multi_replication.py +475 -0
- kedis_python-0.3.0/tests/test_replica_snapshot.py +141 -0
- kedis_python-0.3.0/tests/test_replica_write_deadline.py +279 -0
- kedis_python-0.3.0/tests/test_replication_task.py +217 -0
- kedis_python-0.3.0/tests/test_reply_budgets.py +226 -0
- kedis_python-0.3.0/tests/test_reply_snapshots.py +198 -0
- kedis_python-0.3.0/tests/test_server_flags.py +427 -0
- kedis_python-0.3.0/tests/test_write_deadline.py +242 -0
- kedis_python-0.2.0/kedis_python/replica.py +0 -454
- {kedis_python-0.2.0 → kedis_python-0.3.0}/LICENSE +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/__init__.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/aof.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/benchmark.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/benchmark_asyncio.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/help_data.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/hyperloglog.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/kesp_cli.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/network.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/simulator.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/skiplist.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/store.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/stream.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/textcodec.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/dependency_links.txt +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/entry_points.txt +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/requires.txt +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/top_level.txt +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/setup.cfg +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_aof_persistence.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_aof_records.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bench.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_binary_safety.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bitmap_persistence.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bitmaps.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_cli_housekeep.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_cli_stream_view.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_data_replies.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_del_exists.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_docs_data_types.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_docs_streams.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_help_data.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_history_setup.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_housekeeping.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_hyperloglog.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_inline_block.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp2.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp_decoder.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp_encoder.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lists.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lrange.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lru.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_casing.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_framing.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_harden.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_limits.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_pubsub.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_replica.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_reply_limits.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_server_limits.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_server_protocol_errors.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_commands.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_core.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_commands.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_core.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_server.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_store.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_server.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_store.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_type.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_version.py +0 -0
- {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_zadd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kedis-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Redis-inspired in-memory store in Python.
|
|
5
5
|
Author: V SS Karthik
|
|
6
6
|
License-Expression: MIT
|
|
@@ -53,6 +53,7 @@ Dive into the internal grimoire to understand how Kedis achieves microsecond lat
|
|
|
53
53
|
* [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
|
|
54
54
|
* [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
|
|
55
55
|
* [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
|
|
56
|
+
* [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md)
|
|
56
57
|
* [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
|
|
57
58
|
* [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
|
|
58
59
|
* [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
|
|
@@ -200,7 +201,8 @@ Kedis supports master-replica replication:
|
|
|
200
201
|
|
|
201
202
|
* Replicas connect to a master and perform a `SYNC` handshake to receive the initial dataset
|
|
202
203
|
* The master streams subsequent writes to connected replicas in real time
|
|
203
|
-
* Replicas are read-only — writes issued directly to a replica are rejected
|
|
204
|
+
* Replicas are read-only — writes issued directly to a replica are rejected, and so are writes inside a `MULTI` block: the write gets `-EREADONLY` when it is queued and `EXEC` aborts with `-EXECABORT`
|
|
205
|
+
* A `MULTI`/`EXEC` block runs as one unit on the master and reaches followers as one transaction, which a follower applies as a unit
|
|
204
206
|
|
|
205
207
|
---
|
|
206
208
|
|
|
@@ -211,6 +213,7 @@ Asyncio-based server implementation supporting:
|
|
|
211
213
|
* many concurrent client connections on a single event loop
|
|
212
214
|
* command execution over sockets via the KESP protocol
|
|
213
215
|
* standalone local mode fallback
|
|
216
|
+
* every limit settable at startup, e.g. `kedis-server --port 7001 --max-clients 500` (see [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md))
|
|
214
217
|
|
|
215
218
|
---
|
|
216
219
|
|
|
@@ -313,6 +316,7 @@ The project includes automated tests covering:
|
|
|
313
316
|
* persistence recovery
|
|
314
317
|
* expiration behavior
|
|
315
318
|
* streams and consumer groups, from the data structure up to live TCP sessions
|
|
319
|
+
* protocol fuzzing: a mutation fuzzer, reference readers and property tests (see [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md))
|
|
316
320
|
|
|
317
321
|
Additional coverage is actively being expanded as the project evolves.
|
|
318
322
|
|
|
@@ -384,6 +388,7 @@ Current limitations include:
|
|
|
384
388
|
* limited fault tolerance compared to production databases
|
|
385
389
|
* no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
|
|
386
390
|
* AOF persistence is not buffered — writes can trigger a sync depending on config
|
|
391
|
+
* `SAVE` and `COMPACT` hold the engine lock while they dump the store, so other commands wait (about 1 s for 3 million items); a write forwarded to followers is encoded on the event loop in one go (about 0.55 s for a command with 1,000,000 arguments). Both are listed in [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md#not-covered-yet)
|
|
387
392
|
* consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
|
|
388
393
|
|
|
389
394
|
These limitations are intentional learning opportunities and areas of active development.
|
|
@@ -33,6 +33,7 @@ Dive into the internal grimoire to understand how Kedis achieves microsecond lat
|
|
|
33
33
|
* [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
|
|
34
34
|
* [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
|
|
35
35
|
* [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
|
|
36
|
+
* [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md)
|
|
36
37
|
* [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
|
|
37
38
|
* [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
|
|
38
39
|
* [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
|
|
@@ -180,7 +181,8 @@ Kedis supports master-replica replication:
|
|
|
180
181
|
|
|
181
182
|
* Replicas connect to a master and perform a `SYNC` handshake to receive the initial dataset
|
|
182
183
|
* The master streams subsequent writes to connected replicas in real time
|
|
183
|
-
* Replicas are read-only — writes issued directly to a replica are rejected
|
|
184
|
+
* Replicas are read-only — writes issued directly to a replica are rejected, and so are writes inside a `MULTI` block: the write gets `-EREADONLY` when it is queued and `EXEC` aborts with `-EXECABORT`
|
|
185
|
+
* A `MULTI`/`EXEC` block runs as one unit on the master and reaches followers as one transaction, which a follower applies as a unit
|
|
184
186
|
|
|
185
187
|
---
|
|
186
188
|
|
|
@@ -191,6 +193,7 @@ Asyncio-based server implementation supporting:
|
|
|
191
193
|
* many concurrent client connections on a single event loop
|
|
192
194
|
* command execution over sockets via the KESP protocol
|
|
193
195
|
* standalone local mode fallback
|
|
196
|
+
* every limit settable at startup, e.g. `kedis-server --port 7001 --max-clients 500` (see [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md))
|
|
194
197
|
|
|
195
198
|
---
|
|
196
199
|
|
|
@@ -293,6 +296,7 @@ The project includes automated tests covering:
|
|
|
293
296
|
* persistence recovery
|
|
294
297
|
* expiration behavior
|
|
295
298
|
* streams and consumer groups, from the data structure up to live TCP sessions
|
|
299
|
+
* protocol fuzzing: a mutation fuzzer, reference readers and property tests (see [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md))
|
|
296
300
|
|
|
297
301
|
Additional coverage is actively being expanded as the project evolves.
|
|
298
302
|
|
|
@@ -364,6 +368,7 @@ Current limitations include:
|
|
|
364
368
|
* limited fault tolerance compared to production databases
|
|
365
369
|
* no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
|
|
366
370
|
* AOF persistence is not buffered — writes can trigger a sync depending on config
|
|
371
|
+
* `SAVE` and `COMPACT` hold the engine lock while they dump the store, so other commands wait (about 1 s for 3 million items); a write forwarded to followers is encoded on the event loop in one go (about 0.55 s for a command with 1,000,000 arguments). Both are listed in [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md#not-covered-yet)
|
|
367
372
|
* consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
|
|
368
373
|
|
|
369
374
|
These limitations are intentional learning opportunities and areas of active development.
|
|
@@ -136,57 +136,82 @@ class CommandHandler:
|
|
|
136
136
|
if not tokens:
|
|
137
137
|
return "-ERR empty command or syntax error", []
|
|
138
138
|
|
|
139
|
-
cmd = tokens[0].upper()
|
|
140
|
-
args = tokens[
|
|
141
|
-
1:
|
|
142
|
-
] # (Though your current code passes tokens, let's keep your routing exact)
|
|
143
|
-
|
|
144
139
|
# 🚀 TRACK A: Start the high-precision telemetry stopwatch (in nanoseconds)
|
|
145
140
|
start_time = time.perf_counter_ns()
|
|
141
|
+
try:
|
|
142
|
+
with self._engine_lock:
|
|
143
|
+
return self._run_locked(tokens, client_socket)
|
|
144
|
+
finally:
|
|
145
|
+
self._record_duration(tokens, start_time)
|
|
146
|
+
|
|
147
|
+
def execute_batch(self, batch: list, client_socket=None):
|
|
148
|
+
"""Runs several commands as one unit: the engine lock is taken once, so
|
|
149
|
+
no other client's command can run between them, and the AOF records
|
|
150
|
+
them back to back. Returns (results, forwards): one result per command,
|
|
151
|
+
and the commands a master must send to replicas, in order (see
|
|
152
|
+
replication_payloads). Commands are not skipped or reordered; one that
|
|
153
|
+
fails gives an error result and the rest still run, like the one-at-a-
|
|
154
|
+
time path."""
|
|
155
|
+
results: list = []
|
|
156
|
+
forwards: list = []
|
|
157
|
+
write_commands = self.WRITE_COMMANDS
|
|
158
|
+
starts: list = []
|
|
159
|
+
with self._engine_lock:
|
|
160
|
+
for tokens in batch:
|
|
161
|
+
if not tokens:
|
|
162
|
+
results.append("-ERR empty command or syntax error")
|
|
163
|
+
starts.append(None)
|
|
164
|
+
continue
|
|
165
|
+
starts.append(time.perf_counter_ns())
|
|
166
|
+
result, records = self._run_locked(tokens, client_socket)
|
|
167
|
+
results.append(result)
|
|
168
|
+
if tokens[0].upper() in write_commands:
|
|
169
|
+
forwards.extend(self.replication_payloads(tokens, result, records))
|
|
170
|
+
for tokens, started in zip(batch, starts):
|
|
171
|
+
if started is not None:
|
|
172
|
+
self._record_duration(tokens, started)
|
|
173
|
+
return results, forwards
|
|
174
|
+
|
|
175
|
+
def _run_locked(self, tokens: list[str], client_socket=None):
|
|
176
|
+
"""Runs one command. The caller holds the engine lock."""
|
|
177
|
+
cmd = tokens[0].upper()
|
|
146
178
|
result = None
|
|
147
179
|
records = []
|
|
148
180
|
|
|
149
181
|
try:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
result = handler_function(tokens, client_socket)
|
|
160
|
-
else:
|
|
161
|
-
handler_function = self._commands[cmd]
|
|
162
|
-
# Note: Depending on how your handlers are registered,
|
|
163
|
-
# they might expect 'tokens' or 'args'. Keeping your existing call:
|
|
164
|
-
result = handler_function(tokens)
|
|
182
|
+
try:
|
|
183
|
+
if cmd == "XGAPPLY" and client_socket is not None:
|
|
184
|
+
# Internal replication command: a network client
|
|
185
|
+
# must not be able to write raw group records.
|
|
186
|
+
result = f"-ERR unknown command '{cmd}'"
|
|
187
|
+
elif cmd in self._commands:
|
|
188
|
+
handler_function = self._commands[cmd]
|
|
189
|
+
if cmd in ["SUBSCRIBE", "PUBLISH"]:
|
|
190
|
+
result = handler_function(tokens, client_socket)
|
|
165
191
|
else:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
result = f"-ERR wrong number of arguments for '{cmd}' command"
|
|
192
|
+
# Handlers take the whole token list, command name included.
|
|
193
|
+
result = handler_function(tokens)
|
|
194
|
+
else:
|
|
195
|
+
result = f"-ERR unknown command '{cmd}'"
|
|
196
|
+
finally:
|
|
197
|
+
records = self.store.drain_group_records()
|
|
173
198
|
return result, records
|
|
199
|
+
except TypeError:
|
|
200
|
+
return f"-ERR wrong number of arguments for '{cmd}' command", records
|
|
174
201
|
except Exception as e:
|
|
175
|
-
|
|
176
|
-
return result, records
|
|
202
|
+
return f"-ERR {str(e)}", records
|
|
177
203
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
self.store._log_slow_command(tokens, duration_us)
|
|
204
|
+
def _record_duration(self, tokens: list[str], start_time: int) -> None:
|
|
205
|
+
# 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
|
|
206
|
+
duration_us = (time.perf_counter_ns() - start_time) // 1000
|
|
207
|
+
|
|
208
|
+
# Feed the metrics to the store's slowlog ring buffer if available
|
|
209
|
+
if (
|
|
210
|
+
hasattr(self, "store")
|
|
211
|
+
and self.store
|
|
212
|
+
and hasattr(self.store, "_log_slow_command")
|
|
213
|
+
):
|
|
214
|
+
self.store._log_slow_command(tokens, duration_us)
|
|
190
215
|
|
|
191
216
|
# ---------------------------------------------------------
|
|
192
217
|
# COMMAND HANDLERS (The isolated engine components)
|
|
@@ -36,7 +36,7 @@ class CommandParser:
|
|
|
36
36
|
_COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
|
|
37
37
|
|
|
38
38
|
@staticmethod
|
|
39
|
-
def parse(raw_data, start: int = 0):
|
|
39
|
+
def parse(raw_data, start: int = 0, normalise: bool = True):
|
|
40
40
|
"""
|
|
41
41
|
Parses incoming network data. Returns (tokens, bytes_consumed).
|
|
42
42
|
|
|
@@ -51,12 +51,16 @@ class CommandParser:
|
|
|
51
51
|
raw_data may be bytes or a bytearray. Parsing starts at offset start and
|
|
52
52
|
bytes_consumed counts from there, so a caller can work through a buffer
|
|
53
53
|
holding many pipelined commands without copying or trimming it each time.
|
|
54
|
+
|
|
55
|
+
The first token is taken to be a command name and is uppercased. Pass
|
|
56
|
+
normalise=False for a frame that is data from its first token, such as the
|
|
57
|
+
baseline snapshot a leader sends: its JSON must reach the caller as sent.
|
|
54
58
|
"""
|
|
55
|
-
tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start)
|
|
59
|
+
tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start, normalise)
|
|
56
60
|
return tokens, consumed
|
|
57
61
|
|
|
58
62
|
@staticmethod
|
|
59
|
-
def parse_buffer(raw_data, start: int = 0):
|
|
63
|
+
def parse_buffer(raw_data, start: int = 0, normalise: bool = True):
|
|
60
64
|
"""parse(), plus a hint for incomplete frames: returns (tokens, consumed,
|
|
61
65
|
need). When the next frame is incomplete, need is the length the bytes
|
|
62
66
|
from start + consumed must reach before parsing can make any progress
|
|
@@ -75,7 +79,8 @@ class CommandParser:
|
|
|
75
79
|
# Only the command name is normalised. Every other token is data
|
|
76
80
|
# (keys, values, field names) until a handler says otherwise, so
|
|
77
81
|
# handlers match their own options case-insensitively.
|
|
78
|
-
|
|
82
|
+
if normalise:
|
|
83
|
+
tokens[0] = tokens[0].upper()
|
|
79
84
|
return tokens, end - start, 0
|
|
80
85
|
pos = end # blank frame: skip it and keep scanning
|
|
81
86
|
return [], pos - start, 0
|
|
@@ -267,6 +272,11 @@ class KESPDecoder:
|
|
|
267
272
|
MAX_DEPTH = 32 # nesting guard, so a hostile reply can't blow the stack
|
|
268
273
|
MAX_LINE = 64 * 1024 # a status/error line longer than this is garbage
|
|
269
274
|
MAX_HEADER_LINE = 32 # I<n> / S<n> / A<n> headers are never longer
|
|
275
|
+
# Whole-reply budgets. MAX_DEPTH and the per-array/per-string caps bound one
|
|
276
|
+
# node; these bound the *sum* over a reply, so many small nodes cannot add up
|
|
277
|
+
# to an unbounded amount of memory or work. Looked up at call time.
|
|
278
|
+
MAX_REPLY_ELEMENTS = 4_194_304 # every value in the reply, containers included
|
|
279
|
+
MAX_REPLY_PAYLOAD = 1024 * 1024 * 1024 # string and text-line bytes, summed
|
|
270
280
|
|
|
271
281
|
_COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
|
|
272
282
|
_INTEGER = re.compile(rb"(?:0|-?[1-9][0-9]*)")
|
|
@@ -275,7 +285,12 @@ class KESPDecoder:
|
|
|
275
285
|
_NUMERIC_SIGILS = (b"I", b"S", b"A")
|
|
276
286
|
|
|
277
287
|
@staticmethod
|
|
278
|
-
def decode(
|
|
288
|
+
def decode(
|
|
289
|
+
buf, pos: int = 0, _depth: int = 0, lenient: bool = False, _budget=None
|
|
290
|
+
):
|
|
291
|
+
if _budget is None:
|
|
292
|
+
# [elements left, payload bytes left], shared by the whole reply.
|
|
293
|
+
_budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
279
294
|
if _depth > KESPDecoder.MAX_DEPTH:
|
|
280
295
|
raise ReplyProtocolError("reply nested too deeply")
|
|
281
296
|
if pos >= len(buf):
|
|
@@ -309,6 +324,16 @@ class KESPDecoder:
|
|
|
309
324
|
after = nl + 1
|
|
310
325
|
rest = line[1:]
|
|
311
326
|
|
|
327
|
+
# Charge this value to the whole-reply budget. A reply that is still
|
|
328
|
+
# incomplete never gets here, so a retry is not charged twice.
|
|
329
|
+
if _budget[0] <= 0:
|
|
330
|
+
raise ReplyProtocolError("reply has too many elements")
|
|
331
|
+
_budget[0] -= 1
|
|
332
|
+
if sigil not in (b"N", b"I", b"S", b"A"): # line-carried text
|
|
333
|
+
if len(line) > _budget[1]:
|
|
334
|
+
raise ReplyProtocolError("reply payload too large")
|
|
335
|
+
_budget[1] -= len(line)
|
|
336
|
+
|
|
312
337
|
if sigil == b"N":
|
|
313
338
|
if rest and not lenient:
|
|
314
339
|
raise ReplyProtocolError("invalid nil reply")
|
|
@@ -326,6 +351,10 @@ class KESPDecoder:
|
|
|
326
351
|
length = KESPDecoder._count(rest)
|
|
327
352
|
if length > CommandParser.MAX_BULK_LENGTH:
|
|
328
353
|
raise ReplyProtocolError("invalid bulk length")
|
|
354
|
+
if length > _budget[1]:
|
|
355
|
+
# Refuse on the header, before waiting for the bytes to arrive.
|
|
356
|
+
raise ReplyProtocolError("reply payload too large")
|
|
357
|
+
_budget[1] -= length
|
|
329
358
|
end = after + length
|
|
330
359
|
if len(buf) < end + 1:
|
|
331
360
|
raise IncompleteReply(end + 1)
|
|
@@ -337,9 +366,14 @@ class KESPDecoder:
|
|
|
337
366
|
count = KESPDecoder._count(rest)
|
|
338
367
|
if count > CommandParser.MAX_ARRAY_LENGTH:
|
|
339
368
|
raise ReplyProtocolError("invalid array length")
|
|
369
|
+
if count > _budget[0]:
|
|
370
|
+
# Every element costs at least one; refuse on the header.
|
|
371
|
+
raise ReplyProtocolError("reply has too many elements")
|
|
340
372
|
items = []
|
|
341
373
|
for _ in range(count):
|
|
342
|
-
item, after = KESPDecoder.decode(
|
|
374
|
+
item, after = KESPDecoder.decode(
|
|
375
|
+
buf, after, _depth + 1, lenient, _budget
|
|
376
|
+
)
|
|
343
377
|
items.append(item)
|
|
344
378
|
return items, after
|
|
345
379
|
|
|
@@ -388,10 +422,81 @@ class KESPEncoder:
|
|
|
388
422
|
"""
|
|
389
423
|
Translates raw Python objects from the router into strict KESP network bytes.
|
|
390
424
|
"""
|
|
425
|
+
# The same whole-reply budgets the decoder enforces, so a reply that
|
|
426
|
+
# leaves the server is one the client will accept.
|
|
427
|
+
budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
391
428
|
try:
|
|
392
|
-
return KESPEncoder._encode(data, 0, True)
|
|
429
|
+
return KESPEncoder._encode(data, 0, True, budget)
|
|
393
430
|
except ReplyEncodeError as exc:
|
|
394
|
-
return KESPEncoder.
|
|
431
|
+
return KESPEncoder.failure_reply(exc)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def failure_reply(exc) -> bytes:
|
|
435
|
+
"""The one error line that stands in for a reply that could not be encoded."""
|
|
436
|
+
return KESPEncoder._error_line(f"Internal server error: {exc}")
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def encode_chunks(data, chunk: int = 512):
|
|
440
|
+
"""encode(), in pieces: b"".join(encode_chunks(x)) == encode(x) for every
|
|
441
|
+
x that encode() can encode.
|
|
442
|
+
|
|
443
|
+
A reply with more than `chunk` elements comes out as its header and then
|
|
444
|
+
one piece per `chunk` elements, so a caller can hand control back to the
|
|
445
|
+
event loop between pieces instead of holding it for the whole reply.
|
|
446
|
+
Smaller replies, and replies that are not a flat top-level array or
|
|
447
|
+
dict, come out as a single piece.
|
|
448
|
+
|
|
449
|
+
Unlike encode() this raises ReplyEncodeError when the reply cannot be
|
|
450
|
+
encoded, possibly after some pieces were produced: the caller must hold
|
|
451
|
+
every piece back until the last one is made, then send either all of
|
|
452
|
+
them or failure_reply(exc), so that a failure never leaves half a frame
|
|
453
|
+
on the wire.
|
|
454
|
+
"""
|
|
455
|
+
budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
456
|
+
|
|
457
|
+
if isinstance(data, (list, set, tuple)) and len(data) > chunk:
|
|
458
|
+
# The same checks _encode makes for a top-level array.
|
|
459
|
+
if budget[0] <= 0:
|
|
460
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
461
|
+
budget[0] -= 1
|
|
462
|
+
if len(data) > CommandParser.MAX_ARRAY_LENGTH:
|
|
463
|
+
raise ReplyEncodeError("array too large")
|
|
464
|
+
if len(data) > budget[0]:
|
|
465
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
466
|
+
yield f"A{len(data)}\n".encode("utf-8")
|
|
467
|
+
items = data if isinstance(data, (list, tuple)) else list(data)
|
|
468
|
+
for i in range(0, len(items), chunk):
|
|
469
|
+
yield b"".join(
|
|
470
|
+
[
|
|
471
|
+
KESPEncoder._encode(item, 1, False, budget)
|
|
472
|
+
for item in items[i : i + chunk]
|
|
473
|
+
]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
elif isinstance(data, dict) and len(data) > chunk:
|
|
477
|
+
# The same checks _encode makes for a top-level dict (a flat array of
|
|
478
|
+
# alternating keys and values).
|
|
479
|
+
if budget[0] <= 0:
|
|
480
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
481
|
+
budget[0] -= 1
|
|
482
|
+
if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
|
|
483
|
+
raise ReplyEncodeError("array too large")
|
|
484
|
+
if len(data) * 2 > budget[0]:
|
|
485
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
486
|
+
yield f"A{len(data) * 2}\n".encode("utf-8")
|
|
487
|
+
entries = list(data.items())
|
|
488
|
+
for i in range(0, len(entries), chunk):
|
|
489
|
+
parts = []
|
|
490
|
+
for k, v in entries[i : i + chunk]:
|
|
491
|
+
if budget[0] <= 0:
|
|
492
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
493
|
+
budget[0] -= 1 # the key
|
|
494
|
+
parts.append(KESPEncoder._bulk(str(k), budget))
|
|
495
|
+
parts.append(KESPEncoder._encode(v, 1, False, budget))
|
|
496
|
+
yield b"".join(parts)
|
|
497
|
+
|
|
498
|
+
else:
|
|
499
|
+
yield KESPEncoder._encode(data, 0, True, budget)
|
|
395
500
|
|
|
396
501
|
@staticmethod
|
|
397
502
|
def encode_command(tokens) -> bytes:
|
|
@@ -411,22 +516,39 @@ class KESPEncoder:
|
|
|
411
516
|
raw = to_bytes(text)
|
|
412
517
|
except UnicodeEncodeError:
|
|
413
518
|
raw = text.encode("utf-8", "replace")
|
|
519
|
+
# Errors often echo client input (an unknown command's name, say). A line
|
|
520
|
+
# longer than the decoder's MAX_LINE would be rejected by our own client,
|
|
521
|
+
# so cut it, at a character boundary, and say so.
|
|
522
|
+
limit = KESPDecoder.MAX_LINE - 1 # the 'E' counts towards the line
|
|
523
|
+
if len(raw) > limit:
|
|
524
|
+
cut = limit - 3
|
|
525
|
+
while cut > 0 and raw[cut] & 0xC0 == 0x80:
|
|
526
|
+
cut -= 1 # raw[cut] would be mid-character: step back to its start
|
|
527
|
+
raw = raw[:cut] + b"..."
|
|
414
528
|
return b"E" + raw + b"\n"
|
|
415
529
|
|
|
416
530
|
@staticmethod
|
|
417
|
-
def _bulk(text: str) -> bytes:
|
|
531
|
+
def _bulk(text: str, budget=None) -> bytes:
|
|
418
532
|
try:
|
|
419
533
|
raw = to_bytes(text)
|
|
420
534
|
except UnicodeEncodeError:
|
|
421
535
|
raise ReplyEncodeError("string is not valid UTF-8") from None
|
|
422
536
|
if len(raw) > CommandParser.MAX_BULK_LENGTH:
|
|
423
537
|
raise ReplyEncodeError("string too large")
|
|
538
|
+
if budget is not None:
|
|
539
|
+
if len(raw) > budget[1]:
|
|
540
|
+
raise ReplyEncodeError("reply payload too large")
|
|
541
|
+
budget[1] -= len(raw)
|
|
424
542
|
return b"S%d\n%s\n" % (len(raw), raw)
|
|
425
543
|
|
|
426
544
|
@staticmethod
|
|
427
|
-
def _encode(data, depth: int, top: bool) -> bytes:
|
|
545
|
+
def _encode(data, depth: int, top: bool, budget) -> bytes:
|
|
428
546
|
if depth > KESPDecoder.MAX_DEPTH:
|
|
429
547
|
raise ReplyEncodeError("reply nested too deeply")
|
|
548
|
+
# One element per value, containers included (as the decoder counts).
|
|
549
|
+
if budget[0] <= 0:
|
|
550
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
551
|
+
budget[0] -= 1
|
|
430
552
|
|
|
431
553
|
# 1. Null / Missing Data
|
|
432
554
|
if data is None:
|
|
@@ -450,7 +572,7 @@ class KESPEncoder:
|
|
|
450
572
|
# 4. Strings & Status Messages
|
|
451
573
|
if isinstance(data, str):
|
|
452
574
|
if isinstance(data, BulkString) or not top:
|
|
453
|
-
return KESPEncoder._bulk(data)
|
|
575
|
+
return KESPEncoder._bulk(data, budget)
|
|
454
576
|
|
|
455
577
|
if data in KESPEncoder._STATUSES:
|
|
456
578
|
val = data if data.startswith("+") else f"+{data}"
|
|
@@ -462,16 +584,18 @@ class KESPEncoder:
|
|
|
462
584
|
return KESPEncoder._error_line(data[len(prefix) + 1 :])
|
|
463
585
|
|
|
464
586
|
# Otherwise, it's a standard Binary-Safe Bulk String
|
|
465
|
-
return KESPEncoder._bulk(data)
|
|
587
|
+
return KESPEncoder._bulk(data, budget)
|
|
466
588
|
|
|
467
589
|
# 5. Arrays (Lists/Sets)
|
|
468
590
|
if isinstance(data, (list, set, tuple)):
|
|
469
591
|
if len(data) > CommandParser.MAX_ARRAY_LENGTH:
|
|
470
592
|
raise ReplyEncodeError("array too large")
|
|
593
|
+
if len(data) > budget[0]:
|
|
594
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
471
595
|
header = f"A{len(data)}\n".encode("utf-8")
|
|
472
596
|
# Recursively encode every element inside the array
|
|
473
597
|
elements = b"".join(
|
|
474
|
-
KESPEncoder._encode(item, depth + 1, False) for item in data
|
|
598
|
+
KESPEncoder._encode(item, depth + 1, False, budget) for item in data
|
|
475
599
|
)
|
|
476
600
|
return header + elements
|
|
477
601
|
|
|
@@ -480,11 +604,17 @@ class KESPEncoder:
|
|
|
480
604
|
# A dictionary of 3 items becomes a KESP array of 6 items [k1, v1, k2, v2...]
|
|
481
605
|
if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
|
|
482
606
|
raise ReplyEncodeError("array too large")
|
|
607
|
+
if len(data) * 2 > budget[0]:
|
|
608
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
483
609
|
header = f"A{len(data) * 2}\n".encode("utf-8")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
610
|
+
parts = []
|
|
611
|
+
for k, v in data.items():
|
|
612
|
+
if budget[0] <= 0:
|
|
613
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
614
|
+
budget[0] -= 1 # the key
|
|
615
|
+
parts.append(KESPEncoder._bulk(str(k), budget))
|
|
616
|
+
parts.append(KESPEncoder._encode(v, depth + 1, False, budget))
|
|
617
|
+
elements = b"".join(parts)
|
|
488
618
|
return header + elements
|
|
489
619
|
|
|
490
620
|
# Fallback
|