Merge branch 'append'
This commit is contained in:
		
							
								
								
									
										165
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										165
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -208,9 +208,15 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "bitflags" | ||||
| version = "2.9.2" | ||||
| version = "1.3.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" | ||||
| checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | ||||
|  | ||||
| [[package]] | ||||
| name = "bitflags" | ||||
| version = "2.9.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" | ||||
|  | ||||
| [[package]] | ||||
| name = "block-buffer" | ||||
| @@ -235,9 +241,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" | ||||
|  | ||||
| [[package]] | ||||
| name = "cfg-if" | ||||
| version = "1.0.1" | ||||
| version = "1.0.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" | ||||
| checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" | ||||
|  | ||||
| [[package]] | ||||
| name = "chacha20" | ||||
| @@ -358,6 +364,30 @@ dependencies = [ | ||||
|  "libc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "crc32fast" | ||||
| version = "1.5.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "crossbeam-epoch" | ||||
| version = "0.9.18" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" | ||||
| dependencies = [ | ||||
|  "crossbeam-utils", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "crossbeam-utils" | ||||
| version = "0.8.21" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | ||||
|  | ||||
| [[package]] | ||||
| name = "crypto-common" | ||||
| version = "0.1.6" | ||||
| @@ -406,7 +436,7 @@ dependencies = [ | ||||
|  "hashbrown", | ||||
|  "lock_api", | ||||
|  "once_cell", | ||||
|  "parking_lot_core", | ||||
|  "parking_lot_core 0.9.11", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -526,13 +556,23 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "form_urlencoded" | ||||
| version = "1.2.1" | ||||
| version = "1.2.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" | ||||
| checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | ||||
| dependencies = [ | ||||
|  "percent-encoding", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "fs2" | ||||
| version = "0.4.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "winapi", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "futures" | ||||
| version = "0.3.31" | ||||
| @@ -622,6 +662,15 @@ dependencies = [ | ||||
|  "slab", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "fxhash" | ||||
| version = "0.2.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" | ||||
| dependencies = [ | ||||
|  "byteorder", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "generic-array" | ||||
| version = "0.14.7" | ||||
| @@ -682,6 +731,7 @@ dependencies = [ | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "sha2", | ||||
|  "sled", | ||||
|  "thiserror", | ||||
|  "tokio", | ||||
| ] | ||||
| @@ -732,7 +782,7 @@ dependencies = [ | ||||
|  "intl-memoizer", | ||||
|  "lazy_static", | ||||
|  "log", | ||||
|  "parking_lot", | ||||
|  "parking_lot 0.12.4", | ||||
|  "rust-embed", | ||||
|  "thiserror", | ||||
|  "unic-langid", | ||||
| @@ -861,9 +911,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "idna" | ||||
| version = "1.0.3" | ||||
| version = "1.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" | ||||
| checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | ||||
| dependencies = [ | ||||
|  "idna_adapter", | ||||
|  "smallvec", | ||||
| @@ -889,6 +939,15 @@ dependencies = [ | ||||
|  "generic-array", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "instant" | ||||
| version = "0.1.13" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "intl-memoizer" | ||||
| version = "0.5.3" | ||||
| @@ -910,11 +969,11 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "io-uring" | ||||
| version = "0.7.9" | ||||
| version = "0.7.10" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" | ||||
| checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" | ||||
| dependencies = [ | ||||
|  "bitflags", | ||||
|  "bitflags 2.9.3", | ||||
|  "cfg-if", | ||||
|  "libc", | ||||
| ] | ||||
| @@ -1040,6 +1099,17 @@ version = "0.3.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | ||||
|  | ||||
| [[package]] | ||||
| name = "parking_lot" | ||||
| version = "0.11.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" | ||||
| dependencies = [ | ||||
|  "instant", | ||||
|  "lock_api", | ||||
|  "parking_lot_core 0.8.6", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "parking_lot" | ||||
| version = "0.12.4" | ||||
| @@ -1047,7 +1117,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" | ||||
| dependencies = [ | ||||
|  "lock_api", | ||||
|  "parking_lot_core", | ||||
|  "parking_lot_core 0.9.11", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "parking_lot_core" | ||||
| version = "0.8.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "instant", | ||||
|  "libc", | ||||
|  "redox_syscall 0.2.16", | ||||
|  "smallvec", | ||||
|  "winapi", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1058,7 +1142,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "libc", | ||||
|  "redox_syscall", | ||||
|  "redox_syscall 0.5.17", | ||||
|  "smallvec", | ||||
|  "windows-targets 0.52.6", | ||||
| ] | ||||
| @@ -1075,9 +1159,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "percent-encoding" | ||||
| version = "2.3.1" | ||||
| version = "2.3.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" | ||||
| checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | ||||
|  | ||||
| [[package]] | ||||
| name = "pin-project" | ||||
| @@ -1176,9 +1260,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "proc-macro2" | ||||
| version = "1.0.97" | ||||
| version = "1.0.101" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" | ||||
| checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" | ||||
| dependencies = [ | ||||
|  "unicode-ident", | ||||
| ] | ||||
| @@ -1252,13 +1336,22 @@ dependencies = [ | ||||
|  "url", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "redox_syscall" | ||||
| version = "0.2.16" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" | ||||
| dependencies = [ | ||||
|  "bitflags 1.3.2", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "redox_syscall" | ||||
| version = "0.5.17" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" | ||||
| dependencies = [ | ||||
|  "bitflags", | ||||
|  "bitflags 2.9.3", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1415,9 +1508,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "serde_json" | ||||
| version = "1.0.142" | ||||
| version = "1.0.143" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" | ||||
| checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" | ||||
| dependencies = [ | ||||
|  "itoa", | ||||
|  "memchr", | ||||
| @@ -1466,6 +1559,22 @@ version = "0.4.11" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" | ||||
|  | ||||
| [[package]] | ||||
| name = "sled" | ||||
| version = "0.34.7" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" | ||||
| dependencies = [ | ||||
|  "crc32fast", | ||||
|  "crossbeam-epoch", | ||||
|  "crossbeam-utils", | ||||
|  "fs2", | ||||
|  "fxhash", | ||||
|  "libc", | ||||
|  "log", | ||||
|  "parking_lot 0.11.2", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "smallvec" | ||||
| version = "1.15.1" | ||||
| @@ -1599,7 +1708,7 @@ dependencies = [ | ||||
|  "io-uring", | ||||
|  "libc", | ||||
|  "mio", | ||||
|  "parking_lot", | ||||
|  "parking_lot 0.12.4", | ||||
|  "pin-project-lite", | ||||
|  "signal-hook-registry", | ||||
|  "slab", | ||||
| @@ -1693,9 +1802,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "url" | ||||
| version = "2.5.4" | ||||
| version = "2.5.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" | ||||
| checksum = "137a3c834eaf7139b73688502f3f1141a0337c5d8e4d9b536f9b8c796e26a7c4" | ||||
| dependencies = [ | ||||
|  "form_urlencoded", | ||||
|  "idna", | ||||
| @@ -1754,11 +1863,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | ||||
|  | ||||
| [[package]] | ||||
| name = "winapi-util" | ||||
| version = "0.1.9" | ||||
| version = "0.1.10" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" | ||||
| checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" | ||||
| dependencies = [ | ||||
|  "windows-sys 0.59.0", | ||||
|  "windows-sys 0.60.2", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|   | ||||
							
								
								
									
										36
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,9 +1,29 @@ | ||||
| [workspace] | ||||
| members = ["herodb"] | ||||
| resolver = "2" | ||||
| [package] | ||||
| name = "herodb" | ||||
| version = "0.0.1" | ||||
| authors = ["ThreeFold Tech NV"] | ||||
| edition = "2024" | ||||
|  | ||||
| # You can define shared profiles for all workspace members here | ||||
| [profile.release] | ||||
| lto = true | ||||
| codegen-units = 1 | ||||
| strip = true | ||||
| [dependencies] | ||||
| anyhow = "1.0.59" | ||||
| bytes = "1.3.0" | ||||
| thiserror = "1.0.32" | ||||
| tokio = { version = "1.23.0", features = ["full"] } | ||||
| clap = { version = "4.5.20", features = ["derive"] } | ||||
| byteorder = "1.4.3" | ||||
| futures = "0.3" | ||||
| sled = "0.34" | ||||
| redb = "2.1.3" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| bincode = "1.3" | ||||
| chacha20poly1305 = "0.10.1" | ||||
| rand = "0.8" | ||||
| sha2 = "0.10" | ||||
| age = "0.10" | ||||
| secrecy = "0.8" | ||||
| ed25519-dalek = "2" | ||||
| base64 = "0.22" | ||||
|  | ||||
| [dev-dependencies] | ||||
| redis = { version = "0.24", features = ["aio", "tokio-comp"] } | ||||
|   | ||||
							
								
								
									
										86
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										86
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,5 +1,6 @@ | ||||
| # HeroDB | ||||
|  | ||||
| <<<<<<< HEAD | ||||
| Redis-compatible database server with encryption and AGE cryptographic operations. | ||||
|  | ||||
| ## Features | ||||
| @@ -13,11 +14,37 @@ Redis-compatible database server with encryption and AGE cryptographic operation | ||||
| - Persistent storage using redb | ||||
|  | ||||
| ## Installation | ||||
| ======= | ||||
| HeroDB is a Redis-compatible database built with Rust, offering a flexible and secure storage solution. It supports two primary storage backends: `redb` (default) and `sled`, both with full encryption capabilities. HeroDB aims to provide a robust and performant key-value store with advanced features like data-at-rest encryption, hash operations, list operations, and cursor-based scanning. | ||||
|  | ||||
| ## Purpose | ||||
|  | ||||
| The main purpose of HeroDB is to offer a lightweight, embeddable, and Redis-compatible database that prioritizes data security through transparent encryption. It's designed for applications that require fast, reliable data storage with the option for strong cryptographic protection, without the overhead of a full-fledged Redis server. | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Redis Compatibility**: Supports a subset of Redis commands over RESP (Redis Serialization Protocol) via TCP. | ||||
| - **Dual Backend Support**: | ||||
|     - `redb` (default): Optimized for concurrent access and high-throughput scenarios. | ||||
|     - `sled`: A lock-free, log-structured database, excellent for specific workloads. | ||||
| - **Data-at-Rest Encryption**: Transparent encryption for both backends using the `age` encryption library. | ||||
| - **Key-Value Operations**: Full support for basic string, hash, and list operations. | ||||
| - **Expiration**: Time-to-live (TTL) functionality for keys. | ||||
| - **Scanning**: Cursor-based iteration for keys and hash fields (`SCAN`, `HSCAN`). | ||||
| - **AGE Cryptography Commands**: HeroDB-specific extensions for cryptographic operations. | ||||
|  | ||||
| ## Quick Start | ||||
|  | ||||
| ### Building HeroDB | ||||
|  | ||||
| To build HeroDB, navigate to the project root and run: | ||||
| >>>>>>> append | ||||
|  | ||||
| ```bash | ||||
| cargo build --release | ||||
| ``` | ||||
|  | ||||
| <<<<<<< HEAD | ||||
| ## Usage | ||||
|  | ||||
| ```bash | ||||
| @@ -80,4 +107,61 @@ redis-cli -p 6379 AGE GENENC | ||||
| - **Storage**: redb embedded database | ||||
| - **Protocol**: Redis RESP protocol over TCP | ||||
| - **Encryption**: ChaCha20-Poly1305 for data, AGE for operations | ||||
| - **Concurrency**: Tokio async runtime | ||||
| - **Concurrency**: Tokio async runtime | ||||
| ======= | ||||
| ### Running HeroDB | ||||
|  | ||||
| You can start HeroDB with different backends and encryption options: | ||||
|  | ||||
| #### Default `redb` Backend | ||||
|  | ||||
| ```bash | ||||
| ./target/release/herodb --dir /tmp/herodb_redb --port 6379 | ||||
| ``` | ||||
|  | ||||
| #### `sled` Backend | ||||
|  | ||||
| ```bash | ||||
| ./target/release/herodb --dir /tmp/herodb_sled --port 6379 --sled | ||||
| ``` | ||||
|  | ||||
| #### `redb` with Encryption | ||||
|  | ||||
| ```bash | ||||
| ./target/release/herodb --dir /tmp/herodb_encrypted --port 6379 --encrypt --key mysecretkey | ||||
| ``` | ||||
|  | ||||
| #### `sled` with Encryption | ||||
|  | ||||
| ```bash | ||||
| ./target/release/herodb --dir /tmp/herodb_sled_encrypted --port 6379 --sled --encrypt --key mysecretkey | ||||
| ``` | ||||
|  | ||||
| ## Usage with Redis Clients | ||||
|  | ||||
| HeroDB can be interacted with using any standard Redis client, such as `redis-cli`, `redis-py` (Python), or `ioredis` (Node.js). | ||||
|  | ||||
| ### Example with `redis-cli` | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6379 SET mykey "Hello from HeroDB!" | ||||
| redis-cli -p 6379 GET mykey | ||||
| # → "Hello from HeroDB!" | ||||
|  | ||||
| redis-cli -p 6379 HSET user:1 name "Alice" age "30" | ||||
| redis-cli -p 6379 HGET user:1 name | ||||
| # → "Alice" | ||||
|  | ||||
| redis-cli -p 6379 SCAN 0 MATCH user:* COUNT 10 | ||||
| # → 1) "0" | ||||
| #    2) 1) "user:1" | ||||
| ``` | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
| For more detailed information on commands, features, and advanced usage, please refer to the documentation: | ||||
|  | ||||
| - [Basics](docs/basics.md) | ||||
| - [Supported Commands](docs/cmds.md) | ||||
| - [AGE Cryptography](docs/age.md) | ||||
| >>>>>>> append | ||||
|   | ||||
							
								
								
									
										125
									
								
								docs/cmds.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								docs/cmds.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
|  | ||||
| ## Backend Support | ||||
|  | ||||
| HeroDB supports two storage backends, both with full encryption support: | ||||
|  | ||||
| - **redb** (default): Full-featured, optimized for production use | ||||
| - **sled**: Alternative embedded database with encryption support | ||||
|  | ||||
| ### Starting HeroDB with Different Backends | ||||
|  | ||||
| ```bash | ||||
| # Use default redb backend | ||||
| ./target/release/herodb --dir /tmp/herodb_redb --port 6379 | ||||
|  | ||||
| # Use sled backend | ||||
| ./target/release/herodb --dir /tmp/herodb_sled --port 6379 --sled | ||||
|  | ||||
| # Use redb with encryption | ||||
| ./target/release/herodb --dir /tmp/herodb_encrypted --port 6379 --encrypt --key mysecretkey | ||||
|  | ||||
| # Use sled with encryption | ||||
| ./target/release/herodb --dir /tmp/herodb_sled_encrypted --port 6379 --sled --encrypt --key mysecretkey | ||||
| ``` | ||||
|  | ||||
| ### Command Support by Backend | ||||
|  | ||||
| Command Category | redb | sled | Notes | | ||||
| |-----------------|------|------|-------| | ||||
| **Strings** | | | | | ||||
| SET | ✅ | ✅ | Full support | | ||||
| GET | ✅ | ✅ | Full support | | ||||
| DEL | ✅ | ✅ | Full support | | ||||
| EXISTS | ✅ | ✅ | Full support | | ||||
| INCR/DECR | ✅ | ✅ | Full support | | ||||
| MGET/MSET | ✅ | ✅ | Full support | | ||||
| **Hashes** | | | | | ||||
| HSET | ✅ | ✅ | Full support | | ||||
| HGET | ✅ | ✅ | Full support | | ||||
| HGETALL | ✅ | ✅ | Full support | | ||||
| HDEL | ✅ | ✅ | Full support | | ||||
| HEXISTS | ✅ | ✅ | Full support | | ||||
| HKEYS | ✅ | ✅ | Full support | | ||||
| HVALS | ✅ | ✅ | Full support | | ||||
| HLEN | ✅ | ✅ | Full support | | ||||
| HMGET | ✅ | ✅ | Full support | | ||||
| HSETNX | ✅ | ✅ | Full support | | ||||
| HINCRBY/HINCRBYFLOAT | ✅ | ✅ | Full support | | ||||
| HSCAN | ✅ | ✅ | Full support with pattern matching | | ||||
| **Lists** | | | | | ||||
| LPUSH/RPUSH | ✅ | ✅ | Full support | | ||||
| LPOP/RPOP | ✅ | ✅ | Full support | | ||||
| LLEN | ✅ | ✅ | Full support | | ||||
| LRANGE | ✅ | ✅ | Full support | | ||||
| LINDEX | ✅ | ✅ | Full support | | ||||
| LTRIM | ✅ | ✅ | Full support | | ||||
| LREM | ✅ | ✅ | Full support | | ||||
| BLPOP/BRPOP | ✅ | ❌ | Blocking operations not in sled | | ||||
| **Expiration** | | | | | ||||
| EXPIRE | ✅ | ✅ | Full support in both | | ||||
| TTL | ✅ | ✅ | Full support in both | | ||||
| PERSIST | ✅ | ✅ | Full support in both | | ||||
| SETEX/PSETEX | ✅ | ✅ | Full support in both | | ||||
| EXPIREAT/PEXPIREAT | ✅ | ✅ | Full support in both | | ||||
| **Scanning** | | | | | ||||
| KEYS | ✅ | ✅ | Full support with patterns | | ||||
| SCAN | ✅ | ✅ | Full cursor-based iteration | | ||||
| HSCAN | ✅ | ✅ | Full cursor-based iteration | | ||||
| **Transactions** | | | | | ||||
| MULTI/EXEC/DISCARD | ✅ | ❌ | Only supported in redb | | ||||
| **Encryption** | | | | | ||||
| Data-at-rest encryption | ✅ | ✅ | Both support [age](age.tech) encryption | | ||||
| AGE commands | ✅ | ✅ | Both support AGE crypto commands | | ||||
| **Full-Text Search** | | | | | ||||
| FT.CREATE | ✅ | ✅ | Create search index with schema | | ||||
| FT.ADD | ✅ | ✅ | Add document to search index | | ||||
| FT.SEARCH | ✅ | ✅ | Search documents with query | | ||||
| FT.DEL | ✅ | ✅ | Delete document from index | | ||||
| FT.INFO | ✅ | ✅ | Get index information | | ||||
| FT.DROP | ✅ | ✅ | Drop search index | | ||||
| FT.ALTER | ✅ | ✅ | Alter index schema | | ||||
| FT.AGGREGATE | ✅ | ✅ | Aggregate search results | | ||||
|  | ||||
| ### Performance Considerations | ||||
|  | ||||
| - **redb**: Optimized for concurrent access, better for high-throughput scenarios | ||||
| - **sled**: Lock-free architecture, excellent for specific workloads | ||||
|  | ||||
| ### Encryption Features | ||||
|  | ||||
| Both backends support: | ||||
| - Transparent data-at-rest encryption using the `age` encryption library | ||||
| - Per-database encryption (databases >= 10 are encrypted when `--encrypt` flag is used) | ||||
| - Secure key derivation using the master key | ||||
|  | ||||
| ### Backend Selection Examples | ||||
|  | ||||
| ```bash | ||||
| # Example: Testing both backends | ||||
| redis-cli -p 6379 SET mykey "redb value" | ||||
| redis-cli -p 6381 SET mykey "sled value" | ||||
|  | ||||
| # Example: Using encryption with both | ||||
| ./target/release/herodb --port 6379 --encrypt --key secret123 | ||||
| ./target/release/herodb --port 6381 --sled --encrypt --key secret123 | ||||
|  | ||||
| # Both support the same Redis commands | ||||
| redis-cli -p 6379 HSET user:1 name "Alice" age "30" | ||||
| redis-cli -p 6381 HSET user:1 name "Alice" age "30" | ||||
|  | ||||
| # Both support SCAN operations | ||||
| redis-cli -p 6379 SCAN 0 MATCH user:* COUNT 10 | ||||
| redis-cli -p 6381 SCAN 0 MATCH user:* COUNT 10 | ||||
| ``` | ||||
|  | ||||
| ### Migration Between Backends | ||||
|  | ||||
| To migrate data between backends, use Redis replication or dump/restore: | ||||
|  | ||||
| ```bash | ||||
| # Export from redb | ||||
| redis-cli -p 6379 --rdb dump.rdb | ||||
|  | ||||
| # Import to sled | ||||
| redis-cli -p 6381 --pipe < dump.rdb | ||||
| ``` | ||||
							
								
								
									
										397
									
								
								docs/search.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										397
									
								
								docs/search.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,397 @@ | ||||
| # Full-Text Search with Tantivy | ||||
|  | ||||
| HeroDB includes powerful full-text search capabilities powered by [Tantivy](https://github.com/quickwit-oss/tantivy), a fast full-text search engine library written in Rust. This provides Redis-compatible search commands similar to RediSearch. | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| The search functionality allows you to: | ||||
| - Create search indexes with custom schemas | ||||
| - Index documents with multiple field types | ||||
| - Perform complex queries with filters | ||||
| - Support for text, numeric, date, and geographic data | ||||
| - Real-time search with high performance | ||||
|  | ||||
| ## Search Commands | ||||
|  | ||||
| ### FT.CREATE - Create Search Index | ||||
|  | ||||
| Create a new search index with a defined schema. | ||||
|  | ||||
| ```bash | ||||
| FT.CREATE index_name SCHEMA field_name field_type [options] [field_name field_type [options] ...] | ||||
| ``` | ||||
|  | ||||
| **Field Types:** | ||||
| - `TEXT` - Full-text searchable text fields | ||||
| - `NUMERIC` - Numeric fields (integers, floats) | ||||
| - `TAG` - Tag fields for exact matching | ||||
| - `GEO` - Geographic coordinates (lat,lon) | ||||
| - `DATE` - Date/timestamp fields | ||||
|  | ||||
| **Field Options:** | ||||
| - `STORED` - Store field value for retrieval | ||||
| - `INDEXED` - Make field searchable | ||||
| - `TOKENIZED` - Enable tokenization for text fields | ||||
| - `FAST` - Enable fast access for numeric fields | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| # Create a product search index | ||||
| FT.CREATE products SCHEMA  | ||||
|   title TEXT STORED INDEXED TOKENIZED | ||||
|   description TEXT STORED INDEXED TOKENIZED   | ||||
|   price NUMERIC STORED INDEXED FAST | ||||
|   category TAG STORED | ||||
|   location GEO STORED | ||||
|   created_date DATE STORED INDEXED | ||||
| ``` | ||||
|  | ||||
| ### FT.ADD - Add Document to Index | ||||
|  | ||||
| Add a document to a search index. | ||||
|  | ||||
| ```bash | ||||
| FT.ADD index_name doc_id [SCORE score] FIELDS field_name field_value [field_name field_value ...] | ||||
| ``` | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| # Add a product document | ||||
| FT.ADD products product:1 SCORE 1.0 FIELDS  | ||||
|   title "Wireless Headphones"  | ||||
|   description "High-quality wireless headphones with noise cancellation" | ||||
|   price 199.99 | ||||
|   category "electronics" | ||||
|   location "37.7749,-122.4194" | ||||
|   created_date 1640995200000 | ||||
| ``` | ||||
|  | ||||
| ### FT.SEARCH - Search Documents | ||||
|  | ||||
| Search for documents in an index. | ||||
|  | ||||
| ```bash | ||||
| FT.SEARCH index_name query [LIMIT offset count] [FILTER field min max] [RETURN field [field ...]] | ||||
| ``` | ||||
|  | ||||
| **Query Syntax:** | ||||
| - Simple terms: `wireless headphones` | ||||
| - Phrase queries: `"noise cancellation"` | ||||
| - Field-specific: `title:wireless` | ||||
| - Boolean operators: `wireless AND headphones` | ||||
| - Wildcards: `head*` | ||||
|  | ||||
| **Examples:** | ||||
| ```bash | ||||
| # Simple text search | ||||
| FT.SEARCH products "wireless headphones" | ||||
|  | ||||
| # Search with filters | ||||
| FT.SEARCH products "headphones" FILTER price 100 300 LIMIT 0 10 | ||||
|  | ||||
| # Field-specific search | ||||
| FT.SEARCH products "title:wireless AND category:electronics" | ||||
|  | ||||
| # Return specific fields only | ||||
| FT.SEARCH products "*" RETURN title price | ||||
| ``` | ||||
|  | ||||
| ### FT.DEL - Delete Document | ||||
|  | ||||
| Remove a document from the search index. | ||||
|  | ||||
| ```bash | ||||
| FT.DEL index_name doc_id | ||||
| ``` | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| FT.DEL products product:1 | ||||
| ``` | ||||
|  | ||||
| ### FT.INFO - Get Index Information | ||||
|  | ||||
| Get information about a search index. | ||||
|  | ||||
| ```bash | ||||
| FT.INFO index_name | ||||
| ``` | ||||
|  | ||||
| **Returns:** | ||||
| - Index name and document count | ||||
| - Field definitions and types | ||||
| - Index configuration | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| FT.INFO products | ||||
| ``` | ||||
|  | ||||
| ### FT.DROP - Drop Index | ||||
|  | ||||
| Delete an entire search index. | ||||
|  | ||||
| ```bash | ||||
| FT.DROP index_name | ||||
| ``` | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| FT.DROP products | ||||
| ``` | ||||
|  | ||||
| ### FT.ALTER - Alter Index Schema | ||||
|  | ||||
| Add new fields to an existing index. | ||||
|  | ||||
| ```bash | ||||
| FT.ALTER index_name SCHEMA ADD field_name field_type [options] | ||||
| ``` | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| FT.ALTER products SCHEMA ADD brand TAG STORED | ||||
| ``` | ||||
|  | ||||
| ### FT.AGGREGATE - Aggregate Search Results | ||||
|  | ||||
| Perform aggregations on search results. | ||||
|  | ||||
| ```bash | ||||
| FT.AGGREGATE index_name query [GROUPBY field] [REDUCE function field AS alias] | ||||
| ``` | ||||
|  | ||||
| **Example:** | ||||
| ```bash | ||||
| # Group products by category and count | ||||
| FT.AGGREGATE products "*" GROUPBY category REDUCE COUNT 0 AS count | ||||
| ``` | ||||
|  | ||||
| ## Field Types in Detail | ||||
|  | ||||
| ### TEXT Fields | ||||
| - **Purpose**: Full-text search on natural language content | ||||
| - **Features**: Tokenization, stemming, stop-word removal | ||||
| - **Options**: `STORED`, `INDEXED`, `TOKENIZED` | ||||
| - **Example**: Product titles, descriptions, content | ||||
|  | ||||
| ### NUMERIC Fields   | ||||
| - **Purpose**: Numeric data for range queries and sorting | ||||
| - **Types**: I64, U64, F64 | ||||
| - **Options**: `STORED`, `INDEXED`, `FAST` | ||||
| - **Example**: Prices, quantities, ratings | ||||
|  | ||||
| ### TAG Fields | ||||
| - **Purpose**: Exact-match categorical data | ||||
| - **Features**: No tokenization, exact string matching | ||||
| - **Options**: `STORED`, case sensitivity control | ||||
| - **Example**: Categories, brands, status values | ||||
|  | ||||
| ### GEO Fields | ||||
| - **Purpose**: Geographic coordinates | ||||
| - **Format**: "latitude,longitude" (e.g., "37.7749,-122.4194") | ||||
| - **Features**: Geographic distance queries | ||||
| - **Options**: `STORED` | ||||
|  | ||||
| ### DATE Fields | ||||
| - **Purpose**: Timestamp and date data | ||||
| - **Format**: Unix timestamp in milliseconds | ||||
| - **Features**: Range queries, temporal filtering | ||||
| - **Options**: `STORED`, `INDEXED`, `FAST` | ||||
|  | ||||
| ## Search Query Syntax | ||||
|  | ||||
| ### Basic Queries | ||||
| ```bash | ||||
| # Single term | ||||
| FT.SEARCH products "wireless" | ||||
|  | ||||
| # Multiple terms (AND by default) | ||||
| FT.SEARCH products "wireless headphones" | ||||
|  | ||||
| # Phrase query | ||||
| FT.SEARCH products "\"noise cancellation\"" | ||||
| ``` | ||||
|  | ||||
| ### Field-Specific Queries | ||||
| ```bash | ||||
| # Search in specific field | ||||
| FT.SEARCH products "title:wireless" | ||||
|  | ||||
| # Multiple field queries | ||||
| FT.SEARCH products "title:wireless AND description:bluetooth" | ||||
| ``` | ||||
|  | ||||
| ### Boolean Operators | ||||
| ```bash | ||||
| # AND operator | ||||
| FT.SEARCH products "wireless AND headphones" | ||||
|  | ||||
| # OR operator   | ||||
| FT.SEARCH products "wireless OR bluetooth" | ||||
|  | ||||
| # NOT operator | ||||
| FT.SEARCH products "headphones NOT wired" | ||||
| ``` | ||||
|  | ||||
| ### Wildcards and Fuzzy Search | ||||
| ```bash | ||||
| # Wildcard search | ||||
| FT.SEARCH products "head*" | ||||
|  | ||||
| # Fuzzy search (approximate matching) | ||||
| FT.SEARCH products "%headphone%" | ||||
| ``` | ||||
|  | ||||
| ### Range Queries | ||||
| ```bash | ||||
| # Numeric range in query | ||||
| FT.SEARCH products "@price:[100 300]" | ||||
|  | ||||
| # Date range | ||||
| FT.SEARCH products "@created_date:[1640995200000 1672531200000]" | ||||
| ``` | ||||
|  | ||||
| ## Filtering and Sorting | ||||
|  | ||||
| ### FILTER Clause | ||||
| ```bash | ||||
| # Numeric filter | ||||
| FT.SEARCH products "headphones" FILTER price 100 300 | ||||
|  | ||||
| # Multiple filters | ||||
| FT.SEARCH products "*" FILTER price 100 500 FILTER rating 4 5 | ||||
| ``` | ||||
|  | ||||
| ### LIMIT Clause | ||||
| ```bash | ||||
| # Pagination | ||||
| FT.SEARCH products "wireless" LIMIT 0 10    # First 10 results | ||||
| FT.SEARCH products "wireless" LIMIT 10 10   # Next 10 results | ||||
| ``` | ||||
|  | ||||
| ### RETURN Clause | ||||
| ```bash | ||||
| # Return specific fields | ||||
| FT.SEARCH products "*" RETURN title price | ||||
|  | ||||
| # Return all stored fields (default) | ||||
| FT.SEARCH products "*" | ||||
| ``` | ||||
|  | ||||
| ## Performance Considerations | ||||
|  | ||||
| ### Indexing Strategy | ||||
| - Only index fields you need to search on | ||||
| - Use `FAST` option for frequently filtered numeric fields | ||||
| - Consider storage vs. search performance trade-offs | ||||
|  | ||||
| ### Query Optimization | ||||
| - Use specific field queries when possible | ||||
| - Combine filters with text queries for better performance | ||||
| - Use pagination with LIMIT for large result sets | ||||
|  | ||||
| ### Memory Usage | ||||
| - Tantivy indexes are memory-mapped for performance | ||||
| - Index size depends on document count and field configuration | ||||
| - Monitor disk space for index storage | ||||
|  | ||||
| ## Integration with Redis Commands | ||||
|  | ||||
| Search indexes work alongside regular Redis data: | ||||
|  | ||||
| ```bash | ||||
| # Store product data in Redis hash | ||||
| HSET product:1 title "Wireless Headphones" price "199.99" | ||||
|  | ||||
| # Index the same data for search | ||||
| FT.ADD products product:1 FIELDS title "Wireless Headphones" price 199.99 | ||||
|  | ||||
| # Search returns document IDs that can be used with Redis commands | ||||
| FT.SEARCH products "wireless" | ||||
| # Returns: product:1 | ||||
|  | ||||
| # Retrieve full data using Redis | ||||
| HGETALL product:1 | ||||
| ``` | ||||
|  | ||||
| ## Example Use Cases | ||||
|  | ||||
| ### E-commerce Product Search | ||||
| ```bash | ||||
| # Create product catalog index | ||||
| FT.CREATE catalog SCHEMA  | ||||
|   name TEXT STORED INDEXED TOKENIZED | ||||
|   description TEXT INDEXED TOKENIZED | ||||
|   price NUMERIC STORED INDEXED FAST | ||||
|   category TAG STORED | ||||
|   brand TAG STORED | ||||
|   rating NUMERIC STORED FAST | ||||
|  | ||||
| # Add products | ||||
| FT.ADD catalog prod:1 FIELDS name "iPhone 14" price 999 category "phones" brand "apple" rating 4.5 | ||||
| FT.ADD catalog prod:2 FIELDS name "Samsung Galaxy" price 899 category "phones" brand "samsung" rating 4.3 | ||||
|  | ||||
| # Search queries | ||||
| FT.SEARCH catalog "iPhone" | ||||
| FT.SEARCH catalog "phones" FILTER price 800 1000 | ||||
| FT.SEARCH catalog "@brand:apple" | ||||
| ``` | ||||
|  | ||||
| ### Content Management | ||||
| ```bash | ||||
| # Create content index | ||||
| FT.CREATE content SCHEMA | ||||
|   title TEXT STORED INDEXED TOKENIZED | ||||
|   body TEXT INDEXED TOKENIZED | ||||
|   author TAG STORED | ||||
|   published DATE STORED INDEXED | ||||
|   tags TAG STORED | ||||
|  | ||||
| # Search content | ||||
| FT.SEARCH content "machine learning" | ||||
| FT.SEARCH content "@author:john AND @tags:ai" | ||||
| FT.SEARCH content "*" FILTER published 1640995200000 1672531200000 | ||||
| ``` | ||||
|  | ||||
| ### Geographic Search | ||||
| ```bash | ||||
| # Create location-based index | ||||
| FT.CREATE places SCHEMA | ||||
|   name TEXT STORED INDEXED TOKENIZED | ||||
|   location GEO STORED | ||||
|   type TAG STORED | ||||
|  | ||||
| # Add locations | ||||
| FT.ADD places place:1 FIELDS name "Golden Gate Bridge" location "37.8199,-122.4783" type "landmark" | ||||
|  | ||||
| # Geographic queries (future feature) | ||||
| FT.SEARCH places "@location:[37.7749 -122.4194 10 km]" | ||||
| ``` | ||||
|  | ||||
| ## Error Handling | ||||
|  | ||||
| Common error responses: | ||||
| - `ERR index not found` - Index doesn't exist | ||||
| - `ERR field not found` - Field not defined in schema | ||||
| - `ERR invalid query syntax` - Malformed query | ||||
| - `ERR document not found` - Document ID doesn't exist | ||||
|  | ||||
| ## Best Practices | ||||
|  | ||||
| 1. **Schema Design**: Plan your schema carefully - changes require reindexing | ||||
| 2. **Field Selection**: Only store and index fields you actually need | ||||
| 3. **Batch Operations**: Add multiple documents efficiently | ||||
| 4. **Query Testing**: Test queries for performance with realistic data | ||||
| 5. **Monitoring**: Monitor index size and query performance | ||||
| 6. **Backup**: Include search indexes in backup strategies | ||||
|  | ||||
| ## Future Enhancements | ||||
|  | ||||
| Planned features: | ||||
| - Geographic distance queries | ||||
| - Advanced aggregations and faceting | ||||
| - Highlighting of search results | ||||
| - Synonyms and custom analyzers | ||||
| - Real-time suggestions and autocomplete | ||||
| - Index replication and sharding | ||||
							
								
								
									
										171
									
								
								examples/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								examples/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| # HeroDB Tantivy Search Examples | ||||
|  | ||||
| This directory contains examples demonstrating HeroDB's full-text search capabilities powered by Tantivy. | ||||
|  | ||||
| ## Tantivy Search Demo (Bash Script) | ||||
|  | ||||
| ### Overview | ||||
| The `tantivy_search_demo.sh` script provides a comprehensive demonstration of HeroDB's search functionality using Redis commands. It showcases various search scenarios including basic text search, filtering, sorting, geographic queries, and more. | ||||
|  | ||||
| ### Prerequisites | ||||
| 1. **HeroDB Server**: The server must be running on port 6381 | ||||
| 2. **Redis CLI**: The `redis-cli` tool must be installed and available in your PATH | ||||
|  | ||||
| ### Running the Demo | ||||
|  | ||||
| #### Step 1: Start HeroDB Server | ||||
| ```bash | ||||
| # From the project root directory | ||||
| cargo run -- --port 6381 | ||||
| ``` | ||||
|  | ||||
| #### Step 2: Run the Demo (in a new terminal) | ||||
| ```bash | ||||
| # From the project root directory | ||||
| ./examples/tantivy_search_demo.sh | ||||
| ``` | ||||
|  | ||||
| ### What the Demo Covers | ||||
|  | ||||
| The script demonstrates 15 different search scenarios: | ||||
|  | ||||
| 1. **Index Creation** - Creating a search index with various field types | ||||
| 2. **Data Insertion** - Adding sample products to the index | ||||
| 3. **Basic Text Search** - Simple keyword searches | ||||
| 4. **Filtered Search** - Combining text search with category filters | ||||
| 5. **Numeric Range Search** - Finding products within price ranges | ||||
| 6. **Sorting Results** - Ordering results by different fields | ||||
| 7. **Limited Results** - Pagination and result limiting | ||||
| 8. **Complex Queries** - Multi-field searches with sorting | ||||
| 9. **Geographic Search** - Location-based queries | ||||
| 10. **Index Information** - Getting statistics about the search index | ||||
| 11. **Search Comparison** - Tantivy vs simple pattern matching | ||||
| 12. **Fuzzy Search** - Typo tolerance and approximate matching | ||||
| 13. **Phrase Search** - Exact phrase matching | ||||
| 14. **Boolean Queries** - AND, OR, NOT operators | ||||
| 15. **Cleanup** - Removing test data | ||||
|  | ||||
| ### Sample Data | ||||
|  | ||||
| The demo uses a product catalog with the following fields: | ||||
| - **title** (TEXT) - Product name with higher search weight | ||||
| - **description** (TEXT) - Detailed product description | ||||
| - **category** (TAG) - Comma-separated categories | ||||
| - **price** (NUMERIC) - Product price for range queries | ||||
| - **rating** (NUMERIC) - Customer rating for sorting | ||||
| - **location** (GEO) - Geographic coordinates for location searches | ||||
|  | ||||
| ### Key Redis Commands Demonstrated | ||||
|  | ||||
| #### Index Management | ||||
| ```bash | ||||
| # Create search index | ||||
| FT.CREATE product_catalog ON HASH PREFIX 1 product: SCHEMA title TEXT WEIGHT 2.0 SORTABLE description TEXT category TAG SEPARATOR , price NUMERIC SORTABLE rating NUMERIC SORTABLE location GEO | ||||
|  | ||||
| # Get index information | ||||
| FT.INFO product_catalog | ||||
|  | ||||
| # Drop index | ||||
| FT.DROPINDEX product_catalog | ||||
| ``` | ||||
|  | ||||
| #### Search Queries | ||||
| ```bash | ||||
| # Basic text search | ||||
| FT.SEARCH product_catalog wireless | ||||
|  | ||||
| # Filtered search | ||||
| FT.SEARCH product_catalog 'organic @category:{food}' | ||||
|  | ||||
| # Numeric range | ||||
| FT.SEARCH product_catalog '@price:[50 150]' | ||||
|  | ||||
| # Sorted results | ||||
| FT.SEARCH product_catalog '@category:{electronics}' SORTBY price ASC | ||||
|  | ||||
| # Geographic search | ||||
| FT.SEARCH product_catalog '@location:[37.7749 -122.4194 50 km]' | ||||
|  | ||||
| # Boolean queries | ||||
| FT.SEARCH product_catalog 'wireless AND audio' | ||||
| FT.SEARCH product_catalog 'coffee OR tea' | ||||
|  | ||||
| # Phrase search | ||||
| FT.SEARCH product_catalog '"noise canceling"' | ||||
| ``` | ||||
|  | ||||
| ### Interactive Features | ||||
|  | ||||
| The demo script includes: | ||||
| - **Colored output** for better readability | ||||
| - **Pause between steps** to review results | ||||
| - **Error handling** with clear error messages | ||||
| - **Automatic cleanup** of test data | ||||
| - **Progress indicators** showing what each step demonstrates | ||||
|  | ||||
| ### Troubleshooting | ||||
|  | ||||
| #### HeroDB Not Running | ||||
| ``` | ||||
| ✗ HeroDB is not running on port 6381 | ||||
| ℹ Please start HeroDB with: cargo run -- --port 6381 | ||||
| ``` | ||||
| **Solution**: Start the HeroDB server in a separate terminal. | ||||
|  | ||||
| #### Redis CLI Not Found | ||||
| ``` | ||||
| redis-cli: command not found | ||||
| ``` | ||||
| **Solution**: Install Redis tools or use an alternative Redis client. | ||||
|  | ||||
| #### Connection Refused | ||||
| ``` | ||||
| Could not connect to Redis at localhost:6381: Connection refused | ||||
| ``` | ||||
| **Solution**: Ensure HeroDB is running and listening on the correct port. | ||||
|  | ||||
| ### Manual Testing | ||||
|  | ||||
| You can also run individual commands manually: | ||||
|  | ||||
| ```bash | ||||
| # Connect to HeroDB | ||||
| redis-cli -h localhost -p 6381 | ||||
|  | ||||
| # Create a simple index | ||||
| FT.CREATE myindex ON HASH SCHEMA title TEXT description TEXT | ||||
|  | ||||
| # Add a document | ||||
| HSET doc:1 title "Hello World" description "This is a test document" | ||||
|  | ||||
| # Search | ||||
| FT.SEARCH myindex hello | ||||
| ``` | ||||
|  | ||||
| ### Performance Notes | ||||
|  | ||||
| - **Indexing**: Documents are indexed in real-time as they're added | ||||
| - **Search Speed**: Full-text search is much faster than pattern matching on large datasets | ||||
| - **Memory Usage**: Tantivy indexes are memory-efficient and disk-backed | ||||
| - **Scalability**: Supports millions of documents with sub-second search times | ||||
|  | ||||
| ### Advanced Features | ||||
|  | ||||
| The demo showcases advanced Tantivy features: | ||||
| - **Relevance Scoring** - Results ranked by relevance | ||||
| - **Fuzzy Matching** - Handles typos and approximate matches | ||||
| - **Field Weighting** - Title field has higher search weight | ||||
| - **Multi-field Search** - Search across multiple fields simultaneously | ||||
| - **Geographic Queries** - Distance-based location searches | ||||
| - **Numeric Ranges** - Efficient range queries on numeric fields | ||||
| - **Tag Filtering** - Fast categorical filtering | ||||
|  | ||||
| ### Next Steps | ||||
|  | ||||
| After running the demo, explore: | ||||
| 1. **Custom Schemas** - Define your own field types and configurations | ||||
| 2. **Large Datasets** - Test with thousands or millions of documents | ||||
| 3. **Real Applications** - Integrate search into your applications | ||||
| 4. **Performance Tuning** - Optimize for your specific use case | ||||
|  | ||||
| For more information, see the [search documentation](../herodb/docs/search.md). | ||||
							
								
								
									
										186
									
								
								examples/simple_demo.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								examples/simple_demo.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,186 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # Simple HeroDB Demo - Basic Redis Commands | ||||
| # This script demonstrates basic Redis functionality that's currently implemented | ||||
|  | ||||
| set -e  # Exit on any error | ||||
|  | ||||
| # Configuration | ||||
| REDIS_HOST="localhost" | ||||
| REDIS_PORT="6381" | ||||
| REDIS_CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT" | ||||
|  | ||||
| # Colors for output | ||||
| RED='\033[0;31m' | ||||
| GREEN='\033[0;32m' | ||||
| BLUE='\033[0;34m' | ||||
| YELLOW='\033[1;33m' | ||||
| NC='\033[0m' # No Color | ||||
|  | ||||
| # Function to print colored output | ||||
| print_header() { | ||||
|     echo -e "${BLUE}=== $1 ===${NC}" | ||||
| } | ||||
|  | ||||
| print_success() { | ||||
|     echo -e "${GREEN}✓ $1${NC}" | ||||
| } | ||||
|  | ||||
| print_info() { | ||||
|     echo -e "${YELLOW}ℹ $1${NC}" | ||||
| } | ||||
|  | ||||
| print_error() { | ||||
|     echo -e "${RED}✗ $1${NC}" | ||||
| } | ||||
|  | ||||
| # Function to check if HeroDB is running | ||||
| check_herodb() { | ||||
|     print_info "Checking if HeroDB is running on port $REDIS_PORT..." | ||||
|     if ! $REDIS_CLI ping > /dev/null 2>&1; then | ||||
|         print_error "HeroDB is not running on port $REDIS_PORT" | ||||
|         print_info "Please start HeroDB with: cargo run -- --port $REDIS_PORT" | ||||
|         exit 1 | ||||
|     fi | ||||
|     print_success "HeroDB is running and responding" | ||||
| } | ||||
|  | ||||
| # Function to execute Redis command with error handling | ||||
| execute_cmd() { | ||||
|     local cmd="$1" | ||||
|     local description="$2" | ||||
|      | ||||
|     echo -e "${YELLOW}Command:${NC} $cmd" | ||||
|     if result=$($REDIS_CLI $cmd 2>&1); then | ||||
|         echo -e "${GREEN}Result:${NC} $result" | ||||
|         return 0 | ||||
|     else | ||||
|         print_error "Failed: $description" | ||||
|         echo "Error: $result" | ||||
|         return 1 | ||||
|     fi | ||||
| } | ||||
|  | ||||
| # Main demo function | ||||
| main() { | ||||
|     clear | ||||
|     print_header "HeroDB Basic Functionality Demo" | ||||
|     echo "This demo shows basic Redis commands that are currently implemented" | ||||
|     echo "HeroDB runs on port $REDIS_PORT (instead of Redis default 6379)" | ||||
|     echo | ||||
|  | ||||
|     # Check if HeroDB is running | ||||
|     check_herodb | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 1: Basic Key-Value Operations" | ||||
|      | ||||
|     execute_cmd "SET greeting 'Hello HeroDB!'" "Setting a simple key-value pair" | ||||
|     echo | ||||
|     execute_cmd "GET greeting" "Getting the value" | ||||
|     echo | ||||
|     execute_cmd "SET counter 42" "Setting a numeric value" | ||||
|     echo | ||||
|     execute_cmd "INCR counter" "Incrementing the counter" | ||||
|     echo | ||||
|     execute_cmd "GET counter" "Getting the incremented value" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 2: Hash Operations" | ||||
|      | ||||
|     execute_cmd "HSET user:1 name 'John Doe' email 'john@example.com' age 30" "Setting hash fields" | ||||
|     echo | ||||
|     execute_cmd "HGET user:1 name" "Getting a specific field" | ||||
|     echo | ||||
|     execute_cmd "HGETALL user:1" "Getting all fields" | ||||
|     echo | ||||
|     execute_cmd "HLEN user:1" "Getting hash length" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 3: List Operations" | ||||
|      | ||||
|     execute_cmd "LPUSH tasks 'Write code' 'Test code' 'Deploy code'" "Adding items to list" | ||||
|     echo | ||||
|     execute_cmd "LLEN tasks" "Getting list length" | ||||
|     echo | ||||
|     execute_cmd "LRANGE tasks 0 -1" "Getting all list items" | ||||
|     echo | ||||
|     execute_cmd "LPOP tasks" "Popping from left" | ||||
|     echo | ||||
|     execute_cmd "LRANGE tasks 0 -1" "Checking remaining items" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 4: Key Management" | ||||
|      | ||||
|     execute_cmd "KEYS *" "Listing all keys" | ||||
|     echo | ||||
|     execute_cmd "EXISTS greeting" "Checking if key exists" | ||||
|     echo | ||||
|     execute_cmd "TYPE user:1" "Getting key type" | ||||
|     echo | ||||
|     execute_cmd "DBSIZE" "Getting database size" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 5: Expiration" | ||||
|      | ||||
|     execute_cmd "SET temp_key 'temporary value'" "Setting temporary key" | ||||
|     echo | ||||
|     execute_cmd "EXPIRE temp_key 5" "Setting 5 second expiration" | ||||
|     echo | ||||
|     execute_cmd "TTL temp_key" "Checking time to live" | ||||
|     echo | ||||
|     print_info "Waiting 2 seconds..." | ||||
|     sleep 2 | ||||
|     execute_cmd "TTL temp_key" "Checking TTL again" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 6: Multiple Operations" | ||||
|      | ||||
|     execute_cmd "MSET key1 'value1' key2 'value2' key3 'value3'" "Setting multiple keys" | ||||
|     echo | ||||
|     execute_cmd "MGET key1 key2 key3" "Getting multiple values" | ||||
|     echo | ||||
|     execute_cmd "DEL key1 key2" "Deleting multiple keys" | ||||
|     echo | ||||
|     execute_cmd "EXISTS key1 key2 key3" "Checking existence of multiple keys" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 7: Search Commands (Placeholder)" | ||||
|     print_info "Testing FT.CREATE command (currently returns placeholder response)" | ||||
|      | ||||
|     execute_cmd "FT.CREATE test_index SCHEMA title TEXT description TEXT" "Creating search index" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 8: Server Information" | ||||
|      | ||||
|     execute_cmd "INFO" "Getting server information" | ||||
|     echo | ||||
|     execute_cmd "CONFIG GET dir" "Getting configuration" | ||||
|     echo | ||||
|  | ||||
|     print_header "Step 9: Cleanup" | ||||
|      | ||||
|     execute_cmd "FLUSHDB" "Clearing database" | ||||
|     echo | ||||
|     execute_cmd "DBSIZE" "Confirming database is empty" | ||||
|     echo | ||||
|  | ||||
|     print_header "Demo Summary" | ||||
|     echo "This demonstration showed:" | ||||
|     echo "• Basic key-value operations (GET, SET, INCR)" | ||||
|     echo "• Hash operations (HSET, HGET, HGETALL)" | ||||
|     echo "• List operations (LPUSH, LPOP, LRANGE)" | ||||
|     echo "• Key management (KEYS, EXISTS, TYPE, DEL)" | ||||
|     echo "• Expiration handling (EXPIRE, TTL)" | ||||
|     echo "• Multiple key operations (MSET, MGET)" | ||||
|     echo "• Server information commands" | ||||
|     echo | ||||
|     print_success "HeroDB basic functionality demo completed successfully!" | ||||
|     echo | ||||
|     print_info "Note: Full-text search (FT.*) commands are defined but not yet fully implemented" | ||||
|     print_info "To run HeroDB server: cargo run -- --port 6381" | ||||
|     print_info "To connect with redis-cli: redis-cli -h localhost -p 6381" | ||||
| } | ||||
|  | ||||
| # Run the demo | ||||
| main "$@" | ||||
| @@ -1,28 +0,0 @@ | ||||
| [package] | ||||
| name = "herodb" | ||||
| version = "0.0.1" | ||||
| authors = ["ThreeFold Tech"] | ||||
| edition = "2024" | ||||
|  | ||||
| [dependencies] | ||||
| anyhow = "1.0.59" | ||||
| bytes = "1.3.0" | ||||
| thiserror = "1.0.32" | ||||
| tokio = { version = "1.23.0", features = ["full"] } | ||||
| clap = { version = "4.5.20", features = ["derive"] } | ||||
| byteorder = "1.4.3" | ||||
| futures = "0.3" | ||||
| redb = "2.1.3" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| bincode = "1.3.3" | ||||
| chacha20poly1305 = "0.10.1" | ||||
| rand = "0.8" | ||||
| sha2 = "0.10" | ||||
| age = "0.10" | ||||
| secrecy = "0.8" | ||||
| ed25519-dalek = "2" | ||||
| base64 = "0.22" | ||||
|  | ||||
| [dev-dependencies] | ||||
| redis = { version = "0.24", features = ["aio", "tokio-comp"] } | ||||
| @@ -1,227 +0,0 @@ | ||||
|  | ||||
| # HeroDB Redis Protocol Support: Commands & Client Usage | ||||
|  | ||||
| HeroDB is a Redis-compatible database built using the `redb` database backend.  | ||||
|  | ||||
| It supports a subset of Redis commands over the standard RESP (Redis Serialization Protocol) via TCP, allowing you to interact with it using standard Redis clients like `redis-cli`, Python's `redis-py`, Node.js's `ioredis`, etc. | ||||
|  | ||||
| This document provides: | ||||
| - A list of all currently supported Redis commands. | ||||
| - Example usage with standard Redis clients. | ||||
| - Bash and Rust test-inspired usage examples. | ||||
|  | ||||
| ## Quick Start | ||||
|  | ||||
| Assuming the server is running on localhost at port `$PORT`: | ||||
|  | ||||
| ```bash | ||||
| # Build HeroDB | ||||
| cargo build --release | ||||
|  | ||||
| # Start HeroDB server | ||||
| ./target/release/herodb --dir /tmp/herodb_data --port 6381 --debug | ||||
| ``` | ||||
|  | ||||
| ## Using Standard Redis Clients | ||||
|  | ||||
| ### With `redis-cli` | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 SET mykey "hello" | ||||
| redis-cli -p 6381 GET mykey | ||||
| ``` | ||||
|  | ||||
| ### With Python (`redis-py`) | ||||
|  | ||||
| ```python | ||||
| import redis | ||||
|  | ||||
| r = redis.Redis(host='localhost', port=6381, db=0) | ||||
| r.set('mykey', 'hello') | ||||
| print(r.get('mykey').decode()) | ||||
| ``` | ||||
|  | ||||
| ### With Node.js (`ioredis`) | ||||
|  | ||||
| ```js | ||||
| const Redis = require("ioredis"); | ||||
| const redis = new Redis({ port: 6381, host: "localhost" }); | ||||
|  | ||||
| await redis.set("mykey", "hello"); | ||||
| const value = await redis.get("mykey"); | ||||
| console.log(value); // "hello" | ||||
| ``` | ||||
|  | ||||
| ## Supported Redis Commands | ||||
|  | ||||
| ### String Commands | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `SET`         | Set a key to a string value              | `SET name "Alice"`                        | | ||||
| | `GET`         | Get the value of a key                   | `GET name`                                | | ||||
| | `DEL`         | Delete one or more keys                  | `DEL name age`                            | | ||||
| | `INCR`        | Increment the integer value of a key     | `INCR counter`                            | | ||||
| | `DECR`        | Decrement the integer value of a key     | `DECR counter`                            | | ||||
| | `INCRBY`      | Increment key by a given integer         | `INCRBY counter 5`                        | | ||||
| | `DECRBY`      | Decrement key by a given integer         | `DECRBY counter 3`                        | | ||||
| | `EXISTS`      | Check if a key exists                    | `EXISTS name`                             | | ||||
| | `TYPE`        | Return the type of a key                 | `TYPE name`                               | | ||||
|  | ||||
| ### Hash Commands | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `HSET`        | Set field in hash stored at key          | `HSET user:1 name "Alice"`                | | ||||
| | `HGET`        | Get value of a field in hash             | `HGET user:1 name`                        | | ||||
| | `HGETALL`     | Get all fields and values in a hash      | `HGETALL user:1`                          | | ||||
| | `HDEL`        | Delete one or more fields from hash      | `HDEL user:1 name age`                    | | ||||
| | `HEXISTS`     | Check if field exists in hash            | `HEXISTS user:1 name`                     | | ||||
| | `HKEYS`       | Get all field names in a hash            | `HKEYS user:1`                            | | ||||
| | `HVALS`       | Get all values in a hash                 | `HVALS user:1`                            | | ||||
| | `HLEN`        | Get number of fields in a hash           | `HLEN user:1`                             | | ||||
| | `HMGET`       | Get values of multiple fields            | `HMGET user:1 name age`                   | | ||||
| | `HSETNX`      | Set field only if it does not exist      | `HSETNX user:1 email alice@example.com`   | | ||||
|  | ||||
| ### List Commands | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `LPUSH`       | Insert elements at the head of a list    | `LPUSH mylist "item1" "item2"`            | | ||||
| | `RPUSH`       | Insert elements at the tail of a list    | `RPUSH mylist "item3" "item4"`            | | ||||
| | `LPOP`        | Remove and return element from head      | `LPOP mylist`                             | | ||||
| | `RPOP`        | Remove and return element from tail      | `RPOP mylist`                             | | ||||
| | `BLPOP`       | Blocking remove from head with timeout   | `BLPOP mylist1 mylist2 5`                 | | ||||
| | `BRPOP`       | Blocking remove from tail with timeout   | `BRPOP mylist1 mylist2 5`                 | | ||||
| | `LLEN`        | Get the length of a list                 | `LLEN mylist`                             | | ||||
| | `LREM`        | Remove elements from list                | `LREM mylist 2 "item"`                    | | ||||
| | `LTRIM`       | Trim list to specified range             | `LTRIM mylist 0 5`                         | | ||||
| | `LINDEX`      | Get element by index                     | `LINDEX mylist 0`                         | | ||||
| | `LRANGE`      | Get range of elements                    | `LRANGE mylist 0 -1`                      | | ||||
|  | ||||
| ### Keys & Scanning | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `KEYS`        | Find all keys matching a pattern         | `KEYS user:*`                             | | ||||
| | `SCAN`        | Incrementally iterate keys               | `SCAN 0 MATCH user:* COUNT 10`            | | ||||
|  | ||||
| ### Expiration | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `EXPIRE`      | Set a key's time to live in seconds      | `EXPIRE tempkey 60`                       | | ||||
| | `TTL`         | Get the time to live for a key           | `TTL tempkey`                             | | ||||
| | `PERSIST`     | Remove the expiration from a key         | `PERSIST tempkey`                         | | ||||
|  | ||||
| ### Transactions | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `MULTI`       | Start a transaction block                | `MULTI`                                   | | ||||
| | `EXEC`        | Execute all commands in a transaction    | `EXEC`                                    | | ||||
| | `DISCARD`     | Discard all commands in a transaction    | `DISCARD`                                 | | ||||
|  | ||||
| ### Configuration | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `CONFIG GET`  | Get configuration parameters             | `CONFIG GET dir`                           | | ||||
| | `CONFIG SET`  | Set configuration parameters             | `CONFIG SET maxmemory 100mb`               | | ||||
|  | ||||
| ### Info & Monitoring | ||||
|  | ||||
| | Command       | Description                              | Example Usage                             | | ||||
| |---------------|------------------------------------------|-------------------------------------------| | ||||
| | `INFO`        | Get information and statistics about server | `INFO`                                 | | ||||
| | `PING`        | Ping the server                          | `PING`                                    | | ||||
|  | ||||
| ### AGE Cryptography Commands | ||||
|  | ||||
| | Command            | Description                                   | Example Usage                                 | | ||||
| |--------------------|-----------------------------------------------|-----------------------------------------------| | ||||
| | `AGE GENENC`       | Generate ephemeral encryption keypair         | `AGE GENENC`                                  | | ||||
| | `AGE GENSIGN`      | Generate ephemeral signing keypair            | `AGE GENSIGN`                                 | | ||||
| | `AGE ENCRYPT`      | Encrypt a message using a public key          | `AGE ENCRYPT <recipient> "msg"`               | | ||||
| | `AGE DECRYPT`      | Decrypt a message using a secret key          | `AGE DECRYPT <identity> <ciphertext>`        | | ||||
| | `AGE SIGN`         | Sign a message using a secret key            | `AGE SIGN <sign_secret> "msg"`                | | ||||
| | `AGE VERIFY`       | Verify a signature using a public key         | `AGE VERIFY <pubkey> "msg" <signature>`      | | ||||
| | `AGE KEYGEN`       | Create and persist a named encryption key    | `AGE KEYGEN app1`                             | | ||||
| | `AGE SIGNKEYGEN`   | Create and persist a named signing key       | `AGE SIGNKEYGEN app1`                          | | ||||
| | `AGE ENCRYPTNAME`  | Encrypt using a named key                     | `AGE ENCRYPTNAME app1 "msg"`                   | | ||||
| | `AGE DECRYPTNAME`  | Decrypt using a named key                     | `AGE DECRYPTNAME app1 <ciphertext>`           | | ||||
| | `AGE SIGNNAME`     | Sign using a named key                       | `AGE SIGNNAME app1 "msg"`                      | | ||||
| | `AGE VERIFYNAME`   | Verify using a named key                      | `AGE VERIFYNAME app1 "msg" <signature>`       | | ||||
| | `AGE LIST`         | List all persisted named keys                | `AGE LIST`                                    | | ||||
|  | ||||
| > Note: AGE commands are not part of standard Redis. They are HeroDB-specific extensions for cryptographic operations. | ||||
|  | ||||
| ## Example Usage | ||||
|  | ||||
| ### Basic String Operations | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 SET greeting "Hello, HeroDB!" | ||||
| redis-cli -p 6381 GET greeting | ||||
| # → "Hello, HeroDB!" | ||||
|  | ||||
| redis-cli -p 6381 INCR visits | ||||
| redis-cli -p 6381 INCR visits | ||||
| redis-cli -p 6381 GET visits | ||||
| # → "2" | ||||
| ``` | ||||
|  | ||||
| ### Hash Operations | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 HSET user:1000 name "Alice" age "30" city "NYC" | ||||
| redis-cli -p 6381 HGET user:1000 name | ||||
| # → "Alice" | ||||
|  | ||||
| redis-cli -p 6381 HGETALL user:1000 | ||||
| # → 1) "name" | ||||
| #    2) "Alice" | ||||
| #    3) "age" | ||||
| #    4) "30" | ||||
| #    5) "city" | ||||
| #    6) "NYC" | ||||
| ``` | ||||
|  | ||||
| ### Expiration | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 SET tempkey "temporary" | ||||
| redis-cli -p 6381 EXPIRE tempkey 5 | ||||
| redis-cli -p 6381 TTL tempkey | ||||
| # → (integer) 4 | ||||
|  | ||||
| # After 5 seconds: | ||||
| redis-cli -p 6381 GET tempkey | ||||
| # → (nil) | ||||
| ``` | ||||
|  | ||||
| ### Transactions | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 MULTI | ||||
| redis-cli -p 6381 SET txkey1 "value1" | ||||
| redis-cli -p 6381 SET txkey2 "value2" | ||||
| redis-cli -p 6381 INCR counter | ||||
| redis-cli -p 6381 EXEC | ||||
| # → 1) OK | ||||
| #    2) OK | ||||
| #    3) (integer) 3 | ||||
| ``` | ||||
|  | ||||
| ### Scanning Keys | ||||
|  | ||||
| ```bash | ||||
| redis-cli -p 6381 SET scankey1 "val1" | ||||
| redis-cli -p 6381 SET scankey2 "val2" | ||||
| redis-cli -p 6381 HSET scanhash field1 "val1" | ||||
|  | ||||
| redis-cli -p 6381 SCAN 0 MATCH scankey* | ||||
| # → 1) "0" | ||||
| #    2) 1) "scankey1" | ||||
| #       2) "scankey2" | ||||
| ``` | ||||
| @@ -1,8 +0,0 @@ | ||||
| #[derive(Clone)] | ||||
| pub struct DBOption { | ||||
|     pub dir: String, | ||||
|     pub port: u16, | ||||
|     pub debug: bool, | ||||
|     pub encrypt: bool, | ||||
|     pub encryption_key: Option<String>, // Master encryption key | ||||
| } | ||||
| @@ -1,126 +0,0 @@ | ||||
| use std::{ | ||||
|     path::Path, | ||||
|     time::{SystemTime, UNIX_EPOCH}, | ||||
| }; | ||||
|  | ||||
| use redb::{Database, TableDefinition}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| use crate::crypto::CryptoFactory; | ||||
| use crate::error::DBError; | ||||
|  | ||||
| // Re-export modules | ||||
| mod storage_basic; | ||||
| mod storage_hset; | ||||
| mod storage_lists; | ||||
| mod storage_extra; | ||||
|  | ||||
| // Re-export implementations | ||||
| // Note: These imports are used by the impl blocks in the submodules | ||||
| // The compiler shows them as unused because they're not directly used in this file | ||||
| // but they're needed for the Storage struct methods to be available | ||||
| pub use storage_extra::*; | ||||
|  | ||||
| // Table definitions for different Redis data types | ||||
| const TYPES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("types"); | ||||
| const STRINGS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("strings"); | ||||
| const HASHES_TABLE: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("hashes"); | ||||
| const LISTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("lists"); | ||||
| const STREAMS_META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("streams_meta"); | ||||
| const STREAMS_DATA_TABLE: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("streams_data"); | ||||
| const ENCRYPTED_TABLE: TableDefinition<&str, u8> = TableDefinition::new("encrypted"); | ||||
| const EXPIRATION_TABLE: TableDefinition<&str, u64> = TableDefinition::new("expiration"); | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| pub struct StreamEntry { | ||||
|     pub fields: Vec<(String, String)>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| pub struct ListValue { | ||||
|     pub elements: Vec<String>, | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| pub fn now_in_millis() -> u128 { | ||||
|     let start = SystemTime::now(); | ||||
|     let duration_since_epoch = start.duration_since(UNIX_EPOCH).unwrap(); | ||||
|     duration_since_epoch.as_millis() | ||||
| } | ||||
|  | ||||
| pub struct Storage { | ||||
|     db: Database, | ||||
|     crypto: Option<CryptoFactory>, | ||||
| } | ||||
|  | ||||
| impl Storage { | ||||
|     pub fn new(path: impl AsRef<Path>, should_encrypt: bool, master_key: Option<&str>) -> Result<Self, DBError> { | ||||
|         let db = Database::create(path)?; | ||||
|          | ||||
|         // Create tables if they don't exist | ||||
|         let write_txn = db.begin_write()?; | ||||
|         { | ||||
|             let _ = write_txn.open_table(TYPES_TABLE)?; | ||||
|             let _ = write_txn.open_table(STRINGS_TABLE)?; | ||||
|             let _ = write_txn.open_table(HASHES_TABLE)?; | ||||
|             let _ = write_txn.open_table(LISTS_TABLE)?; | ||||
|             let _ = write_txn.open_table(STREAMS_META_TABLE)?; | ||||
|             let _ = write_txn.open_table(STREAMS_DATA_TABLE)?; | ||||
|             let _ = write_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|             let _ = write_txn.open_table(EXPIRATION_TABLE)?; | ||||
|         } | ||||
|         write_txn.commit()?; | ||||
|          | ||||
|         // Check if database was previously encrypted | ||||
|         let read_txn = db.begin_read()?; | ||||
|         let encrypted_table = read_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|         let was_encrypted = encrypted_table.get("encrypted")?.map(|v| v.value() == 1).unwrap_or(false); | ||||
|         drop(read_txn); | ||||
|          | ||||
|         let crypto = if should_encrypt || was_encrypted { | ||||
|             if let Some(key) = master_key { | ||||
|                 Some(CryptoFactory::new(key.as_bytes())) | ||||
|             } else { | ||||
|                 return Err(DBError("Encryption requested but no master key provided".to_string())); | ||||
|             } | ||||
|         } else { | ||||
|             None | ||||
|         }; | ||||
|          | ||||
|         // If we're enabling encryption for the first time, mark it | ||||
|         if should_encrypt && !was_encrypted { | ||||
|             let write_txn = db.begin_write()?; | ||||
|             { | ||||
|                 let mut encrypted_table = write_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|                 encrypted_table.insert("encrypted", &1u8)?; | ||||
|             } | ||||
|             write_txn.commit()?; | ||||
|         } | ||||
|          | ||||
|         Ok(Storage { | ||||
|             db, | ||||
|             crypto, | ||||
|         }) | ||||
|     } | ||||
|      | ||||
|     pub fn is_encrypted(&self) -> bool { | ||||
|         self.crypto.is_some() | ||||
|     } | ||||
|  | ||||
|     // Helper methods for encryption | ||||
|     fn encrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.encrypt(data)) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn decrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.decrypt(data)?) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1251
									
								
								specs/backgroundinfo/lance.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1251
									
								
								specs/backgroundinfo/lance.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										6847
									
								
								specs/backgroundinfo/lancedb.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6847
									
								
								specs/backgroundinfo/lancedb.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										113
									
								
								specs/backgroundinfo/sled.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								specs/backgroundinfo/sled.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| ======================== | ||||
| CODE SNIPPETS | ||||
| ======================== | ||||
| TITLE: Basic Database Operations with sled in Rust | ||||
| DESCRIPTION: This snippet demonstrates fundamental operations using the `sled` embedded database in Rust. It covers opening a database tree, inserting and retrieving key-value pairs, performing range queries, deleting entries, and executing an atomic compare-and-swap operation. It also shows how to flush changes to disk for durability. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/README.md#_snippet_0 | ||||
|  | ||||
| LANGUAGE: Rust | ||||
| CODE: | ||||
| ``` | ||||
| let tree = sled::open("/tmp/welcome-to-sled")?; | ||||
|  | ||||
| // insert and get, similar to std's BTreeMap | ||||
| let old_value = tree.insert("key", "value")?; | ||||
|  | ||||
| assert_eq!( | ||||
|   tree.get(&"key")?, | ||||
|   Some(sled::IVec::from("value")), | ||||
| ); | ||||
|  | ||||
| // range queries | ||||
| for kv_result in tree.range("key_1".."key_9") {} | ||||
|  | ||||
| // deletion | ||||
| let old_value = tree.remove(&"key")?; | ||||
|  | ||||
| // atomic compare and swap | ||||
| tree.compare_and_swap( | ||||
|   "key", | ||||
|   Some("current_value"), | ||||
|   Some("new_value"), | ||||
| )?; | ||||
|  | ||||
| // block until all operations are stable on disk | ||||
| // (flush_async also available to get a Future) | ||||
| tree.flush()?; | ||||
| ``` | ||||
|  | ||||
| ---------------------------------------- | ||||
|  | ||||
| TITLE: Subscribing to sled Events Asynchronously (Rust) | ||||
| DESCRIPTION: This snippet demonstrates how to asynchronously subscribe to events on key prefixes in a `sled` database. It initializes a `sled` database, creates a `Subscriber` for all key prefixes, inserts a key-value pair to trigger an event, and then uses `extreme::run` to await and process incoming events. The `Subscriber` struct implements `Future<Output=Option<Event>>`, allowing it to be awaited in an async context. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/README.md#_snippet_1 | ||||
|  | ||||
| LANGUAGE: Rust | ||||
| CODE: | ||||
| ``` | ||||
| let sled = sled::open("my_db").unwrap(); | ||||
|  | ||||
| let mut sub = sled.watch_prefix(""); | ||||
|  | ||||
| sled.insert(b"a", b"a").unwrap(); | ||||
|  | ||||
| extreme::run(async move { | ||||
|     while let Some(event) = (&mut sub).await { | ||||
|         println!("got event {:?}", event); | ||||
|     } | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ---------------------------------------- | ||||
|  | ||||
| TITLE: Iterating Subscriber Events with Async/Await in Rust | ||||
| DESCRIPTION: This snippet demonstrates how to asynchronously iterate over events from a `Subscriber` instance in Rust. Since `Subscriber` now implements `Future`, it can be awaited in a loop to process incoming events, enabling efficient prefix watching. The loop continues as long as new events are available. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/CHANGELOG.md#_snippet_0 | ||||
|  | ||||
| LANGUAGE: Rust | ||||
| CODE: | ||||
| ``` | ||||
| while let Some(event) = (&mut subscriber).await {} | ||||
| ``` | ||||
|  | ||||
| ---------------------------------------- | ||||
|  | ||||
| TITLE: Suppressing TSAN Race on Arc::drop in Rust | ||||
| DESCRIPTION: This suppression addresses a false positive race detection by ThreadSanitizer in Rust's `Arc::drop` implementation. TSAN fails to correctly reason about the raw atomic `Acquire` fence used after the strong-count atomic subtraction with a `Release` fence in the `Drop` implementation, leading to an erroneous race report. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/tsan_suppressions.txt#_snippet_0 | ||||
|  | ||||
| LANGUAGE: TSAN Suppression | ||||
| CODE: | ||||
| ``` | ||||
| race:Arc*drop | ||||
| ``` | ||||
|  | ||||
| ---------------------------------------- | ||||
|  | ||||
| TITLE: Suppressing TSAN Race on std::thread::local in Rust | ||||
| DESCRIPTION: This suppression addresses ThreadSanitizer false positives when using Rust's `std::thread::local`. Similar to `lazy_static`, `std::thread::local` utilizes implicit barriers that TSAN fails to recognize, leading to incorrect race condition reports. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/tsan_suppressions.txt#_snippet_2 | ||||
|  | ||||
| LANGUAGE: TSAN Suppression | ||||
| CODE: | ||||
| ``` | ||||
| race:std::thread::local | ||||
| ``` | ||||
|  | ||||
| ---------------------------------------- | ||||
|  | ||||
| TITLE: Suppressing TSAN Race on lazy_static in Rust | ||||
| DESCRIPTION: This suppression targets ThreadSanitizer false positives related to the `lazy_static` crate in Rust. `lazy_static` relies on implicit memory barriers that TSAN does not correctly detect, causing it to report races where none exist. | ||||
|  | ||||
| SOURCE: https://github.com/spacejam/sled/blob/main/tsan_suppressions.txt#_snippet_1 | ||||
|  | ||||
| LANGUAGE: TSAN Suppression | ||||
| CODE: | ||||
| ``` | ||||
| race:lazy_static | ||||
| ``` | ||||
							
								
								
									
										735
									
								
								specs/lance_implementation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										735
									
								
								specs/lance_implementation.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,735 @@ | ||||
| Looking at your request to ensure embedding happens with an external embedding provider configured in Redis at `config:core:aiembed:url`, I'll modify the implementation to use an external embedding service instead of local models. | ||||
|  | ||||
| ## Updated Implementation with External Embedding Provider | ||||
|  | ||||
| ### 1. Update Dependencies in `Cargo.toml` | ||||
|  | ||||
| ```toml | ||||
| [dependencies] | ||||
| # ... existing dependencies ... | ||||
| lance = "0.18" | ||||
| lance-index = "0.18" | ||||
| lance-linalg = "0.18" | ||||
| arrow = "52" | ||||
| arrow-array = "52" | ||||
| arrow-schema = "52" | ||||
| parquet = "52" | ||||
| uuid = { version = "1.10", features = ["v4"] } | ||||
| reqwest = { version = "0.11", features = ["json"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| base64 = "0.22" | ||||
| image = "0.25" | ||||
| ``` | ||||
|  | ||||
| ### 2. Create Enhanced Lance Module with External Embedding | ||||
|  | ||||
| Create `src/lance_store.rs`: | ||||
|  | ||||
| ```rust | ||||
| use std::collections::HashMap; | ||||
| use std::path::PathBuf; | ||||
| use std::sync::Arc; | ||||
| use tokio::sync::RwLock; | ||||
|  | ||||
| use arrow::array::{Float32Array, StringArray, BinaryArray, ArrayRef}; | ||||
| use arrow::datatypes::{DataType, Field, Schema}; | ||||
| use arrow::record_batch::RecordBatch; | ||||
| use lance::dataset::{Dataset, WriteParams, WriteMode}; | ||||
| use lance::index::vector::VectorIndexParams; | ||||
| use lance_index::vector::pq::PQBuildParams; | ||||
| use lance_index::vector::ivf::IvfBuildParams; | ||||
|  | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use crate::error::DBError; | ||||
| use crate::cmd::Protocol; | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| struct EmbeddingRequest { | ||||
|     texts: Option<Vec<String>>, | ||||
|     images: Option<Vec<String>>, // base64 encoded | ||||
|     model: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| struct EmbeddingResponse { | ||||
|     embeddings: Vec<Vec<f32>>, | ||||
|     model: String, | ||||
|     usage: Option<HashMap<String, u32>>, | ||||
| } | ||||
|  | ||||
| pub struct LanceStore { | ||||
|     datasets: Arc<RwLock<HashMap<String, Arc<Dataset>>>>, | ||||
|     data_dir: PathBuf, | ||||
|     http_client: reqwest::Client, | ||||
| } | ||||
|  | ||||
| impl LanceStore { | ||||
|     pub async fn new(data_dir: PathBuf) -> Result<Self, DBError> { | ||||
|         // Create data directory if it doesn't exist | ||||
|         std::fs::create_dir_all(&data_dir) | ||||
|             .map_err(|e| DBError(format!("Failed to create Lance data directory: {}", e)))?; | ||||
|          | ||||
|         let http_client = reqwest::Client::builder() | ||||
|             .timeout(std::time::Duration::from_secs(30)) | ||||
|             .build() | ||||
|             .map_err(|e| DBError(format!("Failed to create HTTP client: {}", e)))?; | ||||
|          | ||||
|         Ok(Self { | ||||
|             datasets: Arc::new(RwLock::new(HashMap::new())), | ||||
|             data_dir, | ||||
|             http_client, | ||||
|         }) | ||||
|     } | ||||
|      | ||||
|     /// Get embedding service URL from Redis config | ||||
|     async fn get_embedding_url(&self, server: &crate::server::Server) -> Result<String, DBError> { | ||||
|         // Get the embedding URL from Redis config | ||||
|         let key = "config:core:aiembed:url"; | ||||
|          | ||||
|         // Use HGET to retrieve the URL from Redis hash | ||||
|         let cmd = crate::cmd::Cmd::HGet { | ||||
|             key: key.to_string(), | ||||
|             field: "url".to_string(), | ||||
|         }; | ||||
|          | ||||
|         // Execute command to get the config | ||||
|         let result = cmd.run(server).await?; | ||||
|          | ||||
|         match result { | ||||
|             Protocol::BulkString(url) => Ok(url), | ||||
|             Protocol::SimpleString(url) => Ok(url), | ||||
|             Protocol::Nil => Err(DBError( | ||||
|                 "Embedding service URL not configured. Set it with: HSET config:core:aiembed:url url <YOUR_EMBEDDING_SERVICE_URL>".to_string() | ||||
|             )), | ||||
|             _ => Err(DBError("Invalid embedding URL configuration".to_string())), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Call external embedding service | ||||
|     async fn call_embedding_service( | ||||
|         &self, | ||||
|         server: &crate::server::Server, | ||||
|         texts: Option<Vec<String>>, | ||||
|         images: Option<Vec<String>>, | ||||
|     ) -> Result<Vec<Vec<f32>>, DBError> { | ||||
|         let url = self.get_embedding_url(server).await?; | ||||
|          | ||||
|         let request = EmbeddingRequest { | ||||
|             texts, | ||||
|             images, | ||||
|             model: None, // Let the service use its default | ||||
|         }; | ||||
|          | ||||
|         let response = self.http_client | ||||
|             .post(&url) | ||||
|             .json(&request) | ||||
|             .send() | ||||
|             .await | ||||
|             .map_err(|e| DBError(format!("Failed to call embedding service: {}", e)))?; | ||||
|          | ||||
|         if !response.status().is_success() { | ||||
|             let status = response.status(); | ||||
|             let error_text = response.text().await.unwrap_or_default(); | ||||
|             return Err(DBError(format!( | ||||
|                 "Embedding service returned error {}: {}",  | ||||
|                 status, error_text | ||||
|             ))); | ||||
|         } | ||||
|          | ||||
|         let embedding_response: EmbeddingResponse = response | ||||
|             .json() | ||||
|             .await | ||||
|             .map_err(|e| DBError(format!("Failed to parse embedding response: {}", e)))?; | ||||
|          | ||||
|         Ok(embedding_response.embeddings) | ||||
|     } | ||||
|      | ||||
|     pub async fn embed_text( | ||||
|         &self,  | ||||
|         server: &crate::server::Server, | ||||
|         texts: Vec<String> | ||||
|     ) -> Result<Vec<Vec<f32>>, DBError> { | ||||
|         if texts.is_empty() { | ||||
|             return Ok(Vec::new()); | ||||
|         } | ||||
|          | ||||
|         self.call_embedding_service(server, Some(texts), None).await | ||||
|     } | ||||
|      | ||||
|     pub async fn embed_image( | ||||
|         &self, | ||||
|         server: &crate::server::Server, | ||||
|         image_bytes: Vec<u8> | ||||
|     ) -> Result<Vec<f32>, DBError> { | ||||
|         // Convert image bytes to base64 | ||||
|         let base64_image = base64::encode(&image_bytes); | ||||
|          | ||||
|         let embeddings = self.call_embedding_service( | ||||
|             server,  | ||||
|             None,  | ||||
|             Some(vec![base64_image]) | ||||
|         ).await?; | ||||
|          | ||||
|         embeddings.into_iter() | ||||
|             .next() | ||||
|             .ok_or_else(|| DBError("No embedding returned for image".to_string())) | ||||
|     } | ||||
|      | ||||
|     pub async fn create_dataset( | ||||
|         &self, | ||||
|         name: &str, | ||||
|         schema: Schema, | ||||
|     ) -> Result<(), DBError> { | ||||
|         let dataset_path = self.data_dir.join(format!("{}.lance", name)); | ||||
|          | ||||
|         // Create empty dataset with schema | ||||
|         let write_params = WriteParams { | ||||
|             mode: WriteMode::Create, | ||||
|             ..Default::default() | ||||
|         }; | ||||
|          | ||||
|         // Create an empty RecordBatch with the schema | ||||
|         let empty_batch = RecordBatch::new_empty(Arc::new(schema)); | ||||
|         let batches = vec![empty_batch]; | ||||
|          | ||||
|         let dataset = Dataset::write( | ||||
|             batches, | ||||
|             dataset_path.to_str().unwrap(), | ||||
|             Some(write_params) | ||||
|         ).await | ||||
|         .map_err(|e| DBError(format!("Failed to create dataset: {}", e)))?; | ||||
|          | ||||
|         let mut datasets = self.datasets.write().await; | ||||
|         datasets.insert(name.to_string(), Arc::new(dataset)); | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     pub async fn write_vectors( | ||||
|         &self, | ||||
|         dataset_name: &str, | ||||
|         vectors: Vec<Vec<f32>>, | ||||
|         metadata: Option<HashMap<String, Vec<String>>>, | ||||
|     ) -> Result<usize, DBError> { | ||||
|         let dataset_path = self.data_dir.join(format!("{}.lance", dataset_name)); | ||||
|          | ||||
|         // Open or get cached dataset | ||||
|         let dataset = self.get_or_open_dataset(dataset_name).await?; | ||||
|          | ||||
|         // Build RecordBatch | ||||
|         let num_vectors = vectors.len(); | ||||
|         if num_vectors == 0 { | ||||
|             return Ok(0); | ||||
|         } | ||||
|          | ||||
|         let dim = vectors.first() | ||||
|             .ok_or_else(|| DBError("Empty vectors".to_string()))? | ||||
|             .len(); | ||||
|          | ||||
|         // Flatten vectors | ||||
|         let flat_vectors: Vec<f32> = vectors.into_iter().flatten().collect(); | ||||
|         let vector_array = Float32Array::from(flat_vectors); | ||||
|         let vector_array = arrow::array::FixedSizeListArray::try_new_from_values( | ||||
|             vector_array,  | ||||
|             dim as i32 | ||||
|         ).map_err(|e| DBError(format!("Failed to create vector array: {}", e)))?; | ||||
|          | ||||
|         let mut arrays: Vec<ArrayRef> = vec![Arc::new(vector_array)]; | ||||
|         let mut fields = vec![Field::new( | ||||
|             "vector", | ||||
|             DataType::FixedSizeList( | ||||
|                 Arc::new(Field::new("item", DataType::Float32, true)), | ||||
|                 dim as i32 | ||||
|             ), | ||||
|             false | ||||
|         )]; | ||||
|          | ||||
|         // Add metadata columns if provided | ||||
|         if let Some(metadata) = metadata { | ||||
|             for (key, values) in metadata { | ||||
|                 if values.len() != num_vectors { | ||||
|                     return Err(DBError(format!( | ||||
|                         "Metadata field '{}' has {} values but expected {}",  | ||||
|                         key, values.len(), num_vectors | ||||
|                     ))); | ||||
|                 } | ||||
|                 let array = StringArray::from(values); | ||||
|                 arrays.push(Arc::new(array)); | ||||
|                 fields.push(Field::new(&key, DataType::Utf8, true)); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         let schema = Arc::new(Schema::new(fields)); | ||||
|         let batch = RecordBatch::try_new(schema, arrays) | ||||
|             .map_err(|e| DBError(format!("Failed to create RecordBatch: {}", e)))?; | ||||
|          | ||||
|         // Append to dataset | ||||
|         let write_params = WriteParams { | ||||
|             mode: WriteMode::Append, | ||||
|             ..Default::default() | ||||
|         }; | ||||
|          | ||||
|         Dataset::write( | ||||
|             vec![batch], | ||||
|             dataset_path.to_str().unwrap(), | ||||
|             Some(write_params) | ||||
|         ).await | ||||
|         .map_err(|e| DBError(format!("Failed to write to dataset: {}", e)))?; | ||||
|          | ||||
|         // Refresh cached dataset | ||||
|         let mut datasets = self.datasets.write().await; | ||||
|         datasets.remove(dataset_name); | ||||
|          | ||||
|         Ok(num_vectors) | ||||
|     } | ||||
|      | ||||
|     pub async fn search_vectors( | ||||
|         &self, | ||||
|         dataset_name: &str, | ||||
|         query_vector: Vec<f32>, | ||||
|         k: usize, | ||||
|         nprobes: Option<usize>, | ||||
|         refine_factor: Option<usize>, | ||||
|     ) -> Result<Vec<(f32, HashMap<String, String>)>, DBError> { | ||||
|         let dataset = self.get_or_open_dataset(dataset_name).await?; | ||||
|          | ||||
|         // Build query | ||||
|         let mut query = dataset.scan(); | ||||
|         query = query.nearest( | ||||
|             "vector", | ||||
|             &query_vector, | ||||
|             k, | ||||
|         ).map_err(|e| DBError(format!("Failed to build search query: {}", e)))?; | ||||
|          | ||||
|         if let Some(nprobes) = nprobes { | ||||
|             query = query.nprobes(nprobes); | ||||
|         } | ||||
|          | ||||
|         if let Some(refine) = refine_factor { | ||||
|             query = query.refine_factor(refine); | ||||
|         } | ||||
|          | ||||
|         // Execute search | ||||
|         let results = query | ||||
|             .try_into_stream() | ||||
|             .await | ||||
|             .map_err(|e| DBError(format!("Failed to execute search: {}", e)))? | ||||
|             .try_collect::<Vec<_>>() | ||||
|             .await | ||||
|             .map_err(|e| DBError(format!("Failed to collect results: {}", e)))?; | ||||
|          | ||||
|         // Process results | ||||
|         let mut output = Vec::new(); | ||||
|         for batch in results { | ||||
|             // Get distances | ||||
|             let distances = batch | ||||
|                 .column_by_name("_distance") | ||||
|                 .ok_or_else(|| DBError("No distance column".to_string()))? | ||||
|                 .as_any() | ||||
|                 .downcast_ref::<Float32Array>() | ||||
|                 .ok_or_else(|| DBError("Invalid distance type".to_string()))?; | ||||
|              | ||||
|             // Get metadata | ||||
|             for i in 0..batch.num_rows() { | ||||
|                 let distance = distances.value(i); | ||||
|                 let mut metadata = HashMap::new(); | ||||
|                  | ||||
|                 for field in batch.schema().fields() { | ||||
|                     if field.name() != "vector" && field.name() != "_distance" { | ||||
|                         if let Some(col) = batch.column_by_name(field.name()) { | ||||
|                             if let Some(str_array) = col.as_any().downcast_ref::<StringArray>() { | ||||
|                                 if !str_array.is_null(i) { | ||||
|                                     metadata.insert( | ||||
|                                         field.name().to_string(), | ||||
|                                         str_array.value(i).to_string() | ||||
|                                     ); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 output.push((distance, metadata)); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         Ok(output) | ||||
|     } | ||||
|      | ||||
|     pub async fn store_multimodal( | ||||
|         &self, | ||||
|         server: &crate::server::Server, | ||||
|         dataset_name: &str, | ||||
|         text: Option<String>, | ||||
|         image_bytes: Option<Vec<u8>>, | ||||
|         metadata: HashMap<String, String>, | ||||
|     ) -> Result<String, DBError> { | ||||
|         // Generate ID | ||||
|         let id = uuid::Uuid::new_v4().to_string(); | ||||
|          | ||||
|         // Generate embeddings using external service | ||||
|         let embedding = if let Some(text) = text.as_ref() { | ||||
|             self.embed_text(server, vec![text.clone()]).await? | ||||
|                 .into_iter() | ||||
|                 .next() | ||||
|                 .ok_or_else(|| DBError("No embedding returned".to_string()))? | ||||
|         } else if let Some(img) = image_bytes.as_ref() { | ||||
|             self.embed_image(server, img.clone()).await? | ||||
|         } else { | ||||
|             return Err(DBError("No text or image provided".to_string())); | ||||
|         }; | ||||
|          | ||||
|         // Prepare metadata | ||||
|         let mut full_metadata = metadata; | ||||
|         full_metadata.insert("id".to_string(), id.clone()); | ||||
|         if let Some(text) = text { | ||||
|             full_metadata.insert("text".to_string(), text); | ||||
|         } | ||||
|         if let Some(img) = image_bytes { | ||||
|             full_metadata.insert("image_base64".to_string(), base64::encode(img)); | ||||
|         } | ||||
|          | ||||
|         // Convert metadata to column vectors | ||||
|         let mut metadata_cols = HashMap::new(); | ||||
|         for (key, value) in full_metadata { | ||||
|             metadata_cols.insert(key, vec![value]); | ||||
|         } | ||||
|          | ||||
|         // Write to dataset | ||||
|         self.write_vectors(dataset_name, vec![embedding], Some(metadata_cols)).await?; | ||||
|          | ||||
|         Ok(id) | ||||
|     } | ||||
|      | ||||
|     pub async fn search_with_text( | ||||
|         &self, | ||||
|         server: &crate::server::Server, | ||||
|         dataset_name: &str, | ||||
|         query_text: String, | ||||
|         k: usize, | ||||
|         nprobes: Option<usize>, | ||||
|         refine_factor: Option<usize>, | ||||
|     ) -> Result<Vec<(f32, HashMap<String, String>)>, DBError> { | ||||
|         // Embed the query text using external service | ||||
|         let embeddings = self.embed_text(server, vec![query_text]).await?; | ||||
|         let query_vector = embeddings.into_iter() | ||||
|             .next() | ||||
|             .ok_or_else(|| DBError("No embedding returned for query".to_string()))?; | ||||
|          | ||||
|         // Search with the embedding | ||||
|         self.search_vectors(dataset_name, query_vector, k, nprobes, refine_factor).await | ||||
|     } | ||||
|      | ||||
|     pub async fn create_index( | ||||
|         &self, | ||||
|         dataset_name: &str, | ||||
|         index_type: &str, | ||||
|         num_partitions: Option<usize>, | ||||
|         num_sub_vectors: Option<usize>, | ||||
|     ) -> Result<(), DBError> { | ||||
|         let dataset = self.get_or_open_dataset(dataset_name).await?; | ||||
|          | ||||
|         let mut params = VectorIndexParams::default(); | ||||
|          | ||||
|         match index_type.to_uppercase().as_str() { | ||||
|             "IVF_PQ" => { | ||||
|                 params.ivf = IvfBuildParams { | ||||
|                     num_partitions: num_partitions.unwrap_or(256), | ||||
|                     ..Default::default() | ||||
|                 }; | ||||
|                 params.pq = PQBuildParams { | ||||
|                     num_sub_vectors: num_sub_vectors.unwrap_or(16), | ||||
|                     ..Default::default() | ||||
|                 }; | ||||
|             } | ||||
|             _ => return Err(DBError(format!("Unsupported index type: {}", index_type))), | ||||
|         } | ||||
|          | ||||
|         dataset.create_index( | ||||
|             &["vector"], | ||||
|             lance::index::IndexType::Vector, | ||||
|             None, | ||||
|             ¶ms, | ||||
|             true | ||||
|         ).await | ||||
|         .map_err(|e| DBError(format!("Failed to create index: {}", e)))?; | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     async fn get_or_open_dataset(&self, name: &str) -> Result<Arc<Dataset>, DBError> { | ||||
|         let mut datasets = self.datasets.write().await; | ||||
|          | ||||
|         if let Some(dataset) = datasets.get(name) { | ||||
|             return Ok(dataset.clone()); | ||||
|         } | ||||
|          | ||||
|         let dataset_path = self.data_dir.join(format!("{}.lance", name)); | ||||
|         if !dataset_path.exists() { | ||||
|             return Err(DBError(format!("Dataset '{}' does not exist", name))); | ||||
|         } | ||||
|          | ||||
|         let dataset = Dataset::open(dataset_path.to_str().unwrap()) | ||||
|             .await | ||||
|             .map_err(|e| DBError(format!("Failed to open dataset: {}", e)))?; | ||||
|          | ||||
|         let dataset = Arc::new(dataset); | ||||
|         datasets.insert(name.to_string(), dataset.clone()); | ||||
|          | ||||
|         Ok(dataset) | ||||
|     } | ||||
|      | ||||
|     pub async fn list_datasets(&self) -> Result<Vec<String>, DBError> { | ||||
|         let mut datasets = Vec::new(); | ||||
|          | ||||
|         let entries = std::fs::read_dir(&self.data_dir) | ||||
|             .map_err(|e| DBError(format!("Failed to read data directory: {}", e)))?; | ||||
|          | ||||
|         for entry in entries { | ||||
|             let entry = entry.map_err(|e| DBError(format!("Failed to read entry: {}", e)))?; | ||||
|             let path = entry.path(); | ||||
|              | ||||
|             if path.is_dir() { | ||||
|                 if let Some(name) = path.file_name() { | ||||
|                     if let Some(name_str) = name.to_str() { | ||||
|                         if name_str.ends_with(".lance") { | ||||
|                             let dataset_name = name_str.trim_end_matches(".lance"); | ||||
|                             datasets.push(dataset_name.to_string()); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         Ok(datasets) | ||||
|     } | ||||
|      | ||||
|     pub async fn drop_dataset(&self, name: &str) -> Result<(), DBError> { | ||||
|         // Remove from cache | ||||
|         let mut datasets = self.datasets.write().await; | ||||
|         datasets.remove(name); | ||||
|          | ||||
|         // Delete from disk | ||||
|         let dataset_path = self.data_dir.join(format!("{}.lance", name)); | ||||
|         if dataset_path.exists() { | ||||
|             std::fs::remove_dir_all(dataset_path) | ||||
|                 .map_err(|e| DBError(format!("Failed to delete dataset: {}", e)))?; | ||||
|         } | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     pub async fn get_dataset_info(&self, name: &str) -> Result<HashMap<String, String>, DBError> { | ||||
|         let dataset = self.get_or_open_dataset(name).await?; | ||||
|          | ||||
|         let mut info = HashMap::new(); | ||||
|         info.insert("name".to_string(), name.to_string()); | ||||
|         info.insert("version".to_string(), dataset.version().to_string()); | ||||
|         info.insert("num_rows".to_string(), dataset.count_rows().await?.to_string()); | ||||
|          | ||||
|         // Get schema info | ||||
|         let schema = dataset.schema(); | ||||
|         let fields: Vec<String> = schema.fields() | ||||
|             .iter() | ||||
|             .map(|f| format!("{}:{}", f.name(), f.data_type())) | ||||
|             .collect(); | ||||
|         info.insert("schema".to_string(), fields.join(", ")); | ||||
|          | ||||
|         Ok(info) | ||||
|     } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 3. Update Command Implementations | ||||
|  | ||||
| Update the command implementations to pass the server reference for embedding service access: | ||||
|  | ||||
| ```rust | ||||
| // In cmd.rs, update the lance command implementations | ||||
|  | ||||
| async fn lance_store_cmd( | ||||
|     server: &Server, | ||||
|     dataset: &str, | ||||
|     text: Option<String>, | ||||
|     image_base64: Option<String>, | ||||
|     metadata: HashMap<String, String>, | ||||
| ) -> Result<Protocol, DBError> { | ||||
|     let lance_store = server.lance_store()?; | ||||
|      | ||||
|     // Decode image if provided | ||||
|     let image_bytes = if let Some(b64) = image_base64 { | ||||
|         Some(base64::decode(b64).map_err(|e|  | ||||
|             DBError(format!("Invalid base64 image: {}", e)))?) | ||||
|     } else { | ||||
|         None | ||||
|     }; | ||||
|      | ||||
|     // Pass server reference for embedding service access | ||||
|     let id = lance_store.store_multimodal( | ||||
|         server,  // Pass server to access Redis config | ||||
|         dataset, | ||||
|         text, | ||||
|         image_bytes, | ||||
|         metadata, | ||||
|     ).await?; | ||||
|      | ||||
|     Ok(Protocol::BulkString(id)) | ||||
| } | ||||
|  | ||||
| async fn lance_embed_text_cmd( | ||||
|     server: &Server, | ||||
|     texts: &[String], | ||||
| ) -> Result<Protocol, DBError> { | ||||
|     let lance_store = server.lance_store()?; | ||||
|      | ||||
|     // Pass server reference for embedding service access | ||||
|     let embeddings = lance_store.embed_text(server, texts.to_vec()).await?; | ||||
|      | ||||
|     // Return as array of vectors | ||||
|     let mut output = Vec::new(); | ||||
|     for embedding in embeddings { | ||||
|         let vector_str = format!("[{}]",  | ||||
|             embedding.iter() | ||||
|                 .map(|f| f.to_string()) | ||||
|                 .collect::<Vec<_>>() | ||||
|                 .join(",") | ||||
|         ); | ||||
|         output.push(Protocol::BulkString(vector_str)); | ||||
|     } | ||||
|      | ||||
|     Ok(Protocol::Array(output)) | ||||
| } | ||||
|  | ||||
| async fn lance_search_text_cmd( | ||||
|     server: &Server, | ||||
|     dataset: &str, | ||||
|     query_text: &str, | ||||
|     k: usize, | ||||
|     nprobes: Option<usize>, | ||||
|     refine_factor: Option<usize>, | ||||
| ) -> Result<Protocol, DBError> { | ||||
|     let lance_store = server.lance_store()?; | ||||
|      | ||||
|     // Search using text query (will be embedded automatically) | ||||
|     let results = lance_store.search_with_text( | ||||
|         server, | ||||
|         dataset, | ||||
|         query_text.to_string(), | ||||
|         k, | ||||
|         nprobes, | ||||
|         refine_factor, | ||||
|     ).await?; | ||||
|      | ||||
|     // Format results | ||||
|     let mut output = Vec::new(); | ||||
|     for (distance, metadata) in results { | ||||
|         let metadata_json = serde_json::to_string(&metadata) | ||||
|             .unwrap_or_else(|_| "{}".to_string()); | ||||
|          | ||||
|         output.push(Protocol::Array(vec![ | ||||
|             Protocol::BulkString(distance.to_string()), | ||||
|             Protocol::BulkString(metadata_json), | ||||
|         ])); | ||||
|     } | ||||
|      | ||||
|     Ok(Protocol::Array(output)) | ||||
| } | ||||
|  | ||||
| // Add new command for text-based search | ||||
| pub enum Cmd { | ||||
|     // ... existing commands ... | ||||
|     LanceSearchText { | ||||
|         dataset: String, | ||||
|         query_text: String, | ||||
|         k: usize, | ||||
|         nprobes: Option<usize>, | ||||
|         refine_factor: Option<usize>, | ||||
|     }, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Usage Examples | ||||
|  | ||||
| ### 1. Configure the Embedding Service | ||||
|  | ||||
| First, users need to configure the embedding service URL: | ||||
|  | ||||
| ```bash | ||||
| # Configure the embedding service endpoint | ||||
| redis-cli> HSET config:core:aiembed:url url "http://localhost:8000/embeddings" | ||||
| OK | ||||
|  | ||||
| # Or use a cloud service | ||||
| redis-cli> HSET config:core:aiembed:url url "https://api.openai.com/v1/embeddings" | ||||
| OK | ||||
| ``` | ||||
|  | ||||
| ### 2. Use Lance Commands with Automatic External Embedding | ||||
|  | ||||
| ```bash | ||||
| # Create a dataset | ||||
| redis-cli> LANCE.CREATE products DIM 1536 SCHEMA name:string price:float category:string | ||||
| OK | ||||
|  | ||||
| # Store text with automatic embedding (calls external service) | ||||
| redis-cli> LANCE.STORE products TEXT "Wireless noise-canceling headphones with 30-hour battery" name:AirPods price:299.99 category:Electronics | ||||
| "uuid-123-456" | ||||
|  | ||||
| # Search using text query (automatically embeds the query) | ||||
| redis-cli> LANCE.SEARCH.TEXT products "best headphones for travel" K 5 | ||||
| 1) "0.92"  | ||||
| 2) "{\"id\":\"uuid-123\",\"name\":\"AirPods\",\"price\":\"299.99\"}" | ||||
|  | ||||
| # Get embeddings directly | ||||
| redis-cli> LANCE.EMBED.TEXT "This text will be embedded" | ||||
| 1) "[0.123, 0.456, 0.789, ...]" | ||||
| ``` | ||||
|  | ||||
| ## External Embedding Service API Specification | ||||
|  | ||||
| The external embedding service should accept POST requests with this format: | ||||
|  | ||||
| ```json | ||||
| // Request | ||||
| { | ||||
|   "texts": ["text1", "text2"],  // Optional | ||||
|   "images": ["base64_img1"],    // Optional | ||||
|   "model": "text-embedding-ada-002"  // Optional | ||||
| } | ||||
|  | ||||
| // Response | ||||
| { | ||||
|   "embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], | ||||
|   "model": "text-embedding-ada-002", | ||||
|   "usage": { | ||||
|     "prompt_tokens": 100, | ||||
|     "total_tokens": 100 | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Error Handling | ||||
|  | ||||
| The implementation includes comprehensive error handling: | ||||
|  | ||||
| 1. **Missing Configuration**: Clear error message if embedding URL not configured | ||||
| 2. **Service Failures**: Graceful handling of embedding service errors | ||||
| 3. **Timeout Protection**: 30-second timeout for embedding requests | ||||
| 4. **Retry Logic**: Could be added for resilience | ||||
|  | ||||
| ## Benefits of This Approach | ||||
|  | ||||
| 1. **Flexibility**: Supports any embedding service with compatible API | ||||
| 2. **Cost Control**: Use your preferred embedding provider | ||||
| 3. **Scalability**: Embedding service can be scaled independently | ||||
| 4. **Consistency**: All embeddings use the same configured service | ||||
| 5. **Security**: API keys and endpoints stored securely in Redis | ||||
|  | ||||
| This implementation ensures that all embedding operations go through the external service configured in Redis, providing a clean separation between the vector database functionality and the embedding generation. | ||||
|  | ||||
|  | ||||
| TODO EXTRA: | ||||
|  | ||||
| - secret for the embedding service API key | ||||
|  | ||||
| @@ -1,5 +1,4 @@ | ||||
| use crate::{error::DBError, protocol::Protocol, server::Server}; | ||||
| use serde::Serialize; | ||||
| use tokio::time::{timeout, Duration}; | ||||
| use futures::future::select_all; | ||||
| 
 | ||||
| @@ -1093,26 +1092,23 @@ async fn dbsize_cmd(server: &Server) -> Result<Protocol, DBError> { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
| struct ServerInfo { | ||||
|     redis_version: String, | ||||
|     encrypted: bool, | ||||
|     selected_db: u64, | ||||
| } | ||||
| 
 | ||||
| async fn info_cmd(server: &Server, section: &Option<String>) -> Result<Protocol, DBError> { | ||||
|     let info = ServerInfo { | ||||
|         redis_version: "7.0.0".to_string(), | ||||
|         encrypted: server.current_storage()?.is_encrypted(), | ||||
|         selected_db: server.selected_db, | ||||
|     }; | ||||
|     let storage_info = server.current_storage()?.info()?; | ||||
|     let mut info_map: std::collections::HashMap<String, String> = storage_info.into_iter().collect(); | ||||
| 
 | ||||
|     info_map.insert("redis_version".to_string(), "7.0.0".to_string()); | ||||
|     info_map.insert("selected_db".to_string(), server.selected_db.to_string()); | ||||
|     info_map.insert("backend".to_string(), format!("{:?}", server.option.backend)); | ||||
| 
 | ||||
| 
 | ||||
|     let mut info_string = String::new(); | ||||
|     info_string.push_str(&format!("# Server\n")); | ||||
|     info_string.push_str(&format!("redis_version:{}\n", info.redis_version)); | ||||
|     info_string.push_str(&format!("encrypted:{}\n", if info.encrypted { 1 } else { 0 })); | ||||
|     info_string.push_str(&format!("# Keyspace\n")); | ||||
|     info_string.push_str(&format!("db{}:keys=0,expires=0,avg_ttl=0\n", info.selected_db)); | ||||
|     info_string.push_str("# Server\n"); | ||||
|     info_string.push_str(&format!("redis_version:{}\n", info_map.get("redis_version").unwrap())); | ||||
|     info_string.push_str(&format!("backend:{}\n", info_map.get("backend").unwrap())); | ||||
|     info_string.push_str(&format!("encrypted:{}\n", info_map.get("is_encrypted").unwrap())); | ||||
|     
 | ||||
|     info_string.push_str("# Keyspace\n"); | ||||
|     info_string.push_str(&format!("db{}:keys={},expires=0,avg_ttl=0\n", info_map.get("selected_db").unwrap(), info_map.get("db_size").unwrap())); | ||||
| 
 | ||||
|     match section { | ||||
|         Some(s) => { | ||||
| @@ -23,6 +23,7 @@ impl From<CryptoError> for crate::error::DBError { | ||||
| } | ||||
| 
 | ||||
| /// Super-simple factory: new(secret) + encrypt(bytes) + decrypt(bytes)
 | ||||
| #[derive(Clone)] | ||||
| pub struct CryptoFactory { | ||||
|     key: chacha20poly1305::Key, | ||||
| } | ||||
| @@ -6,3 +6,5 @@ pub mod options; | ||||
| pub mod protocol; | ||||
| pub mod server; | ||||
| pub mod storage; | ||||
| pub mod storage_trait;  // Add this
 | ||||
| pub mod storage_sled;   // Add this
 | ||||
| @@ -30,6 +30,10 @@ struct Args { | ||||
|     /// Encrypt the database
 | ||||
|     #[arg(long)] | ||||
|     encrypt: bool, | ||||
| 
 | ||||
|     /// Use the sled backend
 | ||||
|     #[arg(long)] | ||||
|     sled: bool, | ||||
| } | ||||
| 
 | ||||
| #[tokio::main] | ||||
| @@ -51,6 +55,11 @@ async fn main() { | ||||
|         debug: args.debug, | ||||
|         encryption_key: args.encryption_key, | ||||
|         encrypt: args.encrypt, | ||||
|         backend: if args.sled { | ||||
|             herodb::options::BackendType::Sled | ||||
|         } else { | ||||
|             herodb::options::BackendType::Redb | ||||
|         }, | ||||
|     }; | ||||
| 
 | ||||
|     // new server
 | ||||
							
								
								
									
										15
									
								
								src/options.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/options.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| #[derive(Debug, Clone)] | ||||
| pub enum BackendType { | ||||
|     Redb, | ||||
|     Sled, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct DBOption { | ||||
|     pub dir: String, | ||||
|     pub port: u16, | ||||
|     pub debug: bool, | ||||
|     pub encrypt: bool, | ||||
|     pub encryption_key: Option<String>, | ||||
|     pub backend: BackendType, | ||||
| } | ||||
| @@ -12,10 +12,12 @@ use crate::error::DBError; | ||||
| use crate::options; | ||||
| use crate::protocol::Protocol; | ||||
| use crate::storage::Storage; | ||||
| use crate::storage_sled::SledStorage; | ||||
| use crate::storage_trait::StorageBackend; | ||||
| 
 | ||||
| #[derive(Clone)] | ||||
| pub struct Server { | ||||
|     pub db_cache: std::sync::Arc<std::sync::RwLock<HashMap<u64, Arc<Storage>>>>, | ||||
|     pub db_cache: std::sync::Arc<std::sync::RwLock<HashMap<u64, Arc<dyn StorageBackend>>>>, | ||||
|     pub option: options::DBOption, | ||||
|     pub client_name: Option<String>, | ||||
|     pub selected_db: u64, // Changed from usize to u64
 | ||||
| @@ -52,7 +54,7 @@ impl Server { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn current_storage(&self) -> Result<Arc<Storage>, DBError> { | ||||
|     pub fn current_storage(&self) -> Result<Arc<dyn StorageBackend>, DBError> { | ||||
|         let mut cache = self.db_cache.write().unwrap(); | ||||
|         
 | ||||
|         if let Some(storage) = cache.get(&self.selected_db) { | ||||
| @@ -73,11 +75,22 @@ impl Server { | ||||
|         
 | ||||
|         println!("Creating new db file: {}", db_file_path.display()); | ||||
|         
 | ||||
|         let storage = Arc::new(Storage::new( | ||||
|             db_file_path, | ||||
|             self.should_encrypt_db(self.selected_db), | ||||
|             self.option.encryption_key.as_deref() | ||||
|         )?); | ||||
|         let storage: Arc<dyn StorageBackend> = match self.option.backend { | ||||
|             options::BackendType::Redb => { | ||||
|                 Arc::new(Storage::new( | ||||
|                     db_file_path, | ||||
|                     self.should_encrypt_db(self.selected_db), | ||||
|                     self.option.encryption_key.as_deref() | ||||
|                 )?) | ||||
|             } | ||||
|             options::BackendType::Sled => { | ||||
|                 Arc::new(SledStorage::new( | ||||
|                     db_file_path, | ||||
|                     self.should_encrypt_db(self.selected_db), | ||||
|                     self.option.encryption_key.as_deref() | ||||
|                 )?) | ||||
|             } | ||||
|         }; | ||||
|         
 | ||||
|         cache.insert(self.selected_db, storage.clone()); | ||||
|         Ok(storage) | ||||
							
								
								
									
										287
									
								
								src/storage/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								src/storage/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,287 @@ | ||||
| use std::{ | ||||
|     path::Path, | ||||
|     sync::Arc, | ||||
|     time::{SystemTime, UNIX_EPOCH}, | ||||
| }; | ||||
|  | ||||
| use redb::{Database, TableDefinition}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| use crate::crypto::CryptoFactory; | ||||
| use crate::error::DBError; | ||||
|  | ||||
| // Re-export modules | ||||
| mod storage_basic; | ||||
| mod storage_hset; | ||||
| mod storage_lists; | ||||
| mod storage_extra; | ||||
|  | ||||
| // Re-export implementations | ||||
| // Note: These imports are used by the impl blocks in the submodules | ||||
| // The compiler shows them as unused because they're not directly used in this file | ||||
| // but they're needed for the Storage struct methods to be available | ||||
| pub use storage_extra::*; | ||||
|  | ||||
| // Table definitions for different Redis data types | ||||
| const TYPES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("types"); | ||||
| const STRINGS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("strings"); | ||||
| const HASHES_TABLE: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("hashes"); | ||||
| const LISTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("lists"); | ||||
| const STREAMS_META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("streams_meta"); | ||||
| const STREAMS_DATA_TABLE: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("streams_data"); | ||||
| const ENCRYPTED_TABLE: TableDefinition<&str, u8> = TableDefinition::new("encrypted"); | ||||
| const EXPIRATION_TABLE: TableDefinition<&str, u64> = TableDefinition::new("expiration"); | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| pub struct StreamEntry { | ||||
|     pub fields: Vec<(String, String)>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| pub struct ListValue { | ||||
|     pub elements: Vec<String>, | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| pub fn now_in_millis() -> u128 { | ||||
|     let start = SystemTime::now(); | ||||
|     let duration_since_epoch = start.duration_since(UNIX_EPOCH).unwrap(); | ||||
|     duration_since_epoch.as_millis() | ||||
| } | ||||
|  | ||||
| pub struct Storage { | ||||
|     db: Database, | ||||
|     crypto: Option<CryptoFactory>, | ||||
| } | ||||
|  | ||||
| impl Storage { | ||||
|     pub fn new(path: impl AsRef<Path>, should_encrypt: bool, master_key: Option<&str>) -> Result<Self, DBError> { | ||||
|         let db = Database::create(path)?; | ||||
|          | ||||
|         // Create tables if they don't exist | ||||
|         let write_txn = db.begin_write()?; | ||||
|         { | ||||
|             let _ = write_txn.open_table(TYPES_TABLE)?; | ||||
|             let _ = write_txn.open_table(STRINGS_TABLE)?; | ||||
|             let _ = write_txn.open_table(HASHES_TABLE)?; | ||||
|             let _ = write_txn.open_table(LISTS_TABLE)?; | ||||
|             let _ = write_txn.open_table(STREAMS_META_TABLE)?; | ||||
|             let _ = write_txn.open_table(STREAMS_DATA_TABLE)?; | ||||
|             let _ = write_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|             let _ = write_txn.open_table(EXPIRATION_TABLE)?; | ||||
|         } | ||||
|         write_txn.commit()?; | ||||
|          | ||||
|         // Check if database was previously encrypted | ||||
|         let read_txn = db.begin_read()?; | ||||
|         let encrypted_table = read_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|         let was_encrypted = encrypted_table.get("encrypted")?.map(|v| v.value() == 1).unwrap_or(false); | ||||
|         drop(read_txn); | ||||
|          | ||||
|         let crypto = if should_encrypt || was_encrypted { | ||||
|             if let Some(key) = master_key { | ||||
|                 Some(CryptoFactory::new(key.as_bytes())) | ||||
|             } else { | ||||
|                 return Err(DBError("Encryption requested but no master key provided".to_string())); | ||||
|             } | ||||
|         } else { | ||||
|             None | ||||
|         }; | ||||
|          | ||||
|         // If we're enabling encryption for the first time, mark it | ||||
|         if should_encrypt && !was_encrypted { | ||||
|             let write_txn = db.begin_write()?; | ||||
|             { | ||||
|                 let mut encrypted_table = write_txn.open_table(ENCRYPTED_TABLE)?; | ||||
|                 encrypted_table.insert("encrypted", &1u8)?; | ||||
|             } | ||||
|             write_txn.commit()?; | ||||
|         } | ||||
|          | ||||
|         Ok(Storage { | ||||
|             db, | ||||
|             crypto, | ||||
|         }) | ||||
|     } | ||||
|      | ||||
|     pub fn is_encrypted(&self) -> bool { | ||||
|         self.crypto.is_some() | ||||
|     } | ||||
|  | ||||
|     // Helper methods for encryption | ||||
|     fn encrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.encrypt(data)) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn decrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.decrypt(data)?) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| use crate::storage_trait::StorageBackend; | ||||
|  | ||||
| impl StorageBackend for Storage { | ||||
|     fn get(&self, key: &str) -> Result<Option<String>, DBError> { | ||||
|         self.get(key) | ||||
|     } | ||||
|  | ||||
|     fn set(&self, key: String, value: String) -> Result<(), DBError> { | ||||
|         self.set(key, value) | ||||
|     } | ||||
|  | ||||
|     fn setx(&self, key: String, value: String, expire_ms: u128) -> Result<(), DBError> { | ||||
|         self.setx(key, value, expire_ms) | ||||
|     } | ||||
|  | ||||
|     fn del(&self, key: String) -> Result<(), DBError> { | ||||
|         self.del(key) | ||||
|     } | ||||
|  | ||||
|     fn exists(&self, key: &str) -> Result<bool, DBError> { | ||||
|         self.exists(key) | ||||
|     } | ||||
|  | ||||
|     fn keys(&self, pattern: &str) -> Result<Vec<String>, DBError> { | ||||
|         self.keys(pattern) | ||||
|     } | ||||
|  | ||||
|     fn dbsize(&self) -> Result<i64, DBError> { | ||||
|         self.dbsize() | ||||
|     } | ||||
|  | ||||
|     fn flushdb(&self) -> Result<(), DBError> { | ||||
|         self.flushdb() | ||||
|     } | ||||
|  | ||||
|     fn get_key_type(&self, key: &str) -> Result<Option<String>, DBError> { | ||||
|         self.get_key_type(key) | ||||
|     } | ||||
|  | ||||
|     fn scan(&self, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError> { | ||||
|         self.scan(cursor, pattern, count) | ||||
|     } | ||||
|  | ||||
|     fn hscan(&self, key: &str, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError> { | ||||
|         self.hscan(key, cursor, pattern, count) | ||||
|     } | ||||
|  | ||||
|     fn hset(&self, key: &str, pairs: Vec<(String, String)>) -> Result<i64, DBError> { | ||||
|         self.hset(key, pairs) | ||||
|     } | ||||
|  | ||||
|     fn hget(&self, key: &str, field: &str) -> Result<Option<String>, DBError> { | ||||
|         self.hget(key, field) | ||||
|     } | ||||
|  | ||||
|     fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>, DBError> { | ||||
|         self.hgetall(key) | ||||
|     } | ||||
|  | ||||
|     fn hdel(&self, key: &str, fields: Vec<String>) -> Result<i64, DBError> { | ||||
|         self.hdel(key, fields) | ||||
|     } | ||||
|  | ||||
|     fn hexists(&self, key: &str, field: &str) -> Result<bool, DBError> { | ||||
|         self.hexists(key, field) | ||||
|     } | ||||
|  | ||||
|     fn hkeys(&self, key: &str) -> Result<Vec<String>, DBError> { | ||||
|         self.hkeys(key) | ||||
|     } | ||||
|  | ||||
|     fn hvals(&self, key: &str) -> Result<Vec<String>, DBError> { | ||||
|         self.hvals(key) | ||||
|     } | ||||
|  | ||||
|     fn hlen(&self, key: &str) -> Result<i64, DBError> { | ||||
|         self.hlen(key) | ||||
|     } | ||||
|  | ||||
|     fn hmget(&self, key: &str, fields: Vec<String>) -> Result<Vec<Option<String>>, DBError> { | ||||
|         self.hmget(key, fields) | ||||
|     } | ||||
|  | ||||
|     fn hsetnx(&self, key: &str, field: &str, value: &str) -> Result<bool, DBError> { | ||||
|         self.hsetnx(key, field, value) | ||||
|     } | ||||
|  | ||||
|     fn lpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError> { | ||||
|         self.lpush(key, elements) | ||||
|     } | ||||
|  | ||||
|     fn rpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError> { | ||||
|         self.rpush(key, elements) | ||||
|     } | ||||
|  | ||||
|     fn lpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError> { | ||||
|         self.lpop(key, count) | ||||
|     } | ||||
|  | ||||
|     fn rpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError> { | ||||
|         self.rpop(key, count) | ||||
|     } | ||||
|  | ||||
|     fn llen(&self, key: &str) -> Result<i64, DBError> { | ||||
|         self.llen(key) | ||||
|     } | ||||
|  | ||||
|     fn lindex(&self, key: &str, index: i64) -> Result<Option<String>, DBError> { | ||||
|         self.lindex(key, index) | ||||
|     } | ||||
|  | ||||
|     fn lrange(&self, key: &str, start: i64, stop: i64) -> Result<Vec<String>, DBError> { | ||||
|         self.lrange(key, start, stop) | ||||
|     } | ||||
|  | ||||
|     fn ltrim(&self, key: &str, start: i64, stop: i64) -> Result<(), DBError> { | ||||
|         self.ltrim(key, start, stop) | ||||
|     } | ||||
|  | ||||
|     fn lrem(&self, key: &str, count: i64, element: &str) -> Result<i64, DBError> { | ||||
|         self.lrem(key, count, element) | ||||
|     } | ||||
|  | ||||
|     fn ttl(&self, key: &str) -> Result<i64, DBError> { | ||||
|         self.ttl(key) | ||||
|     } | ||||
|  | ||||
|     fn expire_seconds(&self, key: &str, secs: u64) -> Result<bool, DBError> { | ||||
|         self.expire_seconds(key, secs) | ||||
|     } | ||||
|  | ||||
|     fn pexpire_millis(&self, key: &str, ms: u128) -> Result<bool, DBError> { | ||||
|         self.pexpire_millis(key, ms) | ||||
|     } | ||||
|  | ||||
|     fn persist(&self, key: &str) -> Result<bool, DBError> { | ||||
|         self.persist(key) | ||||
|     } | ||||
|  | ||||
|     fn expire_at_seconds(&self, key: &str, ts_secs: i64) -> Result<bool, DBError> { | ||||
|         self.expire_at_seconds(key, ts_secs) | ||||
|     } | ||||
|  | ||||
|     fn pexpire_at_millis(&self, key: &str, ts_ms: i64) -> Result<bool, DBError> { | ||||
|         self.pexpire_at_millis(key, ts_ms) | ||||
|     } | ||||
|  | ||||
|     fn is_encrypted(&self) -> bool { | ||||
|         self.is_encrypted() | ||||
|     } | ||||
|      | ||||
|     fn info(&self) -> Result<Vec<(String, String)>, DBError> { | ||||
|         self.info() | ||||
|     } | ||||
|  | ||||
|     fn clone_arc(&self) -> Arc<dyn StorageBackend> { | ||||
|         unimplemented!("Storage cloning not yet implemented for redb backend") | ||||
|     } | ||||
| } | ||||
| @@ -208,6 +208,14 @@ impl Storage { | ||||
|         write_txn.commit()?; | ||||
|         Ok(applied) | ||||
|     } | ||||
| 
 | ||||
|     pub fn info(&self) -> Result<Vec<(String, String)>, DBError> { | ||||
|         let dbsize = self.dbsize()?; | ||||
|         Ok(vec![ | ||||
|             ("db_size".to_string(), dbsize.to_string()), | ||||
|             ("is_encrypted".to_string(), self.is_encrypted().to_string()), | ||||
|         ]) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // Utility function for glob pattern matching
 | ||||
							
								
								
									
										845
									
								
								src/storage_sled/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										845
									
								
								src/storage_sled/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,845 @@ | ||||
| // src/storage_sled/mod.rs | ||||
| use std::path::Path; | ||||
| use std::sync::Arc; | ||||
| use std::collections::HashMap; | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use crate::error::DBError; | ||||
| use crate::storage_trait::StorageBackend; | ||||
| use crate::crypto::CryptoFactory; | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| enum ValueType { | ||||
|     String(String), | ||||
|     Hash(HashMap<String, String>), | ||||
|     List(Vec<String>), | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct StorageValue { | ||||
|     value: ValueType, | ||||
|     expires_at: Option<u128>, // milliseconds since epoch | ||||
| } | ||||
|  | ||||
| pub struct SledStorage { | ||||
|     db: sled::Db, | ||||
|     types: sled::Tree, | ||||
|     crypto: Option<CryptoFactory>, | ||||
| } | ||||
|  | ||||
| impl SledStorage { | ||||
|     pub fn new(path: impl AsRef<Path>, should_encrypt: bool, master_key: Option<&str>) -> Result<Self, DBError> { | ||||
|         let db = sled::open(path).map_err(|e| DBError(format!("Failed to open sled: {}", e)))?; | ||||
|         let types = db.open_tree("types").map_err(|e| DBError(format!("Failed to open types tree: {}", e)))?; | ||||
|          | ||||
|         // Check if database was previously encrypted | ||||
|         let encrypted_tree = db.open_tree("encrypted").map_err(|e| DBError(e.to_string()))?; | ||||
|         let was_encrypted = encrypted_tree.get("encrypted") | ||||
|             .map_err(|e| DBError(e.to_string()))? | ||||
|             .map(|v| v[0] == 1) | ||||
|             .unwrap_or(false); | ||||
|          | ||||
|         let crypto = if should_encrypt || was_encrypted { | ||||
|             if let Some(key) = master_key { | ||||
|                 Some(CryptoFactory::new(key.as_bytes())) | ||||
|             } else { | ||||
|                 return Err(DBError("Encryption requested but no master key provided".to_string())); | ||||
|             } | ||||
|         } else { | ||||
|             None | ||||
|         }; | ||||
|          | ||||
|         // Mark database as encrypted if enabling encryption | ||||
|         if should_encrypt && !was_encrypted { | ||||
|             encrypted_tree.insert("encrypted", &[1u8]) | ||||
|                 .map_err(|e| DBError(e.to_string()))?; | ||||
|             encrypted_tree.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         } | ||||
|          | ||||
|         Ok(SledStorage { db, types, crypto }) | ||||
|     } | ||||
|      | ||||
|     fn now_millis() -> u128 { | ||||
|         SystemTime::now() | ||||
|             .duration_since(UNIX_EPOCH) | ||||
|             .unwrap() | ||||
|             .as_millis() | ||||
|     } | ||||
|      | ||||
|     fn encrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.encrypt(data)) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn decrypt_if_needed(&self, data: &[u8]) -> Result<Vec<u8>, DBError> { | ||||
|         if let Some(crypto) = &self.crypto { | ||||
|             Ok(crypto.decrypt(data)?) | ||||
|         } else { | ||||
|             Ok(data.to_vec()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn get_storage_value(&self, key: &str) -> Result<Option<StorageValue>, DBError> { | ||||
|         match self.db.get(key).map_err(|e| DBError(e.to_string()))? { | ||||
|             Some(encrypted_data) => { | ||||
|                 let decrypted = self.decrypt_if_needed(&encrypted_data)?; | ||||
|                 let storage_val: StorageValue = bincode::deserialize(&decrypted) | ||||
|                     .map_err(|e| DBError(format!("Deserialization error: {}", e)))?; | ||||
|                  | ||||
|                 // Check expiration | ||||
|                 if let Some(expires_at) = storage_val.expires_at { | ||||
|                     if Self::now_millis() > expires_at { | ||||
|                         // Expired, remove it | ||||
|                         self.db.remove(key).map_err(|e| DBError(e.to_string()))?; | ||||
|                         self.types.remove(key).map_err(|e| DBError(e.to_string()))?; | ||||
|                         return Ok(None); | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 Ok(Some(storage_val)) | ||||
|             } | ||||
|             None => Ok(None) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn set_storage_value(&self, key: &str, storage_val: StorageValue) -> Result<(), DBError> { | ||||
|         let data = bincode::serialize(&storage_val) | ||||
|             .map_err(|e| DBError(format!("Serialization error: {}", e)))?; | ||||
|         let encrypted = self.encrypt_if_needed(&data)?; | ||||
|         self.db.insert(key, encrypted).map_err(|e| DBError(e.to_string()))?; | ||||
|          | ||||
|         // Store type info (unencrypted for efficiency) | ||||
|         let type_str = match &storage_val.value { | ||||
|             ValueType::String(_) => "string", | ||||
|             ValueType::Hash(_) => "hash", | ||||
|             ValueType::List(_) => "list", | ||||
|         }; | ||||
|         self.types.insert(key, type_str.as_bytes()).map_err(|e| DBError(e.to_string()))?; | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn glob_match(pattern: &str, text: &str) -> bool { | ||||
|         if pattern == "*" { | ||||
|             return true; | ||||
|         } | ||||
|          | ||||
|         let pattern_chars: Vec<char> = pattern.chars().collect(); | ||||
|         let text_chars: Vec<char> = text.chars().collect(); | ||||
|          | ||||
|         fn match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool { | ||||
|             if pi >= pattern.len() { | ||||
|                 return ti >= text.len(); | ||||
|             } | ||||
|              | ||||
|             if ti >= text.len() { | ||||
|                 return pattern[pi..].iter().all(|&c| c == '*'); | ||||
|             } | ||||
|              | ||||
|             match pattern[pi] { | ||||
|                 '*' => { | ||||
|                     for i in ti..=text.len() { | ||||
|                         if match_recursive(pattern, text, pi + 1, i) { | ||||
|                             return true; | ||||
|                         } | ||||
|                     } | ||||
|                     false | ||||
|                 } | ||||
|                 '?' => match_recursive(pattern, text, pi + 1, ti + 1), | ||||
|                 c => { | ||||
|                     if text[ti] == c { | ||||
|                         match_recursive(pattern, text, pi + 1, ti + 1) | ||||
|                     } else { | ||||
|                         false | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         match_recursive(&pattern_chars, &text_chars, 0, 0) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StorageBackend for SledStorage { | ||||
|     fn get(&self, key: &str) -> Result<Option<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::String(s) => Ok(Some(s)), | ||||
|                 _ => Ok(None) | ||||
|             } | ||||
|             None => Ok(None) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn set(&self, key: String, value: String) -> Result<(), DBError> { | ||||
|         let storage_val = StorageValue { | ||||
|             value: ValueType::String(value), | ||||
|             expires_at: None, | ||||
|         }; | ||||
|         self.set_storage_value(&key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn setx(&self, key: String, value: String, expire_ms: u128) -> Result<(), DBError> { | ||||
|         let storage_val = StorageValue { | ||||
|             value: ValueType::String(value), | ||||
|             expires_at: Some(Self::now_millis() + expire_ms), | ||||
|         }; | ||||
|         self.set_storage_value(&key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn del(&self, key: String) -> Result<(), DBError> { | ||||
|         self.db.remove(&key).map_err(|e| DBError(e.to_string()))?; | ||||
|         self.types.remove(&key).map_err(|e| DBError(e.to_string()))?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn exists(&self, key: &str) -> Result<bool, DBError> { | ||||
|         // Check with expiration | ||||
|         Ok(self.get_storage_value(key)?.is_some()) | ||||
|     } | ||||
|      | ||||
|     fn keys(&self, pattern: &str) -> Result<Vec<String>, DBError> { | ||||
|         let mut keys = Vec::new(); | ||||
|         for item in self.types.iter() { | ||||
|             let (key_bytes, _) = item.map_err(|e| DBError(e.to_string()))?; | ||||
|             let key = String::from_utf8_lossy(&key_bytes).to_string(); | ||||
|              | ||||
|             // Check if key is expired | ||||
|             if self.get_storage_value(&key)?.is_some() { | ||||
|                 if Self::glob_match(pattern, &key) { | ||||
|                     keys.push(key); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Ok(keys) | ||||
|     } | ||||
|      | ||||
|     fn scan(&self, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError> { | ||||
|         let mut result = Vec::new(); | ||||
|         let mut current_cursor = 0u64; | ||||
|         let limit = count.unwrap_or(10) as usize; | ||||
|          | ||||
|         for item in self.types.iter() { | ||||
|             if current_cursor >= cursor { | ||||
|                 let (key_bytes, type_bytes) = item.map_err(|e| DBError(e.to_string()))?; | ||||
|                 let key = String::from_utf8_lossy(&key_bytes).to_string(); | ||||
|                  | ||||
|                 // Check pattern match | ||||
|                 let matches = if let Some(pat) = pattern { | ||||
|                     Self::glob_match(pat, &key) | ||||
|                 } else { | ||||
|                     true | ||||
|                 }; | ||||
|                  | ||||
|                 if matches { | ||||
|                     // Check if key is expired and get value | ||||
|                     if let Some(storage_val) = self.get_storage_value(&key)? { | ||||
|                         let value = match storage_val.value { | ||||
|                             ValueType::String(s) => s, | ||||
|                             _ => String::from_utf8_lossy(&type_bytes).to_string(), | ||||
|                         }; | ||||
|                         result.push((key, value)); | ||||
|                          | ||||
|                         if result.len() >= limit { | ||||
|                             current_cursor += 1; | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             current_cursor += 1; | ||||
|         } | ||||
|          | ||||
|         let next_cursor = if result.len() < limit { 0 } else { current_cursor }; | ||||
|         Ok((next_cursor, result)) | ||||
|     } | ||||
|      | ||||
|     fn dbsize(&self) -> Result<i64, DBError> { | ||||
|         let mut count = 0i64; | ||||
|         for item in self.types.iter() { | ||||
|             let (key_bytes, _) = item.map_err(|e| DBError(e.to_string()))?; | ||||
|             let key = String::from_utf8_lossy(&key_bytes).to_string(); | ||||
|             if self.get_storage_value(&key)?.is_some() { | ||||
|                 count += 1; | ||||
|             } | ||||
|         } | ||||
|         Ok(count) | ||||
|     } | ||||
|      | ||||
|     fn flushdb(&self) -> Result<(), DBError> { | ||||
|         self.db.clear().map_err(|e| DBError(e.to_string()))?; | ||||
|         self.types.clear().map_err(|e| DBError(e.to_string()))?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn get_key_type(&self, key: &str) -> Result<Option<String>, DBError> { | ||||
|         // First check if key exists (handles expiration) | ||||
|         if self.get_storage_value(key)?.is_some() { | ||||
|             match self.types.get(key).map_err(|e| DBError(e.to_string()))? { | ||||
|                 Some(data) => Ok(Some(String::from_utf8_lossy(&data).to_string())), | ||||
|                 None => Ok(None) | ||||
|             } | ||||
|         } else { | ||||
|             Ok(None) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Hash operations | ||||
|     fn hset(&self, key: &str, pairs: Vec<(String, String)>) -> Result<i64, DBError> { | ||||
|         let mut storage_val = self.get_storage_value(key)?.unwrap_or(StorageValue { | ||||
|             value: ValueType::Hash(HashMap::new()), | ||||
|             expires_at: None, | ||||
|         }); | ||||
|          | ||||
|         let hash = match &mut storage_val.value { | ||||
|             ValueType::Hash(h) => h, | ||||
|             _ => return Err(DBError("WRONGTYPE Operation against a key holding the wrong kind of value".to_string())), | ||||
|         }; | ||||
|          | ||||
|         let mut new_fields = 0i64; | ||||
|         for (field, value) in pairs { | ||||
|             if !hash.contains_key(&field) { | ||||
|                 new_fields += 1; | ||||
|             } | ||||
|             hash.insert(field, value); | ||||
|         } | ||||
|          | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(new_fields) | ||||
|     } | ||||
|      | ||||
|     fn hget(&self, key: &str, field: &str) -> Result<Option<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.get(field).cloned()), | ||||
|                 _ => Ok(None) | ||||
|             } | ||||
|             None => Ok(None) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.into_iter().collect()), | ||||
|                 _ => Ok(Vec::new()) | ||||
|             } | ||||
|             None => Ok(Vec::new()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hscan(&self, key: &str, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => { | ||||
|                     let mut result = Vec::new(); | ||||
|                     let mut current_cursor = 0u64; | ||||
|                     let limit = count.unwrap_or(10) as usize; | ||||
|                      | ||||
|                     for (field, value) in h.iter() { | ||||
|                         if current_cursor >= cursor { | ||||
|                             let matches = if let Some(pat) = pattern { | ||||
|                                 Self::glob_match(pat, field) | ||||
|                             } else { | ||||
|                                 true | ||||
|                             }; | ||||
|                              | ||||
|                             if matches { | ||||
|                                 result.push((field.clone(), value.clone())); | ||||
|                                 if result.len() >= limit { | ||||
|                                     current_cursor += 1; | ||||
|                                     break; | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         current_cursor += 1; | ||||
|                     } | ||||
|                      | ||||
|                     let next_cursor = if result.len() < limit { 0 } else { current_cursor }; | ||||
|                     Ok((next_cursor, result)) | ||||
|                 } | ||||
|                 _ => Err(DBError("WRONGTYPE Operation against a key holding the wrong kind of value".to_string())) | ||||
|             } | ||||
|             None => Ok((0, Vec::new())) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hdel(&self, key: &str, fields: Vec<String>) -> Result<i64, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(0) | ||||
|         }; | ||||
|          | ||||
|         let hash = match &mut storage_val.value { | ||||
|             ValueType::Hash(h) => h, | ||||
|             _ => return Ok(0) | ||||
|         }; | ||||
|          | ||||
|         let mut deleted = 0i64; | ||||
|         for field in fields { | ||||
|             if hash.remove(&field).is_some() { | ||||
|                 deleted += 1; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if hash.is_empty() { | ||||
|             self.del(key.to_string())?; | ||||
|         } else { | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         } | ||||
|          | ||||
|         Ok(deleted) | ||||
|     } | ||||
|      | ||||
|     fn hexists(&self, key: &str, field: &str) -> Result<bool, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.contains_key(field)), | ||||
|                 _ => Ok(false) | ||||
|             } | ||||
|             None => Ok(false) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hkeys(&self, key: &str) -> Result<Vec<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.keys().cloned().collect()), | ||||
|                 _ => Ok(Vec::new()) | ||||
|             } | ||||
|             None => Ok(Vec::new()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hvals(&self, key: &str) -> Result<Vec<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.values().cloned().collect()), | ||||
|                 _ => Ok(Vec::new()) | ||||
|             } | ||||
|             None => Ok(Vec::new()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hlen(&self, key: &str) -> Result<i64, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => Ok(h.len() as i64), | ||||
|                 _ => Ok(0) | ||||
|             } | ||||
|             None => Ok(0) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hmget(&self, key: &str, fields: Vec<String>) -> Result<Vec<Option<String>>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::Hash(h) => { | ||||
|                     Ok(fields.into_iter().map(|f| h.get(&f).cloned()).collect()) | ||||
|                 } | ||||
|                 _ => Ok(fields.into_iter().map(|_| None).collect()) | ||||
|             } | ||||
|             None => Ok(fields.into_iter().map(|_| None).collect()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn hsetnx(&self, key: &str, field: &str, value: &str) -> Result<bool, DBError> { | ||||
|         let mut storage_val = self.get_storage_value(key)?.unwrap_or(StorageValue { | ||||
|             value: ValueType::Hash(HashMap::new()), | ||||
|             expires_at: None, | ||||
|         }); | ||||
|          | ||||
|         let hash = match &mut storage_val.value { | ||||
|             ValueType::Hash(h) => h, | ||||
|             _ => return Err(DBError("WRONGTYPE Operation against a key holding the wrong kind of value".to_string())), | ||||
|         }; | ||||
|          | ||||
|         if hash.contains_key(field) { | ||||
|             Ok(false) | ||||
|         } else { | ||||
|             hash.insert(field.to_string(), value.to_string()); | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|             Ok(true) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // List operations | ||||
|     fn lpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError> { | ||||
|         let mut storage_val = self.get_storage_value(key)?.unwrap_or(StorageValue { | ||||
|             value: ValueType::List(Vec::new()), | ||||
|             expires_at: None, | ||||
|         }); | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Err(DBError("WRONGTYPE Operation against a key holding the wrong kind of value".to_string())), | ||||
|         }; | ||||
|          | ||||
|         for element in elements.into_iter().rev() { | ||||
|             list.insert(0, element); | ||||
|         } | ||||
|          | ||||
|         let len = list.len() as i64; | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(len) | ||||
|     } | ||||
|      | ||||
|     fn rpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError> { | ||||
|         let mut storage_val = self.get_storage_value(key)?.unwrap_or(StorageValue { | ||||
|             value: ValueType::List(Vec::new()), | ||||
|             expires_at: None, | ||||
|         }); | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Err(DBError("WRONGTYPE Operation against a key holding the wrong kind of value".to_string())), | ||||
|         }; | ||||
|          | ||||
|         list.extend(elements); | ||||
|         let len = list.len() as i64; | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(len) | ||||
|     } | ||||
|      | ||||
|     fn lpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(Vec::new()) | ||||
|         }; | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Ok(Vec::new()) | ||||
|         }; | ||||
|          | ||||
|         let mut result = Vec::new(); | ||||
|         for _ in 0..count.min(list.len() as u64) { | ||||
|             if let Some(elem) = list.first() { | ||||
|                 result.push(elem.clone()); | ||||
|                 list.remove(0); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if list.is_empty() { | ||||
|             self.del(key.to_string())?; | ||||
|         } else { | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         } | ||||
|          | ||||
|         Ok(result) | ||||
|     } | ||||
|      | ||||
|     fn rpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(Vec::new()) | ||||
|         }; | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Ok(Vec::new()) | ||||
|         }; | ||||
|          | ||||
|         let mut result = Vec::new(); | ||||
|         for _ in 0..count.min(list.len() as u64) { | ||||
|             if let Some(elem) = list.pop() { | ||||
|                 result.push(elem); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if list.is_empty() { | ||||
|             self.del(key.to_string())?; | ||||
|         } else { | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         } | ||||
|          | ||||
|         Ok(result) | ||||
|     } | ||||
|      | ||||
|     fn llen(&self, key: &str) -> Result<i64, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::List(l) => Ok(l.len() as i64), | ||||
|                 _ => Ok(0) | ||||
|             } | ||||
|             None => Ok(0) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn lindex(&self, key: &str, index: i64) -> Result<Option<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::List(list) => { | ||||
|                     let actual_index = if index < 0 { | ||||
|                         list.len() as i64 + index | ||||
|                     } else { | ||||
|                         index | ||||
|                     }; | ||||
|                      | ||||
|                     if actual_index >= 0 && (actual_index as usize) < list.len() { | ||||
|                         Ok(Some(list[actual_index as usize].clone())) | ||||
|                     } else { | ||||
|                         Ok(None) | ||||
|                     } | ||||
|                 } | ||||
|                 _ => Ok(None) | ||||
|             } | ||||
|             None => Ok(None) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn lrange(&self, key: &str, start: i64, stop: i64) -> Result<Vec<String>, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => match storage_val.value { | ||||
|                 ValueType::List(list) => { | ||||
|                     if list.is_empty() { | ||||
|                         return Ok(Vec::new()); | ||||
|                     } | ||||
|                      | ||||
|                     let len = list.len() as i64; | ||||
|                     let start_idx = if start < 0 {  | ||||
|                         std::cmp::max(0, len + start)  | ||||
|                     } else {  | ||||
|                         std::cmp::min(start, len)  | ||||
|                     }; | ||||
|                     let stop_idx = if stop < 0 {  | ||||
|                         std::cmp::max(-1, len + stop)  | ||||
|                     } else {  | ||||
|                         std::cmp::min(stop, len - 1)  | ||||
|                     }; | ||||
|                      | ||||
|                     if start_idx > stop_idx || start_idx >= len { | ||||
|                         return Ok(Vec::new()); | ||||
|                     } | ||||
|                      | ||||
|                     let start_usize = start_idx as usize; | ||||
|                     let stop_usize = (stop_idx + 1) as usize; | ||||
|                      | ||||
|                     Ok(list[start_usize..std::cmp::min(stop_usize, list.len())].to_vec()) | ||||
|                 } | ||||
|                 _ => Ok(Vec::new()) | ||||
|             } | ||||
|             None => Ok(Vec::new()) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn ltrim(&self, key: &str, start: i64, stop: i64) -> Result<(), DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(()) | ||||
|         }; | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Ok(()) | ||||
|         }; | ||||
|          | ||||
|         if list.is_empty() { | ||||
|             return Ok(()); | ||||
|         } | ||||
|          | ||||
|         let len = list.len() as i64; | ||||
|         let start_idx = if start < 0 {  | ||||
|             std::cmp::max(0, len + start)  | ||||
|         } else {  | ||||
|             std::cmp::min(start, len)  | ||||
|         }; | ||||
|         let stop_idx = if stop < 0 {  | ||||
|             std::cmp::max(-1, len + stop)  | ||||
|         } else {  | ||||
|             std::cmp::min(stop, len - 1)  | ||||
|         }; | ||||
|          | ||||
|         if start_idx > stop_idx || start_idx >= len { | ||||
|             self.del(key.to_string())?; | ||||
|         } else { | ||||
|             let start_usize = start_idx as usize; | ||||
|             let stop_usize = (stop_idx + 1) as usize; | ||||
|             *list = list[start_usize..std::cmp::min(stop_usize, list.len())].to_vec(); | ||||
|              | ||||
|             if list.is_empty() { | ||||
|                 self.del(key.to_string())?; | ||||
|             } else { | ||||
|                 self.set_storage_value(key, storage_val)?; | ||||
|                 self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     fn lrem(&self, key: &str, count: i64, element: &str) -> Result<i64, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(0) | ||||
|         }; | ||||
|          | ||||
|         let list = match &mut storage_val.value { | ||||
|             ValueType::List(l) => l, | ||||
|             _ => return Ok(0) | ||||
|         }; | ||||
|          | ||||
|         let mut removed = 0i64; | ||||
|          | ||||
|         if count == 0 { | ||||
|             // Remove all occurrences | ||||
|             let original_len = list.len(); | ||||
|             list.retain(|x| x != element); | ||||
|             removed = (original_len - list.len()) as i64; | ||||
|         } else if count > 0 { | ||||
|             // Remove first count occurrences | ||||
|             let mut to_remove = count as usize; | ||||
|             list.retain(|x| { | ||||
|                 if x == element && to_remove > 0 { | ||||
|                     to_remove -= 1; | ||||
|                     removed += 1; | ||||
|                     false | ||||
|                 } else { | ||||
|                     true | ||||
|                 } | ||||
|             }); | ||||
|         } else { | ||||
|             // Remove last |count| occurrences | ||||
|             let mut to_remove = (-count) as usize; | ||||
|             for i in (0..list.len()).rev() { | ||||
|                 if list[i] == element && to_remove > 0 { | ||||
|                     list.remove(i); | ||||
|                     to_remove -= 1; | ||||
|                     removed += 1; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if list.is_empty() { | ||||
|             self.del(key.to_string())?; | ||||
|         } else { | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         } | ||||
|          | ||||
|         Ok(removed) | ||||
|     } | ||||
|      | ||||
|     // Expiration | ||||
|     fn ttl(&self, key: &str) -> Result<i64, DBError> { | ||||
|         match self.get_storage_value(key)? { | ||||
|             Some(storage_val) => { | ||||
|                 if let Some(expires_at) = storage_val.expires_at { | ||||
|                     let now = Self::now_millis(); | ||||
|                     if now >= expires_at { | ||||
|                         Ok(-2) // Key has expired | ||||
|                     } else { | ||||
|                         Ok(((expires_at - now) / 1000) as i64) // TTL in seconds | ||||
|                     } | ||||
|                 } else { | ||||
|                     Ok(-1) // Key exists but has no expiration | ||||
|                 } | ||||
|             } | ||||
|             None => Ok(-2) // Key does not exist | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn expire_seconds(&self, key: &str, secs: u64) -> Result<bool, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(false) | ||||
|         }; | ||||
|          | ||||
|         storage_val.expires_at = Some(Self::now_millis() + (secs as u128) * 1000); | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(true) | ||||
|     } | ||||
|      | ||||
|     fn pexpire_millis(&self, key: &str, ms: u128) -> Result<bool, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(false) | ||||
|         }; | ||||
|          | ||||
|         storage_val.expires_at = Some(Self::now_millis() + ms); | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(true) | ||||
|     } | ||||
|      | ||||
|     fn persist(&self, key: &str) -> Result<bool, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(false) | ||||
|         }; | ||||
|          | ||||
|         if storage_val.expires_at.is_some() { | ||||
|             storage_val.expires_at = None; | ||||
|             self.set_storage_value(key, storage_val)?; | ||||
|             self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|             Ok(true) | ||||
|         } else { | ||||
|             Ok(false) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn expire_at_seconds(&self, key: &str, ts_secs: i64) -> Result<bool, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(false) | ||||
|         }; | ||||
|          | ||||
|         let expires_at_ms: u128 = if ts_secs <= 0 { 0 } else { (ts_secs as u128) * 1000 }; | ||||
|         storage_val.expires_at = Some(expires_at_ms); | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(true) | ||||
|     } | ||||
|      | ||||
|     fn pexpire_at_millis(&self, key: &str, ts_ms: i64) -> Result<bool, DBError> { | ||||
|         let mut storage_val = match self.get_storage_value(key)? { | ||||
|             Some(sv) => sv, | ||||
|             None => return Ok(false) | ||||
|         }; | ||||
|          | ||||
|         let expires_at_ms: u128 = if ts_ms <= 0 { 0 } else { ts_ms as u128 }; | ||||
|         storage_val.expires_at = Some(expires_at_ms); | ||||
|         self.set_storage_value(key, storage_val)?; | ||||
|         self.db.flush().map_err(|e| DBError(e.to_string()))?; | ||||
|         Ok(true) | ||||
|     } | ||||
|      | ||||
|     fn is_encrypted(&self) -> bool { | ||||
|         self.crypto.is_some() | ||||
|     } | ||||
|      | ||||
|     fn info(&self) -> Result<Vec<(String, String)>, DBError> { | ||||
|         let dbsize = self.dbsize()?; | ||||
|         Ok(vec![ | ||||
|             ("db_size".to_string(), dbsize.to_string()), | ||||
|             ("is_encrypted".to_string(), self.is_encrypted().to_string()), | ||||
|         ]) | ||||
|     } | ||||
|  | ||||
|     fn clone_arc(&self) -> Arc<dyn StorageBackend> { | ||||
|         // Note: This is a simplified clone - in production you might want to | ||||
|         // handle this differently as sled::Db is already Arc internally | ||||
|         Arc::new(SledStorage { | ||||
|             db: self.db.clone(), | ||||
|             types: self.types.clone(), | ||||
|             crypto: self.crypto.clone(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								src/storage_trait.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/storage_trait.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| // src/storage_trait.rs | ||||
| use crate::error::DBError; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| pub trait StorageBackend: Send + Sync { | ||||
|     // Basic key operations | ||||
|     fn get(&self, key: &str) -> Result<Option<String>, DBError>; | ||||
|     fn set(&self, key: String, value: String) -> Result<(), DBError>; | ||||
|     fn setx(&self, key: String, value: String, expire_ms: u128) -> Result<(), DBError>; | ||||
|     fn del(&self, key: String) -> Result<(), DBError>; | ||||
|     fn exists(&self, key: &str) -> Result<bool, DBError>; | ||||
|     fn keys(&self, pattern: &str) -> Result<Vec<String>, DBError>; | ||||
|     fn dbsize(&self) -> Result<i64, DBError>; | ||||
|     fn flushdb(&self) -> Result<(), DBError>; | ||||
|     fn get_key_type(&self, key: &str) -> Result<Option<String>, DBError>; | ||||
|      | ||||
|     // Scanning | ||||
|     fn scan(&self, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError>; | ||||
|     fn hscan(&self, key: &str, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError>; | ||||
|      | ||||
|     // Hash operations | ||||
|     fn hset(&self, key: &str, pairs: Vec<(String, String)>) -> Result<i64, DBError>; | ||||
|     fn hget(&self, key: &str, field: &str) -> Result<Option<String>, DBError>; | ||||
|     fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>, DBError>; | ||||
|     fn hdel(&self, key: &str, fields: Vec<String>) -> Result<i64, DBError>; | ||||
|     fn hexists(&self, key: &str, field: &str) -> Result<bool, DBError>; | ||||
|     fn hkeys(&self, key: &str) -> Result<Vec<String>, DBError>; | ||||
|     fn hvals(&self, key: &str) -> Result<Vec<String>, DBError>; | ||||
|     fn hlen(&self, key: &str) -> Result<i64, DBError>; | ||||
|     fn hmget(&self, key: &str, fields: Vec<String>) -> Result<Vec<Option<String>>, DBError>; | ||||
|     fn hsetnx(&self, key: &str, field: &str, value: &str) -> Result<bool, DBError>; | ||||
|      | ||||
|     // List operations | ||||
|     fn lpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError>; | ||||
|     fn rpush(&self, key: &str, elements: Vec<String>) -> Result<i64, DBError>; | ||||
|     fn lpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError>; | ||||
|     fn rpop(&self, key: &str, count: u64) -> Result<Vec<String>, DBError>; | ||||
|     fn llen(&self, key: &str) -> Result<i64, DBError>; | ||||
|     fn lindex(&self, key: &str, index: i64) -> Result<Option<String>, DBError>; | ||||
|     fn lrange(&self, key: &str, start: i64, stop: i64) -> Result<Vec<String>, DBError>; | ||||
|     fn ltrim(&self, key: &str, start: i64, stop: i64) -> Result<(), DBError>; | ||||
|     fn lrem(&self, key: &str, count: i64, element: &str) -> Result<i64, DBError>; | ||||
|      | ||||
|     // Expiration | ||||
|     fn ttl(&self, key: &str) -> Result<i64, DBError>; | ||||
|     fn expire_seconds(&self, key: &str, secs: u64) -> Result<bool, DBError>; | ||||
|     fn pexpire_millis(&self, key: &str, ms: u128) -> Result<bool, DBError>; | ||||
|     fn persist(&self, key: &str) -> Result<bool, DBError>; | ||||
|     fn expire_at_seconds(&self, key: &str, ts_secs: i64) -> Result<bool, DBError>; | ||||
|     fn pexpire_at_millis(&self, key: &str, ts_ms: i64) -> Result<bool, DBError>; | ||||
|      | ||||
|     // Metadata | ||||
|     fn is_encrypted(&self) -> bool; | ||||
|     fn info(&self) -> Result<Vec<(String, String)>, DBError>; | ||||
|      | ||||
|     // Clone to Arc for sharing | ||||
|     fn clone_arc(&self) -> Arc<dyn StorageBackend>; | ||||
| } | ||||
| @@ -27,6 +27,7 @@ async fn debug_hset_simple() { | ||||
|         debug: false, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
|     
 | ||||
|     let mut server = Server::new(option).await; | ||||
| @@ -18,6 +18,7 @@ async fn debug_hset_return_value() { | ||||
|         debug: false, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
|     
 | ||||
|     let mut server = Server::new(option).await; | ||||
| @@ -22,6 +22,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { | ||||
|         debug: true, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
|     
 | ||||
|     let server = Server::new(option).await; | ||||
| @@ -24,6 +24,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { | ||||
|         debug: true, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
|     
 | ||||
|     let server = Server::new(option).await; | ||||
| @@ -22,6 +22,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { | ||||
|         debug: false, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
|     
 | ||||
|     let server = Server::new(option).await; | ||||
| @@ -22,6 +22,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { | ||||
|         debug: false, | ||||
|         encrypt: false, | ||||
|         encryption_key: None, | ||||
|         backend: herodb::options::BackendType::Redb, | ||||
|     }; | ||||
| 
 | ||||
|     let server = Server::new(option).await; | ||||
| @@ -500,11 +501,11 @@ async fn test_07_age_stateless_suite() { | ||||
|     let mut s = connect(port).await; | ||||
| 
 | ||||
|     // GENENC -> [recipient, identity]
 | ||||
|     let gen_result = send_cmd(&mut s, &["AGE", "GENENC"]).await; | ||||
|     let genenc = send_cmd(&mut s, &["AGE", "GENENC"]).await; | ||||
|     assert!( | ||||
|         gen_result.starts_with("*2\r\n$"), | ||||
|         genenc.starts_with("*2\r\n$"), | ||||
|         "AGE GENENC should return array [recipient, identity], got:\n{}", | ||||
|         gen_result | ||||
|         genenc | ||||
|     ); | ||||
| 
 | ||||
|     // Parse simple RESP array of two bulk strings to extract keys
 | ||||
| @@ -519,7 +520,7 @@ async fn test_07_age_stateless_suite() { | ||||
|         let ident = lines.next().unwrap_or("").to_string(); | ||||
|         (recip, ident) | ||||
|     } | ||||
|     let (recipient, identity) = parse_two_bulk_array(&gen_result); | ||||
|     let (recipient, identity) = parse_two_bulk_array(&genenc); | ||||
|     assert!( | ||||
|         recipient.starts_with("age1") && identity.starts_with("AGE-SECRET-KEY-1"), | ||||
|         "Unexpected AGE key formats.\nrecipient: {}\nidentity: {}", | ||||
		Reference in New Issue
	
	Block a user