fix: restore KvEntry Eq, make contains_key expiry-aware, fix clear_expired crashes#3
Conversation
…pired crashes Restore KvEntry's PartialEq/Eq derives (public-API regression on the generic-value release). Make contains_key expiry-aware via get_item so it agrees with get(), while keeping len()/is_empty() as documented O(1) physical counts; ensure_capacity_ignore_key now uses physical data.contains_key to keep capacity/overwrite semantics identical. Fix two clear_expired() crashes: guard the data.get() with if let Some to avoid unwrapping a deleted key's stale ExpEntry (panic), and remove via data.remove() instead of self.delete() to avoid re-entrant auto-clear recursion that overflowed the stack under the default config. Add regression tests for all three.
uzyn
left a comment
There was a problem hiding this comment.
Standalone Review — PR #3: restore KvEntry Eq, expiry-aware contains_key, fix clear_expired crashes
Reviewed against the PR description as scope. No PRD/sprint plan (standalone mode). I independently verified every claim, including reverting the fix to confirm the new tests reproduce the bugs.
Changes Overview
Four changes to the sparkv crate: (1) restore #[derive(PartialEq, Eq)] on KvEntry<V> — a public-API regression from the generic-value release; (2) make contains_key expiry-aware by delegating to get_item().is_some() (O(1), &self), with new Rustdoc on len/is_empty/contains_key documenting the physical-vs-logical distinction; (3) fix a .unwrap() panic in clear_expired on a deleted key's stale ExpEntry by guarding with if let Some; (4) fix a stack-overflow from re-entrant auto-clear by removing via self.data.remove instead of self.delete.
Scope Alignment
Fully met, no scope creep. Every checkbox in the PR's Requirements list is genuinely satisfied:
- Item 3:
KvEntryderivesDebug, Clone, PartialEq, Eqagain (src/kventry.rs:1);test_eqadded. - Item 4:
contains_keyis nowself.get_item(key).is_some()(src/lib.rs:71-73) — expiry-aware and O(1), consistent withget(). len()/is_empty()bodies unchanged, O(1) physical, with accurate Rustdoc. This is the explicit, defensible maintainer decision (matches Redis DBSIZE, Caffeine, Guava, moka, go-cache) — correct not to "fix."ensure_capacity_ignore_keycorrectly switched to physicalself.data.contains_key(key)(src/lib.rs:117), preserving overwrite/capacity semantics. Verified:test_set_should_fail_if_capacity_exceeded(overwrite-does-not-err path) andtest_clear_expired_with_overwritten_keystill pass.
The get_item (> now) vs is_expired (< now) boundary at the exact == instant is explicitly noted as out-of-scope. Agreed — it is pre-existing, cosmetic, and unreachable in practice.
Potential Bugs
None found. The two crash fixes are correct:
- Panic fix (
src/lib.rs:82-89):if let Some(kv_entry) = self.data.get(...)correctly handles the case where a key was deleted but its staleExpEntryremains on the heap (deleteintentionally does not pop the heap). Theself.expiries.pop()still runs unconditionally, so stale entries are drained either way. - Recursion fix (
src/lib.rs:87):self.data.removeinstead ofself.deletebreaks theclear_expired→delete→clear_expired_if_auto→clear_expiredcycle that overflowed the stack under the default config (auto_clear_expired = true).delete's only data-mutating effect wasself.data.remove, and the heap pop already happens inclear_expired, so cleanup semantics are preserved exactly.
Borrow-checker correctness (verified): the immutable borrow from self.data.get and the mutable borrow from self.data.remove do not conflict because exp_item is an owned clone (peek().cloned()), so &exp_item.key does not borrow self, and NLL ends the get borrow at the last use of kv_entry before remove. Compiles clean under clippy -D warnings.
Test Coverage
Strong. I reverted the fix to the original buggy code and confirmed the new tests actually catch the bugs (not happy-path theater):
test_clear_expired_does_not_panic_on_deleted_key→ FAILED on the buggy.unwrap()(panic), passes on the fix.test_clear_expired_does_not_recurse_under_default_config→ stack overflow / SIGABRT on the buggy recursion, passes on the fix.test_contains_key_is_expiry_awareexercises all three states: expired-but-unswept (agrees withget, still physically counted vialen()==2), live, and non-existent.test_eqcovers both equal (clone) and unequal (mutated value) paths.
Full suite: 28/28 pass, cargo clippy --all-targets -- -D warnings clean, cargo fmt --check clean.
Security Issues
None. No new inputs, dependencies, unsafe code, or data exposure. The changes strictly remove two crash vectors.
Code Quality
Clean. The inline comments at src/lib.rs:87 and :115-116 explain the two non-obvious decisions (why data.remove not delete; why physical contains_key for capacity) exactly where a future reader needs them. Rustdoc on the three accessors is accurate and links correctly.
Summary and Recommended Actions
- Overall verdict: Ready to merge
- Blockers: none
- Non-blockers: none
- Nice-to-haves: none
This is a tight, well-scoped fix. Both crash bugs are real and now provably covered by regression tests; the API-regression and expiry-awareness items are correctly resolved; the deliberate O(1)-len decision is sound and documented. Verification matches every claim in the PR description.
Recommended merge commit message:
fix: restore KvEntry Eq, make contains_key expiry-aware, fix clear_expired crashes
Restore KvEntry's PartialEq/Eq derives (public-API regression on the generic-value
release). Make contains_key expiry-aware via get_item so it agrees with get(), while
keeping len()/is_empty() as documented O(1) physical counts; ensure_capacity_ignore_key
uses physical data.contains_key to keep capacity/overwrite semantics identical.
Fix two clear_expired() crashes: guard data.get() with `if let Some` to avoid unwrapping
a deleted key's stale ExpEntry (panic), and remove via data.remove() instead of
self.delete() to avoid re-entrant auto-clear recursion that overflowed the stack under
the default config. Regression tests added for all three.
Summary
Address two code-review findings on the
SparKV<V>generic-value release, plus two crash bugs discovered while analyzing the expiry machinery.KvEntrylostPartialEq/Eq. Restore the derives (public-API regression). On a generic struct they generate bounded impls, soV = Stringregains them and anyV: Eqgets them conditionally.contains_key()/len()count expired-but-unswept entries. An exact expiry-awarelen()cannot be O(1) under lazy expiry, socontains_keyis made expiry-aware (a free, exact O(1)&selffix) whilelen()/is_empty()stay O(1) physical counts and are documented.clear_expired(). A panic on a deleted key's staleExpEntry, and a stack overflow under the default config from re-entrant auto-clear.Requirements
KvEntryderivesDebug, Clone, PartialEq, Eqagain +test_eq.contains_key(key)returnsfalsefor an expired-but-unswept entry,truefor a live one; consistent withget().len()/is_empty()remain O(1) physical counts (unchanged behavior), with Rustdoc explaining the physical-vs-logical distinction and pointing toget()/clear_expired().ensure_capacity_ignore_keyuses the physicalself.data.contains_key(key)(not the now expiry-aware publiccontains_key), soset()overwrite/capacity behavior and existing capacity tests are preserved.clear_expired()no longer panics on a stale/deleted key (guard thegetwithif let Some(..)).clear_expired()no longer recurses/overflows under the default config (remove viaself.data.remove(&key)instead ofself.delete(&key)).contains_key; the delete-then-expire panic path; the default-config recursion path.cargo fmt --check,cargo test,cargo clippy -- -D warningsall pass.Out of scope
len()/is_empty()exact/expiry-aware (would require O(n) scan or a breaking&mut self— explicitly declined in favor of O(1)).expiriesheap design,auto_clear_expired.get_item(expired_at > now) andExpEntry::is_expired(expired_at < now) at the exact==instant — noted, not fixed.Technical approach
src/kventry.rs—#[derive(Debug, Clone, PartialEq, Eq)];test_eq.src/lib.rs):contains_key(&self, key)->self.get_item(key).is_some()(O(1), expiry-aware).ensure_capacity_ignore_keyuses physicalself.data.contains_key(key)to keep capacity/overwrite semantics identical.len/is_emptybodies unchanged; Rustdoc added to all three accessors.src/lib.rsclear_expired): within theis_expired()branch, theself.data.get(..).unwrap()+self.delete(..)is replaced with a guarded, non-recursive form —if let Some(kv_entry) = self.data.get(..)plusself.data.remove(..)instead ofself.delete(..).delete()only mutateddata(the heap pop already happens here), so cleanup semantics are preserved while theunwrappanic and the re-entrant auto-clear recursion are both removed.Test plan
test_eq(Item 3).test_contains_key_is_expiry_aware:contains_keyisfalsefor an expired-but-unswept key andtruefor a live one (withauto_clear_expired = false), agreeing withget()while the entry stays physically counted.test_clear_expired_does_not_panic_on_deleted_key: delete-then-expire-then-clear_expired()does not panic and cleans up correctly.test_clear_expired_does_not_recurse_under_default_config: default-configset-> expire ->setagain does not overflow; expired entry cleared, live one remains.cargo fmt --check,cargo clippy --all-targets -- -D warningsclean. All three new tests were confirmed to fail (reproduce the bugs) against the unfixed code before the fixes were applied.Notes
len()is provably not O(1) under lazy expiry;len()/is_empty()stay O(1) physical + documented (matches Redis/Caffeine/Guava/moka/go-cache),contains_keyis the free exact O(1) fix.clear_expired()crash bugs (unwrap panic; default-config stack overflow) were discovered during analysis and reproduced before fixing.