-
Notifications
You must be signed in to change notification settings - Fork 25
feat: add mget #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add mget #162
Conversation
WalkthroughAdds nine Redis string commands across cmd and storage: APPEND, STRLEN, GETRANGE, SETRANGE, SETEX, PSETEX, SETNX, GETSET, MGET. Command handlers, storage implementations, multi-instance routing, and comprehensive unit tests were added and wired into the command table. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant CmdHandler
participant StorageLayer
participant Instance
participant RocksDB
Client->>CmdHandler: send command (e.g., APPEND key value)
CmdHandler->>CmdHandler: parse argv, set client key context
CmdHandler->>StorageLayer: call operation (append/getrange/...)
alt multi-key (MGET)
StorageLayer->>StorageLayer: group keys by slot/instance
StorageLayer->>Instance: delegate per-instance mget(keys_subset)
else single-key
StorageLayer->>Instance: delegate operation for key's instance
end
Instance->>Instance: check expiration & type, apply mutation/read
Instance->>RocksDB: read/write persisted value/metadata
RocksDB-->>Instance: db result
Instance-->>StorageLayer: Result<T>
StorageLayer-->>CmdHandler: Result mapped to RespData
CmdHandler-->>Client: RESP reply (Integer/Bulk/Array/Error)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Rationale: Many new command files follow a consistent pattern (reducing per-file cognitive load), but the storage layer changes are dense (expiry semantics, per-key locking, multi-instance routing) and the test suite is large and varied, increasing review surface. Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
🧰 Additional context used🧬 Code graph analysis (2)src/storage/src/storage_impl.rs (3)
src/storage/src/redis_strings.rs (1)
🔇 Additional comments (12)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
🧹 Nitpick comments (13)
src/cmd/src/table.rs (1)
71-72: Heads-up: MGET cross-slot behavior.Current storage routing uses only the first key’s instance (see storage_impl comment). That’s fine for single-instance, but in a sharded/clustered setup MGET should fan out and preserve key order. Consider adding a TODO here linking to a tracking issue for multi-instance fan‑out.
src/cmd/src/mget.rs (1)
70-95: Minor: preallocate response capacity.Tiny perf tweak: reserve capacity before mapping results.
- Ok(values) => { - // Convert Option<String> to RespData - let resp_array: Vec<RespData> = values + Ok(values) => { + // Convert Option<String> to RespData + let mut resp_array: Vec<RespData> = Vec::with_capacity(values.len()); + resp_array.extend( + values .into_iter() - .map(|opt_val| match opt_val { - Some(val) => RespData::BulkString(Some(val.into_bytes().into())), - None => RespData::BulkString(None), - }) - .collect(); + .map(|opt_val| match opt_val { + Some(val) => RespData::BulkString(Some(val.into_bytes().into())), + None => RespData::BulkString(None), + }) + ); client.set_reply(RespData::Array(Some(resp_array)));src/cmd/src/append.rs (2)
68-78: Forward all RedisErr messages (consistency with other commands)Currently only WRONGTYPE is forwarded; others are wrapped. Mirror setex/psetex/getset behavior.
- Err(e) => match e { - storage::error::Error::RedisErr { ref message, .. } - if message.starts_with("WRONGTYPE") => - { - // RedisErr already contains the formatted message - client.set_reply(RespData::Error(message.clone().into())); - } - _ => { - client.set_reply(RespData::Error(format!("ERR {e}").into())); - } - }, + Err(e) => match e { + storage::error::Error::RedisErr { ref message, .. } => { + client.set_reply(RespData::Error(message.clone().into())); + } + _ => client.set_reply(RespData::Error(format!("ERR {e}").into())), + },
52-55: Minor: avoid repeated argv cloningCache argv once per function to reduce small allocations.
Also applies to: 59-63
src/cmd/src/getset.rs (1)
72-81: Ensure GETSET clears TTL (Redis semantics)GETSET should overwrite the key and clear any existing expire. Please confirm storage.getset removes TTL; if not, adjust.
Reference: “The timeout will only be cleared by commands that delete or overwrite the contents of the key, including … GETSET …”. (redis.io)
src/cmd/src/setrange.rs (1)
81-86: Optional: cap resulting length before storage callIf storage enforces i32::MAX result length, consider pre-checking offset + value.len() to fail fast and return a user-friendly ERR.
src/storage/tests/redis_string_test.rs (3)
688-739: Add WRONGTYPE coverage for APPENDThis test only exercises string keys. Please add a case where a non-string key (e.g., created via hset) is passed to append and assert WRONGTYPE.
2158-2240: Add GETSET TTL-clearing testPer Redis, GETSET overwrites and clears any existing expire. Consider a test: SETEX key 1 "v"; then GETSET key "w"; sleep 2s; assert key still exists ("w").
Reference: “The timeout will only be cleared by commands that delete or overwrite the contents of the key, including … GETSET …”. (redis.io)
Also applies to: 2242-2287
741-786: Perf nit: avoid UTF-8 .chars() on large ASCII payloadsUse bytes to validate content and length to reduce overhead.
-assert_eq!(final_value.len(), 10 * 1024 * 1024); -assert!(final_value.chars().all(|c| c == 'x')); +assert_eq!(final_value.as_bytes().len(), 10 * 1024 * 1024); +assert!(final_value.as_bytes().iter().all(|&b| b == b'x'));Also applies to: 773-779
src/storage/src/storage_impl.rs (1)
63-109: Per-key delegations LGTM; consider a small DRY helperStraightforward, consistent slot routing. Optionally extract a helper to reduce repetition, e.g., compute instance_id once and invoke a closure.
src/storage/src/redis_strings.rs (3)
486-524: Clippy: replace redundant pattern match with is_err() in MGETUse is_err() to satisfy lint and simplify logic.
- // Check type - if not string type, return None (like Redis does) - if let Err(_) = self.check_type(val.as_slice(), DataType::String) { + // Check type - if not string type, return None (like Redis does) + if self.check_type(val.as_slice(), DataType::String).is_err() { results.push(None); continue; }Also applies to: 501-505
124-127: Minor cleanup: prefer unwrap_or_default() for VecsReplace unwrap_or_else(Vec::new) with unwrap_or_default for brevity.
- .context(RocksSnafu)? - .unwrap_or_else(Vec::new); + .context(RocksSnafu)? + .unwrap_or_default();Also applies to: 179-182, 285-288, 380-383, 1064-1067
413-419: Optional: unify error text prefixElsewhere you use “ERR …”; here it’s “string exceeds maximum allowed size”. Consider “ERR string exceeds maximum allowed size” for consistency.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
src/cmd/src/append.rs(1 hunks)src/cmd/src/getrange.rs(1 hunks)src/cmd/src/getset.rs(1 hunks)src/cmd/src/lib.rs(1 hunks)src/cmd/src/mget.rs(1 hunks)src/cmd/src/psetex.rs(1 hunks)src/cmd/src/setex.rs(1 hunks)src/cmd/src/setnx.rs(1 hunks)src/cmd/src/setrange.rs(1 hunks)src/cmd/src/strlen.rs(1 hunks)src/cmd/src/table.rs(1 hunks)src/storage/src/redis_strings.rs(5 hunks)src/storage/src/storage_impl.rs(1 hunks)src/storage/tests/redis_string_test.rs(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (14)
src/cmd/src/getrange.rs (2)
src/cmd/src/lib.rs (4)
do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/psetex.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/getset.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/setrange.rs (2)
src/cmd/src/lib.rs (4)
do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/lib.rs (2)
src/storage/src/redis_strings.rs (11)
append(369-437)get(440-469)getrange(172-230)getset(802-851)mget(486-524)psetex(678-718)set(568-591)setex(612-651)setnx(736-779)setrange(258-365)strlen(117-147)src/storage/src/storage_impl.rs (11)
append(63-67)get(37-41)getrange(75-79)getset(105-109)mget(43-55)psetex(93-97)set(31-35)setex(87-91)setnx(99-103)setrange(81-85)strlen(69-73)
src/cmd/src/setnx.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/table.rs (2)
src/storage/src/redis_strings.rs (11)
append(369-437)set(568-591)get(440-469)strlen(117-147)getrange(172-230)setrange(258-365)setex(612-651)psetex(678-718)setnx(736-779)getset(802-851)mget(486-524)src/storage/src/storage_impl.rs (11)
append(63-67)set(31-35)get(37-41)strlen(69-73)getrange(75-79)setrange(81-85)setex(87-91)psetex(93-97)setnx(99-103)getset(105-109)mget(43-55)
src/storage/src/storage_impl.rs (3)
src/storage/src/redis_strings.rs (10)
mget(486-524)incr_decr(1052-1118)append(369-437)strlen(117-147)getrange(172-230)setrange(258-365)setex(612-651)psetex(678-718)setnx(736-779)getset(802-851)src/storage/src/storage.rs (2)
new(78-81)new(112-127)src/storage/src/slot_indexer.rs (2)
new(40-46)key_to_slot_id(63-65)
src/storage/tests/redis_string_test.rs (4)
src/storage/src/util.rs (1)
unique_test_db_path(83-88)src/storage/src/redis.rs (2)
new(96-130)drop(428-436)src/storage/src/redis_strings.rs (1)
get(440-469)src/storage/src/storage_impl.rs (1)
get(37-41)
src/cmd/src/setex.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/strlen.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/cmd/src/mget.rs (2)
src/cmd/src/lib.rs (5)
new(202-213)do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
src/storage/src/redis_strings.rs (1)
src/storage/src/storage_impl.rs (9)
strlen(69-73)getrange(75-79)setrange(81-85)append(63-67)mget(43-55)setex(87-91)psetex(93-97)setnx(99-103)getset(105-109)
src/cmd/src/append.rs (2)
src/cmd/src/lib.rs (4)
do_initial(109-109)do_initial(237-239)do_cmd(111-111)do_cmd(241-257)src/client/src/lib.rs (1)
argv(77-80)
🪛 GitHub Actions: ci
src/storage/src/redis_strings.rs
[error] 502-502: redundant pattern matching, consider using is_err()
🪛 GitHub Check: cargo clippy (ubuntu-latest)
src/storage/src/redis_strings.rs
[failure] 502-502:
redundant pattern matching, consider using is_err()
🔇 Additional comments (14)
src/cmd/src/lib.rs (1)
18-33: Module declarations look good.New modules align with registrations in table.rs and follow existing patterns. One nit: visibility is mixed (older modules like
get/setarepub, new ones private). Confirm no external crates rely on re-exporting these command modules.src/cmd/src/table.rs (1)
55-72: Registrations LGTM.All new string commands are properly added to the command table and consistent with their module names.
src/cmd/src/mget.rs (2)
32-44: Command meta is correct.Name/arity/flags/ACL match Redis semantics for MGET.
60-67: Routing via the first key is appropriate here.This aligns with current storage routing; safe given
arity: -2and prior arg checks.src/cmd/src/strlen.rs (1)
32-43: Command meta and wiring look correct.Arity/flags/ACL are accurate; good use of
do_initialto set routing key.src/cmd/src/getrange.rs (2)
32-43: Command meta is accurate.Name, arity 4, and ACL/flags align with GETRANGE.
58-101: Argument parsing and error handling LGTM.Clean integer parsing with correct Redis-style error text; result mapping matches storage semantics.
src/cmd/src/psetex.rs (1)
90-94: TTL overflow guard LGTMThe i64::MAX/1000 check prevents ms→µs overflow and aligns with storage-level safety.
src/cmd/src/setnx.rs (1)
75-78: Return mapping LGTMInteger 1/0 matches Redis semantics for SETNX.
src/cmd/src/setrange.rs (1)
73-77: Pre-validation LGTMOffset range check with ERR prefix matches tests and avoids needless storage calls.
src/storage/tests/redis_string_test.rs (1)
2502-2543: MGET wrong-type semantics LGTMExpecting None for wrong-type keys aligns with Redis docs: “For every key that does not hold a string value or does not exist, nil is returned… the operation never fails.”
Reference: Redis MGET. (redis.io)
src/cmd/src/append.rs (1)
51-56: Remove proposed bounds checks—framework validates arity before do_initialThe Cmd trait's
execute()method validates argument count viacheck_arg()(lib.rs:120-142) before callingdo_initial(). All commands rely on this framework-level guarantee. The proposed bounds checks in the diff are redundant and inconsistent with the codebase pattern. Only variable-arity commands like mget need internal guards.Likely an incorrect or invalid review comment.
src/storage/src/redis_strings.rs (2)
453-459: Behavior change: GET now errors on expired keys — ensure command layer maps to nilReturning KeyNotFoundSnafu for stale keys is fine, but GET should reply with nil (not an error) to clients. Please confirm the cmd layer converts this specific error into a Null Bulk.
If needed, I can add/adjust a test in cmd GET to assert expired → nil.
371-437: LGTM for STRLEN/GETRANGE/APPEND/SETEX/PSETEX/SETNX/GETSET
- Correct type/expiry checks and per‑key locking where needed.
- TTL overflow checks look good.
Also applies to: 612-651, 679-718, 736-779, 802-851, 118-147, 173-230
| fn do_initial(&self, client: &Client) -> bool { | ||
| let argv = client.argv(); | ||
| let key = argv[1].clone(); | ||
| client.set_key(&key); | ||
| true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Guard argv length to avoid panics
Add length checks before indexing argv[1]/[2].
fn do_initial(&self, client: &Client) -> bool {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 3 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return false;
+ }
let key = argv[1].clone();
client.set_key(&key);
true
}
fn do_cmd(&self, client: &Client, storage: Arc<Storage>) {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 3 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return;
+ }
let key = client.key();
let value = &argv[2];Also applies to: 67-71
🤖 Prompt for AI Agents
In src/cmd/src/getset.rs around lines 60-65 (and also apply the same change to
lines 67-71), the code indexes argv[1] (and argv[2] elsewhere) without checking
argv.len(), which can panic; add explicit length guards (e.g., if argv.len() <=
1 { handle error/return false } and for places needing two args check <=2)
before indexing, return false or send an appropriate client error when the check
fails, and only then safely access argv[1]/argv[2] (retain existing
client.set_key/use logic after the guard).
| fn do_initial(&self, client: &Client) -> bool { | ||
| let argv = client.argv(); | ||
| let key = argv[1].clone(); | ||
| client.set_key(&key); | ||
| true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add argv length guards to prevent OOB
Indexing argv[1]/[2]/[3] without checking can panic if invoked incorrectly.
fn do_initial(&self, client: &Client) -> bool {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return false;
+ }
let key = argv[1].clone();
client.set_key(&key);
true
}
fn do_cmd(&self, client: &Client, storage: Arc<Storage>) {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return;
+ }
let key = client.key();
@@
- let value = &argv[3];
+ let value = &argv[3];Also applies to: 69-76, 96-103
🤖 Prompt for AI Agents
In src/cmd/src/psetex.rs around lines 62-67 (and similarly at 69-76 and 96-103),
the code indexes argv[1]/[2]/[3] without checking argv.len(), which can panic;
add explicit guards validating argv.len() before accessing those indices
(returning an error or false and logging a helpful message when arguments are
missing), and use safe access (e.g., match or get()) to extract values after the
length check so all argument accesses are bounds-checked and the function fails
gracefully instead of panicking.
| fn do_initial(&self, client: &Client) -> bool { | ||
| let argv = client.argv(); | ||
| let key = argv[1].clone(); | ||
| client.set_key(&key); | ||
| true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add argv guards and seconds overflow check
Prevent panics and align overflow safety with PSETEX.
fn do_initial(&self, client: &Client) -> bool {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return false;
+ }
let key = argv[1].clone();
client.set_key(&key);
true
}
fn do_cmd(&self, client: &Client, storage: Arc<Storage>) {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return;
+ }
let key = client.key();
@@
- // Validate seconds - must be positive
+ // Validate seconds - must be positive
if seconds <= 0 {
client.set_reply(RespData::Error("ERR invalid expire time in setex".into()));
return;
}
+ // Prevent seconds→microseconds overflow (seconds * 1_000_000)
+ if seconds > i64::MAX / 1_000_000 {
+ client.set_reply(RespData::Error("ERR invalid expire time in setex".into()));
+ return;
+ }Also applies to: 58-66, 79-86
| fn do_initial(&self, client: &Client) -> bool { | ||
| let argv = client.argv(); | ||
| let key = argv[1].clone(); | ||
| client.set_key(&key); | ||
| true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add argv guards to avoid panics
Check argv length before indexing.
fn do_initial(&self, client: &Client) -> bool {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 3 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return false;
+ }
let key = argv[1].clone();
client.set_key(&key);
true
}
fn do_cmd(&self, client: &Client, storage: Arc<Storage>) {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 3 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return;
+ }
let key = client.key();
let value = &argv[2];Also applies to: 67-71
🤖 Prompt for AI Agents
In src/cmd/src/setnx.rs around lines 60 to 65 (and similarly for 67 to 71), the
code indexes argv[1] without checking argv length which can panic; replace
direct indexing with a length check or use argv.get(1) to safely obtain the
argument, return an error/false or handle the missing-argument case (e.g., log
client error and return false) and only call client.set_key(&key) when the key
exists.
| fn do_initial(&self, client: &Client) -> bool { | ||
| let argv = client.argv(); | ||
| let key = argv[1].clone(); | ||
| client.set_key(&key); | ||
| true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Guard argv length to avoid panics
Add argv length check before indexing.
fn do_initial(&self, client: &Client) -> bool {
- let argv = client.argv();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return false;
+ }
let key = argv[1].clone();
client.set_key(&key);
true
}
fn do_cmd(&self, client: &Client, storage: Arc<Storage>) {
- let key = client.key();
- let argv = client.argv();
+ let key = client.key();
+ let argv = client.argv();
+ if argv.len() != 4 {
+ client.set_reply(RespData::Error("ERR wrong number of arguments for command".into()));
+ return;
+ }Also applies to: 58-61
🤖 Prompt for AI Agents
In src/cmd/src/setrange.rs around lines 51-56 (and similarly 58-61), the code
indexes argv without validating its length which can panic; add explicit checks
on client.argv().len() before accessing argv[1] (ensure len() >= 2) and before
later indexes (adjust required minimum for those uses), and if the check fails
return false or an appropriate error path instead of indexing into argv; perform
the length check once per code path (or use pattern matching like
get(1).ok_or(...) semantics) to avoid panics and keep behavior consistent.
| fn do_cmd(&self, client: &Client, storage: Arc<Storage>) { | ||
| let key = client.key(); | ||
|
|
||
| let result = storage.strlen(&key); | ||
|
|
||
| match result { | ||
| Ok(len) => { | ||
| client.set_reply(RespData::Integer(len as i64)); | ||
| } | ||
| Err(e) => match e { | ||
| storage::error::Error::RedisErr { ref message, .. } | ||
| if message.starts_with("WRONGTYPE") => | ||
| { | ||
| // RedisErr already contains the formatted message | ||
| client.set_reply(RespData::Error(message.clone().into())); | ||
| } | ||
| _ => { | ||
| client.set_reply(RespData::Error(format!("ERR {e}").into())); | ||
| } | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential overflow from storage layer return type (may yield negative STRLEN).
storage::redis_strings::strlen computes user_value.len() as i32 without bounds check. Since plain SET paths don’t cap value size, values > i32::MAX bytes could produce a negative length here. Either cap string sizes on write (like append/setrange already do) or return i64 from storage for strlen.
Proposed storage-side fix (option A: enforce write-time cap in set to keep len ≤ i32::MAX):
--- a/src/storage/src/redis_strings.rs
+++ b/src/storage/src/redis_strings.rs
@@ pub fn set(&self, key: &[u8], value: &[u8]) -> Result<()> {
- let string_key = BaseKey::new(key);
+ // Enforce max size consistent with append/setrange
+ if value.len() > i32::MAX as usize {
+ return Err(RedisErr {
+ message: "ERR string exceeds maximum allowed size".to_string(),
+ location: Default::default(),
+ });
+ }
+ let string_key = BaseKey::new(key);Or (option B: make strlen return i64 safely):
--- a/src/storage/src/redis_strings.rs
+++ b/src/storage/src/redis_strings.rs
- pub fn strlen(&self, key: &[u8]) -> Result<i32> {
+ pub fn strlen(&self, key: &[u8]) -> Result<i64> {
@@
- Ok(user_value.len() as i32)
+ Ok(user_value.len() as i64)Note: Option B requires propagating the i64 return up through storage_impl and the command (reply already supports 64-bit integers).
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/cmd/src/strlen.rs around lines 58–79 the code casts the storage strlen
result into i64 after storage currently returns an i32-sized length, which can
overflow/produce negative values for values > i32::MAX; either (A) enforce a
write-time cap so stored values never exceed i32::MAX (modify
set/append/setrange to reject or truncate writes that would exceed i32::MAX and
add tests), or (B — preferred) change the storage API to return i64: update
storage::redis_strings::strlen to compute and return an i64, update the
storage_impl trait/signature and all callers to accept i64, remove any narrowing
casts in this command and pass the returned i64 directly to
client.set_reply(RespData::Integer(len)), and add unit tests to cover very large
values and propagation.
| pub fn setrange(&self, key: &[u8], offset: i64, value: &[u8]) -> Result<i32> { | ||
| // Validate offset early to avoid unnecessary database operations | ||
| if offset < 0 { | ||
| return Err(RedisErr { | ||
| message: "ERR offset is out of range".to_string(), | ||
| location: Default::default(), | ||
| }); | ||
| } | ||
|
|
||
| // Check for offset upper bound to prevent potential overflow | ||
| if offset > i32::MAX as i64 { | ||
| return Err(RedisErr { | ||
| message: "ERR offset is out of range".to_string(), | ||
| location: Default::default(), | ||
| }); | ||
| } | ||
|
|
||
| let db = self.db.as_ref().context(OptionNoneSnafu { | ||
| message: "db is not initialized".to_string(), | ||
| })?; | ||
|
|
||
| // Get lock for the key | ||
| let key_str = String::from_utf8_lossy(key).to_string(); | ||
| let _lock = ScopeRecordLock::new(self.lock_mgr.as_ref(), &key_str); | ||
|
|
||
| let string_key = BaseKey::new(key); | ||
| let encode_value = db | ||
| .get_opt(&string_key.encode()?, &self.read_options) | ||
| .context(RocksSnafu)? | ||
| .unwrap_or_else(Vec::new); | ||
|
|
||
| let mut existing_value = Vec::new(); | ||
| let mut ctime: u64 = Utc::now().timestamp_micros() as u64; | ||
| let mut etime: u64 = 0; | ||
|
|
||
| if !encode_value.is_empty() { | ||
| // Check type first to match Redis compatibility | ||
| self.check_type(encode_value.as_slice(), DataType::String)?; | ||
|
|
||
| let decode_value = ParsedStringsValue::new(&encode_value[..])?; | ||
|
|
||
| // If key is not expired, use existing value | ||
| if !decode_value.is_stale() { | ||
| existing_value = decode_value.user_value().to_vec(); | ||
| ctime = decode_value.ctime(); | ||
| etime = decode_value.etime(); | ||
| } | ||
| // If expired, treat as empty string (existing_value remains empty) | ||
| } | ||
|
|
||
| // Early return optimization: if value is empty and offset is within bounds | ||
| if value.is_empty() { | ||
| let current_len = existing_value.len() as i32; | ||
| // If offset is within current string, no modification needed | ||
| if offset <= current_len as i64 { | ||
| return Ok(current_len); | ||
| } | ||
| // If offset is beyond current string, we need to pad | ||
| } | ||
|
|
||
| let offset_usize = offset as usize; | ||
| let current_len = existing_value.len(); | ||
|
|
||
| // Calculate required length | ||
| let required_len = offset_usize + value.len(); | ||
|
|
||
| // Check for string length overflow | ||
| if required_len > i32::MAX as usize { | ||
| return Err(RedisErr { | ||
| message: "ERR string exceeds maximum allowed size".to_string(), | ||
| location: Default::default(), | ||
| }); | ||
| } | ||
|
|
||
| let mut new_value = existing_value; | ||
|
|
||
| // If offset is beyond current length, pad with zero bytes | ||
| if offset_usize > current_len { | ||
| new_value.resize(offset_usize, 0); | ||
| } | ||
|
|
||
| // If new value extends beyond current length, extend the vector | ||
| if required_len > new_value.len() { | ||
| new_value.resize(required_len, 0); | ||
| } | ||
|
|
||
| // Overwrite the range with the new value | ||
| new_value[offset_usize..offset_usize + value.len()].copy_from_slice(value); | ||
|
|
||
| let new_len = new_value.len(); | ||
|
|
||
| // Set new value with metadata | ||
| let mut string_value = StringValue::new(new_value); | ||
| string_value.set_ctime(ctime); | ||
| string_value.set_etime(etime); | ||
|
|
||
| let cf = self | ||
| .get_cf_handle(ColumnFamilyIndex::MetaCF) | ||
| .context(OptionNoneSnafu { | ||
| message: "cf is not initialized".to_string(), | ||
| })?; | ||
| let mut batch = rocksdb::WriteBatch::default(); | ||
| batch.put_cf(&cf, string_key.encode()?, string_value.encode()); | ||
| db.write_opt(batch, &self.write_options) | ||
| .context(RocksSnafu)?; | ||
|
|
||
| Ok(new_len as i32) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SETRANGE with empty value incorrectly grows the string with zero-bytes
Redis does not extend the string when value is empty; it performs no write and returns current length even if offset > current length. Current code pads with zeros and changes length.
Fix by unconditionally early‑returning when value.is_empty():
- // Early return optimization: if value is empty and offset is within bounds
- if value.is_empty() {
- let current_len = existing_value.len() as i32;
- // If offset is within current string, no modification needed
- if offset <= current_len as i64 {
- return Ok(current_len);
- }
- // If offset is beyond current string, we need to pad
- }
+ // Redis semantics: empty value must not modify the string (no growth/padding)
+ if value.is_empty() {
+ return Ok(existing_value.len() as i32);
+ }🤖 Prompt for AI Agents
In src/storage/src/redis_strings.rs around lines 258 to 365, the handler for
SETRANGE currently pads and writes when value.is_empty(), which incorrectly
grows the string; change the logic to unconditionally early-return the current
length (existing_value.len() as i32) when value.is_empty() so no padding,
metadata changes, or RocksDB writes occur (i.e., perform the empty-value check
after existing_value is determined and before any resizing, batch creation, or
db.write calls).
Summary by CodeRabbit
New Features
Tests