Compare commits
28 Commits
9177fa4091
...
vector
Author | SHA1 | Date | |
---|---|---|---|
68dfc77f5f | |||
9410176684 | |||
ab56fad635 | |||
a1127b72da | |||
3850df89be | |||
45195d403e | |||
f17b441ca1 | |||
ff4ea1d844 | |||
c9e1dcdb6c | |||
56699b9abb | |||
dd90a49615 | |||
9054737e84 | |||
09553f54c8 | |||
|
58cb1e8d5e | ||
d3d92819cf | |||
4fd48f8b0d | |||
4bedf71c2d | |||
b9987a027b | |||
|
3b9756a4e1 | ||
f22a25f5a1 | |||
|
892e6e2b90 | ||
|
b9a9f3e6d6 | ||
|
463000c8f7 | ||
|
a92c90e9cb | ||
|
34808fc1c9 | ||
|
b644bf873f | ||
|
a306544a34 | ||
|
afa1033cd6 |
5848
Cargo.lock
generated
5848
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
51
Cargo.toml
51
Cargo.toml
@@ -1,12 +1,41 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"herodb",
|
||||
"supervisor",
|
||||
]
|
||||
resolver = "2"
|
||||
[package]
|
||||
name = "herodb"
|
||||
version = "0.0.1"
|
||||
authors = ["Pin Fang <fpfangpin@hotmail.com>"]
|
||||
edition = "2021"
|
||||
|
||||
# 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"
|
||||
# Lance vector database dependencies
|
||||
lance = "0.33"
|
||||
lance-index = "0.33"
|
||||
lance-linalg = "0.33"
|
||||
# Use Arrow version compatible with Lance 0.33
|
||||
arrow = "55.2"
|
||||
arrow-array = "55.2"
|
||||
arrow-schema = "55.2"
|
||||
parquet = "55.2"
|
||||
uuid = { version = "1.10", features = ["v4"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
image = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
redis = { version = "0.24", features = ["aio", "tokio-comp"] }
|
||||
|
85
README.md
Normal file
85
README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# HeroDB
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### 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)
|
188
docs/age.md
Normal file
188
docs/age.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# HeroDB AGE usage: Stateless vs Key‑Managed
|
||||
|
||||
This document explains how to use the AGE cryptography commands exposed by HeroDB over the Redis protocol in two modes:
|
||||
- Stateless (ephemeral keys; nothing stored on the server)
|
||||
- Key‑managed (server‑persisted, named keys)
|
||||
|
||||
If you are new to the codebase, the exact tests that exercise these behaviors are:
|
||||
- [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||
- [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||
|
||||
Implementation entry points:
|
||||
- [herodb/src/age.rs](herodb/src/age.rs)
|
||||
- Dispatch from [herodb/src/cmd.rs](herodb/src/cmd.rs)
|
||||
|
||||
Note: Database-at-rest encryption flags in the test harness are unrelated to AGE commands; those flags control storage-level encryption of DB files. See the harness near [rust.start_test_server()](herodb/tests/usage_suite.rs:10).
|
||||
|
||||
## Quick start
|
||||
|
||||
Assuming the server is running on localhost on some $PORT:
|
||||
```bash
|
||||
~/code/git.ourworld.tf/herocode/herodb/herodb/build.sh
|
||||
~/code/git.ourworld.tf/herocode/herodb/target/release/herodb --dir /tmp/data --debug --$PORT 6381 --encryption-key 1234 --encrypt
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
export PORT=6381
|
||||
# Generate an ephemeral keypair and encrypt/decrypt a message (stateless mode)
|
||||
redis-cli -p $PORT AGE GENENC
|
||||
# → returns an array: [recipient, identity]
|
||||
|
||||
redis-cli -p $PORT AGE ENCRYPT <recipient> "hello world"
|
||||
# → returns ciphertext (base64 in a bulk string)
|
||||
|
||||
redis-cli -p $PORT AGE DECRYPT <identity> <ciphertext_b64>
|
||||
# → returns "hello world"
|
||||
```
|
||||
|
||||
For key‑managed mode, generate a named key once and reference it by name afterwards:
|
||||
|
||||
```bash
|
||||
redis-cli -p $PORT AGE KEYGEN app1
|
||||
# → persists encryption keypair under name "app1"
|
||||
|
||||
redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello"
|
||||
redis-cli -p $PORT AGE DECRYPTNAME app1 <ciphertext_b64>
|
||||
```
|
||||
|
||||
## Stateless AGE (ephemeral)
|
||||
|
||||
Characteristics
|
||||
|
||||
- No server‑side storage of keys.
|
||||
- You pass the actual key material with every call.
|
||||
- Not listable via AGE LIST.
|
||||
|
||||
Commands and examples
|
||||
|
||||
1) Ephemeral encryption keys
|
||||
|
||||
```bash
|
||||
# Generate an ephemeral encryption keypair
|
||||
redis-cli -p $PORT AGE GENENC
|
||||
# Example output (abridged):
|
||||
# 1) "age1qz..." # recipient (public key) = can be used by others e.g. to verify what I sign
|
||||
# 2) "AGE-SECRET-KEY-1..." # identity (secret) = is like my private, cannot lose this one
|
||||
|
||||
# Encrypt with the recipient public key
|
||||
redis-cli -p $PORT AGE ENCRYPT "age1qz..." "hello world"
|
||||
|
||||
# → returns bulk string payload: base64 ciphertext (encrypted content)
|
||||
|
||||
# Decrypt with the identity (secret) in other words your private key
|
||||
redis-cli -p $PORT AGE DECRYPT "AGE-SECRET-KEY-1..." "<ciphertext_b64>"
|
||||
# → "hello world"
|
||||
```
|
||||
|
||||
2) Ephemeral signing keys
|
||||
|
||||
> ? is this same as my private key
|
||||
|
||||
```bash
|
||||
|
||||
# Generate an ephemeral signing keypair
|
||||
redis-cli -p $PORT AGE GENSIGN
|
||||
# Example output:
|
||||
# 1) "<verify_pub_b64>"
|
||||
# 2) "<sign_secret_b64>"
|
||||
|
||||
# Sign a message with the secret
|
||||
redis-cli -p $PORT AGE SIGN "<sign_secret_b64>" "msg"
|
||||
# → returns "<signature_b64>"
|
||||
|
||||
# Verify with the public key
|
||||
redis-cli -p $PORT AGE VERIFY "<verify_pub_b64>" "msg" "<signature_b64>"
|
||||
# → 1 (valid) or 0 (invalid)
|
||||
```
|
||||
|
||||
When to use
|
||||
- You do not want the server to store private keys.
|
||||
- You already manage key material on the client side.
|
||||
- You need ad‑hoc operations without persistence.
|
||||
|
||||
Reference test: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||
|
||||
## Key‑managed AGE (persistent, named)
|
||||
|
||||
Characteristics
|
||||
- Server generates and persists keypairs under a chosen name.
|
||||
- Clients refer to keys by name; raw secrets are not supplied on each call.
|
||||
- Keys are discoverable via AGE LIST.
|
||||
|
||||
Commands and examples
|
||||
|
||||
1) Named encryption keys
|
||||
|
||||
```bash
|
||||
# Create/persist a named encryption keypair
|
||||
redis-cli -p $PORT AGE KEYGEN app1
|
||||
# → returns [recipient, identity] but also stores them under name "app1"
|
||||
|
||||
> TODO: should not return identity (security, but there can be separate function to export it e.g. AGE EXPORTKEY app1)
|
||||
|
||||
# Encrypt using the stored public key
|
||||
redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello"
|
||||
# → returns bulk string payload: base64 ciphertext
|
||||
|
||||
# Decrypt using the stored secret
|
||||
redis-cli -p $PORT AGE DECRYPTNAME app1 "<ciphertext_b64>"
|
||||
# → "hello"
|
||||
```
|
||||
|
||||
2) Named signing keys
|
||||
|
||||
```bash
|
||||
# Create/persist a named signing keypair
|
||||
redis-cli -p $PORT AGE SIGNKEYGEN app1
|
||||
# → returns [verify_pub_b64, sign_secret_b64] and stores under name "app1"
|
||||
|
||||
> TODO: should not return sign_secret_b64 (for security, but there can be separate function to export it e.g. AGE EXPORTSIGNKEY app1)
|
||||
|
||||
# Sign using the stored secret
|
||||
redis-cli -p $PORT AGE SIGNNAME app1 "msg"
|
||||
# → returns "<signature_b64>"
|
||||
|
||||
# Verify using the stored public key
|
||||
redis-cli -p $PORT AGE VERIFYNAME app1 "msg" "<signature_b64>"
|
||||
# → 1 (valid) or 0 (invalid)
|
||||
```
|
||||
|
||||
3) List stored AGE keys
|
||||
|
||||
```bash
|
||||
redis-cli -p $PORT AGE LIST
|
||||
# Example output includes labels such as "encpub" and your key names (e.g., "app1")
|
||||
```
|
||||
|
||||
When to use
|
||||
- You want centralized key storage/rotation and fewer secrets on the client.
|
||||
- You need names/labels for workflows and can trust the server with secrets.
|
||||
- You want discoverability (AGE LIST) and simpler client commands.
|
||||
|
||||
Reference test: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||
|
||||
## Choosing a mode
|
||||
|
||||
- Prefer Stateless when:
|
||||
- Minimizing server trust for secret material is the priority.
|
||||
- Clients already have a secure mechanism to store/distribute keys.
|
||||
- Prefer Key‑managed when:
|
||||
- Centralized lifecycle, naming, and discoverability are beneficial.
|
||||
- You plan to integrate rotation, ACLs, or auditability on the server side.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat identities and signing secrets as sensitive; avoid logging them.
|
||||
- For key‑managed mode, ensure server storage (and backups) are protected.
|
||||
- AGE operations here are application‑level crypto and are distinct from database-at-rest encryption configured in the test harness.
|
||||
|
||||
## Repository pointers
|
||||
|
||||
- Stateless examples in tests: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||
- Key‑managed examples in tests: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||
- AGE implementation: [herodb/src/age.rs](herodb/src/age.rs)
|
||||
- Command dispatch: [herodb/src/cmd.rs](herodb/src/cmd.rs)
|
||||
- Bash demo: [herodb/examples/age_bash_demo.sh](herodb/examples/age_bash_demo.sh)
|
||||
- Rust persistent demo: [herodb/examples/age_persist_demo.rs](herodb/examples/age_persist_demo.rs)
|
||||
- Additional notes: [herodb/instructions/encrypt.md](herodb/instructions/encrypt.md)
|
623
docs/basics.md
Normal file
623
docs/basics.md
Normal file
@@ -0,0 +1,623 @@
|
||||
Here's an expanded version of the cmds.md documentation to include the list commands:
|
||||
# HeroDB Commands
|
||||
|
||||
HeroDB implements a subset of Redis commands over the Redis protocol. This document describes the available commands and their usage.
|
||||
|
||||
## String Commands
|
||||
|
||||
### PING
|
||||
Ping the server to test connectivity.
|
||||
```bash
|
||||
redis-cli -p $PORT PING
|
||||
# → PONG
|
||||
```
|
||||
|
||||
### ECHO
|
||||
Echo the given message.
|
||||
```bash
|
||||
redis-cli -p $PORT ECHO "hello"
|
||||
# → hello
|
||||
```
|
||||
|
||||
### SET
|
||||
Set a key to hold a string value.
|
||||
```bash
|
||||
redis-cli -p $PORT SET key value
|
||||
# → OK
|
||||
```
|
||||
|
||||
Options:
|
||||
- EX seconds: Set expiration in seconds
|
||||
- PX milliseconds: Set expiration in milliseconds
|
||||
- NX: Only set if key doesn't exist
|
||||
- XX: Only set if key exists
|
||||
- GET: Return old value
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
redis-cli -p $PORT SET key value EX 60
|
||||
redis-cli -p $PORT SET key value PX 1000
|
||||
redis-cli -p $PORT SET key value NX
|
||||
redis-cli -p $PORT SET key value XX
|
||||
redis-cli -p $PORT SET key value GET
|
||||
```
|
||||
|
||||
### GET
|
||||
Get the value of a key.
|
||||
```bash
|
||||
redis-cli -p $PORT GET key
|
||||
# → value
|
||||
```
|
||||
|
||||
### MGET
|
||||
Get values of multiple keys.
|
||||
```bash
|
||||
redis-cli -p $PORT MGET key1 key2 key3
|
||||
# → 1) "value1"
|
||||
# 2) "value2"
|
||||
# 3) (nil)
|
||||
```
|
||||
|
||||
### MSET
|
||||
Set multiple key-value pairs.
|
||||
```bash
|
||||
redis-cli -p $PORT MSET key1 value1 key2 value2
|
||||
# → OK
|
||||
```
|
||||
|
||||
### INCR
|
||||
Increment the integer value of a key by 1.
|
||||
```bash
|
||||
redis-cli -p $PORT SET counter 10
|
||||
redis-cli -p $PORT INCR counter
|
||||
# → 11
|
||||
```
|
||||
|
||||
### DEL
|
||||
Delete a key.
|
||||
```bash
|
||||
redis-cli -p $PORT DEL key
|
||||
# → 1
|
||||
```
|
||||
|
||||
For multiple keys:
|
||||
```bash
|
||||
redis-cli -p $PORT DEL key1 key2 key3
|
||||
# → number of keys deleted
|
||||
```
|
||||
|
||||
### TYPE
|
||||
Determine the type of a key.
|
||||
```bash
|
||||
redis-cli -p $PORT TYPE key
|
||||
# → string
|
||||
```
|
||||
|
||||
### EXISTS
|
||||
Check if a key exists.
|
||||
```bash
|
||||
redis-cli -p $PORT EXISTS key
|
||||
# → 1 (exists) or 0 (doesn't exist)
|
||||
```
|
||||
|
||||
For multiple keys:
|
||||
```bash
|
||||
redis-cli -p $PORT EXISTS key1 key2 key3
|
||||
# → count of existing keys
|
||||
```
|
||||
|
||||
### EXPIRE / PEXPIRE
|
||||
Set expiration time for a key.
|
||||
```bash
|
||||
redis-cli -p $PORT EXPIRE key 60
|
||||
# → 1 (timeout set) or 0 (timeout not set)
|
||||
|
||||
redis-cli -p $PORT PEXPIRE key 1000
|
||||
# → 1 (timeout set) or 0 (timeout not set)
|
||||
```
|
||||
|
||||
### EXPIREAT / PEXPIREAT
|
||||
Set expiration timestamp for a key.
|
||||
```bash
|
||||
redis-cli -p $PORT EXPIREAT key 1672531200
|
||||
# → 1 (timeout set) or 0 (timeout not set)
|
||||
|
||||
redis-cli -p $PORT PEXPIREAT key 1672531200000
|
||||
# → 1 (timeout set) or 0 (timeout not set)
|
||||
```
|
||||
|
||||
### TTL
|
||||
Get the time to live for a key.
|
||||
```bash
|
||||
redis-cli -p $PORT TTL key
|
||||
# → remaining time in seconds
|
||||
```
|
||||
|
||||
### PERSIST
|
||||
Remove expiration from a key.
|
||||
```bash
|
||||
redis-cli -p $PORT PERSIST key
|
||||
# → 1 (timeout removed) or 0 (key has no timeout)
|
||||
```
|
||||
|
||||
## Hash Commands
|
||||
|
||||
### HSET
|
||||
Set field-value pairs in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HSET hashkey field1 value1 field2 value2
|
||||
# → number of fields added
|
||||
```
|
||||
|
||||
### HGET
|
||||
Get value of a field in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HGET hashkey field1
|
||||
# → value1
|
||||
```
|
||||
|
||||
### HGETALL
|
||||
Get all field-value pairs in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HGETALL hashkey
|
||||
# → 1) "field1"
|
||||
# 2) "value1"
|
||||
# 3) "field2"
|
||||
# 4) "value2"
|
||||
```
|
||||
|
||||
### HDEL
|
||||
Delete fields from a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HDEL hashkey field1 field2
|
||||
# → number of fields deleted
|
||||
```
|
||||
|
||||
### HEXISTS
|
||||
Check if a field exists in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HEXISTS hashkey field1
|
||||
# → 1 (exists) or 0 (doesn't exist)
|
||||
```
|
||||
|
||||
### HKEYS
|
||||
Get all field names in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HKEYS hashkey
|
||||
# → 1) "field1"
|
||||
# 2) "field2"
|
||||
```
|
||||
|
||||
### HVALS
|
||||
Get all values in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HVALS hashkey
|
||||
# → 1) "value1"
|
||||
# 2) "value2"
|
||||
```
|
||||
|
||||
### HLEN
|
||||
Get number of fields in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HLEN hashkey
|
||||
# → number of fields
|
||||
```
|
||||
|
||||
### HMGET
|
||||
Get values of multiple fields in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HMGET hashkey field1 field2 field3
|
||||
# → 1) "value1"
|
||||
# 2) "value2"
|
||||
# 3) (nil)
|
||||
```
|
||||
|
||||
### HSETNX
|
||||
Set field-value pair in hash only if field doesn't exist.
|
||||
```bash
|
||||
redis-cli -p $PORT HSETNX hashkey field1 value1
|
||||
# → 1 (field set) or 0 (field not set)
|
||||
```
|
||||
|
||||
### HINCRBY
|
||||
Increment integer value of a field in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HINCRBY hashkey field1 5
|
||||
# → new value
|
||||
```
|
||||
|
||||
### HINCRBYFLOAT
|
||||
Increment float value of a field in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HINCRBYFLOAT hashkey field1 3.14
|
||||
# → new value
|
||||
```
|
||||
|
||||
### HSCAN
|
||||
Incrementally iterate over fields in a hash.
|
||||
```bash
|
||||
redis-cli -p $PORT HSCAN hashkey 0
|
||||
# → 1) "next_cursor"
|
||||
# 2) 1) "field1"
|
||||
# 2) "value1"
|
||||
# 3) "field2"
|
||||
# 4) "value2"
|
||||
```
|
||||
|
||||
Options:
|
||||
- MATCH pattern: Filter fields by pattern
|
||||
- COUNT number: Suggest number of fields to return
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
redis-cli -p $PORT HSCAN hashkey 0 MATCH f*
|
||||
redis-cli -p $PORT HSCAN hashkey 0 COUNT 10
|
||||
redis-cli -p $PORT HSCAN hashkey 0 MATCH f* COUNT 10
|
||||
```
|
||||
|
||||
## List Commands
|
||||
|
||||
### LPUSH
|
||||
Insert elements at the head of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LPUSH listkey element1 element2 element3
|
||||
# → number of elements in the list
|
||||
```
|
||||
|
||||
### RPUSH
|
||||
Insert elements at the tail of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT RPUSH listkey element1 element2 element3
|
||||
# → number of elements in the list
|
||||
```
|
||||
|
||||
### LPOP
|
||||
Remove and return elements from the head of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LPOP listkey
|
||||
# → element1
|
||||
```
|
||||
|
||||
With count:
|
||||
```bash
|
||||
redis-cli -p $PORT LPOP listkey 2
|
||||
# → 1) "element1"
|
||||
# 2) "element2"
|
||||
```
|
||||
|
||||
### RPOP
|
||||
Remove and return elements from the tail of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT RPOP listkey
|
||||
# → element3
|
||||
```
|
||||
|
||||
With count:
|
||||
```bash
|
||||
redis-cli -p $PORT RPOP listkey 2
|
||||
# → 1) "element3"
|
||||
# 2) "element2"
|
||||
```
|
||||
|
||||
### LLEN
|
||||
Get the length of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LLEN listkey
|
||||
# → number of elements in the list
|
||||
```
|
||||
|
||||
### LINDEX
|
||||
Get element at index in a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LINDEX listkey 0
|
||||
# → first element
|
||||
```
|
||||
|
||||
Negative indices count from the end:
|
||||
```bash
|
||||
redis-cli -p $PORT LINDEX listkey -1
|
||||
# → last element
|
||||
```
|
||||
|
||||
### LRANGE
|
||||
Get a range of elements from a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LRANGE listkey 0 -1
|
||||
# → 1) "element1"
|
||||
# 2) "element2"
|
||||
# 3) "element3"
|
||||
```
|
||||
|
||||
### LTRIM
|
||||
Trim a list to specified range.
|
||||
```bash
|
||||
redis-cli -p $PORT LTRIM listkey 0 1
|
||||
# → OK (list now contains only first 2 elements)
|
||||
```
|
||||
|
||||
### LREM
|
||||
Remove elements from a list.
|
||||
```bash
|
||||
redis-cli -p $PORT LREM listkey 2 element1
|
||||
# → number of elements removed
|
||||
```
|
||||
|
||||
Count values:
|
||||
- Positive: Remove from head
|
||||
- Negative: Remove from tail
|
||||
- Zero: Remove all
|
||||
|
||||
### LINSERT
|
||||
Insert element before or after pivot element.
|
||||
```bash
|
||||
redis-cli -p $PORT LINSERT listkey BEFORE pivot newelement
|
||||
# → number of elements in the list
|
||||
```
|
||||
|
||||
### BLPOP
|
||||
Blocking remove and return elements from the head of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT BLPOP listkey1 listkey2 5
|
||||
# → 1) "listkey1"
|
||||
# 2) "element1"
|
||||
```
|
||||
|
||||
If no elements are available, blocks for specified timeout (in seconds) until an element is pushed to one of the lists.
|
||||
|
||||
### BRPOP
|
||||
Blocking remove and return elements from the tail of a list.
|
||||
```bash
|
||||
redis-cli -p $PORT BRPOP listkey1 listkey2 5
|
||||
# → 1) "listkey1"
|
||||
# 2) "element1"
|
||||
```
|
||||
|
||||
If no elements are available, blocks for specified timeout (in seconds) until an element is pushed to one of the lists.
|
||||
|
||||
## Keyspace Commands
|
||||
|
||||
### KEYS
|
||||
Get all keys matching pattern.
|
||||
```bash
|
||||
redis-cli -p $PORT KEYS *
|
||||
# → 1) "key1"
|
||||
# 2) "key2"
|
||||
```
|
||||
|
||||
### SCAN
|
||||
Incrementally iterate over keys.
|
||||
```bash
|
||||
redis-cli -p $PORT SCAN 0
|
||||
# → 1) "next_cursor"
|
||||
# 2) 1) "key1"
|
||||
# 2) "key2"
|
||||
```
|
||||
|
||||
Options:
|
||||
- MATCH pattern: Filter keys by pattern
|
||||
- COUNT number: Suggest number of keys to return
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
redis-cli -p $PORT SCAN 0 MATCH k*
|
||||
redis-cli -p $PORT SCAN 0 COUNT 10
|
||||
redis-cli -p $PORT SCAN 0 MATCH k* COUNT 10
|
||||
```
|
||||
|
||||
### DBSIZE
|
||||
Get number of keys in current database.
|
||||
```bash
|
||||
redis-cli -p $PORT DBSIZE
|
||||
# → number of keys
|
||||
```
|
||||
|
||||
### FLUSHDB
|
||||
Remove all keys from current database.
|
||||
```bash
|
||||
redis-cli -p $PORT FLUSHDB
|
||||
# → OK
|
||||
```
|
||||
|
||||
## Configuration Commands
|
||||
|
||||
### CONFIG GET
|
||||
Get configuration parameter.
|
||||
```bash
|
||||
redis-cli -p $PORT CONFIG GET dir
|
||||
# → 1) "dir"
|
||||
# 2) "/path/to/db"
|
||||
|
||||
redis-cli -p $PORT CONFIG GET dbfilename
|
||||
# → 1) "dbfilename"
|
||||
# 2) "0.db"
|
||||
```
|
||||
|
||||
## Client Commands
|
||||
|
||||
### CLIENT SETNAME
|
||||
Set current connection name.
|
||||
```bash
|
||||
redis-cli -p $PORT CLIENT SETNAME myconnection
|
||||
# → OK
|
||||
```
|
||||
|
||||
### CLIENT GETNAME
|
||||
Get current connection name.
|
||||
```bash
|
||||
redis-cli -p $PORT CLIENT GETNAME
|
||||
# → myconnection
|
||||
```
|
||||
|
||||
## Transaction Commands
|
||||
|
||||
### MULTI
|
||||
Start a transaction block.
|
||||
```bash
|
||||
redis-cli -p $PORT MULTI
|
||||
# → OK
|
||||
```
|
||||
|
||||
### EXEC
|
||||
Execute all commands in transaction block.
|
||||
```bash
|
||||
redis-cli -p $PORT MULTI
|
||||
redis-cli -p $PORT SET key1 value1
|
||||
redis-cli -p $PORT SET key2 value2
|
||||
redis-cli -p $PORT EXEC
|
||||
# → 1) OK
|
||||
# 2) OK
|
||||
```
|
||||
|
||||
### DISCARD
|
||||
Discard all commands in transaction block.
|
||||
```bash
|
||||
redis-cli -p $PORT MULTI
|
||||
redis-cli -p $PORT SET key1 value1
|
||||
redis-cli -p $PORT DISCARD
|
||||
# → OK
|
||||
```
|
||||
|
||||
## AGE Commands
|
||||
|
||||
### AGE GENENC
|
||||
Generate ephemeral encryption keypair.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE GENENC
|
||||
# → 1) "recipient_public_key"
|
||||
# 2) "identity_secret_key"
|
||||
```
|
||||
|
||||
### AGE ENCRYPT
|
||||
Encrypt message with recipient public key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE ENCRYPT recipient_public_key "message"
|
||||
# → base64_encoded_ciphertext
|
||||
```
|
||||
|
||||
### AGE DECRYPT
|
||||
Decrypt ciphertext with identity secret key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE DECRYPT identity_secret_key base64_encoded_ciphertext
|
||||
# → decrypted_message
|
||||
```
|
||||
|
||||
### AGE GENSIGN
|
||||
Generate ephemeral signing keypair.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE GENSIGN
|
||||
# → 1) "verify_public_key"
|
||||
# 2) "sign_secret_key"
|
||||
```
|
||||
|
||||
### AGE SIGN
|
||||
Sign message with signing secret key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE SIGN sign_secret_key "message"
|
||||
# → base64_encoded_signature
|
||||
```
|
||||
|
||||
### AGE VERIFY
|
||||
Verify signature with verify public key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE VERIFY verify_public_key "message" base64_encoded_signature
|
||||
# → 1 (valid) or 0 (invalid)
|
||||
```
|
||||
|
||||
### AGE KEYGEN
|
||||
Generate and persist named encryption keypair.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE KEYGEN keyname
|
||||
# → 1) "recipient_public_key"
|
||||
# 2) "identity_secret_key"
|
||||
```
|
||||
|
||||
### AGE SIGNKEYGEN
|
||||
Generate and persist named signing keypair.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE SIGNKEYGEN keyname
|
||||
# → 1) "verify_public_key"
|
||||
# 2) "sign_secret_key"
|
||||
```
|
||||
|
||||
### AGE ENCRYPTNAME
|
||||
Encrypt message with named key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE ENCRYPTNAME keyname "message"
|
||||
# → base64_encoded_ciphertext
|
||||
```
|
||||
|
||||
### AGE DECRYPTNAME
|
||||
Decrypt ciphertext with named key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE DECRYPTNAME keyname base64_encoded_ciphertext
|
||||
# → decrypted_message
|
||||
```
|
||||
|
||||
### AGE SIGNNAME
|
||||
Sign message with named signing key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE SIGNNAME keyname "message"
|
||||
# → base64_encoded_signature
|
||||
```
|
||||
|
||||
### AGE VERIFYNAME
|
||||
Verify signature with named verify key.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE VERIFYNAME keyname "message" base64_encoded_signature
|
||||
# → 1 (valid) or 0 (invalid)
|
||||
```
|
||||
|
||||
### AGE LIST
|
||||
List all stored AGE keys.
|
||||
```bash
|
||||
redis-cli -p $PORT AGE LIST
|
||||
# → 1) "keyname1"
|
||||
# 2) "keyname2"
|
||||
```
|
||||
|
||||
## Server Information Commands
|
||||
|
||||
### INFO
|
||||
Get server information.
|
||||
```bash
|
||||
redis-cli -p $PORT INFO
|
||||
# → Server information
|
||||
```
|
||||
|
||||
With section:
|
||||
```bash
|
||||
redis-cli -p $PORT INFO replication
|
||||
# → Replication information
|
||||
```
|
||||
|
||||
### COMMAND
|
||||
Get command information (stub implementation).
|
||||
```bash
|
||||
redis-cli -p $PORT COMMAND
|
||||
# → Empty array (stub)
|
||||
```
|
||||
|
||||
## Database Selection
|
||||
|
||||
### SELECT
|
||||
Select database by index.
|
||||
```bash
|
||||
redis-cli -p $PORT SELECT 0
|
||||
# → OK
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
This expanded documentation includes all the list commands that were implemented in the cmd.rs file:
|
||||
1. LPUSH - push elements to the left (head) of a list
|
||||
2. RPUSH - push elements to the right (tail) of a list
|
||||
3. LPOP - pop elements from the left (head) of a list
|
||||
4. RPOP - pop elements from the right (tail) of a list
|
||||
5. BLPOP - blocking pop from the left with timeout
|
||||
6. BRPOP - blocking pop from the right with timeout
|
||||
7. LLEN - get list length
|
||||
8. LREM - remove elements from list
|
||||
9. LTRIM - trim list to range
|
||||
10. LINDEX - get element by index
|
||||
11. LRANGE - get range of elements
|
||||
|
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
|
||||
```
|
454
docs/lance_vector_db.md
Normal file
454
docs/lance_vector_db.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Lance Vector Database Operations
|
||||
|
||||
HeroDB includes a powerful vector database integration using Lance, enabling high-performance vector storage, search, and multimodal data management. By default, it uses Ollama for local text embeddings, with support for custom external embedding services.
|
||||
|
||||
## Overview
|
||||
|
||||
The Lance vector database integration provides:
|
||||
|
||||
- **High-performance vector storage** using Lance's columnar format
|
||||
- **Local Ollama integration** for text embeddings (default, no external dependencies)
|
||||
- **Custom embedding service support** for advanced use cases
|
||||
- **Text embedding support** (images via custom services)
|
||||
- **Vector similarity search** with configurable parameters
|
||||
- **Scalable indexing** with IVF_PQ (Inverted File with Product Quantization)
|
||||
- **Redis-compatible command interface**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ HeroDB │ │ External │ │ Lance │
|
||||
│ Redis Server │◄──►│ Embedding │ │ Vector Store │
|
||||
│ │ │ Service │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
Redis Protocol HTTP API Arrow/Parquet
|
||||
Commands JSON Requests Columnar Storage
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Lance Store**: High-performance columnar vector storage
|
||||
2. **Ollama Integration**: Local embedding service (default)
|
||||
3. **Custom Embedding Service**: Optional HTTP API for advanced use cases
|
||||
4. **Redis Command Interface**: Familiar Redis-style commands
|
||||
5. **Arrow Schema**: Flexible schema definition for metadata
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Setup (Ollama)
|
||||
|
||||
HeroDB uses Ollama by default for text embeddings. No configuration is required if Ollama is running locally:
|
||||
|
||||
```bash
|
||||
# Install Ollama (if not already installed)
|
||||
# Visit: https://ollama.ai
|
||||
|
||||
# Pull the embedding model
|
||||
ollama pull nomic-embed-text
|
||||
|
||||
# Ollama automatically runs on localhost:11434
|
||||
# HeroDB will use this by default
|
||||
```
|
||||
|
||||
**Default Configuration:**
|
||||
- **URL**: `http://localhost:11434`
|
||||
- **Model**: `nomic-embed-text`
|
||||
- **Dimensions**: 768 (for nomic-embed-text)
|
||||
|
||||
### Custom Embedding Service (Optional)
|
||||
|
||||
To use a custom embedding service instead of Ollama:
|
||||
|
||||
```bash
|
||||
# Set custom embedding service URL
|
||||
redis-cli HSET config:core:aiembed url "http://your-embedding-service:8080/embed"
|
||||
|
||||
# Optional: Set authentication if required
|
||||
redis-cli HSET config:core:aiembed token "your-api-token"
|
||||
```
|
||||
|
||||
### Embedding Service API Contracts
|
||||
|
||||
#### Ollama API (Default)
|
||||
HeroDB calls Ollama using this format:
|
||||
|
||||
```bash
|
||||
POST http://localhost:11434/api/embeddings
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "nomic-embed-text",
|
||||
"prompt": "Your text to embed"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"embedding": [0.1, 0.2, 0.3, ...]
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Service API
|
||||
Your custom embedding service should accept POST requests with this JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"texts": ["text1", "text2"], // Optional: array of texts
|
||||
"images": ["base64_image1", "base64_image2"], // Optional: base64 encoded images
|
||||
"model": "your-model-name" // Optional: model specification
|
||||
}
|
||||
```
|
||||
|
||||
And return responses in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], // Array of embedding vectors
|
||||
"model": "model-name", // Model used
|
||||
"usage": { // Optional usage stats
|
||||
"tokens": 100,
|
||||
"requests": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Dataset Management
|
||||
|
||||
#### LANCE CREATE
|
||||
Create a new vector dataset with specified dimensions and optional schema.
|
||||
|
||||
```bash
|
||||
LANCE CREATE <dataset> DIM <dimension> [SCHEMA field:type ...]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dataset`: Name of the dataset
|
||||
- `dimension`: Vector dimension (e.g., 384, 768, 1536)
|
||||
- `field:type`: Optional metadata fields (string, int, float, bool)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Create a simple dataset for 384-dimensional vectors
|
||||
LANCE CREATE documents DIM 384
|
||||
|
||||
# Create dataset with metadata schema
|
||||
LANCE CREATE products DIM 768 SCHEMA category:string price:float available:bool
|
||||
```
|
||||
|
||||
#### LANCE LIST
|
||||
List all available datasets.
|
||||
|
||||
```bash
|
||||
LANCE LIST
|
||||
```
|
||||
|
||||
**Returns:** Array of dataset names
|
||||
|
||||
#### LANCE INFO
|
||||
Get information about a specific dataset.
|
||||
|
||||
```bash
|
||||
LANCE INFO <dataset>
|
||||
```
|
||||
|
||||
**Returns:** Dataset metadata including name, version, row count, and schema
|
||||
|
||||
#### LANCE DROP
|
||||
Delete a dataset and all its data.
|
||||
|
||||
```bash
|
||||
LANCE DROP <dataset>
|
||||
```
|
||||
|
||||
### Data Operations
|
||||
|
||||
#### LANCE STORE
|
||||
Store multimodal data (text/images) with automatic embedding generation.
|
||||
|
||||
```bash
|
||||
LANCE STORE <dataset> [TEXT <text>] [IMAGE <base64>] [key value ...]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dataset`: Target dataset name
|
||||
- `TEXT`: Text content to embed
|
||||
- `IMAGE`: Base64-encoded image to embed
|
||||
- `key value`: Metadata key-value pairs
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Store text with metadata
|
||||
LANCE STORE documents TEXT "Machine learning is transforming industries" category "AI" author "John Doe"
|
||||
|
||||
# Store image with metadata
|
||||
LANCE STORE images IMAGE "iVBORw0KGgoAAAANSUhEUgAA..." category "nature" tags "landscape,mountains"
|
||||
|
||||
# Store both text and image
|
||||
LANCE STORE multimodal TEXT "Beautiful sunset" IMAGE "base64data..." location "California"
|
||||
```
|
||||
|
||||
**Returns:** Unique ID of the stored item
|
||||
|
||||
### Search Operations
|
||||
|
||||
#### LANCE SEARCH
|
||||
Search using a raw vector.
|
||||
|
||||
```bash
|
||||
LANCE SEARCH <dataset> VECTOR <vector> K <k> [NPROBES <n>] [REFINE <r>]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dataset`: Dataset to search
|
||||
- `vector`: Comma-separated vector values (e.g., "0.1,0.2,0.3")
|
||||
- `k`: Number of results to return
|
||||
- `NPROBES`: Number of partitions to search (optional)
|
||||
- `REFINE`: Refine factor for better accuracy (optional)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
LANCE SEARCH documents VECTOR "0.1,0.2,0.3,0.4" K 5 NPROBES 10
|
||||
```
|
||||
|
||||
#### LANCE SEARCH.TEXT
|
||||
Search using text query (automatically embedded).
|
||||
|
||||
```bash
|
||||
LANCE SEARCH.TEXT <dataset> <query_text> K <k> [NPROBES <n>] [REFINE <r>]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dataset`: Dataset to search
|
||||
- `query_text`: Text query to search for
|
||||
- `k`: Number of results to return
|
||||
- `NPROBES`: Number of partitions to search (optional)
|
||||
- `REFINE`: Refine factor for better accuracy (optional)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
LANCE SEARCH.TEXT documents "artificial intelligence applications" K 10 NPROBES 20
|
||||
```
|
||||
|
||||
**Returns:** Array of results with distance scores and metadata
|
||||
|
||||
### Embedding Operations
|
||||
|
||||
#### LANCE EMBED.TEXT
|
||||
Generate embeddings for text without storing.
|
||||
|
||||
```bash
|
||||
LANCE EMBED.TEXT <text1> [text2] [text3] ...
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
LANCE EMBED.TEXT "Hello world" "Machine learning" "Vector database"
|
||||
```
|
||||
|
||||
**Returns:** Array of embedding vectors
|
||||
|
||||
### Index Management
|
||||
|
||||
#### LANCE CREATE.INDEX
|
||||
Create a vector index for faster search performance.
|
||||
|
||||
```bash
|
||||
LANCE CREATE.INDEX <dataset> <index_type> [PARTITIONS <n>] [SUBVECTORS <n>]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dataset`: Dataset to index
|
||||
- `index_type`: Index type (currently supports "IVF_PQ")
|
||||
- `PARTITIONS`: Number of partitions (default: 256)
|
||||
- `SUBVECTORS`: Number of sub-vectors for PQ (default: 16)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
LANCE CREATE.INDEX documents IVF_PQ PARTITIONS 512 SUBVECTORS 32
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### 1. Document Search System
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
LANCE CREATE documents DIM 384 SCHEMA title:string content:string category:string
|
||||
|
||||
# Store documents
|
||||
LANCE STORE documents TEXT "Introduction to machine learning algorithms" title "ML Basics" category "education"
|
||||
LANCE STORE documents TEXT "Deep learning neural networks explained" title "Deep Learning" category "education"
|
||||
LANCE STORE documents TEXT "Building scalable web applications" title "Web Dev" category "programming"
|
||||
|
||||
# Create index for better performance
|
||||
LANCE CREATE.INDEX documents IVF_PQ PARTITIONS 256
|
||||
|
||||
# Search
|
||||
LANCE SEARCH.TEXT documents "neural networks" K 5
|
||||
```
|
||||
|
||||
### 2. Image Similarity Search
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
LANCE CREATE images DIM 512 SCHEMA filename:string tags:string
|
||||
|
||||
# Store images (base64 encoded)
|
||||
LANCE STORE images IMAGE "iVBORw0KGgoAAAANSUhEUgAA..." filename "sunset.jpg" tags "nature,landscape"
|
||||
LANCE STORE images IMAGE "iVBORw0KGgoAAAANSUhEUgBB..." filename "city.jpg" tags "urban,architecture"
|
||||
|
||||
# Search by image
|
||||
LANCE STORE temp_search IMAGE "query_image_base64..."
|
||||
# Then use the returned ID to get embedding and search
|
||||
```
|
||||
|
||||
### 3. Multimodal Content Management
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
LANCE CREATE content DIM 768 SCHEMA type:string source:string
|
||||
|
||||
# Store mixed content
|
||||
LANCE STORE content TEXT "Product description for smartphone" type "product" source "catalog"
|
||||
LANCE STORE content IMAGE "product_image_base64..." type "product_image" source "catalog"
|
||||
|
||||
# Search across all content types
|
||||
LANCE SEARCH.TEXT content "smartphone features" K 10
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Vector Dimensions
|
||||
- **384**: Good for general text (e.g., sentence-transformers)
|
||||
- **768**: Standard for BERT-like models
|
||||
- **1536**: OpenAI text-embedding-ada-002
|
||||
- **Higher dimensions**: Better accuracy but slower search
|
||||
|
||||
### Index Configuration
|
||||
- **More partitions**: Better for larger datasets (>100K vectors)
|
||||
- **More sub-vectors**: Better compression but slower search
|
||||
- **NPROBES**: Higher values = better accuracy, slower search
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Create indexes** for datasets with >1000 vectors
|
||||
2. **Use appropriate dimensions** based on your embedding model
|
||||
3. **Configure NPROBES** based on accuracy vs speed requirements
|
||||
4. **Batch operations** when possible for better performance
|
||||
5. **Monitor embedding service** response times and rate limits
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common error scenarios and solutions:
|
||||
|
||||
### Embedding Service Errors
|
||||
```bash
|
||||
# Error: Embedding service not configured
|
||||
ERR Embedding service URL not configured. Set it with: HSET config:core:aiembed url <YOUR_EMBEDDING_SERVICE_URL>
|
||||
|
||||
# Error: Service unavailable
|
||||
ERR Embedding service returned error 404 Not Found
|
||||
```
|
||||
|
||||
**Solution:** Ensure embedding service is running and URL is correct.
|
||||
|
||||
### Dataset Errors
|
||||
```bash
|
||||
# Error: Dataset doesn't exist
|
||||
ERR Dataset 'mydata' does not exist
|
||||
|
||||
# Error: Dimension mismatch
|
||||
ERR Vector dimension mismatch: expected 384, got 768
|
||||
```
|
||||
|
||||
**Solution:** Create dataset first or check vector dimensions.
|
||||
|
||||
### Search Errors
|
||||
```bash
|
||||
# Error: Invalid vector format
|
||||
ERR Invalid vector format
|
||||
|
||||
# Error: No index available
|
||||
ERR No index available for fast search
|
||||
```
|
||||
|
||||
**Solution:** Check vector format or create an index.
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### With Python
|
||||
```python
|
||||
import redis
|
||||
import json
|
||||
|
||||
r = redis.Redis(host='localhost', port=6379)
|
||||
|
||||
# Create dataset
|
||||
r.execute_command('LANCE', 'CREATE', 'docs', 'DIM', '384')
|
||||
|
||||
# Store document
|
||||
result = r.execute_command('LANCE', 'STORE', 'docs',
|
||||
'TEXT', 'Machine learning tutorial',
|
||||
'category', 'education')
|
||||
print(f"Stored with ID: {result}")
|
||||
|
||||
# Search
|
||||
results = r.execute_command('LANCE', 'SEARCH.TEXT', 'docs',
|
||||
'machine learning', 'K', '5')
|
||||
print(f"Search results: {results}")
|
||||
```
|
||||
|
||||
### With Node.js
|
||||
```javascript
|
||||
const redis = require('redis');
|
||||
const client = redis.createClient();
|
||||
|
||||
// Create dataset
|
||||
await client.sendCommand(['LANCE', 'CREATE', 'docs', 'DIM', '384']);
|
||||
|
||||
// Store document
|
||||
const id = await client.sendCommand(['LANCE', 'STORE', 'docs',
|
||||
'TEXT', 'Deep learning guide',
|
||||
'category', 'AI']);
|
||||
|
||||
// Search
|
||||
const results = await client.sendCommand(['LANCE', 'SEARCH.TEXT', 'docs',
|
||||
'deep learning', 'K', '10']);
|
||||
```
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Check if Lance store is available
|
||||
LANCE LIST
|
||||
|
||||
# Check dataset health
|
||||
LANCE INFO mydataset
|
||||
|
||||
# Test embedding service
|
||||
LANCE EMBED.TEXT "test"
|
||||
```
|
||||
|
||||
### Maintenance Operations
|
||||
```bash
|
||||
# Backup: Use standard Redis backup procedures
|
||||
# The Lance data is stored separately in the data directory
|
||||
|
||||
# Cleanup: Remove unused datasets
|
||||
LANCE DROP old_dataset
|
||||
|
||||
# Reindex: Drop and recreate indexes if needed
|
||||
LANCE DROP dataset_name
|
||||
LANCE CREATE dataset_name DIM 384
|
||||
# Re-import data
|
||||
LANCE CREATE.INDEX dataset_name IVF_PQ
|
||||
```
|
||||
|
||||
This integration provides a powerful foundation for building AI-powered applications with vector search capabilities while maintaining the familiar Redis interface.
|
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
|
356
examples/README.md
Normal file
356
examples/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# HeroDB Examples
|
||||
|
||||
This directory contains examples demonstrating HeroDB's capabilities including full-text search powered by Tantivy and vector database operations using Lance.
|
||||
|
||||
## Available Examples
|
||||
|
||||
1. **[Tantivy Search Demo](#tantivy-search-demo-bash-script)** - Full-text search capabilities
|
||||
2. **[Lance Vector Database Demo](#lance-vector-database-demo-bash-script)** - Vector database and AI operations
|
||||
3. **[AGE Encryption Demo](age_bash_demo.sh)** - Cryptographic operations
|
||||
4. **[Simple Demo](simple_demo.sh)** - Basic Redis operations
|
||||
|
||||
---
|
||||
|
||||
## Lance Vector Database Demo (Bash Script)
|
||||
|
||||
### Overview
|
||||
The `lance_vector_demo.sh` script provides a comprehensive demonstration of HeroDB's vector database capabilities using Lance. It showcases vector storage, similarity search, multimodal data handling, and AI-powered operations with external embedding services.
|
||||
|
||||
### Prerequisites
|
||||
1. **HeroDB Server**: The server must be running (default port 6379)
|
||||
2. **Redis CLI**: The `redis-cli` tool must be installed and available in your PATH
|
||||
3. **Embedding Service** (optional): For full functionality, set up an external embedding service
|
||||
|
||||
### Running the Demo
|
||||
|
||||
#### Step 1: Start HeroDB Server
|
||||
```bash
|
||||
# From the project root directory
|
||||
cargo run -- --dir ./test_data --port 6379
|
||||
```
|
||||
|
||||
#### Step 2: Run the Demo (in a new terminal)
|
||||
```bash
|
||||
# From the project root directory
|
||||
./examples/lance_vector_demo.sh
|
||||
```
|
||||
|
||||
### What the Demo Covers
|
||||
|
||||
The script demonstrates comprehensive vector database operations:
|
||||
|
||||
1. **Dataset Management**
|
||||
- Creating vector datasets with custom dimensions
|
||||
- Defining schemas with metadata fields
|
||||
- Listing and inspecting datasets
|
||||
- Dataset information and statistics
|
||||
|
||||
2. **Embedding Operations**
|
||||
- Text embedding generation via external services
|
||||
- Multimodal embedding support (text + images)
|
||||
- Batch embedding operations
|
||||
|
||||
3. **Data Storage**
|
||||
- Storing text documents with automatic embedding
|
||||
- Storing images with metadata
|
||||
- Multimodal content storage
|
||||
- Rich metadata support
|
||||
|
||||
4. **Vector Search**
|
||||
- Similarity search with raw vectors
|
||||
- Text-based semantic search
|
||||
- Configurable search parameters (K, NPROBES, REFINE)
|
||||
- Cross-modal search capabilities
|
||||
|
||||
5. **Index Management**
|
||||
- Creating IVF_PQ indexes for performance
|
||||
- Custom index parameters
|
||||
- Performance optimization
|
||||
|
||||
6. **Advanced Features**
|
||||
- Error handling and recovery
|
||||
- Performance testing concepts
|
||||
- Monitoring and maintenance
|
||||
- Cleanup operations
|
||||
|
||||
### Key Lance Commands Demonstrated
|
||||
|
||||
#### Dataset Management
|
||||
```bash
|
||||
# Create vector dataset
|
||||
LANCE CREATE documents DIM 384
|
||||
|
||||
# Create dataset with schema
|
||||
LANCE CREATE products DIM 768 SCHEMA category:string price:float available:bool
|
||||
|
||||
# List datasets
|
||||
LANCE LIST
|
||||
|
||||
# Get dataset information
|
||||
LANCE INFO documents
|
||||
```
|
||||
|
||||
#### Data Operations
|
||||
```bash
|
||||
# Store text with metadata
|
||||
LANCE STORE documents TEXT "Machine learning tutorial" category "education" author "John Doe"
|
||||
|
||||
# Store image with metadata
|
||||
LANCE STORE images IMAGE "base64_encoded_image..." filename "photo.jpg" tags "nature,landscape"
|
||||
|
||||
# Store multimodal content
|
||||
LANCE STORE content TEXT "Product description" IMAGE "base64_image..." type "product"
|
||||
```
|
||||
|
||||
#### Search Operations
|
||||
```bash
|
||||
# Search with raw vector
|
||||
LANCE SEARCH documents VECTOR "0.1,0.2,0.3,0.4" K 5
|
||||
|
||||
# Semantic text search
|
||||
LANCE SEARCH.TEXT documents "artificial intelligence" K 10 NPROBES 20
|
||||
|
||||
# Generate embeddings
|
||||
LANCE EMBED.TEXT "Hello world" "Machine learning"
|
||||
```
|
||||
|
||||
#### Index Management
|
||||
```bash
|
||||
# Create performance index
|
||||
LANCE CREATE.INDEX documents IVF_PQ PARTITIONS 256 SUBVECTORS 16
|
||||
|
||||
# Drop dataset
|
||||
LANCE DROP old_dataset
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Setting Up Embedding Service
|
||||
```bash
|
||||
# Configure embedding service URL
|
||||
redis-cli HSET config:core:aiembed url "http://your-embedding-service:8080/embed"
|
||||
|
||||
# Optional: Set authentication token
|
||||
redis-cli HSET config:core:aiembed token "your-api-token"
|
||||
```
|
||||
|
||||
#### Embedding Service API
|
||||
Your embedding service should accept POST requests:
|
||||
```json
|
||||
{
|
||||
"texts": ["text1", "text2"],
|
||||
"images": ["base64_image1", "base64_image2"],
|
||||
"model": "your-model-name"
|
||||
}
|
||||
```
|
||||
|
||||
And return responses:
|
||||
```json
|
||||
{
|
||||
"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]],
|
||||
"model": "model-name",
|
||||
"usage": {"tokens": 100, "requests": 2}
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive Features
|
||||
|
||||
The demo script includes:
|
||||
- **Colored output** for better readability
|
||||
- **Step-by-step execution** with explanations
|
||||
- **Error handling** demonstrations
|
||||
- **Automatic cleanup** options
|
||||
- **Performance testing** concepts
|
||||
- **Real-world usage** examples
|
||||
|
||||
### Use Cases Demonstrated
|
||||
|
||||
1. **Document Search System**
|
||||
- Semantic document retrieval
|
||||
- Metadata filtering
|
||||
- Relevance ranking
|
||||
|
||||
2. **Image Similarity Search**
|
||||
- Visual content matching
|
||||
- Tag-based filtering
|
||||
- Multimodal queries
|
||||
|
||||
3. **Product Recommendations**
|
||||
- Feature-based similarity
|
||||
- Category filtering
|
||||
- Price range queries
|
||||
|
||||
4. **Content Management**
|
||||
- Mixed media storage
|
||||
- Cross-modal search
|
||||
- Rich metadata support
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
426
examples/lance_vector_demo.sh
Executable file
426
examples/lance_vector_demo.sh
Executable file
@@ -0,0 +1,426 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Lance Vector Database Demo Script
|
||||
# This script demonstrates all Lance vector database operations in HeroDB
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Configuration
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
execute_command() {
|
||||
local cmd="$1"
|
||||
local description="$2"
|
||||
|
||||
echo
|
||||
log_info "Executing: $description"
|
||||
echo "Command: $cmd"
|
||||
|
||||
if result=$($cmd 2>&1); then
|
||||
log_success "Result: $result"
|
||||
else
|
||||
log_error "Failed: $result"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if HeroDB is running
|
||||
check_herodb() {
|
||||
log_info "Checking if HeroDB is running..."
|
||||
if ! $REDIS_CLI ping > /dev/null 2>&1; then
|
||||
log_error "HeroDB is not running. Please start it first:"
|
||||
echo " cargo run -- --dir ./test_data --port $REDIS_PORT"
|
||||
exit 1
|
||||
fi
|
||||
log_success "HeroDB is running"
|
||||
}
|
||||
|
||||
# Setup embedding service configuration
|
||||
setup_embedding_service() {
|
||||
log_info "Setting up embedding service configuration..."
|
||||
|
||||
# Note: This is a mock URL for demonstration
|
||||
# In production, replace with your actual embedding service
|
||||
execute_command \
|
||||
"$REDIS_CLI HSET config:core:aiembed url 'http://localhost:8080/embed'" \
|
||||
"Configure embedding service URL"
|
||||
|
||||
# Optional: Set authentication token
|
||||
# execute_command \
|
||||
# "$REDIS_CLI HSET config:core:aiembed token 'your-api-token'" \
|
||||
# "Configure embedding service token"
|
||||
|
||||
log_warning "Note: Embedding service at http://localhost:8080/embed is not running."
|
||||
log_warning "Some operations will fail, but this demonstrates the command structure."
|
||||
}
|
||||
|
||||
# Dataset Management Operations
|
||||
demo_dataset_management() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " DATASET MANAGEMENT DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
# List datasets (should be empty initially)
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE LIST" \
|
||||
"List all datasets (initially empty)"
|
||||
|
||||
# Create a simple dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE documents DIM 384" \
|
||||
"Create a simple document dataset with 384 dimensions"
|
||||
|
||||
# Create a dataset with schema
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE products DIM 768 SCHEMA category:string price:float available:bool description:string" \
|
||||
"Create products dataset with custom schema"
|
||||
|
||||
# Create an image dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE images DIM 512 SCHEMA filename:string tags:string width:int height:int" \
|
||||
"Create images dataset for multimodal content"
|
||||
|
||||
# List datasets again
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE LIST" \
|
||||
"List all datasets (should show 3 datasets)"
|
||||
|
||||
# Get info about datasets
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE INFO documents" \
|
||||
"Get information about documents dataset"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE INFO products" \
|
||||
"Get information about products dataset"
|
||||
}
|
||||
|
||||
# Embedding Operations
|
||||
demo_embedding_operations() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " EMBEDDING OPERATIONS DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_warning "The following operations will fail because no embedding service is running."
|
||||
log_warning "This demonstrates the command structure and error handling."
|
||||
|
||||
# Try to embed text (will fail without embedding service)
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE EMBED.TEXT 'Hello world'" \
|
||||
"Generate embedding for single text" || true
|
||||
|
||||
# Try to embed multiple texts
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE EMBED.TEXT 'Machine learning' 'Artificial intelligence' 'Deep learning'" \
|
||||
"Generate embeddings for multiple texts" || true
|
||||
}
|
||||
|
||||
# Data Storage Operations
|
||||
demo_data_storage() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " DATA STORAGE DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_warning "Storage operations will fail without embedding service, but show command structure."
|
||||
|
||||
# Store text documents
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE STORE documents TEXT 'Introduction to machine learning algorithms and their applications in modern AI systems' category 'education' author 'John Doe' difficulty 'beginner'" \
|
||||
"Store a document with text and metadata" || true
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE STORE documents TEXT 'Deep learning neural networks for computer vision tasks' category 'research' author 'Jane Smith' difficulty 'advanced'" \
|
||||
"Store another document" || true
|
||||
|
||||
# Store product information
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE STORE products TEXT 'High-performance laptop with 16GB RAM and SSD storage' category 'electronics' price '1299.99' available 'true'" \
|
||||
"Store product with text description" || true
|
||||
|
||||
# Store image with metadata (using placeholder base64)
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE STORE images IMAGE 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' filename 'sample.png' tags 'test,demo' width '1' height '1'" \
|
||||
"Store image with metadata (1x1 pixel PNG)" || true
|
||||
|
||||
# Store multimodal content
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE STORE images TEXT 'Beautiful sunset over mountains' IMAGE 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' filename 'sunset.png' tags 'nature,landscape' location 'California'" \
|
||||
"Store multimodal content (text + image)" || true
|
||||
}
|
||||
|
||||
# Search Operations
|
||||
demo_search_operations() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " SEARCH OPERATIONS DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_warning "Search operations will fail without data, but show command structure."
|
||||
|
||||
# Search with raw vector
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH documents VECTOR '0.1,0.2,0.3,0.4,0.5' K 5" \
|
||||
"Search with raw vector (5 results)" || true
|
||||
|
||||
# Search with vector and parameters
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH documents VECTOR '0.1,0.2,0.3,0.4,0.5' K 10 NPROBES 20 REFINE 2" \
|
||||
"Search with vector and advanced parameters" || true
|
||||
|
||||
# Text-based search
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH.TEXT documents 'machine learning algorithms' K 5" \
|
||||
"Search using text query" || true
|
||||
|
||||
# Text search with parameters
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH.TEXT products 'laptop computer' K 3 NPROBES 10" \
|
||||
"Search products using text with parameters" || true
|
||||
|
||||
# Search in image dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH.TEXT images 'sunset landscape' K 5" \
|
||||
"Search images using text description" || true
|
||||
}
|
||||
|
||||
# Index Management Operations
|
||||
demo_index_management() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " INDEX MANAGEMENT DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
# Create indexes for better search performance
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE.INDEX documents IVF_PQ" \
|
||||
"Create default IVF_PQ index for documents"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE.INDEX products IVF_PQ PARTITIONS 512 SUBVECTORS 32" \
|
||||
"Create IVF_PQ index with custom parameters for products"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE.INDEX images IVF_PQ PARTITIONS 256 SUBVECTORS 16" \
|
||||
"Create IVF_PQ index for images dataset"
|
||||
|
||||
log_success "Indexes created successfully"
|
||||
}
|
||||
|
||||
# Advanced Usage Examples
|
||||
demo_advanced_usage() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " ADVANCED USAGE EXAMPLES"
|
||||
echo "=========================================="
|
||||
|
||||
# Create a specialized dataset for semantic search
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE semantic_search DIM 1536 SCHEMA title:string content:string url:string timestamp:string source:string" \
|
||||
"Create dataset for semantic search with rich metadata"
|
||||
|
||||
# Demonstrate batch operations concept
|
||||
log_info "Batch operations example (would store multiple items):"
|
||||
echo " for doc in documents:"
|
||||
echo " LANCE STORE semantic_search TEXT \"\$doc_content\" title \"\$title\" url \"\$url\""
|
||||
|
||||
# Show monitoring commands
|
||||
log_info "Monitoring and maintenance commands:"
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE LIST" \
|
||||
"List all datasets for monitoring"
|
||||
|
||||
# Show dataset statistics
|
||||
for dataset in documents products images semantic_search; do
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE INFO $dataset" \
|
||||
"Get statistics for $dataset" || true
|
||||
done
|
||||
}
|
||||
|
||||
# Cleanup Operations
|
||||
demo_cleanup() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " CLEANUP OPERATIONS DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_info "Demonstrating cleanup operations..."
|
||||
|
||||
# Drop individual datasets
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP semantic_search" \
|
||||
"Drop semantic_search dataset"
|
||||
|
||||
# List remaining datasets
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE LIST" \
|
||||
"List remaining datasets"
|
||||
|
||||
# Ask user if they want to clean up all test data
|
||||
echo
|
||||
read -p "Do you want to clean up all test datasets? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP documents" \
|
||||
"Drop documents dataset"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP products" \
|
||||
"Drop products dataset"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP images" \
|
||||
"Drop images dataset"
|
||||
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE LIST" \
|
||||
"Verify all datasets are cleaned up"
|
||||
|
||||
log_success "All test datasets cleaned up"
|
||||
else
|
||||
log_info "Keeping test datasets for further experimentation"
|
||||
fi
|
||||
}
|
||||
|
||||
# Error Handling Demo
|
||||
demo_error_handling() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " ERROR HANDLING DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_info "Demonstrating various error conditions..."
|
||||
|
||||
# Try to access non-existent dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE INFO nonexistent_dataset" \
|
||||
"Try to get info for non-existent dataset" || true
|
||||
|
||||
# Try to search non-existent dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH nonexistent_dataset VECTOR '0.1,0.2' K 5" \
|
||||
"Try to search non-existent dataset" || true
|
||||
|
||||
# Try to drop non-existent dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP nonexistent_dataset" \
|
||||
"Try to drop non-existent dataset" || true
|
||||
|
||||
# Try invalid vector format
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE SEARCH documents VECTOR 'invalid,vector,format' K 5" \
|
||||
"Try search with invalid vector format" || true
|
||||
|
||||
log_info "Error handling demonstration complete"
|
||||
}
|
||||
|
||||
# Performance Testing Demo
|
||||
demo_performance_testing() {
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " PERFORMANCE TESTING DEMO"
|
||||
echo "=========================================="
|
||||
|
||||
log_info "Creating performance test dataset..."
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE CREATE perf_test DIM 128 SCHEMA batch_id:string item_id:string" \
|
||||
"Create performance test dataset"
|
||||
|
||||
log_info "Performance testing would involve:"
|
||||
echo " 1. Bulk loading thousands of vectors"
|
||||
echo " 2. Creating indexes with different parameters"
|
||||
echo " 3. Measuring search latency with various K values"
|
||||
echo " 4. Testing different NPROBES settings"
|
||||
echo " 5. Monitoring memory usage"
|
||||
|
||||
log_info "Example performance test commands:"
|
||||
echo " # Test search speed with different parameters"
|
||||
echo " time redis-cli LANCE SEARCH.TEXT perf_test 'query' K 10"
|
||||
echo " time redis-cli LANCE SEARCH.TEXT perf_test 'query' K 10 NPROBES 50"
|
||||
echo " time redis-cli LANCE SEARCH.TEXT perf_test 'query' K 100 NPROBES 100"
|
||||
|
||||
# Clean up performance test dataset
|
||||
execute_command \
|
||||
"$REDIS_CLI LANCE DROP perf_test" \
|
||||
"Clean up performance test dataset"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo " LANCE VECTOR DATABASE DEMO SCRIPT"
|
||||
echo "=========================================="
|
||||
echo
|
||||
echo "This script demonstrates all Lance vector database operations."
|
||||
echo "Note: Some operations will fail without a running embedding service."
|
||||
echo "This is expected and demonstrates error handling."
|
||||
echo
|
||||
|
||||
# Check prerequisites
|
||||
check_herodb
|
||||
|
||||
# Setup
|
||||
setup_embedding_service
|
||||
|
||||
# Run demos
|
||||
demo_dataset_management
|
||||
demo_embedding_operations
|
||||
demo_data_storage
|
||||
demo_search_operations
|
||||
demo_index_management
|
||||
demo_advanced_usage
|
||||
demo_error_handling
|
||||
demo_performance_testing
|
||||
|
||||
# Cleanup
|
||||
demo_cleanup
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " DEMO COMPLETE"
|
||||
echo "=========================================="
|
||||
echo
|
||||
log_success "Lance vector database demo completed successfully!"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Set up a real embedding service (OpenAI, Hugging Face, etc.)"
|
||||
echo "2. Update the embedding service URL configuration"
|
||||
echo "3. Try storing and searching real data"
|
||||
echo "4. Experiment with different vector dimensions and index parameters"
|
||||
echo "5. Build your AI-powered application!"
|
||||
echo
|
||||
echo "For more information, see docs/lance_vector_db.md"
|
||||
}
|
||||
|
||||
# Run the demo
|
||||
main "$@"
|
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 = ["Pin Fang <fpfangpin@hotmail.com>"]
|
||||
edition = "2021"
|
||||
|
||||
[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,970 +0,0 @@
|
||||
use crate::{error::DBError, protocol::Protocol, server::Server};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Cmd {
|
||||
Ping,
|
||||
Echo(String),
|
||||
Select(u64), // Changed from u16 to u64
|
||||
Get(String),
|
||||
Set(String, String),
|
||||
SetPx(String, String, u128),
|
||||
SetEx(String, String, u128),
|
||||
Keys,
|
||||
ConfigGet(String),
|
||||
Info(Option<String>),
|
||||
Del(String),
|
||||
Type(String),
|
||||
Incr(String),
|
||||
Multi,
|
||||
Exec,
|
||||
Discard,
|
||||
// Hash commands
|
||||
HSet(String, Vec<(String, String)>),
|
||||
HGet(String, String),
|
||||
HGetAll(String),
|
||||
HDel(String, Vec<String>),
|
||||
HExists(String, String),
|
||||
HKeys(String),
|
||||
HVals(String),
|
||||
HLen(String),
|
||||
HMGet(String, Vec<String>),
|
||||
HSetNx(String, String, String),
|
||||
HScan(String, u64, Option<String>, Option<u64>), // key, cursor, pattern, count
|
||||
Scan(u64, Option<String>, Option<u64>), // cursor, pattern, count
|
||||
Ttl(String),
|
||||
Exists(String),
|
||||
Quit,
|
||||
Client(Vec<String>),
|
||||
ClientSetName(String),
|
||||
ClientGetName,
|
||||
// List commands
|
||||
LPush(String, Vec<String>),
|
||||
RPush(String, Vec<String>),
|
||||
LPop(String, Option<u64>),
|
||||
RPop(String, Option<u64>),
|
||||
LLen(String),
|
||||
LRem(String, i64, String),
|
||||
LTrim(String, i64, i64),
|
||||
LIndex(String, i64),
|
||||
LRange(String, i64, i64),
|
||||
FlushDb,
|
||||
Unknow(String),
|
||||
// AGE (rage) commands — stateless
|
||||
AgeGenEnc,
|
||||
AgeGenSign,
|
||||
AgeEncrypt(String, String), // recipient, message
|
||||
AgeDecrypt(String, String), // identity, ciphertext_b64
|
||||
AgeSign(String, String), // signing_secret, message
|
||||
AgeVerify(String, String, String), // verify_pub, message, signature_b64
|
||||
|
||||
// NEW: persistent named-key commands
|
||||
AgeKeygen(String), // name
|
||||
AgeSignKeygen(String), // name
|
||||
AgeEncryptName(String, String), // name, message
|
||||
AgeDecryptName(String, String), // name, ciphertext_b64
|
||||
AgeSignName(String, String), // name, message
|
||||
AgeVerifyName(String, String, String), // name, message, signature_b64
|
||||
AgeList,
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
pub fn from(s: &str) -> Result<(Self, Protocol, &str), DBError> {
|
||||
let (protocol, remaining) = Protocol::from(s)?;
|
||||
match protocol.clone() {
|
||||
Protocol::Array(p) => {
|
||||
let cmd = p.into_iter().map(|x| x.decode()).collect::<Vec<_>>();
|
||||
if cmd.is_empty() {
|
||||
return Err(DBError("cmd length is 0".to_string()));
|
||||
}
|
||||
Ok((
|
||||
match cmd[0].to_lowercase().as_str() {
|
||||
"select" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError("wrong number of arguments for SELECT".to_string()));
|
||||
}
|
||||
let idx = cmd[1].parse::<u64>().map_err(|_| DBError("ERR DB index is not an integer".to_string()))?;
|
||||
Cmd::Select(idx)
|
||||
}
|
||||
"echo" => Cmd::Echo(cmd[1].clone()),
|
||||
"ping" => Cmd::Ping,
|
||||
"get" => Cmd::Get(cmd[1].clone()),
|
||||
"set" => {
|
||||
if cmd.len() == 5 && cmd[3].to_lowercase() == "px" {
|
||||
Cmd::SetPx(cmd[1].clone(), cmd[2].clone(), cmd[4].parse().unwrap())
|
||||
} else if cmd.len() == 5 && cmd[3].to_lowercase() == "ex" {
|
||||
Cmd::SetEx(cmd[1].clone(), cmd[2].clone(), cmd[4].parse().unwrap())
|
||||
} else if cmd.len() == 3 {
|
||||
Cmd::Set(cmd[1].clone(), cmd[2].clone())
|
||||
} else {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
}
|
||||
"setex" => {
|
||||
if cmd.len() != 4 {
|
||||
return Err(DBError(format!("wrong number of arguments for SETEX command")));
|
||||
}
|
||||
Cmd::SetEx(cmd[1].clone(), cmd[3].clone(), cmd[2].parse().unwrap())
|
||||
}
|
||||
"config" => {
|
||||
if cmd.len() != 3 || cmd[1].to_lowercase() != "get" {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
} else {
|
||||
Cmd::ConfigGet(cmd[2].clone())
|
||||
}
|
||||
}
|
||||
"keys" => {
|
||||
if cmd.len() != 2 || cmd[1] != "*" {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
} else {
|
||||
Cmd::Keys
|
||||
}
|
||||
}
|
||||
"info" => {
|
||||
let section = if cmd.len() == 2 {
|
||||
Some(cmd[1].clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Cmd::Info(section)
|
||||
}
|
||||
"del" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
Cmd::Del(cmd[1].clone())
|
||||
}
|
||||
"type" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
Cmd::Type(cmd[1].clone())
|
||||
}
|
||||
"incr" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
Cmd::Incr(cmd[1].clone())
|
||||
}
|
||||
"multi" => {
|
||||
if cmd.len() != 1 {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
Cmd::Multi
|
||||
}
|
||||
"exec" => {
|
||||
if cmd.len() != 1 {
|
||||
return Err(DBError(format!("unsupported cmd {:?}", cmd)));
|
||||
}
|
||||
Cmd::Exec
|
||||
}
|
||||
"discard" => Cmd::Discard,
|
||||
// Hash commands
|
||||
"hset" => {
|
||||
if cmd.len() < 4 || (cmd.len() - 2) % 2 != 0 {
|
||||
return Err(DBError(format!("wrong number of arguments for HSET command")));
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
let mut i = 2;
|
||||
while i + 1 < cmd.len() {
|
||||
pairs.push((cmd[i].clone(), cmd[i + 1].clone()));
|
||||
i += 2;
|
||||
}
|
||||
Cmd::HSet(cmd[1].clone(), pairs)
|
||||
}
|
||||
"hget" => {
|
||||
if cmd.len() != 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for HGET command")));
|
||||
}
|
||||
Cmd::HGet(cmd[1].clone(), cmd[2].clone())
|
||||
}
|
||||
"hgetall" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for HGETALL command")));
|
||||
}
|
||||
Cmd::HGetAll(cmd[1].clone())
|
||||
}
|
||||
"hdel" => {
|
||||
if cmd.len() < 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for HDEL command")));
|
||||
}
|
||||
Cmd::HDel(cmd[1].clone(), cmd[2..].to_vec())
|
||||
}
|
||||
"hexists" => {
|
||||
if cmd.len() != 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for HEXISTS command")));
|
||||
}
|
||||
Cmd::HExists(cmd[1].clone(), cmd[2].clone())
|
||||
}
|
||||
"hkeys" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for HKEYS command")));
|
||||
}
|
||||
Cmd::HKeys(cmd[1].clone())
|
||||
}
|
||||
"hvals" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for HVALS command")));
|
||||
}
|
||||
Cmd::HVals(cmd[1].clone())
|
||||
}
|
||||
"hlen" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for HLEN command")));
|
||||
}
|
||||
Cmd::HLen(cmd[1].clone())
|
||||
}
|
||||
"hmget" => {
|
||||
if cmd.len() < 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for HMGET command")));
|
||||
}
|
||||
Cmd::HMGet(cmd[1].clone(), cmd[2..].to_vec())
|
||||
}
|
||||
"hsetnx" => {
|
||||
if cmd.len() != 4 {
|
||||
return Err(DBError(format!("wrong number of arguments for HSETNX command")));
|
||||
}
|
||||
Cmd::HSetNx(cmd[1].clone(), cmd[2].clone(), cmd[3].clone())
|
||||
}
|
||||
"hscan" => {
|
||||
if cmd.len() < 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for HSCAN command")));
|
||||
}
|
||||
|
||||
let key = cmd[1].clone();
|
||||
let cursor = cmd[2].parse::<u64>().map_err(|_|
|
||||
DBError("ERR invalid cursor".to_string()))?;
|
||||
|
||||
let mut pattern = None;
|
||||
let mut count = None;
|
||||
let mut i = 3;
|
||||
|
||||
while i < cmd.len() {
|
||||
match cmd[i].to_lowercase().as_str() {
|
||||
"match" => {
|
||||
if i + 1 >= cmd.len() {
|
||||
return Err(DBError("ERR syntax error".to_string()));
|
||||
}
|
||||
pattern = Some(cmd[i + 1].clone());
|
||||
i += 2;
|
||||
}
|
||||
"count" => {
|
||||
if i + 1 >= cmd.len() {
|
||||
return Err(DBError("ERR syntax error".to_string()));
|
||||
}
|
||||
count = Some(cmd[i + 1].parse::<u64>().map_err(|_|
|
||||
DBError("ERR value is not an integer or out of range".to_string()))?);
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
return Err(DBError(format!("ERR syntax error")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::HScan(key, cursor, pattern, count)
|
||||
}
|
||||
"scan" => {
|
||||
if cmd.len() < 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for SCAN command")));
|
||||
}
|
||||
|
||||
let cursor = cmd[1].parse::<u64>().map_err(|_|
|
||||
DBError("ERR invalid cursor".to_string()))?;
|
||||
|
||||
let mut pattern = None;
|
||||
let mut count = None;
|
||||
let mut i = 2;
|
||||
|
||||
while i < cmd.len() {
|
||||
match cmd[i].to_lowercase().as_str() {
|
||||
"match" => {
|
||||
if i + 1 >= cmd.len() {
|
||||
return Err(DBError("ERR syntax error".to_string()));
|
||||
}
|
||||
pattern = Some(cmd[i + 1].clone());
|
||||
i += 2;
|
||||
}
|
||||
"count" => {
|
||||
if i + 1 >= cmd.len() {
|
||||
return Err(DBError("ERR syntax error".to_string()));
|
||||
}
|
||||
count = Some(cmd[i + 1].parse::<u64>().map_err(|_|
|
||||
DBError("ERR value is not an integer or out of range".to_string()))?);
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
return Err(DBError(format!("ERR syntax error")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Scan(cursor, pattern, count)
|
||||
}
|
||||
"ttl" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for TTL command")));
|
||||
}
|
||||
Cmd::Ttl(cmd[1].clone())
|
||||
}
|
||||
"exists" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for EXISTS command")));
|
||||
}
|
||||
Cmd::Exists(cmd[1].clone())
|
||||
}
|
||||
"quit" => {
|
||||
if cmd.len() != 1 {
|
||||
return Err(DBError(format!("wrong number of arguments for QUIT command")));
|
||||
}
|
||||
Cmd::Quit
|
||||
}
|
||||
"client" => {
|
||||
if cmd.len() > 1 {
|
||||
match cmd[1].to_lowercase().as_str() {
|
||||
"setname" => {
|
||||
if cmd.len() == 3 {
|
||||
Cmd::ClientSetName(cmd[2].clone())
|
||||
} else {
|
||||
return Err(DBError("wrong number of arguments for 'client setname' command".to_string()));
|
||||
}
|
||||
}
|
||||
"getname" => {
|
||||
if cmd.len() == 2 {
|
||||
Cmd::ClientGetName
|
||||
} else {
|
||||
return Err(DBError("wrong number of arguments for 'client getname' command".to_string()));
|
||||
}
|
||||
}
|
||||
_ => Cmd::Client(cmd[1..].to_vec()),
|
||||
}
|
||||
} else {
|
||||
Cmd::Client(vec![])
|
||||
}
|
||||
}
|
||||
"lpush" => {
|
||||
if cmd.len() < 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for LPUSH command")));
|
||||
}
|
||||
Cmd::LPush(cmd[1].clone(), cmd[2..].to_vec())
|
||||
}
|
||||
"rpush" => {
|
||||
if cmd.len() < 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for RPUSH command")));
|
||||
}
|
||||
Cmd::RPush(cmd[1].clone(), cmd[2..].to_vec())
|
||||
}
|
||||
"lpop" => {
|
||||
if cmd.len() < 2 || cmd.len() > 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for LPOP command")));
|
||||
}
|
||||
let count = if cmd.len() == 3 {
|
||||
Some(cmd[2].parse::<u64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Cmd::LPop(cmd[1].clone(), count)
|
||||
}
|
||||
"rpop" => {
|
||||
if cmd.len() < 2 || cmd.len() > 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for RPOP command")));
|
||||
}
|
||||
let count = if cmd.len() == 3 {
|
||||
Some(cmd[2].parse::<u64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Cmd::RPop(cmd[1].clone(), count)
|
||||
}
|
||||
"llen" => {
|
||||
if cmd.len() != 2 {
|
||||
return Err(DBError(format!("wrong number of arguments for LLEN command")));
|
||||
}
|
||||
Cmd::LLen(cmd[1].clone())
|
||||
}
|
||||
"lrem" => {
|
||||
if cmd.len() != 4 {
|
||||
return Err(DBError(format!("wrong number of arguments for LREM command")));
|
||||
}
|
||||
let count = cmd[2].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
Cmd::LRem(cmd[1].clone(), count, cmd[3].clone())
|
||||
}
|
||||
"ltrim" => {
|
||||
if cmd.len() != 4 {
|
||||
return Err(DBError(format!("wrong number of arguments for LTRIM command")));
|
||||
}
|
||||
let start = cmd[2].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
let stop = cmd[3].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
Cmd::LTrim(cmd[1].clone(), start, stop)
|
||||
}
|
||||
"lindex" => {
|
||||
if cmd.len() != 3 {
|
||||
return Err(DBError(format!("wrong number of arguments for LINDEX command")));
|
||||
}
|
||||
let index = cmd[2].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
Cmd::LIndex(cmd[1].clone(), index)
|
||||
}
|
||||
"lrange" => {
|
||||
if cmd.len() != 4 {
|
||||
return Err(DBError(format!("wrong number of arguments for LRANGE command")));
|
||||
}
|
||||
let start = cmd[2].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
let stop = cmd[3].parse::<i64>().map_err(|_| DBError("ERR value is not an integer or out of range".to_string()))?;
|
||||
Cmd::LRange(cmd[1].clone(), start, stop)
|
||||
}
|
||||
"flushdb" => {
|
||||
if cmd.len() != 1 {
|
||||
return Err(DBError("wrong number of arguments for FLUSHDB command".to_string()));
|
||||
}
|
||||
Cmd::FlushDb
|
||||
}
|
||||
"age" => {
|
||||
if cmd.len() < 2 {
|
||||
return Err(DBError("wrong number of arguments for AGE".to_string()));
|
||||
}
|
||||
match cmd[1].to_lowercase().as_str() {
|
||||
// stateless
|
||||
"genenc" => { if cmd.len() != 2 { return Err(DBError("AGE GENENC takes no args".to_string())); }
|
||||
Cmd::AgeGenEnc }
|
||||
"gensign" => { if cmd.len() != 2 { return Err(DBError("AGE GENSIGN takes no args".to_string())); }
|
||||
Cmd::AgeGenSign }
|
||||
"encrypt" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPT <recipient> <message>".to_string())); }
|
||||
Cmd::AgeEncrypt(cmd[2].clone(), cmd[3].clone()) }
|
||||
"decrypt" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPT <identity> <ciphertext_b64>".to_string())); }
|
||||
Cmd::AgeDecrypt(cmd[2].clone(), cmd[3].clone()) }
|
||||
"sign" => { if cmd.len() != 4 { return Err(DBError("AGE SIGN <signing_secret> <message>".to_string())); }
|
||||
Cmd::AgeSign(cmd[2].clone(), cmd[3].clone()) }
|
||||
"verify" => { if cmd.len() != 5 { return Err(DBError("AGE VERIFY <verify_pub> <message> <signature_b64>".to_string())); }
|
||||
Cmd::AgeVerify(cmd[2].clone(), cmd[3].clone(), cmd[4].clone()) }
|
||||
|
||||
// persistent names
|
||||
"keygen" => { if cmd.len() != 3 { return Err(DBError("AGE KEYGEN <name>".to_string())); }
|
||||
Cmd::AgeKeygen(cmd[2].clone()) }
|
||||
"signkeygen" => { if cmd.len() != 3 { return Err(DBError("AGE SIGNKEYGEN <name>".to_string())); }
|
||||
Cmd::AgeSignKeygen(cmd[2].clone()) }
|
||||
"encryptname" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPTNAME <name> <message>".to_string())); }
|
||||
Cmd::AgeEncryptName(cmd[2].clone(), cmd[3].clone()) }
|
||||
"decryptname" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPTNAME <name> <ciphertext_b64>".to_string())); }
|
||||
Cmd::AgeDecryptName(cmd[2].clone(), cmd[3].clone()) }
|
||||
"signname" => { if cmd.len() != 4 { return Err(DBError("AGE SIGNNAME <name> <message>".to_string())); }
|
||||
Cmd::AgeSignName(cmd[2].clone(), cmd[3].clone()) }
|
||||
"verifyname" => { if cmd.len() != 5 { return Err(DBError("AGE VERIFYNAME <name> <message> <signature_b64>".to_string())); }
|
||||
Cmd::AgeVerifyName(cmd[2].clone(), cmd[3].clone(), cmd[4].clone()) }
|
||||
"list" => { if cmd.len() != 2 { return Err(DBError("AGE LIST".to_string())); }
|
||||
Cmd::AgeList }
|
||||
_ => return Err(DBError(format!("unsupported AGE subcommand {:?}", cmd))),
|
||||
}
|
||||
}
|
||||
_ => Cmd::Unknow(cmd[0].clone()),
|
||||
},
|
||||
protocol,
|
||||
remaining
|
||||
))
|
||||
}
|
||||
_ => Err(DBError(format!(
|
||||
"fail to parse as cmd for {:?}",
|
||||
protocol
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(self, server: &mut Server) -> Result<Protocol, DBError> {
|
||||
// Handle queued commands for transactions
|
||||
if server.queued_cmd.is_some()
|
||||
&& !matches!(self, Cmd::Exec)
|
||||
&& !matches!(self, Cmd::Multi)
|
||||
&& !matches!(self, Cmd::Discard)
|
||||
{
|
||||
let protocol = self.clone().to_protocol();
|
||||
server.queued_cmd.as_mut().unwrap().push((self, protocol));
|
||||
return Ok(Protocol::SimpleString("QUEUED".to_string()));
|
||||
}
|
||||
|
||||
match self {
|
||||
Cmd::Select(db) => select_cmd(server, db).await,
|
||||
Cmd::Ping => Ok(Protocol::SimpleString("PONG".to_string())),
|
||||
Cmd::Echo(s) => Ok(Protocol::BulkString(s)),
|
||||
Cmd::Get(k) => get_cmd(server, &k).await,
|
||||
Cmd::Set(k, v) => set_cmd(server, &k, &v).await,
|
||||
Cmd::SetPx(k, v, x) => set_px_cmd(server, &k, &v, &x).await,
|
||||
Cmd::SetEx(k, v, x) => set_ex_cmd(server, &k, &v, &x).await,
|
||||
Cmd::Del(k) => del_cmd(server, &k).await,
|
||||
Cmd::ConfigGet(name) => config_get_cmd(&name, server),
|
||||
Cmd::Keys => keys_cmd(server).await,
|
||||
Cmd::Info(section) => info_cmd(server, §ion).await,
|
||||
Cmd::Type(k) => type_cmd(server, &k).await,
|
||||
Cmd::Incr(key) => incr_cmd(server, &key).await,
|
||||
Cmd::Multi => {
|
||||
server.queued_cmd = Some(Vec::<(Cmd, Protocol)>::new());
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
}
|
||||
Cmd::Exec => exec_cmd(server).await,
|
||||
Cmd::Discard => {
|
||||
if server.queued_cmd.is_some() {
|
||||
server.queued_cmd = None;
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
} else {
|
||||
Ok(Protocol::err("ERR DISCARD without MULTI"))
|
||||
}
|
||||
}
|
||||
// Hash commands
|
||||
Cmd::HSet(key, pairs) => hset_cmd(server, &key, &pairs).await,
|
||||
Cmd::HGet(key, field) => hget_cmd(server, &key, &field).await,
|
||||
Cmd::HGetAll(key) => hgetall_cmd(server, &key).await,
|
||||
Cmd::HDel(key, fields) => hdel_cmd(server, &key, &fields).await,
|
||||
Cmd::HExists(key, field) => hexists_cmd(server, &key, &field).await,
|
||||
Cmd::HKeys(key) => hkeys_cmd(server, &key).await,
|
||||
Cmd::HVals(key) => hvals_cmd(server, &key).await,
|
||||
Cmd::HLen(key) => hlen_cmd(server, &key).await,
|
||||
Cmd::HMGet(key, fields) => hmget_cmd(server, &key, &fields).await,
|
||||
Cmd::HSetNx(key, field, value) => hsetnx_cmd(server, &key, &field, &value).await,
|
||||
Cmd::HScan(key, cursor, pattern, count) => hscan_cmd(server, &key, &cursor, pattern.as_deref(), &count).await,
|
||||
Cmd::Scan(cursor, pattern, count) => scan_cmd(server, &cursor, pattern.as_deref(), &count).await,
|
||||
Cmd::Ttl(key) => ttl_cmd(server, &key).await,
|
||||
Cmd::Exists(key) => exists_cmd(server, &key).await,
|
||||
Cmd::Quit => Ok(Protocol::SimpleString("OK".to_string())),
|
||||
Cmd::Client(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
||||
Cmd::ClientSetName(name) => client_setname_cmd(server, &name).await,
|
||||
Cmd::ClientGetName => client_getname_cmd(server).await,
|
||||
// List commands
|
||||
Cmd::LPush(key, elements) => lpush_cmd(server, &key, &elements).await,
|
||||
Cmd::RPush(key, elements) => rpush_cmd(server, &key, &elements).await,
|
||||
Cmd::LPop(key, count) => lpop_cmd(server, &key, &count).await,
|
||||
Cmd::RPop(key, count) => rpop_cmd(server, &key, &count).await,
|
||||
Cmd::LLen(key) => llen_cmd(server, &key).await,
|
||||
Cmd::LRem(key, count, element) => lrem_cmd(server, &key, count, &element).await,
|
||||
Cmd::LTrim(key, start, stop) => ltrim_cmd(server, &key, start, stop).await,
|
||||
Cmd::LIndex(key, index) => lindex_cmd(server, &key, index).await,
|
||||
Cmd::LRange(key, start, stop) => lrange_cmd(server, &key, start, stop).await,
|
||||
Cmd::FlushDb => flushdb_cmd(server).await,
|
||||
// AGE (rage): stateless
|
||||
Cmd::AgeGenEnc => Ok(crate::age::cmd_age_genenc().await),
|
||||
Cmd::AgeGenSign => Ok(crate::age::cmd_age_gensign().await),
|
||||
Cmd::AgeEncrypt(recipient, message) => Ok(crate::age::cmd_age_encrypt(&recipient, &message).await),
|
||||
Cmd::AgeDecrypt(identity, ct_b64) => Ok(crate::age::cmd_age_decrypt(&identity, &ct_b64).await),
|
||||
Cmd::AgeSign(secret, message) => Ok(crate::age::cmd_age_sign(&secret, &message).await),
|
||||
Cmd::AgeVerify(vpub, msg, sig_b64) => Ok(crate::age::cmd_age_verify(&vpub, &msg, &sig_b64).await),
|
||||
|
||||
// AGE (rage): persistent named keys
|
||||
Cmd::AgeKeygen(name) => Ok(crate::age::cmd_age_keygen(server, &name).await),
|
||||
Cmd::AgeSignKeygen(name) => Ok(crate::age::cmd_age_signkeygen(server, &name).await),
|
||||
Cmd::AgeEncryptName(name, message) => Ok(crate::age::cmd_age_encrypt_name(server, &name, &message).await),
|
||||
Cmd::AgeDecryptName(name, ct_b64) => Ok(crate::age::cmd_age_decrypt_name(server, &name, &ct_b64).await),
|
||||
Cmd::AgeSignName(name, message) => Ok(crate::age::cmd_age_sign_name(server, &name, &message).await),
|
||||
Cmd::AgeVerifyName(name, message, sig_b64) => Ok(crate::age::cmd_age_verify_name(server, &name, &message, &sig_b64).await),
|
||||
Cmd::AgeList => Ok(crate::age::cmd_age_list(server).await),
|
||||
Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_protocol(self) -> Protocol {
|
||||
match self {
|
||||
Cmd::Select(db) => Protocol::Array(vec![Protocol::BulkString("select".to_string()), Protocol::BulkString(db.to_string())]),
|
||||
Cmd::Ping => Protocol::Array(vec![Protocol::BulkString("ping".to_string())]),
|
||||
Cmd::Echo(s) => Protocol::Array(vec![Protocol::BulkString("echo".to_string()), Protocol::BulkString(s)]),
|
||||
Cmd::Get(k) => Protocol::Array(vec![Protocol::BulkString("get".to_string()), Protocol::BulkString(k)]),
|
||||
Cmd::Set(k, v) => Protocol::Array(vec![Protocol::BulkString("set".to_string()), Protocol::BulkString(k), Protocol::BulkString(v)]),
|
||||
_ => Protocol::SimpleString("...".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn flushdb_cmd(server: &mut Server) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.flushdb() {
|
||||
Ok(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn select_cmd(server: &mut Server, db: u64) -> Result<Protocol, DBError> {
|
||||
// Test if we can access the database (this will create it if needed)
|
||||
server.selected_db = db;
|
||||
match server.current_storage() {
|
||||
Ok(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lindex_cmd(server: &Server, key: &str, index: i64) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.lindex(key, index) {
|
||||
Ok(Some(element)) => Ok(Protocol::BulkString(element)),
|
||||
Ok(None) => Ok(Protocol::Null),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lrange_cmd(server: &Server, key: &str, start: i64, stop: i64) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.lrange(key, start, stop) {
|
||||
Ok(elements) => Ok(Protocol::Array(elements.into_iter().map(Protocol::BulkString).collect())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ltrim_cmd(server: &Server, key: &str, start: i64, stop: i64) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.ltrim(key, start, stop) {
|
||||
Ok(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lrem_cmd(server: &Server, key: &str, count: i64, element: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.lrem(key, count, element) {
|
||||
Ok(removed_count) => Ok(Protocol::SimpleString(removed_count.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn llen_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.llen(key) {
|
||||
Ok(len) => Ok(Protocol::SimpleString(len.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lpop_cmd(server: &Server, key: &str, count: &Option<u64>) -> Result<Protocol, DBError> {
|
||||
let count_val = count.unwrap_or(1);
|
||||
match server.current_storage()?.lpop(key, count_val) {
|
||||
Ok(elements) => {
|
||||
if elements.is_empty() {
|
||||
if count.is_some() {
|
||||
Ok(Protocol::Array(vec![]))
|
||||
} else {
|
||||
Ok(Protocol::Null)
|
||||
}
|
||||
} else if count.is_some() {
|
||||
Ok(Protocol::Array(elements.into_iter().map(Protocol::BulkString).collect()))
|
||||
} else {
|
||||
Ok(Protocol::BulkString(elements[0].clone()))
|
||||
}
|
||||
},
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rpop_cmd(server: &Server, key: &str, count: &Option<u64>) -> Result<Protocol, DBError> {
|
||||
let count_val = count.unwrap_or(1);
|
||||
match server.current_storage()?.rpop(key, count_val) {
|
||||
Ok(elements) => {
|
||||
if elements.is_empty() {
|
||||
if count.is_some() {
|
||||
Ok(Protocol::Array(vec![]))
|
||||
} else {
|
||||
Ok(Protocol::Null)
|
||||
}
|
||||
} else if count.is_some() {
|
||||
Ok(Protocol::Array(elements.into_iter().map(Protocol::BulkString).collect()))
|
||||
} else {
|
||||
Ok(Protocol::BulkString(elements[0].clone()))
|
||||
}
|
||||
},
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lpush_cmd(server: &Server, key: &str, elements: &[String]) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.lpush(key, elements.to_vec()) {
|
||||
Ok(len) => Ok(Protocol::SimpleString(len.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rpush_cmd(server: &Server, key: &str, elements: &[String]) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.rpush(key, elements.to_vec()) {
|
||||
Ok(len) => Ok(Protocol::SimpleString(len.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_cmd(server: &mut Server) -> Result<Protocol, DBError> {
|
||||
// Move the queued commands out of `server` so we drop the borrow immediately.
|
||||
let cmds = if let Some(cmds) = server.queued_cmd.take() {
|
||||
cmds
|
||||
} else {
|
||||
return Ok(Protocol::err("ERR EXEC without MULTI"));
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (cmd, _) in cmds {
|
||||
// Use Box::pin to handle recursion in async function
|
||||
let res = Box::pin(cmd.run(server)).await?;
|
||||
out.push(res);
|
||||
}
|
||||
Ok(Protocol::Array(out))
|
||||
}
|
||||
|
||||
async fn incr_cmd(server: &Server, key: &String) -> Result<Protocol, DBError> {
|
||||
let storage = server.current_storage()?;
|
||||
let current_value = storage.get(key)?;
|
||||
|
||||
let new_value = match current_value {
|
||||
Some(v) => {
|
||||
match v.parse::<i64>() {
|
||||
Ok(num) => num + 1,
|
||||
Err(_) => return Ok(Protocol::err("ERR value is not an integer or out of range")),
|
||||
}
|
||||
}
|
||||
None => 1,
|
||||
};
|
||||
|
||||
storage.set(key.clone(), new_value.to_string())?;
|
||||
Ok(Protocol::SimpleString(new_value.to_string()))
|
||||
}
|
||||
|
||||
fn config_get_cmd(name: &String, server: &Server) -> Result<Protocol, DBError> {
|
||||
let value = match name.as_str() {
|
||||
"dir" => Some(server.option.dir.clone()),
|
||||
"dbfilename" => Some(format!("{}.db", server.selected_db)),
|
||||
"databases" => Some("16".to_string()), // Hardcoded as per original logic
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(val) = value {
|
||||
Ok(Protocol::Array(vec![
|
||||
Protocol::BulkString(name.clone()),
|
||||
Protocol::BulkString(val),
|
||||
]))
|
||||
} else {
|
||||
// Return an empty array for unknown config options, which is standard Redis behavior
|
||||
Ok(Protocol::Array(vec![]))
|
||||
}
|
||||
}
|
||||
|
||||
async fn keys_cmd(server: &Server) -> Result<Protocol, DBError> {
|
||||
let keys = server.current_storage()?.keys("*")?;
|
||||
Ok(Protocol::Array(
|
||||
keys.into_iter().map(Protocol::BulkString).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[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 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));
|
||||
|
||||
|
||||
match section {
|
||||
Some(s) => match s.as_str() {
|
||||
"replication" => Ok(Protocol::BulkString(
|
||||
"role:master\nmaster_replid:8371b4fb1155b71f4a04d3e1bc3e18c4a990aeea\nmaster_repl_offset:0\n".to_string()
|
||||
)),
|
||||
_ => Err(DBError(format!("unsupported section {:?}", s))),
|
||||
},
|
||||
None => {
|
||||
Ok(Protocol::BulkString(info_string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn type_cmd(server: &Server, k: &String) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.get_key_type(k)? {
|
||||
Some(type_str) => Ok(Protocol::SimpleString(type_str)),
|
||||
None => Ok(Protocol::SimpleString("none".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn del_cmd(server: &Server, k: &str) -> Result<Protocol, DBError> {
|
||||
server.current_storage()?.del(k.to_string())?;
|
||||
Ok(Protocol::SimpleString("1".to_string()))
|
||||
}
|
||||
|
||||
async fn set_ex_cmd(
|
||||
server: &Server,
|
||||
k: &str,
|
||||
v: &str,
|
||||
x: &u128,
|
||||
) -> Result<Protocol, DBError> {
|
||||
server.current_storage()?.setx(k.to_string(), v.to_string(), *x * 1000)?;
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
}
|
||||
|
||||
async fn set_px_cmd(
|
||||
server: &Server,
|
||||
k: &str,
|
||||
v: &str,
|
||||
x: &u128,
|
||||
) -> Result<Protocol, DBError> {
|
||||
server.current_storage()?.setx(k.to_string(), v.to_string(), *x)?;
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
}
|
||||
|
||||
async fn set_cmd(server: &Server, k: &str, v: &str) -> Result<Protocol, DBError> {
|
||||
server.current_storage()?.set(k.to_string(), v.to_string())?;
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
}
|
||||
|
||||
async fn get_cmd(server: &Server, k: &str) -> Result<Protocol, DBError> {
|
||||
let v = server.current_storage()?.get(k)?;
|
||||
Ok(v.map_or(Protocol::Null, Protocol::BulkString))
|
||||
}
|
||||
|
||||
// Hash command implementations
|
||||
async fn hset_cmd(server: &Server, key: &str, pairs: &[(String, String)]) -> Result<Protocol, DBError> {
|
||||
let new_fields = server.current_storage()?.hset(key, pairs.to_vec())?;
|
||||
Ok(Protocol::SimpleString(new_fields.to_string()))
|
||||
}
|
||||
|
||||
async fn hget_cmd(server: &Server, key: &str, field: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hget(key, field) {
|
||||
Ok(Some(value)) => Ok(Protocol::BulkString(value)),
|
||||
Ok(None) => Ok(Protocol::Null),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hgetall_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hgetall(key) {
|
||||
Ok(pairs) => {
|
||||
let mut result = Vec::new();
|
||||
for (field, value) in pairs {
|
||||
result.push(Protocol::BulkString(field));
|
||||
result.push(Protocol::BulkString(value));
|
||||
}
|
||||
Ok(Protocol::Array(result))
|
||||
}
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hdel_cmd(server: &Server, key: &str, fields: &[String]) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hdel(key, fields.to_vec()) {
|
||||
Ok(deleted) => Ok(Protocol::SimpleString(deleted.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hexists_cmd(server: &Server, key: &str, field: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hexists(key, field) {
|
||||
Ok(exists) => Ok(Protocol::SimpleString(if exists { "1" } else { "0" }.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hkeys_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hkeys(key) {
|
||||
Ok(keys) => Ok(Protocol::Array(
|
||||
keys.into_iter().map(Protocol::BulkString).collect(),
|
||||
)),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hvals_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hvals(key) {
|
||||
Ok(values) => Ok(Protocol::Array(
|
||||
values.into_iter().map(Protocol::BulkString).collect(),
|
||||
)),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hlen_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hlen(key) {
|
||||
Ok(len) => Ok(Protocol::SimpleString(len.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hmget_cmd(server: &Server, key: &str, fields: &[String]) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hmget(key, fields.to_vec()) {
|
||||
Ok(values) => {
|
||||
let result: Vec<Protocol> = values
|
||||
.into_iter()
|
||||
.map(|v| v.map_or(Protocol::Null, Protocol::BulkString))
|
||||
.collect();
|
||||
Ok(Protocol::Array(result))
|
||||
}
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hsetnx_cmd(server: &Server, key: &str, field: &str, value: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hsetnx(key, field, value) {
|
||||
Ok(was_set) => Ok(Protocol::SimpleString(if was_set { "1" } else { "0" }.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_cmd(
|
||||
server: &Server,
|
||||
cursor: &u64,
|
||||
pattern: Option<&str>,
|
||||
count: &Option<u64>
|
||||
) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.scan(*cursor, pattern, *count) {
|
||||
Ok((next_cursor, key_value_pairs)) => {
|
||||
let mut result = Vec::new();
|
||||
result.push(Protocol::BulkString(next_cursor.to_string()));
|
||||
// For SCAN, we only return the keys, not the values
|
||||
let keys: Vec<Protocol> = key_value_pairs.into_iter().map(|(key, _)| Protocol::BulkString(key)).collect();
|
||||
result.push(Protocol::Array(keys));
|
||||
Ok(Protocol::Array(result))
|
||||
}
|
||||
Err(e) => Ok(Protocol::err(&format!("ERR {}", e.0))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn hscan_cmd(
|
||||
server: &Server,
|
||||
key: &str,
|
||||
cursor: &u64,
|
||||
pattern: Option<&str>,
|
||||
count: &Option<u64>
|
||||
) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.hscan(key, *cursor, pattern, *count) {
|
||||
Ok((next_cursor, field_value_pairs)) => {
|
||||
let mut result = Vec::new();
|
||||
result.push(Protocol::BulkString(next_cursor.to_string()));
|
||||
// For HSCAN, we return field-value pairs flattened
|
||||
let mut fields_and_values = Vec::new();
|
||||
for (field, value) in field_value_pairs {
|
||||
fields_and_values.push(Protocol::BulkString(field));
|
||||
fields_and_values.push(Protocol::BulkString(value));
|
||||
}
|
||||
result.push(Protocol::Array(fields_and_values));
|
||||
Ok(Protocol::Array(result))
|
||||
}
|
||||
Err(e) => Ok(Protocol::err(&format!("ERR {}", e.0))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ttl_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.ttl(key) {
|
||||
Ok(ttl) => Ok(Protocol::SimpleString(ttl.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exists_cmd(server: &Server, key: &str) -> Result<Protocol, DBError> {
|
||||
match server.current_storage()?.exists(key) {
|
||||
Ok(exists) => Ok(Protocol::SimpleString(if exists { "1" } else { "0" }.to_string())),
|
||||
Err(e) => Ok(Protocol::err(&e.0)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_setname_cmd(server: &mut Server, name: &str) -> Result<Protocol, DBError> {
|
||||
server.client_name = Some(name.to_string());
|
||||
Ok(Protocol::SimpleString("OK".to_string()))
|
||||
}
|
||||
|
||||
async fn client_getname_cmd(server: &Server) -> Result<Protocol, DBError> {
|
||||
match &server.client_name {
|
||||
Some(name) => Ok(Protocol::BulkString(name.clone())),
|
||||
None => Ok(Protocol::Null),
|
||||
}
|
||||
}
|
@@ -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,136 +0,0 @@
|
||||
use core::str;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::cmd::Cmd;
|
||||
use crate::error::DBError;
|
||||
use crate::options;
|
||||
use crate::protocol::Protocol;
|
||||
use crate::storage::Storage;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
pub db_cache: std::sync::Arc<std::sync::RwLock<HashMap<u64, Arc<Storage>>>>,
|
||||
pub option: options::DBOption,
|
||||
pub client_name: Option<String>,
|
||||
pub selected_db: u64, // Changed from usize to u64
|
||||
pub queued_cmd: Option<Vec<(Cmd, Protocol)>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn new(option: options::DBOption) -> Self {
|
||||
Server {
|
||||
db_cache: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||
option,
|
||||
client_name: None,
|
||||
selected_db: 0,
|
||||
queued_cmd: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_storage(&self) -> Result<Arc<Storage>, DBError> {
|
||||
let mut cache = self.db_cache.write().unwrap();
|
||||
|
||||
if let Some(storage) = cache.get(&self.selected_db) {
|
||||
return Ok(storage.clone());
|
||||
}
|
||||
|
||||
|
||||
// Create new database file
|
||||
let db_file_path = std::path::PathBuf::from(self.option.dir.clone())
|
||||
.join(format!("{}.db", self.selected_db));
|
||||
|
||||
// Ensure the directory exists before creating the database file
|
||||
if let Some(parent_dir) = db_file_path.parent() {
|
||||
std::fs::create_dir_all(parent_dir).map_err(|e| {
|
||||
DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e))
|
||||
})?;
|
||||
}
|
||||
|
||||
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()
|
||||
)?);
|
||||
|
||||
cache.insert(self.selected_db, storage.clone());
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
fn should_encrypt_db(&self, db_index: u64) -> bool {
|
||||
// DB 0-9 are non-encrypted, DB 10+ are encrypted
|
||||
self.option.encrypt && db_index >= 10
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
&mut self,
|
||||
mut stream: tokio::net::TcpStream,
|
||||
) -> Result<(), DBError> {
|
||||
let mut buf = [0; 512];
|
||||
|
||||
loop {
|
||||
let len = match stream.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
println!("[handle] connection closed");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(len) => len,
|
||||
Err(e) => {
|
||||
println!("[handle] read error: {:?}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut s = str::from_utf8(&buf[..len])?;
|
||||
while !s.is_empty() {
|
||||
let (cmd, protocol, remaining) = match Cmd::from(s) {
|
||||
Ok((cmd, protocol, remaining)) => (cmd, protocol, remaining),
|
||||
Err(e) => {
|
||||
println!("\x1b[31;1mprotocol error: {:?}\x1b[0m", e);
|
||||
(Cmd::Unknow("protocol_error".to_string()), Protocol::err(&format!("protocol error: {}", e.0)), "")
|
||||
}
|
||||
};
|
||||
s = remaining;
|
||||
|
||||
if self.option.debug {
|
||||
println!("\x1b[34;1mgot command: {:?}, protocol: {:?}\x1b[0m", cmd, protocol);
|
||||
} else {
|
||||
println!("got command: {:?}, protocol: {:?}", cmd, protocol);
|
||||
}
|
||||
|
||||
// Check if this is a QUIT command before processing
|
||||
let is_quit = matches!(cmd, Cmd::Quit);
|
||||
|
||||
let res = match cmd.run(self).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if self.option.debug {
|
||||
eprintln!("[run error] {:?}", e);
|
||||
}
|
||||
Protocol::err(&format!("ERR {}", e.0))
|
||||
}
|
||||
};
|
||||
|
||||
if self.option.debug {
|
||||
println!("\x1b[34;1mqueued cmd {:?}\x1b[0m", self.queued_cmd);
|
||||
println!("\x1b[32;1mgoing to send response {}\x1b[0m", res.encode());
|
||||
} else {
|
||||
print!("queued cmd {:?}", self.queued_cmd);
|
||||
println!("going to send response {}", res.encode());
|
||||
}
|
||||
|
||||
_ = stream.write(res.encode().as_bytes()).await?;
|
||||
|
||||
// If this was a QUIT command, close the connection
|
||||
if is_quit {
|
||||
println!("[handle] QUIT command received, closing connection");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
143
run.sh
Executable file
143
run.sh
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
|
||||
# Test script for HeroDB - Redis-compatible database with redb backend
|
||||
# This script starts the server and runs comprehensive tests
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
DB_DIR="/tmp/test_db"
|
||||
PORT=6381
|
||||
SERVER_PID=""
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to cleanup on exit
|
||||
cleanup() {
|
||||
if [ ! -z "$SERVER_PID" ]; then
|
||||
print_status "Stopping HeroDB server (PID: $SERVER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Clean up test database
|
||||
if [ -d "$DB_DIR" ]; then
|
||||
print_status "Cleaning up test database directory..."
|
||||
rm -rf "$DB_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set trap to cleanup on script exit
|
||||
trap cleanup EXIT
|
||||
|
||||
# Function to wait for server to start
|
||||
wait_for_server() {
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
print_status "Waiting for server to start on port $PORT..."
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if nc -z localhost $PORT 2>/dev/null; then
|
||||
print_success "Server is ready!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
print_error "Server failed to start within $max_attempts seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to send Redis command and get response
|
||||
redis_cmd() {
|
||||
local cmd="$1"
|
||||
local expected="$2"
|
||||
|
||||
print_status "Testing: $cmd"
|
||||
|
||||
local result=$(echo "$cmd" | redis-cli -p $PORT --raw 2>/dev/null || echo "ERROR")
|
||||
|
||||
if [ "$expected" != "" ] && [ "$result" != "$expected" ]; then
|
||||
print_error "Expected: '$expected', Got: '$result'"
|
||||
return 1
|
||||
else
|
||||
print_success "✓ $cmd -> $result"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
print_status "Starting HeroDB"
|
||||
|
||||
# Build the project
|
||||
print_status "Building HeroDB..."
|
||||
if ! cargo build -p herodb --release; then
|
||||
print_error "Failed to build HeroDB"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create test database directory
|
||||
mkdir -p "$DB_DIR"
|
||||
|
||||
# Start the server
|
||||
print_status "Starting HeroDB server..."
|
||||
${SCRIPT_DIR}/target/release/herodb --dir "$DB_DIR" --port $PORT &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
if ! wait_for_server; then
|
||||
print_error "Failed to start server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
print_error "cargo is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v nc &> /dev/null; then
|
||||
print_warning "netcat (nc) not found - some tests may not work properly"
|
||||
fi
|
||||
|
||||
if ! command -v redis-cli &> /dev/null; then
|
||||
print_warning "redis-cli not found - using netcat fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run dependency check and main function
|
||||
check_dependencies
|
||||
main "$@"
|
||||
tail -f /dev/null
|
@@ -1,4 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🧪 Running HeroDB Redis Compatibility Tests"
|
||||
echo "=========================================="
|
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
|
||||
|
2043
src/cmd.rs
Normal file
2043
src/cmd.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
@@ -9,6 +9,12 @@ use bincode;
|
||||
#[derive(Debug)]
|
||||
pub struct DBError(pub String);
|
||||
|
||||
impl std::fmt::Display for DBError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for DBError {
|
||||
fn from(item: std::io::Error) -> Self {
|
||||
DBError(item.to_string().clone())
|
||||
@@ -92,3 +98,40 @@ impl From<chacha20poly1305::Error> for DBError {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Lance and related dependencies error handling
|
||||
impl From<lance::Error> for DBError {
|
||||
fn from(item: lance::Error) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arrow::error::ArrowError> for DBError {
|
||||
fn from(item: arrow::error::ArrowError) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for DBError {
|
||||
fn from(item: reqwest::Error) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<image::ImageError> for DBError {
|
||||
fn from(item: image::ImageError) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for DBError {
|
||||
fn from(item: uuid::Error) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for DBError {
|
||||
fn from(item: base64::DecodeError) -> Self {
|
||||
DBError(item.to_string())
|
||||
}
|
||||
}
|
609
src/lance_store.rs
Normal file
609
src/lance_store.rs
Normal file
@@ -0,0 +1,609 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use arrow::array::{Float32Array, StringArray, ArrayRef, FixedSizeListArray, Array};
|
||||
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
|
||||
use arrow::record_batch::{RecordBatch, RecordBatchReader};
|
||||
use arrow::error::ArrowError;
|
||||
use lance::dataset::{Dataset, WriteParams, WriteMode};
|
||||
use lance::index::vector::VectorIndexParams;
|
||||
use lance_index::vector::pq::PQBuildParams;
|
||||
use lance_index::vector::ivf::IvfBuildParams;
|
||||
use lance_index::DatasetIndexExt;
|
||||
use lance_linalg::distance::MetricType;
|
||||
use futures::TryStreamExt;
|
||||
use base64::Engine;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::error::DBError;
|
||||
|
||||
// Simple RecordBatchReader implementation for Vec<RecordBatch>
|
||||
struct VecRecordBatchReader {
|
||||
batches: std::vec::IntoIter<Result<RecordBatch, ArrowError>>,
|
||||
}
|
||||
|
||||
impl VecRecordBatchReader {
|
||||
fn new(batches: Vec<RecordBatch>) -> Self {
|
||||
let result_batches = batches.into_iter().map(Ok).collect::<Vec<_>>();
|
||||
Self {
|
||||
batches: result_batches.into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for VecRecordBatchReader {
|
||||
type Item = Result<RecordBatch, ArrowError>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.batches.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordBatchReader for VecRecordBatchReader {
|
||||
fn schema(&self) -> SchemaRef {
|
||||
// This is a simplified implementation - in practice you'd want to store the schema
|
||||
Arc::new(Schema::empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
// Ollama-specific request/response structures
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OllamaEmbeddingRequest {
|
||||
model: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OllamaEmbeddingResponse {
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
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, default to local Ollama
|
||||
async fn get_embedding_url(&self, server: &crate::server::Server) -> Result<String, DBError> {
|
||||
// Get the embedding URL from Redis config directly from storage
|
||||
let storage = server.current_storage()?;
|
||||
match storage.hget("config:core:aiembed", "url")? {
|
||||
Some(url) => Ok(url),
|
||||
None => Ok("http://localhost:11434".to_string()), // Default to local Ollama
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we're using Ollama (default) or custom embedding service
|
||||
async fn is_ollama_service(&self, server: &crate::server::Server) -> Result<bool, DBError> {
|
||||
let url = self.get_embedding_url(server).await?;
|
||||
Ok(url.contains("localhost:11434") || url.contains("127.0.0.1:11434"))
|
||||
}
|
||||
|
||||
/// Call external embedding service (Ollama or custom)
|
||||
async fn call_embedding_service(
|
||||
&self,
|
||||
server: &crate::server::Server,
|
||||
texts: Option<Vec<String>>,
|
||||
images: Option<Vec<String>>,
|
||||
) -> Result<Vec<Vec<f32>>, DBError> {
|
||||
let base_url = self.get_embedding_url(server).await?;
|
||||
let is_ollama = self.is_ollama_service(server).await?;
|
||||
|
||||
if is_ollama {
|
||||
// Use Ollama API format
|
||||
if let Some(texts) = texts {
|
||||
let mut embeddings = Vec::new();
|
||||
for text in texts {
|
||||
let url = format!("{}/api/embeddings", base_url);
|
||||
let request = OllamaEmbeddingRequest {
|
||||
model: "nomic-embed-text".to_string(),
|
||||
prompt: text,
|
||||
};
|
||||
|
||||
let response = self.http_client
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DBError(format!("Failed to call Ollama 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!(
|
||||
"Ollama embedding service returned error {}: {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let ollama_response: OllamaEmbeddingResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DBError(format!("Failed to parse Ollama embedding response: {}", e)))?;
|
||||
|
||||
embeddings.push(ollama_response.embedding);
|
||||
}
|
||||
Ok(embeddings)
|
||||
} else if let Some(_images) = images {
|
||||
// Ollama doesn't support image embeddings with this API yet
|
||||
Err(DBError("Image embeddings not supported with Ollama. Please configure a custom embedding service.".to_string()))
|
||||
} else {
|
||||
Err(DBError("No text or images provided for embedding".to_string()))
|
||||
}
|
||||
} else {
|
||||
// Use custom embedding service API format
|
||||
let request = EmbeddingRequest {
|
||||
texts,
|
||||
images,
|
||||
model: None, // Let the service use its default
|
||||
};
|
||||
|
||||
let response = self.http_client
|
||||
.post(&base_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::engine::general_purpose::STANDARD.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));
|
||||
|
||||
// Use RecordBatchReader for Lance 0.33
|
||||
let reader = VecRecordBatchReader::new(vec![empty_batch]);
|
||||
let dataset = Dataset::write(
|
||||
reader,
|
||||
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 values_array = Float32Array::from(flat_vectors);
|
||||
let field = Arc::new(Field::new("item", DataType::Float32, true));
|
||||
let vector_array = FixedSizeListArray::try_new(
|
||||
field,
|
||||
dim as i32,
|
||||
Arc::new(values_array),
|
||||
None
|
||||
).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()
|
||||
};
|
||||
|
||||
let reader = VecRecordBatchReader::new(vec![batch]);
|
||||
Dataset::write(
|
||||
reader,
|
||||
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 query_array = Float32Array::from(query_vector.clone());
|
||||
let mut query = dataset.scan();
|
||||
query.nearest(
|
||||
"vector",
|
||||
&query_array,
|
||||
k,
|
||||
).map_err(|e| DBError(format!("Failed to build search query: {}", e)))?;
|
||||
|
||||
if let Some(nprobes) = nprobes {
|
||||
query.nprobs(nprobes);
|
||||
}
|
||||
|
||||
// Note: refine_factor might not be available in this Lance version
|
||||
// if let Some(refine) = refine_factor {
|
||||
// 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::engine::general_purpose::STANDARD.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?;
|
||||
|
||||
match index_type.to_uppercase().as_str() {
|
||||
"IVF_PQ" => {
|
||||
let ivf_params = IvfBuildParams {
|
||||
num_partitions: num_partitions.unwrap_or(256),
|
||||
..Default::default()
|
||||
};
|
||||
let pq_params = PQBuildParams {
|
||||
num_sub_vectors: num_sub_vectors.unwrap_or(16),
|
||||
..Default::default()
|
||||
};
|
||||
let params = VectorIndexParams::with_ivf_pq_params(
|
||||
MetricType::L2,
|
||||
ivf_params,
|
||||
pq_params,
|
||||
);
|
||||
|
||||
// Get a mutable reference to the dataset
|
||||
let mut dataset_mut = Dataset::open(self.data_dir.join(format!("{}.lance", dataset_name)).to_str().unwrap())
|
||||
.await
|
||||
.map_err(|e| DBError(format!("Failed to open dataset for indexing: {}", e)))?;
|
||||
|
||||
dataset_mut.create_index(
|
||||
&["vector"],
|
||||
lance_index::IndexType::Vector,
|
||||
None,
|
||||
¶ms,
|
||||
true
|
||||
).await
|
||||
.map_err(|e| DBError(format!("Failed to create index: {}", e)))?;
|
||||
}
|
||||
_ => return Err(DBError(format!("Unsupported index type: {}", index_type))),
|
||||
}
|
||||
|
||||
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().version.to_string());
|
||||
info.insert("num_rows".to_string(), dataset.count_rows(None).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)
|
||||
}
|
||||
}
|
@@ -2,7 +2,10 @@ pub mod age; // NEW
|
||||
pub mod cmd;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod lance_store; // Add Lance store module
|
||||
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,
|
||||
}
|
@@ -19,6 +19,10 @@ impl fmt::Display for Protocol {
|
||||
|
||||
impl Protocol {
|
||||
pub fn from(protocol: &str) -> Result<(Self, &str), DBError> {
|
||||
if protocol.is_empty() {
|
||||
// Incomplete frame; caller should read more bytes
|
||||
return Err(DBError("[incomplete] empty".to_string()));
|
||||
}
|
||||
let ret = match protocol.chars().nth(0) {
|
||||
Some('+') => Self::parse_simple_string_sfx(&protocol[1..]),
|
||||
Some('$') => Self::parse_bulk_string_sfx(&protocol[1..]),
|
||||
@@ -101,21 +105,20 @@ impl Protocol {
|
||||
let size = Self::parse_usize(&protocol[..len_end])?;
|
||||
let data_start = len_end + 2;
|
||||
let data_end = data_start + size;
|
||||
let s = Self::parse_string(&protocol[data_start..data_end])?;
|
||||
|
||||
if protocol.len() < data_end + 2 || &protocol[data_end..data_end+2] != "\r\n" {
|
||||
Err(DBError(format!(
|
||||
"[new bulk string] unmatched string length in prototocl {:?}",
|
||||
protocol,
|
||||
)))
|
||||
} else {
|
||||
Ok((Protocol::BulkString(s), &protocol[data_end + 2..]))
|
||||
// If we don't yet have the full bulk payload + trailing CRLF, signal INCOMPLETE
|
||||
if protocol.len() < data_end + 2 {
|
||||
return Err(DBError("[incomplete] bulk body".to_string()));
|
||||
}
|
||||
if &protocol[data_end..data_end + 2] != "\r\n" {
|
||||
return Err(DBError("[incomplete] bulk terminator".to_string()));
|
||||
}
|
||||
|
||||
let s = Self::parse_string(&protocol[data_start..data_end])?;
|
||||
Ok((Protocol::BulkString(s), &protocol[data_end + 2..]))
|
||||
} else {
|
||||
Err(DBError(format!(
|
||||
"[new bulk string] unsupported protocol: {:?}",
|
||||
protocol
|
||||
)))
|
||||
// No CRLF after bulk length header yet
|
||||
Err(DBError("[incomplete] bulk header".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,16 +128,25 @@ impl Protocol {
|
||||
let mut remaining = &s[len_end + 2..];
|
||||
let mut vec = vec![];
|
||||
for _ in 0..array_len {
|
||||
let (p, rem) = Protocol::from(remaining)?;
|
||||
vec.push(p);
|
||||
remaining = rem;
|
||||
match Protocol::from(remaining) {
|
||||
Ok((p, rem)) => {
|
||||
vec.push(p);
|
||||
remaining = rem;
|
||||
}
|
||||
Err(e) => {
|
||||
// Propagate incomplete so caller can read more bytes
|
||||
if e.0.starts_with("[incomplete]") {
|
||||
return Err(e);
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((Protocol::Array(vec), remaining))
|
||||
} else {
|
||||
Err(DBError(format!(
|
||||
"[new array] unsupported protocol: {:?}",
|
||||
s
|
||||
)))
|
||||
// No CRLF after array header yet
|
||||
Err(DBError("[incomplete] array header".to_string()))
|
||||
}
|
||||
}
|
||||
|
285
src/server.rs
Normal file
285
src/server.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use core::str;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::cmd::Cmd;
|
||||
use crate::error::DBError;
|
||||
use crate::lance_store::LanceStore;
|
||||
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<dyn StorageBackend>>>>,
|
||||
pub option: options::DBOption,
|
||||
pub client_name: Option<String>,
|
||||
pub selected_db: u64, // Changed from usize to u64
|
||||
pub queued_cmd: Option<Vec<(Cmd, Protocol)>>,
|
||||
|
||||
// BLPOP waiter registry: per (db_index, key) FIFO of waiters
|
||||
pub list_waiters: Arc<Mutex<HashMap<u64, HashMap<String, Vec<Waiter>>>>>,
|
||||
pub waiter_seq: Arc<AtomicU64>,
|
||||
|
||||
// Lance vector store
|
||||
pub lance_store: Option<Arc<LanceStore>>,
|
||||
}
|
||||
|
||||
pub struct Waiter {
|
||||
pub id: u64,
|
||||
pub side: PopSide,
|
||||
pub tx: oneshot::Sender<(String, String)>, // (key, element)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PopSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn new(option: options::DBOption) -> Self {
|
||||
// Initialize Lance store
|
||||
let lance_data_dir = std::path::PathBuf::from(&option.dir).join("lance");
|
||||
let lance_store = match LanceStore::new(lance_data_dir).await {
|
||||
Ok(store) => Some(Arc::new(store)),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to initialize Lance store: {}", e.0);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Server {
|
||||
db_cache: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||
option,
|
||||
client_name: None,
|
||||
selected_db: 0,
|
||||
queued_cmd: None,
|
||||
|
||||
list_waiters: Arc::new(Mutex::new(HashMap::new())),
|
||||
waiter_seq: Arc::new(AtomicU64::new(1)),
|
||||
lance_store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lance_store(&self) -> Result<Arc<LanceStore>, DBError> {
|
||||
self.lance_store
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| DBError("Lance store not initialized".to_string()))
|
||||
}
|
||||
|
||||
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) {
|
||||
return Ok(storage.clone());
|
||||
}
|
||||
|
||||
|
||||
// Create new database file
|
||||
let db_file_path = std::path::PathBuf::from(self.option.dir.clone())
|
||||
.join(format!("{}.db", self.selected_db));
|
||||
|
||||
// Ensure the directory exists before creating the database file
|
||||
if let Some(parent_dir) = db_file_path.parent() {
|
||||
std::fs::create_dir_all(parent_dir).map_err(|e| {
|
||||
DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e))
|
||||
})?;
|
||||
}
|
||||
|
||||
println!("Creating new db file: {}", db_file_path.display());
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn should_encrypt_db(&self, db_index: u64) -> bool {
|
||||
// DB 0-9 are non-encrypted, DB 10+ are encrypted
|
||||
self.option.encrypt && db_index >= 10
|
||||
}
|
||||
|
||||
// ----- BLPOP waiter helpers -----
|
||||
|
||||
pub async fn register_waiter(&self, db_index: u64, key: &str, side: PopSide) -> (u64, oneshot::Receiver<(String, String)>) {
|
||||
let id = self.waiter_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel::<(String, String)>();
|
||||
|
||||
let mut guard = self.list_waiters.lock().await;
|
||||
let per_db = guard.entry(db_index).or_insert_with(HashMap::new);
|
||||
let q = per_db.entry(key.to_string()).or_insert_with(Vec::new);
|
||||
q.push(Waiter { id, side, tx });
|
||||
(id, rx)
|
||||
}
|
||||
|
||||
pub async fn unregister_waiter(&self, db_index: u64, key: &str, id: u64) {
|
||||
let mut guard = self.list_waiters.lock().await;
|
||||
if let Some(per_db) = guard.get_mut(&db_index) {
|
||||
if let Some(q) = per_db.get_mut(key) {
|
||||
q.retain(|w| w.id != id);
|
||||
if q.is_empty() {
|
||||
per_db.remove(key);
|
||||
}
|
||||
}
|
||||
if per_db.is_empty() {
|
||||
guard.remove(&db_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called after LPUSH/RPUSH to deliver to blocked BLPOP waiters.
|
||||
pub async fn drain_waiters_after_push(&self, key: &str) -> Result<(), DBError> {
|
||||
let db_index = self.selected_db;
|
||||
|
||||
loop {
|
||||
// Check if any waiter exists
|
||||
let maybe_waiter = {
|
||||
let mut guard = self.list_waiters.lock().await;
|
||||
if let Some(per_db) = guard.get_mut(&db_index) {
|
||||
if let Some(q) = per_db.get_mut(key) {
|
||||
if !q.is_empty() {
|
||||
// Pop FIFO
|
||||
Some(q.remove(0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let waiter = if let Some(w) = maybe_waiter { w } else { break };
|
||||
|
||||
// Pop one element depending on waiter side
|
||||
let elems = match waiter.side {
|
||||
PopSide::Left => self.current_storage()?.lpop(key, 1)?,
|
||||
PopSide::Right => self.current_storage()?.rpop(key, 1)?,
|
||||
};
|
||||
if elems.is_empty() {
|
||||
// Nothing to deliver; re-register waiter at the front to preserve order
|
||||
let mut guard = self.list_waiters.lock().await;
|
||||
let per_db = guard.entry(db_index).or_insert_with(HashMap::new);
|
||||
let q = per_db.entry(key.to_string()).or_insert_with(Vec::new);
|
||||
q.insert(0, waiter);
|
||||
break;
|
||||
} else {
|
||||
let elem = elems[0].clone();
|
||||
// Send to waiter; if receiver dropped, just continue
|
||||
let _ = waiter.tx.send((key.to_string(), elem));
|
||||
// Loop to try to satisfy more waiters if more elements remain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
&mut self,
|
||||
mut stream: tokio::net::TcpStream,
|
||||
) -> Result<(), DBError> {
|
||||
// Accumulate incoming bytes to handle partial RESP frames
|
||||
let mut acc = String::new();
|
||||
let mut buf = vec![0u8; 8192];
|
||||
|
||||
loop {
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
println!("[handle] connection closed");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
println!("[handle] read error: {:?}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Append to accumulator. RESP for our usage is ASCII-safe.
|
||||
acc.push_str(str::from_utf8(&buf[..n])?);
|
||||
|
||||
// Try to parse as many complete commands as are available in 'acc'.
|
||||
loop {
|
||||
let parsed = Cmd::from(&acc);
|
||||
let (cmd, protocol, remaining) = match parsed {
|
||||
Ok((cmd, protocol, remaining)) => (cmd, protocol, remaining),
|
||||
Err(_e) => {
|
||||
// Incomplete or invalid frame; assume incomplete and wait for more data.
|
||||
// This avoids emitting spurious protocol_error for split frames.
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Advance the accumulator to the unparsed remainder
|
||||
acc = remaining.to_string();
|
||||
|
||||
if self.option.debug {
|
||||
println!("\x1b[34;1mgot command: {:?}, protocol: {:?}\x1b[0m", cmd, protocol);
|
||||
} else {
|
||||
println!("got command: {:?}, protocol: {:?}", cmd, protocol);
|
||||
}
|
||||
|
||||
// Check if this is a QUIT command before processing
|
||||
let is_quit = matches!(cmd, Cmd::Quit);
|
||||
|
||||
let res = match cmd.run(self).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if self.option.debug {
|
||||
eprintln!("[run error] {:?}", e);
|
||||
}
|
||||
Protocol::err(&format!("ERR {}", e.0))
|
||||
}
|
||||
};
|
||||
|
||||
if self.option.debug {
|
||||
println!("\x1b[34;1mqueued cmd {:?}\x1b[0m", self.queued_cmd);
|
||||
println!("\x1b[32;1mgoing to send response {}\x1b[0m", res.encode());
|
||||
} else {
|
||||
print!("queued cmd {:?}", self.queued_cmd);
|
||||
println!("going to send response {}", res.encode());
|
||||
}
|
||||
|
||||
_ = stream.write(res.encode().as_bytes()).await?;
|
||||
|
||||
// If this was a QUIT command, close the connection
|
||||
if is_quit {
|
||||
println!("[handle] QUIT command received, closing connection");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Continue parsing any further complete commands already in 'acc'
|
||||
if acc.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
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")
|
||||
}
|
||||
}
|
@@ -216,3 +216,30 @@ impl Storage {
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn dbsize(&self) -> Result<i64, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let expiration_table = read_txn.open_table(EXPIRATION_TABLE)?;
|
||||
|
||||
let mut count: i64 = 0;
|
||||
let mut iter = types_table.iter()?;
|
||||
while let Some(entry) = iter.next() {
|
||||
let entry = entry?;
|
||||
let key = entry.0.value();
|
||||
let ty = entry.1.value();
|
||||
|
||||
if ty == "string" {
|
||||
if let Some(expires_at) = expiration_table.get(key)? {
|
||||
if now_in_millis() > expires_at.value() as u128 {
|
||||
// Skip logically expired string keys
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
@@ -98,6 +98,124 @@ impl Storage {
|
||||
None => Ok(false), // Key does not exist
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Expiration helpers (string keys only, consistent with TTL/EXISTS) --------
|
||||
|
||||
// Set expiry in seconds; returns true if applied (key exists and is string), false otherwise
|
||||
pub fn expire_seconds(&self, key: &str, secs: u64) -> Result<bool, DBError> {
|
||||
// Determine eligibility first to avoid holding borrows across commit
|
||||
let mut applied = false;
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let types_table = write_txn.open_table(TYPES_TABLE)?;
|
||||
let is_string = types_table
|
||||
.get(key)?
|
||||
.map(|v| v.value() == "string")
|
||||
.unwrap_or(false);
|
||||
if is_string {
|
||||
let mut expiration_table = write_txn.open_table(EXPIRATION_TABLE)?;
|
||||
let expires_at = now_in_millis() + (secs as u128) * 1000;
|
||||
expiration_table.insert(key, &(expires_at as u64))?;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
write_txn.commit()?;
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
// Set expiry in milliseconds; returns true if applied (key exists and is string), false otherwise
|
||||
pub fn pexpire_millis(&self, key: &str, ms: u128) -> Result<bool, DBError> {
|
||||
let mut applied = false;
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let types_table = write_txn.open_table(TYPES_TABLE)?;
|
||||
let is_string = types_table
|
||||
.get(key)?
|
||||
.map(|v| v.value() == "string")
|
||||
.unwrap_or(false);
|
||||
if is_string {
|
||||
let mut expiration_table = write_txn.open_table(EXPIRATION_TABLE)?;
|
||||
let expires_at = now_in_millis() + ms;
|
||||
expiration_table.insert(key, &(expires_at as u64))?;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
write_txn.commit()?;
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
// Remove expiry if present; returns true if removed, false otherwise
|
||||
pub fn persist(&self, key: &str) -> Result<bool, DBError> {
|
||||
let mut removed = false;
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let types_table = write_txn.open_table(TYPES_TABLE)?;
|
||||
let is_string = types_table
|
||||
.get(key)?
|
||||
.map(|v| v.value() == "string")
|
||||
.unwrap_or(false);
|
||||
if is_string {
|
||||
let mut expiration_table = write_txn.open_table(EXPIRATION_TABLE)?;
|
||||
if expiration_table.remove(key)?.is_some() {
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
write_txn.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
// Absolute EXPIREAT in seconds since epoch
|
||||
// Returns true if applied (key exists and is string), false otherwise
|
||||
pub fn expire_at_seconds(&self, key: &str, ts_secs: i64) -> Result<bool, DBError> {
|
||||
let mut applied = false;
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let types_table = write_txn.open_table(TYPES_TABLE)?;
|
||||
let is_string = types_table
|
||||
.get(key)?
|
||||
.map(|v| v.value() == "string")
|
||||
.unwrap_or(false);
|
||||
if is_string {
|
||||
let mut expiration_table = write_txn.open_table(EXPIRATION_TABLE)?;
|
||||
let expires_at_ms: u128 = if ts_secs <= 0 { 0 } else { (ts_secs as u128) * 1000 };
|
||||
expiration_table.insert(key, &((expires_at_ms as u64)))?;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
write_txn.commit()?;
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
// Absolute PEXPIREAT in milliseconds since epoch
|
||||
// Returns true if applied (key exists and is string), false otherwise
|
||||
pub fn pexpire_at_millis(&self, key: &str, ts_ms: i64) -> Result<bool, DBError> {
|
||||
let mut applied = false;
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let types_table = write_txn.open_table(TYPES_TABLE)?;
|
||||
let is_string = types_table
|
||||
.get(key)?
|
||||
.map(|v| v.value() == "string")
|
||||
.unwrap_or(false);
|
||||
if is_string {
|
||||
let mut expiration_table = write_txn.open_table(EXPIRATION_TABLE)?;
|
||||
let expires_at_ms: u128 = if ts_ms <= 0 { 0 } else { ts_ms as u128 };
|
||||
expiration_table.insert(key, &((expires_at_ms as u64)))?;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
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
|
@@ -148,8 +148,6 @@ impl Storage {
|
||||
|
||||
pub fn hexists(&self, key: &str, field: &str) -> Result<bool, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
||||
@@ -168,8 +166,6 @@ impl Storage {
|
||||
|
||||
pub fn hkeys(&self, key: &str) -> Result<Vec<String>, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
||||
@@ -200,8 +196,6 @@ impl Storage {
|
||||
// ✅ ENCRYPTION APPLIED: All values are decrypted after retrieval
|
||||
pub fn hvals(&self, key: &str) -> Result<Vec<String>, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
||||
@@ -233,8 +227,6 @@ impl Storage {
|
||||
|
||||
pub fn hlen(&self, key: &str) -> Result<i64, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
||||
@@ -265,8 +257,6 @@ impl Storage {
|
||||
// ✅ ENCRYPTION APPLIED: Values are decrypted after retrieval
|
||||
pub fn hmget(&self, key: &str, fields: Vec<String>) -> Result<Vec<Option<String>>, DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
||||
@@ -334,8 +324,6 @@ impl Storage {
|
||||
// ✅ ENCRYPTION APPLIED: Values are decrypted after retrieval
|
||||
pub fn hscan(&self, key: &str, cursor: u64, pattern: Option<&str>, count: Option<u64>) -> Result<(u64, Vec<(String, String)>), DBError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
|
||||
let types_table = read_txn.open_table(TYPES_TABLE)?;
|
||||
let key_type = {
|
||||
let access_guard = types_table.get(key)?;
|
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>;
|
||||
}
|
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "supervisor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# The supervisor will eventually depend on the herodb crate.
|
||||
# We can add this dependency now.
|
||||
# herodb = { path = "../herodb" }
|
@@ -1,4 +0,0 @@
|
||||
fn main() {
|
||||
println!("Hello from the supervisor crate!");
|
||||
// Supervisor logic will be implemented here.
|
||||
}
|
@@ -1,10 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Test script for HeroDB - Redis-compatible database with redb backend
|
||||
# This script starts the server and runs comprehensive tests
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -298,7 +299,7 @@ main() {
|
||||
|
||||
# Start the server
|
||||
print_status "Starting HeroDB server..."
|
||||
./target/release/herodb --dir "$DB_DIR" --port $PORT &
|
||||
../target/release/herodb --dir "$DB_DIR" --port $PORT &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
@@ -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;
|
@@ -16,9 +16,9 @@ fn get_redis_connection(port: u16) -> Connection {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if attempts >= 20 {
|
||||
if attempts >= 120 {
|
||||
panic!(
|
||||
"Failed to connect to Redis server after 20 attempts: {}",
|
||||
"Failed to connect to Redis server after 120 attempts: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -88,8 +88,8 @@ fn setup_server() -> (ServerProcessGuard, u16) {
|
||||
test_dir,
|
||||
};
|
||||
|
||||
// Give the server a moment to start
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
// Give the server time to build and start (cargo run may compile first)
|
||||
std::thread::sleep(Duration::from_millis(2500));
|
||||
|
||||
(guard, port)
|
||||
}
|
@@ -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;
|
893
tests/usage_suite.rs
Normal file
893
tests/usage_suite.rs
Normal file
@@ -0,0 +1,893 @@
|
||||
use herodb::{options::DBOption, server::Server};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// =========================
|
||||
// Helpers
|
||||
// =========================
|
||||
|
||||
async fn start_test_server(test_name: &str) -> (Server, u16) {
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
static PORT_COUNTER: AtomicU16 = AtomicU16::new(17100);
|
||||
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let test_dir = format!("/tmp/herodb_usage_suite_{}", test_name);
|
||||
let _ = std::fs::remove_dir_all(&test_dir);
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
|
||||
let option = DBOption {
|
||||
dir: test_dir,
|
||||
port,
|
||||
debug: false,
|
||||
encrypt: false,
|
||||
encryption_key: None,
|
||||
backend: herodb::options::BackendType::Redb,
|
||||
};
|
||||
|
||||
let server = Server::new(option).await;
|
||||
(server, port)
|
||||
}
|
||||
|
||||
async fn spawn_listener(server: Server, port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.expect("bind listener");
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let mut s_clone = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = s_clone.handle(stream).await;
|
||||
});
|
||||
}
|
||||
Err(_e) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Build RESP array for args ["PING"] -> "*1\r\n$4\r\nPING\r\n"
|
||||
fn build_resp(args: &[&str]) -> String {
|
||||
let mut s = format!("*{}\r\n", args.len());
|
||||
for a in args {
|
||||
s.push_str(&format!("${}\r\n{}\r\n", a.len(), a));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
async fn connect(port: u16) -> TcpStream {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
||||
Ok(s) => return s,
|
||||
Err(_) if attempts < 30 => {
|
||||
attempts += 1;
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(e) => panic!("Failed to connect: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_crlf(buf: &[u8], start: usize) -> Option<usize> {
|
||||
let mut i = start;
|
||||
while i + 1 < buf.len() {
|
||||
if buf[i] == b'\r' && buf[i + 1] == b'\n' {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_number_i64(buf: &[u8], start: usize, end: usize) -> Option<i64> {
|
||||
let s = std::str::from_utf8(&buf[start..end]).ok()?;
|
||||
s.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
// Return number of bytes that make up a complete RESP element starting at 'i', or None if incomplete.
|
||||
fn parse_elem(buf: &[u8], i: usize) -> Option<usize> {
|
||||
if i >= buf.len() {
|
||||
return None;
|
||||
}
|
||||
match buf[i] {
|
||||
b'+' | b'-' | b':' => {
|
||||
let end = find_crlf(buf, i + 1)?;
|
||||
Some(end + 2 - i)
|
||||
}
|
||||
b'$' => {
|
||||
let hdr_end = find_crlf(buf, i + 1)?;
|
||||
let n = parse_number_i64(buf, i + 1, hdr_end)?;
|
||||
if n < 0 {
|
||||
// Null bulk string: only header
|
||||
Some(hdr_end + 2 - i)
|
||||
} else {
|
||||
let need = hdr_end + 2 + (n as usize) + 2;
|
||||
if need <= buf.len() {
|
||||
Some(need - i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
b'*' => {
|
||||
let hdr_end = find_crlf(buf, i + 1)?;
|
||||
let n = parse_number_i64(buf, i + 1, hdr_end)?;
|
||||
if n < 0 {
|
||||
// Null array: only header
|
||||
Some(hdr_end + 2 - i)
|
||||
} else {
|
||||
let mut j = hdr_end + 2;
|
||||
for _ in 0..(n as usize) {
|
||||
let consumed = parse_elem(buf, j)?;
|
||||
j += consumed;
|
||||
}
|
||||
Some(j - i)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resp_frame_len(buf: &[u8]) -> Option<usize> {
|
||||
parse_elem(buf, 0)
|
||||
}
|
||||
|
||||
async fn read_full_resp(stream: &mut TcpStream) -> String {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(8192);
|
||||
let mut tmp = vec![0u8; 4096];
|
||||
|
||||
loop {
|
||||
if let Some(total) = resp_frame_len(&buf) {
|
||||
if buf.len() >= total {
|
||||
return String::from_utf8_lossy(&buf[..total]).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::time::timeout(Duration::from_secs(2), stream.read(&mut tmp)).await {
|
||||
Ok(Ok(n)) => {
|
||||
if n == 0 {
|
||||
if let Some(total) = resp_frame_len(&buf) {
|
||||
if buf.len() >= total {
|
||||
return String::from_utf8_lossy(&buf[..total]).to_string();
|
||||
}
|
||||
}
|
||||
return String::from_utf8_lossy(&buf).to_string();
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
Ok(Err(e)) => panic!("read error: {}", e),
|
||||
Err(_) => panic!("timeout waiting for reply"),
|
||||
}
|
||||
|
||||
if buf.len() > 8 * 1024 * 1024 {
|
||||
panic!("reply too large");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_cmd(stream: &mut TcpStream, args: &[&str]) -> String {
|
||||
let req = build_resp(args);
|
||||
stream.write_all(req.as_bytes()).await.unwrap();
|
||||
read_full_resp(stream).await
|
||||
}
|
||||
|
||||
// Assert helpers with clearer output
|
||||
fn assert_contains(haystack: &str, needle: &str, ctx: &str) {
|
||||
assert!(
|
||||
haystack.contains(needle),
|
||||
"ASSERT CONTAINS failed: '{}' not found in response.\nContext: {}\nResponse:\n{}",
|
||||
needle,
|
||||
ctx,
|
||||
haystack
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_eq_resp(actual: &str, expected: &str, ctx: &str) {
|
||||
assert!(
|
||||
actual == expected,
|
||||
"ASSERT EQUAL failed.\nContext: {}\nExpected:\n{:?}\nActual:\n{:?}",
|
||||
ctx,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract the payload of a single RESP Bulk String reply.
|
||||
/// Example input:
|
||||
/// "$5\r\nhello\r\n" -> Some("hello".to_string())
|
||||
fn extract_bulk_payload(resp: &str) -> Option<String> {
|
||||
// find first CRLF after "$len"
|
||||
let first = resp.find("\r\n")?;
|
||||
let after = &resp[(first + 2)..];
|
||||
// find next CRLF ending payload
|
||||
let second = after.find("\r\n")?;
|
||||
Some(after[..second].to_string())
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Test suites
|
||||
// =========================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_01_connection_and_info() {
|
||||
let (server, port) = start_test_server("conn_info").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// redis-cli may send COMMAND DOCS, our server replies empty array; harmless.
|
||||
let pong = send_cmd(&mut s, &["PING"]).await;
|
||||
assert_contains(&pong, "PONG", "PING should return PONG");
|
||||
|
||||
let echo = send_cmd(&mut s, &["ECHO", "hello"]).await;
|
||||
assert_contains(&echo, "hello", "ECHO hello");
|
||||
|
||||
// INFO (general)
|
||||
let info = send_cmd(&mut s, &["INFO"]).await;
|
||||
assert_contains(&info, "redis_version", "INFO should include redis_version");
|
||||
|
||||
// INFO REPLICATION (static stub)
|
||||
let repl = send_cmd(&mut s, &["INFO", "replication"]).await;
|
||||
assert_contains(&repl, "role:master", "INFO replication role");
|
||||
|
||||
// CONFIG GET subset
|
||||
let cfg = send_cmd(&mut s, &["CONFIG", "GET", "databases"]).await;
|
||||
assert_contains(&cfg, "databases", "CONFIG GET databases");
|
||||
assert_contains(&cfg, "16", "CONFIG GET databases value");
|
||||
|
||||
// CLIENT name
|
||||
let setname = send_cmd(&mut s, &["CLIENT", "SETNAME", "myapp"]).await;
|
||||
assert_contains(&setname, "OK", "CLIENT SETNAME");
|
||||
|
||||
let getname = send_cmd(&mut s, &["CLIENT", "GETNAME"]).await;
|
||||
assert_contains(&getname, "myapp", "CLIENT GETNAME");
|
||||
|
||||
// SELECT db
|
||||
let sel = send_cmd(&mut s, &["SELECT", "0"]).await;
|
||||
assert_contains(&sel, "OK", "SELECT 0");
|
||||
|
||||
// QUIT should close connection after sending OK
|
||||
let quit = send_cmd(&mut s, &["QUIT"]).await;
|
||||
assert_contains(&quit, "OK", "QUIT should return OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_02_strings_and_expiry() {
|
||||
let (server, port) = start_test_server("strings").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// SET / GET
|
||||
let set = send_cmd(&mut s, &["SET", "user:1", "alice"]).await;
|
||||
assert_contains(&set, "OK", "SET user:1 alice");
|
||||
|
||||
let get = send_cmd(&mut s, &["GET", "user:1"]).await;
|
||||
assert_contains(&get, "alice", "GET user:1");
|
||||
|
||||
// EXISTS / DEL
|
||||
let ex1 = send_cmd(&mut s, &["EXISTS", "user:1"]).await;
|
||||
assert_contains(&ex1, "1", "EXISTS user:1");
|
||||
|
||||
let del = send_cmd(&mut s, &["DEL", "user:1"]).await;
|
||||
assert_contains(&del, "1", "DEL user:1");
|
||||
|
||||
let ex0 = send_cmd(&mut s, &["EXISTS", "user:1"]).await;
|
||||
assert_contains(&ex0, "0", "EXISTS after DEL");
|
||||
|
||||
// INCR behavior
|
||||
let i1 = send_cmd(&mut s, &["INCR", "count"]).await;
|
||||
assert_contains(&i1, "1", "INCR new key -> 1");
|
||||
let i2 = send_cmd(&mut s, &["INCR", "count"]).await;
|
||||
assert_contains(&i2, "2", "INCR existing -> 2");
|
||||
let _ = send_cmd(&mut s, &["SET", "notnum", "abc"]).await;
|
||||
let ierr = send_cmd(&mut s, &["INCR", "notnum"]).await;
|
||||
assert_contains(&ierr, "ERR", "INCR on non-numeric should ERR");
|
||||
|
||||
// Expiration via SET EX
|
||||
let setex = send_cmd(&mut s, &["SET", "tmp:1", "boom", "EX", "1"]).await;
|
||||
assert_contains(&setex, "OK", "SET tmp:1 EX 1");
|
||||
|
||||
let g_immediate = send_cmd(&mut s, &["GET", "tmp:1"]).await;
|
||||
assert_contains(&g_immediate, "boom", "GET tmp:1 immediately");
|
||||
|
||||
let ttl = send_cmd(&mut s, &["TTL", "tmp:1"]).await;
|
||||
// Implementation returns a SimpleString, accept any numeric content
|
||||
assert!(
|
||||
ttl.contains("1") || ttl.contains("0"),
|
||||
"TTL should be 1 or 0, got: {}",
|
||||
ttl
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(1100)).await;
|
||||
let g_after = send_cmd(&mut s, &["GET", "tmp:1"]).await;
|
||||
assert_contains(&g_after, "$-1", "GET tmp:1 after expiry -> Null");
|
||||
|
||||
// TYPE
|
||||
let _ = send_cmd(&mut s, &["SET", "t", "v"]).await;
|
||||
let ty = send_cmd(&mut s, &["TYPE", "t"]).await;
|
||||
assert_contains(&ty, "string", "TYPE string key");
|
||||
let ty_none = send_cmd(&mut s, &["TYPE", "noexist"]).await;
|
||||
assert_contains(&ty_none, "none", "TYPE nonexistent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_03_scan_and_keys() {
|
||||
let (server, port) = start_test_server("scan").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
for i in 0..5 {
|
||||
let _ = send_cmd(&mut s, &["SET", &format!("key{}", i), &format!("value{}", i)]).await;
|
||||
}
|
||||
|
||||
let scan = send_cmd(&mut s, &["SCAN", "0", "MATCH", "key*", "COUNT", "10"]).await;
|
||||
assert_contains(&scan, "key0", "SCAN should return keys with MATCH");
|
||||
assert_contains(&scan, "key4", "SCAN should return last key");
|
||||
|
||||
let keys = send_cmd(&mut s, &["KEYS", "*"]).await;
|
||||
assert_contains(&keys, "key0", "KEYS * includes key0");
|
||||
assert_contains(&keys, "key4", "KEYS * includes key4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_04_hashes_suite() {
|
||||
let (server, port) = start_test_server("hashes").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// HSET (single, returns number of new fields)
|
||||
let h1 = send_cmd(&mut s, &["HSET", "profile:1", "name", "alice"]).await;
|
||||
assert_contains(&h1, "1", "HSET new field -> 1");
|
||||
|
||||
// HGET
|
||||
let hg = send_cmd(&mut s, &["HGET", "profile:1", "name"]).await;
|
||||
assert_contains(&hg, "alice", "HGET existing field");
|
||||
|
||||
// HSET multiple
|
||||
let h2 = send_cmd(&mut s, &["HSET", "profile:1", "age", "30", "city", "paris"]).await;
|
||||
assert_contains(&h2, "2", "HSET added 2 new fields");
|
||||
|
||||
// HMGET
|
||||
let hmg = send_cmd(&mut s, &["HMGET", "profile:1", "name", "age", "city", "nope"]).await;
|
||||
assert_contains(&hmg, "alice", "HMGET name");
|
||||
assert_contains(&hmg, "30", "HMGET age");
|
||||
assert_contains(&hmg, "paris", "HMGET city");
|
||||
assert_contains(&hmg, "$-1", "HMGET non-existent -> Null");
|
||||
|
||||
// HGETALL
|
||||
let hga = send_cmd(&mut s, &["HGETALL", "profile:1"]).await;
|
||||
assert_contains(&hga, "name", "HGETALL contains name");
|
||||
assert_contains(&hga, "alice", "HGETALL contains alice");
|
||||
|
||||
// HLEN
|
||||
let hlen = send_cmd(&mut s, &["HLEN", "profile:1"]).await;
|
||||
assert_contains(&hlen, "3", "HLEN is 3");
|
||||
|
||||
// HEXISTS
|
||||
let hex1 = send_cmd(&mut s, &["HEXISTS", "profile:1", "age"]).await;
|
||||
assert_contains(&hex1, "1", "HEXISTS age true");
|
||||
let hex0 = send_cmd(&mut s, &["HEXISTS", "profile:1", "nope"]).await;
|
||||
assert_contains(&hex0, "0", "HEXISTS nope false");
|
||||
|
||||
// HKEYS / HVALS
|
||||
let hkeys = send_cmd(&mut s, &["HKEYS", "profile:1"]).await;
|
||||
assert_contains(&hkeys, "name", "HKEYS includes name");
|
||||
let hvals = send_cmd(&mut s, &["HVALS", "profile:1"]).await;
|
||||
assert_contains(&hvals, "alice", "HVALS includes alice");
|
||||
|
||||
// HSETNX
|
||||
let hnx0 = send_cmd(&mut s, &["HSETNX", "profile:1", "name", "bob"]).await;
|
||||
assert_contains(&hnx0, "0", "HSETNX existing field -> 0");
|
||||
let hnx1 = send_cmd(&mut s, &["HSETNX", "profile:1", "nickname", "ali"]).await;
|
||||
assert_contains(&hnx1, "1", "HSETNX new field -> 1");
|
||||
|
||||
// HSCAN
|
||||
let hscan = send_cmd(&mut s, &["HSCAN", "profile:1", "0", "MATCH", "n*", "COUNT", "10"]).await;
|
||||
assert_contains(&hscan, "name", "HSCAN matches fields starting with n");
|
||||
assert_contains(&hscan, "nickname", "HSCAN nickname present");
|
||||
|
||||
// HDEL
|
||||
let hdel = send_cmd(&mut s, &["HDEL", "profile:1", "city", "age"]).await;
|
||||
assert_contains(&hdel, "2", "HDEL removed two fields");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_05_lists_suite_including_blpop() {
|
||||
let (server, port) = start_test_server("lists").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut a = connect(port).await;
|
||||
|
||||
// LPUSH / RPUSH / LLEN
|
||||
let lp = send_cmd(&mut a, &["LPUSH", "q:jobs", "a", "b"]).await;
|
||||
assert_contains(&lp, "2", "LPUSH added 2, length 2");
|
||||
|
||||
let rp = send_cmd(&mut a, &["RPUSH", "q:jobs", "c"]).await;
|
||||
assert_contains(&rp, "3", "RPUSH now length 3");
|
||||
|
||||
let llen = send_cmd(&mut a, &["LLEN", "q:jobs"]).await;
|
||||
assert_contains(&llen, "3", "LLEN 3");
|
||||
|
||||
// LINDEX / LRANGE
|
||||
let lidx = send_cmd(&mut a, &["LINDEX", "q:jobs", "0"]).await;
|
||||
assert_eq_resp(&lidx, "$1\r\nb\r\n", "LINDEX q:jobs 0 should be b");
|
||||
|
||||
let lr = send_cmd(&mut a, &["LRANGE", "q:jobs", "0", "-1"]).await;
|
||||
assert_eq_resp(&lr, "*3\r\n$1\r\nb\r\n$1\r\na\r\n$1\r\nc\r\n", "LRANGE q:jobs 0 -1 should be [b,a,c]");
|
||||
|
||||
// LTRIM
|
||||
let ltrim = send_cmd(&mut a, &["LTRIM", "q:jobs", "0", "1"]).await;
|
||||
assert_contains(<rim, "OK", "LTRIM OK");
|
||||
let lr_post = send_cmd(&mut a, &["LRANGE", "q:jobs", "0", "-1"]).await;
|
||||
assert_eq_resp(&lr_post, "*2\r\n$1\r\nb\r\n$1\r\na\r\n", "After LTRIM, list [b,a]");
|
||||
|
||||
// LREM remove first occurrence of b
|
||||
let lrem = send_cmd(&mut a, &["LREM", "q:jobs", "1", "b"]).await;
|
||||
assert_contains(&lrem, "1", "LREM removed 1");
|
||||
|
||||
// LPOP and RPOP
|
||||
let lpop1 = send_cmd(&mut a, &["LPOP", "q:jobs"]).await;
|
||||
assert_contains(&lpop1, "$1\r\na\r\n", "LPOP returns a");
|
||||
let rpop_empty = send_cmd(&mut a, &["RPOP", "q:jobs"]).await; // empty now
|
||||
assert_contains(&rpop_empty, "$-1", "RPOP on empty -> Null");
|
||||
|
||||
// LPOP with count on empty -> []
|
||||
let lpop0 = send_cmd(&mut a, &["LPOP", "q:jobs", "2"]).await;
|
||||
assert_eq_resp(&lpop0, "*0\r\n", "LPOP with count on empty returns empty array");
|
||||
|
||||
// BLPOP: block on one client, push from another
|
||||
let c1 = connect(port).await;
|
||||
let mut c2 = connect(port).await;
|
||||
|
||||
// Start BLPOP on c1
|
||||
let blpop_task = tokio::spawn(async move {
|
||||
let mut c1_local = c1;
|
||||
send_cmd(&mut c1_local, &["BLPOP", "q:block", "5"]).await
|
||||
});
|
||||
|
||||
// Give it time to register waiter
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Push from c2 to wake BLPOP
|
||||
let _ = send_cmd(&mut c2, &["LPUSH", "q:block", "x"]).await;
|
||||
|
||||
// Await BLPOP result
|
||||
let blpop_res = blpop_task.await.expect("BLPOP task join");
|
||||
assert_contains(&blpop_res, "q:block", "BLPOP returned key");
|
||||
assert_contains(&blpop_res, "x", "BLPOP returned element");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_06_flushdb_suite() {
|
||||
let (server, port) = start_test_server("flushdb").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
let _ = send_cmd(&mut s, &["SET", "k1", "v1"]).await;
|
||||
let _ = send_cmd(&mut s, &["HSET", "h1", "f", "v"]).await;
|
||||
let _ = send_cmd(&mut s, &["LPUSH", "l1", "a"]).await;
|
||||
|
||||
let keys_before = send_cmd(&mut s, &["KEYS", "*"]).await;
|
||||
assert_contains(&keys_before, "k1", "have string key before FLUSHDB");
|
||||
assert_contains(&keys_before, "h1", "have hash key before FLUSHDB");
|
||||
assert_contains(&keys_before, "l1", "have list key before FLUSHDB");
|
||||
|
||||
let fl = send_cmd(&mut s, &["FLUSHDB"]).await;
|
||||
assert_contains(&fl, "OK", "FLUSHDB OK");
|
||||
|
||||
let keys_after = send_cmd(&mut s, &["KEYS", "*"]).await;
|
||||
assert_eq_resp(&keys_after, "*0\r\n", "DB should be empty after FLUSHDB");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_07_age_stateless_suite() {
|
||||
let (server, port) = start_test_server("age_stateless").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// GENENC -> [recipient, identity]
|
||||
let gen = send_cmd(&mut s, &["AGE", "GENENC"]).await;
|
||||
assert!(
|
||||
gen.starts_with("*2\r\n$"),
|
||||
"AGE GENENC should return array [recipient, identity], got:\n{}",
|
||||
gen
|
||||
);
|
||||
|
||||
// Parse simple RESP array of two bulk strings to extract keys
|
||||
fn parse_two_bulk_array(resp: &str) -> (String, String) {
|
||||
// naive parse for tests
|
||||
let mut lines = resp.lines();
|
||||
let _ = lines.next(); // *2
|
||||
// $len
|
||||
let _ = lines.next();
|
||||
let recip = lines.next().unwrap_or("").to_string();
|
||||
let _ = lines.next();
|
||||
let ident = lines.next().unwrap_or("").to_string();
|
||||
(recip, ident)
|
||||
}
|
||||
let (recipient, identity) = parse_two_bulk_array(&gen);
|
||||
assert!(
|
||||
recipient.starts_with("age1") && identity.starts_with("AGE-SECRET-KEY-1"),
|
||||
"Unexpected AGE key formats.\nrecipient: {}\nidentity: {}",
|
||||
recipient,
|
||||
identity
|
||||
);
|
||||
|
||||
// ENCRYPT / DECRYPT
|
||||
let ct = send_cmd(&mut s, &["AGE", "ENCRYPT", &recipient, "hello world"]).await;
|
||||
let ct_b64 = extract_bulk_payload(&ct).expect("Failed to parse bulk payload from ENCRYPT");
|
||||
let pt = send_cmd(&mut s, &["AGE", "DECRYPT", &identity, &ct_b64]).await;
|
||||
assert_contains(&pt, "hello world", "AGE DECRYPT round-trip");
|
||||
|
||||
// GENSIGN -> [verify_pub_b64, sign_secret_b64]
|
||||
let gensign = send_cmd(&mut s, &["AGE", "GENSIGN"]).await;
|
||||
let (verify_pub, sign_secret) = parse_two_bulk_array(&gensign);
|
||||
assert!(
|
||||
!verify_pub.is_empty() && !sign_secret.is_empty(),
|
||||
"GENSIGN returned empty keys"
|
||||
);
|
||||
|
||||
// SIGN / VERIFY
|
||||
let sig = send_cmd(&mut s, &["AGE", "SIGN", &sign_secret, "msg"]).await;
|
||||
let sig_b64 = extract_bulk_payload(&sig).expect("Failed to parse bulk payload from SIGN");
|
||||
let v_ok = send_cmd(&mut s, &["AGE", "VERIFY", &verify_pub, "msg", &sig_b64]).await;
|
||||
assert_contains(&v_ok, "1", "VERIFY should be 1 for valid signature");
|
||||
|
||||
let v_bad = send_cmd(&mut s, &["AGE", "VERIFY", &verify_pub, "tampered", &sig_b64]).await;
|
||||
assert_contains(&v_bad, "0", "VERIFY should be 0 for invalid message/signature");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_08_age_persistent_named_suite() {
|
||||
let (server, port) = start_test_server("age_persistent").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// KEYGEN + ENCRYPTNAME/DECRYPTNAME
|
||||
let kg = send_cmd(&mut s, &["AGE", "KEYGEN", "app1"]).await;
|
||||
assert!(
|
||||
kg.starts_with("*2\r\n"),
|
||||
"AGE KEYGEN should return [recipient, identity], got:\n{}",
|
||||
kg
|
||||
);
|
||||
|
||||
let ct = send_cmd(&mut s, &["AGE", "ENCRYPTNAME", "app1", "hello"]).await;
|
||||
let ct_b64 = extract_bulk_payload(&ct).expect("Failed to parse bulk payload from ENCRYPTNAME");
|
||||
let pt = send_cmd(&mut s, &["AGE", "DECRYPTNAME", "app1", &ct_b64]).await;
|
||||
assert_contains(&pt, "hello", "DECRYPTNAME round-trip");
|
||||
|
||||
// SIGNKEYGEN + SIGNNAME/VERIFYNAME
|
||||
let skg = send_cmd(&mut s, &["AGE", "SIGNKEYGEN", "app1"]).await;
|
||||
assert!(
|
||||
skg.starts_with("*2\r\n"),
|
||||
"AGE SIGNKEYGEN should return [verify_pub, sign_secret], got:\n{}",
|
||||
skg
|
||||
);
|
||||
|
||||
let sig = send_cmd(&mut s, &["AGE", "SIGNNAME", "app1", "m"] ).await;
|
||||
let sig_b64 = extract_bulk_payload(&sig).expect("Failed to parse bulk payload from SIGNNAME");
|
||||
let v1 = send_cmd(&mut s, &["AGE", "VERIFYNAME", "app1", "m", &sig_b64]).await;
|
||||
assert_contains(&v1, "1", "VERIFYNAME valid => 1");
|
||||
|
||||
let v0 = send_cmd(&mut s, &["AGE", "VERIFYNAME", "app1", "bad", &sig_b64]).await;
|
||||
assert_contains(&v0, "0", "VERIFYNAME invalid => 0");
|
||||
|
||||
// AGE LIST
|
||||
let lst = send_cmd(&mut s, &["AGE", "LIST"]).await;
|
||||
assert_contains(&lst, "encpub", "AGE LIST label encpub");
|
||||
assert_contains(&lst, "app1", "AGE LIST includes app1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_10_expire_pexpire_persist() {
|
||||
let (server, port) = start_test_server("expire_suite").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// EXPIRE: seconds
|
||||
let _ = send_cmd(&mut s, &["SET", "exp:s", "v"]).await;
|
||||
let ex = send_cmd(&mut s, &["EXPIRE", "exp:s", "1"]).await;
|
||||
assert_contains(&ex, "1", "EXPIRE exp:s 1 -> 1 (applied)");
|
||||
let ttl1 = send_cmd(&mut s, &["TTL", "exp:s"]).await;
|
||||
assert!(
|
||||
ttl1.contains("1") || ttl1.contains("0"),
|
||||
"TTL exp:s should be 1 or 0, got: {}",
|
||||
ttl1
|
||||
);
|
||||
sleep(Duration::from_millis(1100)).await;
|
||||
let get_after = send_cmd(&mut s, &["GET", "exp:s"]).await;
|
||||
assert_contains(&get_after, "$-1", "GET after expiry should be Null");
|
||||
let ttl_after = send_cmd(&mut s, &["TTL", "exp:s"]).await;
|
||||
assert_contains(&ttl_after, "-2", "TTL after expiry -> -2");
|
||||
let exists_after = send_cmd(&mut s, &["EXISTS", "exp:s"]).await;
|
||||
assert_contains(&exists_after, "0", "EXISTS after expiry -> 0");
|
||||
|
||||
// PEXPIRE: milliseconds
|
||||
let _ = send_cmd(&mut s, &["SET", "exp:ms", "v"]).await;
|
||||
let pex = send_cmd(&mut s, &["PEXPIRE", "exp:ms", "1500"]).await;
|
||||
assert_contains(&pex, "1", "PEXPIRE exp:ms 1500 -> 1 (applied)");
|
||||
let ttl_ms1 = send_cmd(&mut s, &["TTL", "exp:ms"]).await;
|
||||
assert!(
|
||||
ttl_ms1.contains("1") || ttl_ms1.contains("0"),
|
||||
"TTL exp:ms should be 1 or 0 soon after PEXPIRE, got: {}",
|
||||
ttl_ms1
|
||||
);
|
||||
sleep(Duration::from_millis(1600)).await;
|
||||
let exists_ms_after = send_cmd(&mut s, &["EXISTS", "exp:ms"]).await;
|
||||
assert_contains(&exists_ms_after, "0", "EXISTS exp:ms after ms expiry -> 0");
|
||||
|
||||
// PERSIST: remove expiration
|
||||
let _ = send_cmd(&mut s, &["SET", "exp:persist", "v"]).await;
|
||||
let _ = send_cmd(&mut s, &["EXPIRE", "exp:persist", "5"]).await;
|
||||
let ttl_pre = send_cmd(&mut s, &["TTL", "exp:persist"]).await;
|
||||
assert!(
|
||||
ttl_pre.contains("5") || ttl_pre.contains("4") || ttl_pre.contains("3") || ttl_pre.contains("2") || ttl_pre.contains("1") || ttl_pre.contains("0"),
|
||||
"TTL exp:persist should be >=0 before persist, got: {}",
|
||||
ttl_pre
|
||||
);
|
||||
let persist1 = send_cmd(&mut s, &["PERSIST", "exp:persist"]).await;
|
||||
assert_contains(&persist1, "1", "PERSIST should remove expiration");
|
||||
let ttl_post = send_cmd(&mut s, &["TTL", "exp:persist"]).await;
|
||||
assert_contains(&ttl_post, "-1", "TTL after PERSIST -> -1 (no expiration)");
|
||||
// Second persist should return 0 (nothing to remove)
|
||||
let persist2 = send_cmd(&mut s, &["PERSIST", "exp:persist"]).await;
|
||||
assert_contains(&persist2, "0", "PERSIST again -> 0 (no expiration to remove)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_11_set_with_options() {
|
||||
let (server, port) = start_test_server("set_opts").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// SET with GET on non-existing key -> returns Null, sets value
|
||||
let set_get1 = send_cmd(&mut s, &["SET", "s1", "v1", "GET"]).await;
|
||||
assert_contains(&set_get1, "$-1", "SET s1 v1 GET returns Null when key didn't exist");
|
||||
let g1 = send_cmd(&mut s, &["GET", "s1"]).await;
|
||||
assert_contains(&g1, "v1", "GET s1 after first SET");
|
||||
|
||||
// SET with GET should return old value, then set to new
|
||||
let set_get2 = send_cmd(&mut s, &["SET", "s1", "v2", "GET"]).await;
|
||||
assert_contains(&set_get2, "v1", "SET s1 v2 GET returns previous value v1");
|
||||
let g2 = send_cmd(&mut s, &["GET", "s1"]).await;
|
||||
assert_contains(&g2, "v2", "GET s1 now v2");
|
||||
|
||||
// NX prevents update when key exists; with GET should return Null and not change
|
||||
let set_nx = send_cmd(&mut s, &["SET", "s1", "v3", "NX", "GET"]).await;
|
||||
assert_contains(&set_nx, "$-1", "SET s1 v3 NX GET returns Null when not set");
|
||||
let g3 = send_cmd(&mut s, &["GET", "s1"]).await;
|
||||
assert_contains(&g3, "v2", "GET s1 remains v2 after NX prevented write");
|
||||
|
||||
// NX allows set when key does not exist
|
||||
let set_nx2 = send_cmd(&mut s, &["SET", "s2", "v10", "NX"]).await;
|
||||
assert_contains(&set_nx2, "OK", "SET s2 v10 NX -> OK for new key");
|
||||
let g4 = send_cmd(&mut s, &["GET", "s2"]).await;
|
||||
assert_contains(&g4, "v10", "GET s2 is v10");
|
||||
|
||||
// XX requires existing key; with GET returns old value and sets new
|
||||
let set_xx = send_cmd(&mut s, &["SET", "s2", "v11", "XX", "GET"]).await;
|
||||
assert_contains(&set_xx, "v10", "SET s2 v11 XX GET returns previous v10");
|
||||
let g5 = send_cmd(&mut s, &["GET", "s2"]).await;
|
||||
assert_contains(&g5, "v11", "GET s2 is now v11");
|
||||
|
||||
// PX expiration path via SET options
|
||||
let set_px = send_cmd(&mut s, &["SET", "s3", "vpx", "PX", "500"]).await;
|
||||
assert_contains(&set_px, "OK", "SET s3 vpx PX 500 -> OK");
|
||||
let ttl_px1 = send_cmd(&mut s, &["TTL", "s3"]).await;
|
||||
assert!(
|
||||
ttl_px1.contains("0") || ttl_px1.contains("1"),
|
||||
"TTL s3 immediately after PX should be 1 or 0, got: {}",
|
||||
ttl_px1
|
||||
);
|
||||
sleep(Duration::from_millis(650)).await;
|
||||
let g6 = send_cmd(&mut s, &["GET", "s3"]).await;
|
||||
assert_contains(&g6, "$-1", "GET s3 after PX expiry -> Null");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_09_mget_mset_and_variadic_exists_del() {
|
||||
let (server, port) = start_test_server("mget_mset_variadic").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// MSET multiple keys
|
||||
let mset = send_cmd(&mut s, &["MSET", "k1", "v1", "k2", "v2", "k3", "v3"]).await;
|
||||
assert_contains(&mset, "OK", "MSET k1 v1 k2 v2 k3 v3 -> OK");
|
||||
|
||||
// MGET should return values and Null for missing
|
||||
let mget = send_cmd(&mut s, &["MGET", "k1", "k2", "nope", "k3"]).await;
|
||||
// Expect an array with 4 entries; verify payloads
|
||||
assert_contains(&mget, "v1", "MGET k1");
|
||||
assert_contains(&mget, "v2", "MGET k2");
|
||||
assert_contains(&mget, "v3", "MGET k3");
|
||||
assert_contains(&mget, "$-1", "MGET missing returns Null");
|
||||
|
||||
// EXISTS variadic: count how many exist
|
||||
let exists_multi = send_cmd(&mut s, &["EXISTS", "k1", "nope", "k3"]).await;
|
||||
// Server returns SimpleString numeric, e.g. +2
|
||||
assert_contains(&exists_multi, "2", "EXISTS k1 nope k3 -> 2");
|
||||
|
||||
// DEL variadic: delete multiple keys, return count deleted
|
||||
let del_multi = send_cmd(&mut s, &["DEL", "k1", "k3", "nope"]).await;
|
||||
assert_contains(&del_multi, "2", "DEL k1 k3 nope -> 2");
|
||||
|
||||
// Verify deletion
|
||||
let exists_after = send_cmd(&mut s, &["EXISTS", "k1", "k3"]).await;
|
||||
assert_contains(&exists_after, "0", "EXISTS k1 k3 after DEL -> 0");
|
||||
|
||||
// MGET after deletion should include Nulls for deleted keys
|
||||
let mget_after = send_cmd(&mut s, &["MGET", "k1", "k2", "k3"]).await;
|
||||
assert_contains(&mget_after, "$-1", "MGET k1 after DEL -> Null");
|
||||
assert_contains(&mget_after, "v2", "MGET k2 remains");
|
||||
assert_contains(&mget_after, "$-1", "MGET k3 after DEL -> Null");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_12_hash_incr() {
|
||||
let (server, port) = start_test_server("hash_incr").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// Integer increments
|
||||
let _ = send_cmd(&mut s, &["HSET", "hinc", "a", "1"]).await;
|
||||
let r1 = send_cmd(&mut s, &["HINCRBY", "hinc", "a", "2"]).await;
|
||||
assert_contains(&r1, "3", "HINCRBY hinc a 2 -> 3");
|
||||
|
||||
let r2 = send_cmd(&mut s, &["HINCRBY", "hinc", "a", "-1"]).await;
|
||||
assert_contains(&r2, "2", "HINCRBY hinc a -1 -> 2");
|
||||
|
||||
let r3 = send_cmd(&mut s, &["HINCRBY", "hinc", "b", "5"]).await;
|
||||
assert_contains(&r3, "5", "HINCRBY hinc b 5 -> 5");
|
||||
|
||||
// HINCRBY error on non-integer field
|
||||
let _ = send_cmd(&mut s, &["HSET", "hinc", "s", "x"]).await;
|
||||
let r_err = send_cmd(&mut s, &["HINCRBY", "hinc", "s", "1"]).await;
|
||||
assert_contains(&r_err, "ERR", "HINCRBY on non-integer field should ERR");
|
||||
|
||||
// Float increments
|
||||
let r4 = send_cmd(&mut s, &["HINCRBYFLOAT", "hinc", "f", "1.5"]).await;
|
||||
assert_contains(&r4, "1.5", "HINCRBYFLOAT hinc f 1.5 -> 1.5");
|
||||
|
||||
let r5 = send_cmd(&mut s, &["HINCRBYFLOAT", "hinc", "f", "2.5"]).await;
|
||||
// Could be "4", "4.0", or "4.000000", accept "4" substring
|
||||
assert_contains(&r5, "4", "HINCRBYFLOAT hinc f 2.5 -> 4");
|
||||
|
||||
// HINCRBYFLOAT error on non-float field
|
||||
let _ = send_cmd(&mut s, &["HSET", "hinc", "notf", "abc"]).await;
|
||||
let r6 = send_cmd(&mut s, &["HINCRBYFLOAT", "hinc", "notf", "1"]).await;
|
||||
assert_contains(&r6, "ERR", "HINCRBYFLOAT on non-float field should ERR");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_05b_brpop_suite() {
|
||||
let (server, port) = start_test_server("lists_brpop").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut a = connect(port).await;
|
||||
|
||||
// RPUSH some initial data, BRPOP should take from the right
|
||||
let _ = send_cmd(&mut a, &["RPUSH", "q:rjobs", "1", "2"]).await;
|
||||
let br_nonblock = send_cmd(&mut a, &["BRPOP", "q:rjobs", "0"]).await;
|
||||
// Should pop the rightmost element "2"
|
||||
assert_contains(&br_nonblock, "q:rjobs", "BRPOP returns key");
|
||||
assert_contains(&br_nonblock, "2", "BRPOP returns rightmost element");
|
||||
|
||||
// Now test blocking BRPOP: start blocked client, then RPUSH from another client
|
||||
let c1 = connect(port).await;
|
||||
let mut c2 = connect(port).await;
|
||||
|
||||
// Start BRPOP on c1
|
||||
let brpop_task = tokio::spawn(async move {
|
||||
let mut c1_local = c1;
|
||||
send_cmd(&mut c1_local, &["BRPOP", "q:blockr", "5"]).await
|
||||
});
|
||||
|
||||
// Give it time to register waiter
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Push from right to wake BRPOP
|
||||
let _ = send_cmd(&mut c2, &["RPUSH", "q:blockr", "X"]).await;
|
||||
|
||||
// Await BRPOP result
|
||||
let brpop_res = brpop_task.await.expect("BRPOP task join");
|
||||
assert_contains(&brpop_res, "q:blockr", "BRPOP returned key");
|
||||
assert_contains(&brpop_res, "X", "BRPOP returned element");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_13_dbsize() {
|
||||
let (server, port) = start_test_server("dbsize").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// Initially empty
|
||||
let n0 = send_cmd(&mut s, &["DBSIZE"]).await;
|
||||
assert_contains(&n0, "0", "DBSIZE initial should be 0");
|
||||
|
||||
// Add a string, a hash, and a list -> dbsize = 3
|
||||
let _ = send_cmd(&mut s, &["SET", "s", "v"]).await;
|
||||
let _ = send_cmd(&mut s, &["HSET", "h", "f", "v"]).await;
|
||||
let _ = send_cmd(&mut s, &["LPUSH", "l", "a", "b"]).await;
|
||||
|
||||
let n3 = send_cmd(&mut s, &["DBSIZE"]).await;
|
||||
assert_contains(&n3, "3", "DBSIZE after adding s,h,l should be 3");
|
||||
|
||||
// Expire the string and wait, dbsize should drop to 2
|
||||
let _ = send_cmd(&mut s, &["PEXPIRE", "s", "400"]).await;
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let n2 = send_cmd(&mut s, &["DBSIZE"]).await;
|
||||
assert_contains(&n2, "2", "DBSIZE after string expiry should be 2");
|
||||
|
||||
// Delete remaining keys and confirm 0
|
||||
let _ = send_cmd(&mut s, &["DEL", "h"]).await;
|
||||
let _ = send_cmd(&mut s, &["DEL", "l"]).await;
|
||||
|
||||
let n_final = send_cmd(&mut s, &["DBSIZE"]).await;
|
||||
assert_contains(&n_final, "0", "DBSIZE after deleting all keys should be 0");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_14_expireat_pexpireat() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let (server, port) = start_test_server("expireat_suite").await;
|
||||
spawn_listener(server, port).await;
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let mut s = connect(port).await;
|
||||
|
||||
// EXPIREAT: seconds since epoch
|
||||
let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
let _ = send_cmd(&mut s, &["SET", "exp:at:s", "v"]).await;
|
||||
let exat = send_cmd(&mut s, &["EXPIREAT", "exp:at:s", &format!("{}", now_secs + 1)]).await;
|
||||
assert_contains(&exat, "1", "EXPIREAT exp:at:s now+1s -> 1 (applied)");
|
||||
let ttl1 = send_cmd(&mut s, &["TTL", "exp:at:s"]).await;
|
||||
assert!(
|
||||
ttl1.contains("1") || ttl1.contains("0"),
|
||||
"TTL exp:at:s should be 1 or 0 shortly after EXPIREAT, got: {}",
|
||||
ttl1
|
||||
);
|
||||
sleep(Duration::from_millis(1200)).await;
|
||||
let exists_after_exat = send_cmd(&mut s, &["EXISTS", "exp:at:s"]).await;
|
||||
assert_contains(&exists_after_exat, "0", "EXISTS exp:at:s after EXPIREAT expiry -> 0");
|
||||
|
||||
// PEXPIREAT: milliseconds since epoch
|
||||
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as i64;
|
||||
let _ = send_cmd(&mut s, &["SET", "exp:at:ms", "v"]).await;
|
||||
let pexat = send_cmd(&mut s, &["PEXPIREAT", "exp:at:ms", &format!("{}", now_ms + 450)]).await;
|
||||
assert_contains(&pexat, "1", "PEXPIREAT exp:at:ms now+450ms -> 1 (applied)");
|
||||
let ttl2 = send_cmd(&mut s, &["TTL", "exp:at:ms"]).await;
|
||||
assert!(
|
||||
ttl2.contains("0") || ttl2.contains("1"),
|
||||
"TTL exp:at:ms should be 0..1 soon after PEXPIREAT, got: {}",
|
||||
ttl2
|
||||
);
|
||||
sleep(Duration::from_millis(600)).await;
|
||||
let exists_after_pexat = send_cmd(&mut s, &["EXISTS", "exp:at:ms"]).await;
|
||||
assert_contains(&exists_after_pexat, "0", "EXISTS exp:at:ms after PEXPIREAT expiry -> 0");
|
||||
}
|
Reference in New Issue
Block a user