diff --git a/redis-array-playground/README.md b/redis-array-playground/README.md new file mode 100644 index 0000000..76c5ca2 --- /dev/null +++ b/redis-array-playground/README.md @@ -0,0 +1,81 @@ +# redis-array-playground + +Build artefacts and rebuild scripts for `../redis-array.html`, an in-browser +playground for the new Redis Array type proposed in +[redis/redis#15162](https://github.com/redis/redis/pull/15162) (see also +antirez's writeup at [antirez.com/news/164](https://antirez.com/news/164)). + +## What runs where + +The playground compiles **the unmodified `t_array.c`, `sparsearray.c`, +`util.c`, `sds.c`, `fast_float_strtod.c`, `fpconv_dtoa.c` and `sha256.c`** +from the [`antirez:array`](https://github.com/antirez/redis/tree/array) +branch, plus the bundled **TRE** regex library from `deps/tre/`, into a +single WebAssembly module. + +JavaScript on the page only: + +1. Builds the `argv` blob from the form inputs. +2. Calls `wasm_dispatch(cmd_index, argv_blob)`. +3. Decodes the typed reply buffer the C code writes into shared memory. +4. Renders that reply. + +There is **no JS reimplementation of any AR\* command** — every iteration, +predicate, regex match and aggregation runs from the actual Redis sources +on the array branch: + +- `arScanIter` from `t_array.c` drives `ARSCAN`, `ARGREP` and `AROP`. +- `tre_regncompb` / `tre_regnexecb` from the vendored TRE library handle + `ARGREP RE`. +- `stringmatchlen` from `util.c` handles `ARGREP GLOB`. +- `arEncode` / `arDecode` from `sparsearray.c` handle the tagged-pointer + value packing. + +Replies use the same `addReply*` family as a real Redis server; the only +difference is that the stub `addReply*` writes records into a tagged +binary buffer instead of a TCP connection. + +## Files + +| File | Origin | Notes | +|---|---|---| +| `redis-array.js` | generated by `build_wasm.sh` | SINGLE\_FILE Emscripten module — embeds the wasm binary as base64. ~145 KB. | +| `ar-commands.json` | generated by `build_commands.py` | All `src/commands/ar*.json` definitions merged into one file. Drives the dynamic forms in the HTML. | +| `src-stub/server.h` | hand-written | Minimal subset of Redis' `server.h`: declares only what `t_array.c` and `util.c` actually reference. | +| `src-stub/redis_stubs.c` | hand-written | Tiny Redis runtime: `zmalloc`, an `sds`-keyed dict for the keyspace, and `addReply*` writing into a tagged binary buffer. | +| `src-stub/wasm_entry.c` | hand-written | JS-callable entry points: `wasm_init`, `wasm_dispatch`, `wasm_reply_buf_*`, `wasm_list_keys`, `wasm_key_stats`, `wasm_drop_key`, `wasm_flush_all`. | +| `build_wasm.sh` | hand-written | Reproducible Emscripten build — copies the Redis sources into a temp dir, then links them with the stub. | +| `build_commands.py` | hand-written | Combines `src/commands/ar*.json` into `ar-commands.json`. | + +The reply wire format the C side emits is documented at the top of +`redis_stubs.c`; the JS-side decoder lives in `WasmEngine._decode` inside +`../redis-array.html`. + +## What's *not* in the build + +The WASM module deliberately stops short of being a full `redis-server`. +Networking, threading, eval/scripting, persistence (RDB/AOF), replication, +cluster, pubsub, modules, expiration and ACL are all absent. The array +type doesn't depend on them, so they're stubbed where t\_array.c happens +to call them (`signalModifiedKey`, `notifyKeyspaceEvent`, +`updateKeysizesHist`, `updateSlotAllocSize`, `keyModified`, +`kvobjAllocSize` are all no-ops). This keeps the bundle around 65 KB +gzipped instead of multiple MB. + +## Rebuilding + +```bash +# Get the array branch +git clone https://github.com/redis/redis /tmp/redis +git -C /tmp/redis remote add antirez https://github.com/antirez/redis +git -C /tmp/redis fetch antirez array +git -C /tmp/redis checkout -b array antirez/array + +# Combined command catalog +python3 build_commands.py /tmp/redis ar-commands.json + +# WASM bundle (requires emscripten activated on PATH) +./build_wasm.sh /tmp/redis +``` + +The playground HTML at `../redis-array.html` fetches both files at runtime. diff --git a/redis-array-playground/ar-commands.json b/redis-array-playground/ar-commands.json new file mode 100644 index 0000000..afcf23c --- /dev/null +++ b/redis-array-playground/ar-commands.json @@ -0,0 +1,1317 @@ +{ + "_meta": { + "generated_at": "2026-05-04T15:00:59Z", + "source": "github.com/antirez/redis@array", + "files": [ + { + "command": "ARCOUNT", + "file": "arcount.json" + }, + { + "command": "ARDEL", + "file": "ardel.json" + }, + { + "command": "ARDELRANGE", + "file": "ardelrange.json" + }, + { + "command": "ARGET", + "file": "arget.json" + }, + { + "command": "ARGETRANGE", + "file": "argetrange.json" + }, + { + "command": "ARGREP", + "file": "argrep.json" + }, + { + "command": "ARINFO", + "file": "arinfo.json" + }, + { + "command": "ARINSERT", + "file": "arinsert.json" + }, + { + "command": "ARLASTITEMS", + "file": "arlastitems.json" + }, + { + "command": "ARLEN", + "file": "arlen.json" + }, + { + "command": "ARMGET", + "file": "armget.json" + }, + { + "command": "ARMSET", + "file": "armset.json" + }, + { + "command": "ARNEXT", + "file": "arnext.json" + }, + { + "command": "AROP", + "file": "arop.json" + }, + { + "command": "ARRING", + "file": "arring.json" + }, + { + "command": "ARSCAN", + "file": "arscan.json" + }, + { + "command": "ARSEEK", + "file": "arseek.json" + }, + { + "command": "ARSET", + "file": "arset.json" + } + ], + "count": 18 + }, + "commands": { + "ARCOUNT": { + "summary": "Returns the number of non-empty elements in an array.", + "complexity": "O(1)", + "group": "array", + "since": "8.8.0", + "arity": 2, + "function": "arcountCommand", + "command_flags": [ + "READONLY", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "The number of non-empty elements, or 0 if key does not exist.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + } + ] + }, + "ARDEL": { + "summary": "Deletes elements at the specified indices in an array.", + "complexity": "O(N) where N is the number of indices to delete", + "group": "array", + "since": "8.8.0", + "arity": -3, + "function": "ardelCommand", + "command_flags": [ + "WRITE", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "DELETE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Number of elements deleted.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer", + "multiple": true + } + ] + }, + "ARDELRANGE": { + "summary": "Deletes elements in one or more ranges.", + "complexity": "Proportional to the number of existing elements / slices touched, not to the numeric span of the requested ranges", + "group": "array", + "since": "8.8.0", + "arity": -4, + "function": "ardelrangeCommand", + "command_flags": [ + "WRITE" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "DELETE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Number of elements deleted.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "range", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer" + } + ] + } + ] + }, + "ARGET": { + "summary": "Gets the value at an index in an array.", + "complexity": "O(1)", + "group": "array", + "since": "8.8.0", + "arity": 3, + "function": "argetCommand", + "command_flags": [ + "READONLY", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "oneOf": [ + { + "description": "The value at the given index.", + "type": "string" + }, + { + "description": "Null reply if key or index does not exist.", + "type": "null" + } + ] + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer" + } + ] + }, + "ARGETRANGE": { + "summary": "Gets values in a range of indices.", + "complexity": "O(N) where N is the range length", + "group": "array", + "since": "8.8.0", + "arity": 4, + "function": "argetrangeCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer" + } + ] + }, + "ARGREP": { + "summary": "Searches array elements in a range using textual predicates.", + "complexity": "O(P * C) where P is the number of visited positions in touched slices and C is the cost of evaluating the predicates on one existing element.", + "group": "array", + "since": "8.8.0", + "arity": -6, + "function": "argrepCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Array of matching indexes, or flat index-value pairs when WITHVALUES is used.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "integer", + "description": "Index of a matching element" + }, + { + "type": "string", + "description": "Matching value when WITHVALUES is used" + } + ] + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "string" + }, + { + "name": "end", + "type": "string" + }, + { + "name": "predicate", + "type": "oneof", + "multiple": true, + "arguments": [ + { + "name": "exact", + "type": "block", + "arguments": [ + { + "name": "exact", + "type": "pure-token", + "token": "EXACT" + }, + { + "name": "string", + "type": "string" + } + ] + }, + { + "name": "match", + "type": "block", + "arguments": [ + { + "name": "match", + "type": "pure-token", + "token": "MATCH" + }, + { + "name": "string", + "type": "string" + } + ] + }, + { + "name": "glob", + "type": "block", + "arguments": [ + { + "name": "glob", + "type": "pure-token", + "token": "GLOB" + }, + { + "name": "pattern", + "type": "string" + } + ] + }, + { + "name": "re", + "type": "block", + "arguments": [ + { + "name": "re", + "type": "pure-token", + "token": "RE" + }, + { + "name": "pattern", + "type": "string" + } + ] + } + ] + }, + { + "name": "options", + "type": "oneof", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "and", + "type": "pure-token", + "token": "AND" + }, + { + "name": "or", + "type": "pure-token", + "token": "OR" + }, + { + "name": "limit", + "type": "integer", + "token": "LIMIT" + }, + { + "name": "withvalues", + "type": "pure-token", + "token": "WITHVALUES" + }, + { + "name": "nocase", + "type": "pure-token", + "token": "NOCASE" + } + ] + } + ] + }, + "ARINFO": { + "summary": "Returns metadata about an array.", + "complexity": "O(1), or O(N) with FULL option where N is the number of slices.", + "group": "array", + "since": "8.8.0", + "arity": -2, + "function": "arinfoCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "count": { + "type": "integer", + "description": "Total number of non-empty elements." + }, + "len": { + "type": "integer", + "description": "Logical length (highest index + 1)." + }, + "next-insert-index": { + "type": "integer", + "description": "Index the next ARINSERT would use, or 0 if unset/exhausted." + }, + "slices": { + "type": "integer", + "description": "Number of allocated slices." + }, + "directory-size": { + "type": "integer", + "description": "Directory allocation capacity (flat dir_alloc or superdir sdir_cap)." + }, + "super-dir-entries": { + "type": "integer", + "description": "Number of super-directory entries (0 if not in superdir mode)." + }, + "slice-size": { + "type": "integer", + "description": "Configured slice size." + }, + "dense-slices": { + "type": "integer", + "description": "Number of dense slices (FULL only)." + }, + "sparse-slices": { + "type": "integer", + "description": "Number of sparse slices (FULL only)." + }, + "avg-dense-size": { + "type": "number", + "description": "Average allocation size of dense slices (FULL only)." + }, + "avg-dense-fill": { + "type": "number", + "description": "Average fill rate of dense slices (FULL only)." + }, + "avg-sparse-size": { + "type": "number", + "description": "Average capacity of sparse slices (FULL only)." + } + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "full", + "type": "pure-token", + "token": "FULL", + "optional": true + } + ] + }, + "ARINSERT": { + "summary": "Inserts one or more values at consecutive indices.", + "complexity": "O(N) where N is the number of values", + "group": "array", + "since": "8.8.0", + "arity": -3, + "function": "arinsertCommand", + "command_flags": [ + "WRITE", + "DENYOOM", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "The last index where a value was inserted.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "multiple": true + } + ] + }, + "ARLASTITEMS": { + "summary": "Returns the most recently inserted elements.", + "complexity": "O(N) where N is the count", + "group": "array", + "since": "8.8.0", + "arity": -3, + "function": "arlastitemsCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "rev", + "type": "pure-token", + "token": "REV", + "optional": true + } + ] + }, + "ARLEN": { + "summary": "Returns the length of an array (max index + 1).", + "complexity": "O(1)", + "group": "array", + "since": "8.8.0", + "arity": 2, + "function": "arlenCommand", + "command_flags": [ + "READONLY", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "The length of the array (max index + 1), or 0 if key does not exist.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + } + ] + }, + "ARMGET": { + "summary": "Gets values at multiple indices in an array.", + "complexity": "O(N) where N is the number of indices", + "group": "array", + "since": "8.8.0", + "arity": -3, + "function": "armgetCommand", + "command_flags": [ + "READONLY", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer", + "multiple": true + } + ] + }, + "ARMSET": { + "summary": "Sets multiple index-value pairs in an array.", + "complexity": "O(N) where N is the number of pairs", + "group": "array", + "since": "8.8.0", + "arity": -4, + "function": "armsetCommand", + "command_flags": [ + "WRITE", + "DENYOOM", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Number of new slots that were set (previously empty).", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "index", + "type": "integer" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + "ARNEXT": { + "summary": "Returns the next index ARINSERT would use.", + "complexity": "O(1)", + "group": "array", + "since": "8.8.0", + "arity": 2, + "function": "arnextCommand", + "command_flags": [ + "READONLY", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "oneOf": [ + { + "description": "The next index ARINSERT would use. Returns 0 for missing keys or when no insert happened yet.", + "type": "integer" + }, + { + "description": "Null when the insertion cursor is exhausted (next insert would overflow).", + "type": "null" + } + ] + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + } + ] + }, + "AROP": { + "summary": "Performs aggregate operations on array elements in a range.", + "complexity": "O(P) where P is visited positions in touched slices (dense scanned slots + sparse entries), with worst-case O(|end-start|+1) and typical case close to O(N), where N is the number of existing elements in range.", + "group": "array", + "since": "8.8.0", + "arity": -5, + "function": "aropCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "oneOf": [ + { + "description": "Result of the operation.", + "type": "string" + }, + { + "description": "Integer result for MATCH, USED, AND, OR, XOR.", + "type": "integer" + }, + { + "description": "Null if no elements match the operation.", + "type": "null" + } + ] + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer" + }, + { + "name": "operation", + "type": "oneof", + "arguments": [ + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "and", + "type": "pure-token", + "token": "AND" + }, + { + "name": "or", + "type": "pure-token", + "token": "OR" + }, + { + "name": "xor", + "type": "pure-token", + "token": "XOR" + }, + { + "name": "match", + "type": "block", + "arguments": [ + { + "name": "match", + "type": "pure-token", + "token": "MATCH" + }, + { + "name": "value", + "type": "string" + } + ] + }, + { + "name": "used", + "type": "pure-token", + "token": "USED" + } + ] + } + ] + }, + "ARRING": { + "summary": "Inserts values into a ring buffer of specified size, wrapping and truncating as needed.", + "complexity": "O(M) normally, O(N+M) on ring resize, where N is the maximum of the old and new ring size and M is the number of inserted values", + "group": "array", + "since": "8.8.0", + "arity": -4, + "function": "arringCommand", + "command_flags": [ + "WRITE", + "DENYOOM" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "The last index where a value was inserted.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "size", + "type": "integer" + }, + { + "name": "value", + "type": "string", + "multiple": true + } + ] + }, + "ARSCAN": { + "summary": "Iterates existing elements in a range, returning index-value pairs.", + "complexity": "O(P) where P is visited positions in touched slices (dense scanned slots + sparse entries), with worst-case O(|end-start|+1) and typical case close to O(N), where N is the number of existing elements in range.", + "group": "array", + "since": "8.8.0", + "arity": -4, + "function": "arscanCommand", + "command_flags": [ + "READONLY" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RO", + "ACCESS" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Flat array of index-value pairs: [idx1, val1, idx2, val2, ...]", + "type": "array", + "items": { + "oneOf": [ + { + "type": "integer", + "description": "Index of existing element" + }, + { + "type": "string", + "description": "Value at that index" + } + ] + } + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer" + }, + { + "name": "limit", + "token": "LIMIT", + "type": "integer", + "optional": true + } + ] + }, + "ARSEEK": { + "summary": "Sets the ARINSERT / ARRING cursor to a specific index.", + "complexity": "O(1)", + "group": "array", + "since": "8.8.0", + "arity": 3, + "function": "arseekCommand", + "command_flags": [ + "WRITE", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "1 if the cursor was set, 0 if the key does not exist.", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer" + } + ] + }, + "ARSET": { + "summary": "Sets one or more contiguous values starting at an index in an array.", + "complexity": "O(N) where N is the number of values", + "group": "array", + "since": "8.8.0", + "arity": -4, + "function": "arsetCommand", + "command_flags": [ + "WRITE", + "DENYOOM", + "FAST" + ], + "acl_categories": [ + "ARRAY" + ], + "key_specs": [ + { + "flags": [ + "RW", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "description": "Number of new slots that were set (previously empty).", + "type": "integer" + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer" + }, + { + "name": "value", + "type": "string", + "multiple": true + } + ] + } + } +} diff --git a/redis-array-playground/build_commands.py b/redis-array-playground/build_commands.py new file mode 100755 index 0000000..e7df854 --- /dev/null +++ b/redis-array-playground/build_commands.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Combine src/commands/ar*.json from a Redis checkout into one file. + +Usage: build_commands.py [REDIS_SRC_DIR] [OUT_PATH] + +Defaults: + REDIS_SRC_DIR = /tmp/redis + OUT_PATH = redis-array-playground/ar-commands.json + +The result is a sorted dict keyed by uppercase command name (ARSET, ARGET, ...) +with the original definition under each key, plus a top-level "_meta" block +recording where each file came from. The playground HTML reads this JSON +directly to drive its dynamic forms and inline documentation. +""" + +import json +import pathlib +import sys +from datetime import datetime, timezone + +DEFAULT_REDIS_DIR = pathlib.Path("/tmp/redis") +DEFAULT_OUT = pathlib.Path(__file__).parent / "ar-commands.json" + + +def main(argv: list[str]) -> int: + redis_dir = pathlib.Path(argv[1]) if len(argv) > 1 else DEFAULT_REDIS_DIR + out_path = pathlib.Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT + + cmd_dir = redis_dir / "src" / "commands" + if not cmd_dir.is_dir(): + print(f"error: {cmd_dir} does not exist", file=sys.stderr) + return 1 + + # ar*.json but not e.g. arena.json from a different repo state + files = sorted(p for p in cmd_dir.glob("ar*.json")) + if not files: + print(f"error: no ar*.json under {cmd_dir}", file=sys.stderr) + return 1 + + combined: dict = {} + sources: list[dict] = [] + for path in files: + with path.open() as f: + payload = json.load(f) + if not isinstance(payload, dict) or len(payload) != 1: + print(f"warning: skipping unexpected shape in {path}", file=sys.stderr) + continue + name, body = next(iter(payload.items())) + combined[name.upper()] = body + sources.append({"command": name.upper(), "file": path.name}) + + out = { + "_meta": { + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": "github.com/antirez/redis@array", + "files": sources, + "count": len(combined), + }, + "commands": dict(sorted(combined.items())), + } + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w") as f: + json.dump(out, f, indent=2) + f.write("\n") + print(f"Wrote {len(combined)} commands to {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/redis-array-playground/build_wasm.sh b/redis-array-playground/build_wasm.sh new file mode 100755 index 0000000..5647c58 --- /dev/null +++ b/redis-array-playground/build_wasm.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Rebuild redis-array.js (the SINGLE_FILE WASM bundle). +# +# This compiles the *unmodified* sparsearray.c, t_array.c, util.c, sds.c, +# fast_float_strtod.c and fpconv_dtoa.c from a checkout of the +# antirez:array branch, plus the bundled TRE regex library +# (deps/tre/lib/*.c), against a small Redis-runtime shim that lives in +# src-stub/. Net effect: every AR* command runs the actual Redis C — no +# JavaScript reimplementation of iteration, predicates, aggregation, etc. +# +# Usage: ./build_wasm.sh [REDIS_DIR] + +set -euo pipefail + +REDIS_DIR=${1:-/tmp/redis} +HERE=$(cd "$(dirname "$0")" && pwd) + +if ! command -v emcc >/dev/null; then + echo "error: emcc not on PATH; activate emsdk first" >&2 + exit 1 +fi +if [ ! -f "$REDIS_DIR/src/sparsearray.c" ]; then + echo "error: $REDIS_DIR/src/sparsearray.c not found." >&2 + echo "Clone redis/redis and switch to the antirez:array branch first:" >&2 + echo " git clone https://github.com/redis/redis $REDIS_DIR" >&2 + echo " git -C $REDIS_DIR remote add antirez https://github.com/antirez/redis" >&2 + echo " git -C $REDIS_DIR fetch antirez array" >&2 + echo " git -C $REDIS_DIR checkout -b array antirez/array" >&2 + exit 1 +fi + +BUILD=$(mktemp -d) +trap 'rm -rf "$BUILD"' EXIT + +# Redis sources (unmodified) +mkdir -p "$BUILD/redis-src" +cp "$REDIS_DIR/src/sparsearray.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sparsearray.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/t_array.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/util.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/util.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sds.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sds.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sdsalloc.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/fast_float_strtod.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/fast_float_strtod.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/zmalloc.h" "$BUILD/redis-src/" 2>/dev/null || true +cp "$REDIS_DIR/src/redisassert.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/fmacros.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sha256.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/sha256.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/config.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/src/solarisfixes.h" "$BUILD/redis-src/" 2>/dev/null || true +# fpconv (vendored) +cp "$REDIS_DIR/deps/fpconv/fpconv_dtoa.c" "$BUILD/redis-src/" +cp "$REDIS_DIR/deps/fpconv/fpconv_dtoa.h" "$BUILD/redis-src/" +cp "$REDIS_DIR/deps/fpconv/fpconv_powers.h" "$BUILD/redis-src/" +# TRE (vendored). t_array.c uses a relative `#include +# "../deps/tre/local_includes/tre.h"`, so we recreate that exact layout +# instead of flattening everything into one directory. +mkdir -p "$BUILD/deps/tre/local_includes" "$BUILD/deps/tre/lib" +cp "$REDIS_DIR/deps/tre/local_includes"/* "$BUILD/deps/tre/local_includes/" +cp "$REDIS_DIR/deps/tre/lib"/* "$BUILD/deps/tre/lib/" +ln -s "$BUILD/redis-src" "$BUILD/src" + +# Our shim +cp "$HERE/src-stub/server.h" "$BUILD/redis-src/" +cp "$HERE/src-stub/redis_stubs.c" "$BUILD/redis-src/" +cp "$HERE/src-stub/wasm_entry.c" "$BUILD/redis-src/" + +REDIS_C_SOURCES=( + "$BUILD/redis-src/sparsearray.c" + "$BUILD/redis-src/t_array.c" + "$BUILD/redis-src/util.c" + "$BUILD/redis-src/sds.c" + "$BUILD/redis-src/sha256.c" + "$BUILD/redis-src/fast_float_strtod.c" + "$BUILD/redis-src/fpconv_dtoa.c" + "$BUILD/redis-src/redis_stubs.c" + "$BUILD/redis-src/wasm_entry.c" +) + +TRE_C_SOURCES=( + "$BUILD/deps/tre/lib/regcomp.c" + "$BUILD/deps/tre/lib/regerror.c" + "$BUILD/deps/tre/lib/regexec.c" + "$BUILD/deps/tre/lib/tre-ast.c" + "$BUILD/deps/tre/lib/tre-compile.c" + "$BUILD/deps/tre/lib/tre-filter.c" + "$BUILD/deps/tre/lib/tre-match-backtrack.c" + "$BUILD/deps/tre/lib/tre-match-parallel.c" + "$BUILD/deps/tre/lib/tre-mem.c" + "$BUILD/deps/tre/lib/tre-parse.c" + "$BUILD/deps/tre/lib/tre-stack.c" + "$BUILD/deps/tre/lib/xmalloc.c" +) + +EXPORTS='[ +"_wasm_init","_wasm_dispatch","_wasm_reply_buf_ptr","_wasm_reply_buf_len", +"_wasm_drop_key","_wasm_flush_all","_wasm_list_keys","_wasm_key_stats", +"_malloc","_free"]' +RUNTIME='["ccall","cwrap","HEAPU8","HEAP32","stringToUTF8","UTF8ToString","getValue","setValue"]' + +emcc -O2 -DNDEBUG \ + -I"$BUILD/redis-src" \ + -I"$BUILD/deps/tre/local_includes" \ + -I"$BUILD/deps/tre/lib" \ + -DTRE_REGEX_T_FIELD=value -DHAVE_CONFIG_H=0 \ + -Wno-incompatible-pointer-types-discards-qualifiers \ + -Wno-implicit-function-declaration \ + "${REDIS_C_SOURCES[@]}" "${TRE_C_SOURCES[@]}" \ + -o "$HERE/redis-array.js" \ + -s MODULARIZE=1 \ + -s EXPORT_NAME=createRedisArrayModule \ + -s ENVIRONMENT=web,worker,node \ + -s SINGLE_FILE=1 \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s INITIAL_MEMORY=8MB \ + -s STACK_SIZE=2MB \ + -s "EXPORTED_RUNTIME_METHODS=$RUNTIME" \ + -s "EXPORTED_FUNCTIONS=$EXPORTS" + +echo "Wrote $HERE/redis-array.js ($(wc -c < "$HERE/redis-array.js") bytes)" diff --git a/redis-array-playground/redis-array.js b/redis-array-playground/redis-array.js new file mode 100644 index 0000000..b81f2fe Binary files /dev/null and b/redis-array-playground/redis-array.js differ diff --git a/redis-array-playground/src-stub/redis_stubs.c b/redis-array-playground/src-stub/redis_stubs.c new file mode 100644 index 0000000..b1f4d18 --- /dev/null +++ b/redis-array-playground/src-stub/redis_stubs.c @@ -0,0 +1,507 @@ +/* redis_stubs.c — minimal Redis runtime for the WASM playground. + * + * Provides: + * - zmalloc with a length prefix (so sparsearray's alloc tracking works). + * - sds string lookups + a tiny in-memory keyspace. + * - a tagged binary reply buffer that t_array.c writes into via addReply*. + * + * The buffer wire format (each record): + * + * byte type: + * 0 nil + * 1 simple string (u32 len, bytes) + * 2 error (u32 len, bytes) + * 3 signed integer (i64) + * 4 unsigned int (u64) + * 5 bulk string (u32 len, bytes) + * 6 array (u32 count, recursive elements) + * 7 double (8 bytes IEEE 754) + * 8 map (u32 count, recursive [k, v, ...] elements) + * + * Deferred arrays: addReplyDeferredLen() writes type 6 + a 4-byte placeholder + * and returns the offset; setDeferredArrayLen() backpatches the placeholder. + */ + +#include "server.h" +#include "sds.h" +#include "sdsalloc.h" +#include "fast_float_strtod.h" +#include "fpconv_dtoa.h" + +#include +#include +#include +#include +#include +#include +#include + +/* ============================================================================ + * zmalloc: malloc with a length prefix so sparsearray's alloc-size tracking + * actually has something to read back. + * ========================================================================= */ + +void *zmalloc(size_t size) { + size_t *p = (size_t *)malloc(size + sizeof(size_t)); + if (!p) abort(); + p[0] = size; + return (void *)(p + 1); +} +void *zcalloc(size_t size) { + void *p = zmalloc(size); + memset(p, 0, size); + return p; +} +void *zrealloc(void *ptr, size_t size) { + if (!ptr) return zmalloc(size); + size_t *raw = ((size_t *)ptr) - 1; + size_t *np = (size_t *)realloc(raw, size + sizeof(size_t)); + if (!np) abort(); + np[0] = size; + return (void *)(np + 1); +} +void zfree(void *ptr) { + if (!ptr) return; + free(((size_t *)ptr) - 1); +} +size_t zmalloc_size(void *ptr) { + if (!ptr) return 0; + return ((size_t *)ptr)[-1] + sizeof(size_t); +} +size_t zmalloc_usable_size(void *ptr) { return zmalloc_size(ptr); } +/* sds.c expects an *_usable family that also returns the usable size. */ +void *zmalloc_usable(size_t size, size_t *usable) { + void *p = zmalloc(size); + if (usable) *usable = zmalloc_size(p); + return p; +} +void *ztrymalloc_usable(size_t size, size_t *usable) { + return zmalloc_usable(size, usable); +} +void *zrealloc_usable(void *ptr, size_t size, size_t *usable, size_t *old_usable) { + if (old_usable) *old_usable = ptr ? zmalloc_size(ptr) : 0; + void *p = zrealloc(ptr, size); + if (usable) *usable = zmalloc_size(p); + return p; +} +/* redisassert.h uses _serverAssert via the SDS path. */ +void _serverAssert(const char *estr, const char *file, int line) { + fprintf(stderr, "ASSERT: %s @ %s:%d\n", estr, file, line); + abort(); +} + +/* ============================================================================ + * Keyspace: tiny open-addressed table keyed by sds. The playground only ever + * keeps a handful of array keys at once, so this is intentionally simple. + * ========================================================================= */ + +typedef struct kvEntry { sds key; robj *val; } kvEntry; + +#define KV_INITIAL_CAP 16 + +typedef struct kvDict { + kvEntry *slots; + size_t cap; + size_t used; +} kvDict; + +static kvDict *kvNew(void) { + kvDict *d = zcalloc(sizeof(*d)); + d->cap = KV_INITIAL_CAP; + d->slots = zcalloc(sizeof(kvEntry) * d->cap); + return d; +} + +static size_t kvHash(const char *s, size_t n) { + /* FNV-1a — speed and quality both irrelevant at playground scale. + * Compute in 64 bits then narrow to size_t (32-bit on wasm32). */ + uint64_t h = 1469598103934665603ULL; + for (size_t i = 0; i < n; i++) { h ^= (unsigned char)s[i]; h *= 1099511628211ULL; } + return (size_t)h; +} + +static int kvSlot(kvDict *d, const char *k, size_t n, size_t *out) { + size_t mask = d->cap - 1; + size_t h = kvHash(k, n) & mask; + for (size_t i = 0; i < d->cap; i++) { + size_t pos = (h + i) & mask; + if (d->slots[pos].key == NULL) { *out = pos; return 0; } + if (sdslen(d->slots[pos].key) == n && + memcmp(d->slots[pos].key, k, n) == 0) { *out = pos; return 1; } + } + *out = 0; + return 0; +} + +static void kvGrow(kvDict *d) { + size_t newcap = d->cap * 2; + kvEntry *old = d->slots; + size_t oldcap = d->cap; + d->slots = zcalloc(sizeof(kvEntry) * newcap); + d->cap = newcap; + d->used = 0; + for (size_t i = 0; i < oldcap; i++) { + if (old[i].key) { + size_t pos; + kvSlot(d, old[i].key, sdslen(old[i].key), &pos); + d->slots[pos] = old[i]; + d->used++; + } + } + zfree(old); +} + +static robj *kvGet(kvDict *d, sds key) { + size_t pos; + if (kvSlot(d, key, sdslen(key), &pos)) return d->slots[pos].val; + return NULL; +} + +static void kvPut(kvDict *d, sds key, robj *val) { + if ((d->used + 1) * 2 > d->cap) kvGrow(d); + size_t pos; + if (kvSlot(d, key, sdslen(key), &pos)) { + /* Replace */ + if (d->slots[pos].val) freeArrayObject(d->slots[pos].val); + sdsfree(d->slots[pos].key); + } else { + d->used++; + } + d->slots[pos].key = sdsdup(key); + d->slots[pos].val = val; +} + +static int kvDel(kvDict *d, sds key) { + size_t pos; + if (!kvSlot(d, key, sdslen(key), &pos)) return 0; + sdsfree(d->slots[pos].key); + if (d->slots[pos].val) freeArrayObject(d->slots[pos].val); + d->slots[pos].key = NULL; + d->slots[pos].val = NULL; + /* Backshift to keep linear probing valid. */ + size_t mask = d->cap - 1; + size_t i = (pos + 1) & mask; + while (d->slots[i].key) { + kvEntry e = d->slots[i]; + d->slots[i].key = NULL; + d->slots[i].val = NULL; + size_t want; + kvSlot(d, e.key, sdslen(e.key), &want); + d->slots[want] = e; + i = (i + 1) & mask; + } + d->used--; + return 1; +} + +/* The single playground database. */ +static redisDb playground_db; + +void wasm_db_init(void) { + if (!playground_db.backend) { + playground_db.backend = kvNew(); + playground_db.id = 0; + } +} +redisDb *wasm_db(void) { return &playground_db; } + +/* Iterate keys: used by the JS keyspace panel. Calls cb(key_data, key_len, ctx) + * for every populated entry. */ +typedef void (*wasm_db_iter_cb)(const char *key, size_t klen, void *ctx); +void wasm_db_iter(wasm_db_iter_cb cb, void *ctx) { + kvDict *d = playground_db.backend; + if (!d) return; + for (size_t i = 0; i < d->cap; i++) { + if (d->slots[i].key) + cb(d->slots[i].key, sdslen(d->slots[i].key), ctx); + } +} + +void wasm_db_drop(const char *key, size_t klen) { + kvDict *d = playground_db.backend; + if (!d) return; + sds tmp = sdsnewlen(key, klen); + kvDel(d, tmp); + sdsfree(tmp); +} + +void wasm_db_flush(void) { + kvDict *d = playground_db.backend; + if (!d) return; + for (size_t i = 0; i < d->cap; i++) { + if (d->slots[i].key) { + sdsfree(d->slots[i].key); + if (d->slots[i].val) freeArrayObject(d->slots[i].val); + d->slots[i].key = NULL; + d->slots[i].val = NULL; + } + } + d->used = 0; +} + +/* Per-key stats for the side panel — pulled directly from the live array. */ +int wasm_db_stats(const char *key, size_t klen, + uint64_t *out_len, uint64_t *out_count, int64_t *out_cur, + uint64_t *out_alloc) { + kvDict *d = playground_db.backend; + if (!d) return 0; + sds tmp = sdsnewlen(key, klen); + robj *o = kvGet(d, tmp); + sdsfree(tmp); + if (!o || o->type != OBJ_ARRAY) return 0; + redisArray *ar = o->ptr; + *out_len = arLen(ar); + *out_count = arCount(ar); + *out_cur = (ar->insert_idx == AR_INSERT_IDX_NONE) ? -1 : (int64_t)ar->insert_idx; + *out_alloc = ar->alloc_size; + return 1; +} + +/* ============================================================================ + * Reply buffer: typed, length-prefixed, designed for cheap JS decode. + * ========================================================================= */ + +#define REPLY_NIL 0 +#define REPLY_SIMPLE 1 +#define REPLY_ERROR 2 +#define REPLY_INT 3 +#define REPLY_UINT 4 +#define REPLY_BULK 5 +#define REPLY_ARRAY 6 +#define REPLY_DOUBLE 7 +#define REPLY_MAP 8 + +static void rbReserve(redisReplyBuf *r, size_t need) { + if (r->len + need <= r->cap) return; + size_t cap = r->cap ? r->cap : 256; + while (cap < r->len + need) cap *= 2; + r->buf = realloc(r->buf, cap); + r->cap = cap; +} +static void rbWrite(redisReplyBuf *r, const void *p, size_t n) { + rbReserve(r, n); + memcpy(r->buf + r->len, p, n); + r->len += n; +} +static void rbU8(redisReplyBuf *r, uint8_t v) { rbWrite(r, &v, 1); } +static void rbU32(redisReplyBuf *r, uint32_t v) { rbWrite(r, &v, 4); } +static void rbI64(redisReplyBuf *r, int64_t v) { rbWrite(r, &v, 8); } +static void rbU64(redisReplyBuf *r, uint64_t v) { rbWrite(r, &v, 8); } +static void rbF64(redisReplyBuf *r, double v) { rbWrite(r, &v, 8); } + +void wasm_reply_reset(client *c) { + c->reply.len = 0; +} +uint8_t *wasm_reply_buf(client *c) { return c->reply.buf; } +uint32_t wasm_reply_len(client *c) { return (uint32_t)c->reply.len; } + +void addReplyNull(client *c) { rbU8(&c->reply, REPLY_NIL); } + +void addReplyError(client *c, const char *err) { + rbU8(&c->reply, REPLY_ERROR); + size_t n = strlen(err); + rbU32(&c->reply, (uint32_t)n); + rbWrite(&c->reply, err, n); +} +void addReplyErrorObject(client *c, robj *err) { + sds s = err->ptr; + rbU8(&c->reply, REPLY_ERROR); + rbU32(&c->reply, (uint32_t)sdslen(s)); + rbWrite(&c->reply, s, sdslen(s)); +} +void addReplyErrorFormat(client *c, const char *fmt, ...) { + char tmp[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(tmp, sizeof(tmp), fmt, ap); + va_end(ap); + if (n < 0) n = 0; + if ((size_t)n >= sizeof(tmp)) n = (int)sizeof(tmp) - 1; + rbU8(&c->reply, REPLY_ERROR); + rbU32(&c->reply, (uint32_t)n); + rbWrite(&c->reply, tmp, n); +} +void addReplyErrorArity(client *c) { + addReplyError(c, "wrong number of arguments"); +} + +void addReplyLongLong(client *c, long long ll) { + rbU8(&c->reply, REPLY_INT); + rbI64(&c->reply, ll); +} +void addReplyUnsignedLongLong(client *c, unsigned long long ull) { + rbU8(&c->reply, REPLY_UINT); + rbU64(&c->reply, ull); +} +void addReplyDouble(client *c, double d) { + rbU8(&c->reply, REPLY_DOUBLE); + rbF64(&c->reply, d); +} + +void addReplyArrayLen(client *c, long length) { + rbU8(&c->reply, REPLY_ARRAY); + rbU32(&c->reply, (uint32_t)length); +} +void addReplyMapLen(client *c, long length) { + rbU8(&c->reply, REPLY_MAP); + rbU32(&c->reply, (uint32_t)length); +} + +void addReplyBulkCBuffer(client *c, const void *p, size_t len) { + rbU8(&c->reply, REPLY_BULK); + rbU32(&c->reply, (uint32_t)len); + rbWrite(&c->reply, p, len); +} +void addReplyBulkCString(client *c, const char *s) { + addReplyBulkCBuffer(c, s, strlen(s)); +} + +/* Deferred reply: write the array tag + a 4-byte placeholder, return the + * offset of the placeholder so setDeferredArrayLen can patch it. */ +void *addReplyDeferredLen(client *c) { + rbU8(&c->reply, REPLY_ARRAY); + rbReserve(&c->reply, 4); + size_t off = c->reply.len; + uint32_t z = 0; + rbWrite(&c->reply, &z, 4); + return (void *)(uintptr_t)(off + 1); /* +1 so we can recognise NULL */ +} +void setDeferredArrayLen(client *c, void *node, long length) { + if (!node) return; + size_t off = (size_t)(uintptr_t)node - 1; + uint32_t n = (uint32_t)length; + memcpy(c->reply.buf + off, &n, 4); +} +void setDeferredMapLen(client *c, void *node, long length) { + if (!node) return; + size_t off = (size_t)(uintptr_t)node - 1; + /* The leading tag byte was already a REPLY_ARRAY; switch to map. */ + c->reply.buf[off - 4] = REPLY_MAP; /* not actually used by t_array.c */ + uint32_t n = (uint32_t)length; + memcpy(c->reply.buf + off, &n, 4); +} + +/* Shared replies. */ +static robj shared_czero_obj; +static robj shared_syntaxerr_obj; +sharedObjectsStruct shared; + +/* ============================================================================ + * Keyspace lookups + array object lifecycle. + * ========================================================================= */ + +robj *createObject(int type, void *ptr) { + robj *o = zmalloc(sizeof(*o)); + o->type = type; + o->encoding = OBJ_ENCODING_RAW; + o->ptr = ptr; + return o; +} + +robj *createArrayObject(void) { + robj *o = createObject(OBJ_ARRAY, arNew()); + o->encoding = OBJ_ENCODING_SLICED_ARRAY; + return o; +} + +void freeArrayObject(robj *o) { + if (!o) return; + if (o->type == OBJ_ARRAY && o->ptr) arFree(o->ptr); + zfree(o); +} + +void decrRefCount(robj *o) { + if (!o) return; + if (o == &shared_czero_obj || o == &shared_syntaxerr_obj) return; + if (o->type == OBJ_ARRAY) freeArrayObject(o); + else { if (o->ptr) sdsfree(o->ptr); zfree(o); } +} + +int checkType(client *c, robj *o, int type) { + if (o == NULL || o->type == type) return 0; + addReplyError(c, "WRONGTYPE Operation against a key holding the wrong kind of value"); + return 1; +} + +robj *lookupKeyRead(redisDb *db, robj *key) { + return kvGet(db->backend, key->ptr); +} +robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply) { + robj *o = kvGet(c->db->backend, key->ptr); + if (!o) { + if (reply) addReplyErrorObject(c, reply); + else addReplyNull(c); + } + return o; +} +robj *lookupKeyWrite(redisDb *db, robj *key) { + return kvGet(db->backend, key->ptr); +} + +void dbAdd(redisDb *db, robj *key, robj **valref) { + kvPut(db->backend, key->ptr, *valref); + /* Read it back so callers using *valref get the stored object. The shim + * stores by value-copy of the pointer, so the same pointer is fine. */ +} + +/* Stubs that t_array.c calls but the playground doesn't care about. */ +void signalModifiedKey(client *c, redisDb *db, robj *key) { UNUSED(c); UNUSED(db); UNUSED(key); } +void notifyKeyspaceEvent(int type, const char *event, robj *key, int dbid) { + UNUSED(type); UNUSED(event); UNUSED(key); UNUSED(dbid); +} +void updateKeysizesHist(redisDb *db, int type, uint64_t before, int64_t after) { + UNUSED(db); UNUSED(type); UNUSED(before); UNUSED(after); +} +size_t kvobjAllocSize(kvobj *kv) { UNUSED(kv); return 0; } +int getKeySlot(sds key) { UNUSED(key); return 0; } +void updateSlotAllocSize(redisDb *db, int slot, robj *o, size_t old_alloc, size_t new_alloc) { + UNUSED(db); UNUSED(slot); UNUSED(o); UNUSED(old_alloc); UNUSED(new_alloc); +} +void keyModified(client *c, redisDb *db, robj *key, robj *o, int set) { + UNUSED(c); UNUSED(db); UNUSED(key); UNUSED(o); UNUSED(set); +} +void dbDeleteSkipKeysizesUpdate(redisDb *db, robj *key) { + sds k = key->ptr; + kvDel(db->backend, k); +} + +/* getLongLongFromObjectOrReply: parse argv[i] (sds) into long long. */ +int getLongLongFromObjectOrReply(client *c, robj *o, long long *target, + const char *msg) { + sds s = o->ptr; + long long v; + if (string2ll(s, sdslen(s), &v)) { + *target = v; + return C_OK; + } + addReplyError(c, msg ? msg : "value is not an integer or out of range"); + return C_ERR; +} + +/* ============================================================================ + * One-time setup. + * ========================================================================= */ + +void wasm_runtime_init(void) { + server.array_slice_size = AR_SLICE_SIZE_DEFAULT; + server.array_sparse_kmax = AR_SPARSE_KMAX_DEFAULT; + server.array_sparse_kmin = AR_SPARSE_KMIN_DEFAULT; + server.dirty = 0; + server.memory_tracking_enabled = 0; + + /* shared.czero is a bulk reply equivalent to ":0\r\n"; t_array.c only ever + * uses it via lookupKeyReadOrReply(c, key, shared.czero), where we just + * need any non-NULL robj. shared.syntaxerr is used via addReplyErrorObject + * so it must contain a string. */ + shared_czero_obj.type = OBJ_STRING; + shared_czero_obj.encoding = OBJ_ENCODING_RAW; + shared_czero_obj.ptr = sdsnew("0"); + shared_syntaxerr_obj.type = OBJ_STRING; + shared_syntaxerr_obj.encoding = OBJ_ENCODING_RAW; + shared_syntaxerr_obj.ptr = sdsnew("syntax error"); + shared.czero = &shared_czero_obj; + shared.syntaxerr = &shared_syntaxerr_obj; +} + +/* server / sharedObjectsStruct definitions. */ +redisServer server; diff --git a/redis-array-playground/src-stub/server.h b/redis-array-playground/src-stub/server.h new file mode 100644 index 0000000..c468886 --- /dev/null +++ b/redis-array-playground/src-stub/server.h @@ -0,0 +1,175 @@ +/* server.h shim for the Redis Array WASM playground. + * + * t_array.c (the real, unmodified file from antirez:array) and util.c are + * compiled against this header instead of the full Redis server.h. We declare + * just the types and APIs they actually reference; everything else stays out. + * The corresponding implementations live in redis_stubs.c. */ + +#ifndef __SERVER_SHIM_H +#define __SERVER_SHIM_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sds.h" +#include "sparsearray.h" +#include "util.h" + +#define UNUSED(x) ((void)(x)) +#define ALWAYS_INLINE __attribute__((always_inline)) inline + +/* Status codes */ +#define C_OK 0 +#define C_ERR -1 + +/* Object types */ +#define OBJ_STRING 0 +#define OBJ_ARRAY 8 + +/* Encodings (only the ones t_array.c references) */ +#define OBJ_ENCODING_RAW 0 +#define OBJ_ENCODING_INT 1 +#define OBJ_ENCODING_EMBSTR 8 +#define OBJ_ENCODING_SLICED_ARRAY 13 + +/* zmalloc family — backed by libc malloc with a length prefix so that + * sparsearray's alloc tracking has something to read. */ +void *zmalloc(size_t size); +void *zcalloc(size_t size); +void *zrealloc(void *ptr, size_t size); +void zfree(void *ptr); +size_t zmalloc_size(void *ptr); +size_t zmalloc_usable_size(void *ptr); + +/* Number formatting / parsing — used both by sparsearray and t_array. */ +int ll2string(char *s, size_t len, long long value); +int string2ll(const char *s, size_t slen, long long *value); +int string2ull(const char *s, unsigned long long *value); +int d2string(char *buf, size_t len, double value); +int string2d(const char *s, size_t slen, double *dp); + +/* stringmatchlen powers GLOB matching in ARGREP. */ +int stringmatchlen(const char *pattern, int patternLen, + const char *string, int stringLen, int nocase); + +/* sds len — provided by sds.c which we link as-is. */ + +/* Minimal robj. Layout differs from the real Redis robj on purpose: we don't + * need refcounts, LRU bits, or a kvobj overlay here, so a flat struct is + * easier to keep in sync with the stubs. The set of fields t_array.c reads is + * (encoding, ptr); we add type to round things out. */ +typedef struct redisObject { + int type; + int encoding; + void *ptr; +} robj, kvobj; + +/* Minimal redisDb wrapper. The actual lookup is done via a small dict the + * stub keeps in redis_stubs.c. */ +typedef struct redisDb { + void *backend; /* opaque pointer to the in-memory key map */ + int id; +} redisDb; + +/* Minimal client. addReply* writes typed reply records into c->reply. + * The buffer format is the wire encoding consumed by the JS playground. */ +typedef struct redisReplyBuf { + uint8_t *buf; + size_t len; + size_t cap; +} redisReplyBuf; + +typedef struct client { + int argc; + robj **argv; + redisDb *db; + redisReplyBuf reply; +} client; + +/* Shared static replies referenced by t_array.c. */ +typedef struct sharedObjectsStruct { + robj *czero; + robj *syntaxerr; +} sharedObjectsStruct; +extern sharedObjectsStruct shared; + +/* Server-wide config / counters t_array.c and sparsearray.c read. */ +typedef struct redisServer { + /* sparsearray.c knobs */ + uint32_t array_slice_size; + uint32_t array_sparse_kmax; + uint32_t array_sparse_kmin; + /* defrag stats — t_array.c never touches these but sparsearray.c does */ + long long stat_active_defrag_scanned; + unsigned long active_defrag_max_scan_fields; + /* t_array.c bumps server.dirty after every write */ + long long dirty; + /* gates kvobjAllocSize() — we always leave this off */ + int memory_tracking_enabled; +} redisServer; +extern redisServer server; + +/* Reply API (consumed only by t_array.c). All entry points eventually push a + * tagged record onto c->reply. */ +void addReplyNull(client *c); +void addReplyError(client *c, const char *err); +void addReplyErrorObject(client *c, robj *err); +void addReplyErrorFormat(client *c, const char *fmt, ...); +void addReplyErrorArity(client *c); +void addReplyLongLong(client *c, long long ll); +void addReplyUnsignedLongLong(client *c, unsigned long long ull); +void addReplyDouble(client *c, double d); +void addReplyArrayLen(client *c, long length); +void addReplyMapLen(client *c, long length); +void addReplyBulkCBuffer(client *c, const void *p, size_t len); +void addReplyBulkCString(client *c, const char *s); +void *addReplyDeferredLen(client *c); +void setDeferredArrayLen(client *c, void *node, long length); +void setDeferredMapLen(client *c, void *node, long length); +void addReplyArrayValue(client *c, void *v); /* Defined in t_array.c. */ + +/* Argument parsing helpers t_array.c uses. */ +int getLongLongFromObjectOrReply(client *c, robj *o, long long *target, + const char *msg); + +/* Object lifecycle (only what's needed for arrays/strings). */ +robj *createObject(int type, void *ptr); +robj *createArrayObject(void); +void freeArrayObject(robj *o); +void decrRefCount(robj *o); +int checkType(client *c, robj *o, int type); + +/* Keyspace lookup. db->backend is a small open-addressed dict in + * redis_stubs.c keyed by sds. */ +robj *lookupKeyRead(redisDb *db, robj *key); +robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply); +robj *lookupKeyWrite(redisDb *db, robj *key); +void dbAdd(redisDb *db, robj *key, robj **valref); + +/* These are all no-ops in the playground but t_array.c calls them. */ +void signalModifiedKey(client *c, redisDb *db, robj *key); +void notifyKeyspaceEvent(int type, const char *event, robj *key, int dbid); +void updateKeysizesHist(redisDb *db, int type, uint64_t before, int64_t after); +size_t kvobjAllocSize(kvobj *kv); +int getKeySlot(sds key); +void updateSlotAllocSize(redisDb *db, int slot, robj *o, size_t old_alloc, size_t new_alloc); +void keyModified(client *c, redisDb *db, robj *key, robj *o, int set); +void dbDeleteSkipKeysizesUpdate(redisDb *db, robj *key); + +/* notifyKeyspaceEvent classification flags (t_array.c references these). */ +#define NOTIFY_GENERIC (1<<0) +#define NOTIFY_ARRAY (1<<14) + +/* serverAssert / serverPanic — same as before. */ +#define serverAssert(_e) ((_e) ? (void)0 : (void)fprintf(stderr, "ASSERT: %s\n", #_e)) +#define serverPanic(...) abort() + +#endif diff --git a/redis-array-playground/src-stub/wasm_entry.c b/redis-array-playground/src-stub/wasm_entry.c new file mode 100644 index 0000000..5a75e70 --- /dev/null +++ b/redis-array-playground/src-stub/wasm_entry.c @@ -0,0 +1,192 @@ +/* wasm_entry.c — JS-callable entry points. + * + * The JS playground builds an argv blob in WASM memory and calls + * wasm_dispatch(); we wrap each argv entry into a Redis robj backed by an sds + * string, look up the matching *Command function from the array branch's + * t_array.c, and let it run. After the command returns, the JS side reads the + * tagged reply buffer with wasm_reply_buf() / wasm_reply_len(). + */ + +#include "server.h" +#include "sds.h" +#include +#include + +void wasm_runtime_init(void); +void wasm_db_init(void); +redisDb *wasm_db(void); +void wasm_reply_reset(client *c); +uint8_t *wasm_reply_buf(client *c); +uint32_t wasm_reply_len(client *c); +void wasm_db_drop(const char *key, size_t klen); +void wasm_db_flush(void); +int wasm_db_stats(const char *key, size_t klen, + uint64_t *out_len, uint64_t *out_count, int64_t *out_cur, + uint64_t *out_alloc); + +/* All AR* commands declared in t_array.c. */ +void argetCommand(client *c); +void armgetCommand(client *c); +void arsetCommand(client *c); +void armsetCommand(client *c); +void ardelCommand(client *c); +void ardelrangeCommand(client *c); +void arlenCommand(client *c); +void arcountCommand(client *c); +void argetrangeCommand(client *c); +void arscanCommand(client *c); +void argrepCommand(client *c); +void aropCommand(client *c); +void arinsertCommand(client *c); +void arringCommand(client *c); +void arnextCommand(client *c); +void arseekCommand(client *c); +void arlastitemsCommand(client *c); +void arinfoCommand(client *c); + +/* arsetCommand is in t_array.c; the array branch uses a single function for + * ARSET so we don't dispatch differently per command. The mapping below is + * by index into the JS-side command table. */ +typedef void (*arCommandFn)(client *c); + +static arCommandFn cmd_table[] = { + argetCommand, /* 0 ARGET */ + armgetCommand, /* 1 ARMGET */ + arsetCommand, /* 2 ARSET */ + armsetCommand, /* 3 ARMSET */ + ardelCommand, /* 4 ARDEL */ + ardelrangeCommand, /* 5 ARDELRANGE */ + arlenCommand, /* 6 ARLEN */ + arcountCommand, /* 7 ARCOUNT */ + argetrangeCommand, /* 8 ARGETRANGE */ + arscanCommand, /* 9 ARSCAN */ + argrepCommand, /* 10 ARGREP */ + aropCommand, /* 11 AROP */ + arinsertCommand, /* 12 ARINSERT */ + arringCommand, /* 13 ARRING */ + arnextCommand, /* 14 ARNEXT */ + arseekCommand, /* 15 ARSEEK */ + arlastitemsCommand, /* 16 ARLASTITEMS */ + arinfoCommand, /* 17 ARINFO */ +}; +static const int cmd_count = sizeof(cmd_table) / sizeof(cmd_table[0]); + +/* The single client we run every command against. We reuse it across calls; + * argv is rebuilt per dispatch. */ +static client G_CLIENT; +static int G_INIT = 0; + +EMSCRIPTEN_KEEPALIVE +void wasm_init(void) { + if (G_INIT) return; + wasm_runtime_init(); + wasm_db_init(); + G_CLIENT.db = wasm_db(); + G_CLIENT.argc = 0; + G_CLIENT.argv = NULL; + G_CLIENT.reply.buf = NULL; + G_CLIENT.reply.len = 0; + G_CLIENT.reply.cap = 0; + G_INIT = 1; +} + +/* Argv blob layout (little-endian): + * u32 argc + * for each arg: u32 len, bytes + */ + +EMSCRIPTEN_KEEPALIVE +int wasm_dispatch(int cmd_index, const uint8_t *argv_blob, uint32_t blob_len) { + if (cmd_index < 0 || cmd_index >= cmd_count) return -1; + if (blob_len < 4) return -1; + uint32_t argc; + memcpy(&argc, argv_blob, 4); + size_t off = 4; + if (argc > 4096) return -1; /* sanity */ + + /* Build argv as robj* pointing at sds. */ + robj **argv = malloc(sizeof(robj *) * argc); + robj *args = malloc(sizeof(robj) * argc); + for (uint32_t i = 0; i < argc; i++) { + if (off + 4 > blob_len) { /* malformed */ free(argv); free(args); return -1; } + uint32_t len; + memcpy(&len, argv_blob + off, 4); + off += 4; + if (off + len > blob_len) { free(argv); free(args); return -1; } + sds s = sdsnewlen(argv_blob + off, len); + off += len; + args[i].type = OBJ_STRING; + args[i].encoding = OBJ_ENCODING_RAW; + args[i].ptr = s; + argv[i] = &args[i]; + } + + G_CLIENT.argc = argc; + G_CLIENT.argv = argv; + wasm_reply_reset(&G_CLIENT); + + cmd_table[cmd_index](&G_CLIENT); + + /* Free argv. */ + for (uint32_t i = 0; i < argc; i++) sdsfree(args[i].ptr); + free(argv); + free(args); + G_CLIENT.argc = 0; + G_CLIENT.argv = NULL; + return 0; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *wasm_reply_buf_ptr(void) { return wasm_reply_buf(&G_CLIENT); } + +EMSCRIPTEN_KEEPALIVE +uint32_t wasm_reply_buf_len(void) { return wasm_reply_len(&G_CLIENT); } + +EMSCRIPTEN_KEEPALIVE +void wasm_drop_key(const uint8_t *key, uint32_t klen) { + wasm_db_drop((const char *)key, klen); +} + +EMSCRIPTEN_KEEPALIVE +void wasm_flush_all(void) { wasm_db_flush(); } + +/* For the JS-side keyspace listing: caller passes a buffer; we write + * (u32 num_keys) followed by each (u32 key_len, key bytes). Returns total + * bytes written, or -1 if the buffer is too small. */ +typedef struct { uint8_t *out; uint32_t cap; uint32_t pos; uint32_t count; int oom; } ListCtx; +extern void wasm_db_iter(void (*cb)(const char *, size_t, void *), void *); + +static void list_keys_cb(const char *k, size_t n, void *vc) { + ListCtx *c = vc; + if (c->oom) return; + if (c->pos + 4 + n > c->cap) { c->oom = 1; return; } + uint32_t ln = (uint32_t)n; + memcpy(c->out + c->pos, &ln, 4); c->pos += 4; + memcpy(c->out + c->pos, k, n); c->pos += n; + c->count++; +} + +EMSCRIPTEN_KEEPALIVE +int wasm_list_keys(uint8_t *out, uint32_t out_size) { + if (out_size < 4) return -1; + ListCtx ctx = { out, out_size, 4, 0, 0 }; + wasm_db_iter(list_keys_cb, &ctx); + if (ctx.oom) return -1; + memcpy(ctx.out, &ctx.count, 4); + return (int)ctx.pos; +} + +/* Per-key stats for the side panel — separate call so JS can fetch quickly + * without re-encoding via dispatch. */ +EMSCRIPTEN_KEEPALIVE +int wasm_key_stats(const uint8_t *key, uint32_t klen, uint8_t *out, uint32_t out_size) { + if (out_size < 32) return -1; + uint64_t len, count, alloc; + int64_t cur; + if (!wasm_db_stats((const char *)key, klen, &len, &count, &cur, &alloc)) return 0; + memcpy(out + 0, &len, 8); + memcpy(out + 8, &count, 8); + memcpy(out + 16, &cur, 8); + memcpy(out + 24, &alloc, 8); + return 1; +} diff --git a/redis-array.html b/redis-array.html new file mode 100644 index 0000000..b1fea5f --- /dev/null +++ b/redis-array.html @@ -0,0 +1,1233 @@ + + + + + +Redis Array Playground + + + +
+

Redis Array Playground loading…

+ + +
+ +
+ + +
+
Initializing WASM…
+
+ + +
+ + + + + + +