Skip to main content

kevy_store/
string_set.rs

1//! `Store` SET-family write path: encoding pick (`Int` / `ArcBulk` /
2//! inline `Str`), NX/XX guards, the single-probe `maxmemory == 0`
3//! fast path, and the bio-drop hand-off of displaced values.
4//! Split out of `string.rs` (GET family + INCR) to keep both under the
5//! 500-LOC house cap.
6
7#[cfg(not(feature = "std"))]
8use crate::nostd_prelude::*;
9use crate::util::parse_canonical_i64;
10use crate::value::{BULK_THRESHOLD, SmallBytes, Value};
11use crate::{Entry, Store, deadline_at, now_ns};
12use alloc::sync::Arc;
13use core::time::Duration;
14
15/// L2 + L1: pick the optimal encoding for `bytes` at SET time:
16/// 1. Canonical i64 ASCII → `Value::Int(n)` (smallest + INCR fast path)
17/// 2. > [`BULK_THRESHOLD`] bytes → `Value::ArcBulk(Arc<[u8]>)` (lets the
18///    > reactor reply path borrow the bytes for `writev` zero-copy GET)
19/// 3. Else → `Value::Str(SmallBytes::from_slice(bytes))` (inline-cache-
20///    line storage, beats Arc indirection for small values)
21#[inline]
22fn pick_value_for_set(bytes: &[u8]) -> Value {
23    if let Some(n) = parse_canonical_i64(bytes) {
24        return Value::Int(n);
25    }
26    if bytes.len() > BULK_THRESHOLD {
27        return Value::ArcBulk(Arc::new(Box::<[u8]>::from(bytes)));
28    }
29    Value::Str(SmallBytes::from_slice(bytes))
30}
31
32#[inline]
33pub(crate) fn pick_value_for_set_owned(bytes: Vec<u8>) -> Value {
34    if let Some(n) = parse_canonical_i64(&bytes) {
35        return Value::Int(n);
36    }
37    if bytes.len() > BULK_THRESHOLD {
38        // `Arc::new(box)` is zero-copy when `len ==
39        // capacity` (shrink-to-fit no-ops). See `Value::ArcBulk` doc.
40        return Value::ArcBulk(Arc::new(bytes.into_boxed_slice()));
41    }
42    Value::Str(SmallBytes::from_vec(bytes))
43}
44
45impl Store {
46    /// `SET` — overwrites any existing value/type. NX/XX guards; clears TTL.
47    /// Takes an owned `Vec` so a >22 B value's allocation is adopted as-is
48    /// (no copy). For callers holding a borrowed slice, prefer
49    /// [`Self::set_slice`] — it skips the `to_vec` entirely for values that
50    /// inline.
51    pub fn set(
52        &mut self,
53        key: &[u8],
54        value: Vec<u8>,
55        expire: Option<Duration>,
56        nx: bool,
57        xx: bool,
58    ) -> bool {
59        self.set_value(key, pick_value_for_set_owned(value), expire, nx, xx)
60    }
61
62    /// [`Self::set`] for a borrowed value. Values ≤ 22 B store inline in the
63    /// entry — zero allocator traffic, where `set(key, value.to_vec(), …)`
64    /// paid a malloc for the `Vec` and a free when the inline copy dropped
65    /// it (the dominant overwrite-SET pattern). Larger values pay the same
66    /// single allocation either way.
67    pub fn set_slice(
68        &mut self,
69        key: &[u8],
70        value: &[u8],
71        expire: Option<Duration>,
72        nx: bool,
73        xx: bool,
74    ) -> bool {
75        self.set_value(key, pick_value_for_set(value), expire, nx, xx)
76    }
77
78    fn set_value(
79        &mut self,
80        key: &[u8],
81        new_value: Value,
82        expire: Option<Duration>,
83        nx: bool,
84        xx: bool,
85    ) -> bool {
86        // Single-probe overwrite-SET fast path for default
87        // `maxmemory == 0` (the bench and production-common case). Goes
88        // through `kevy_map::RawEntryMut` so the
89        // Occupied arm mutates the entry in place and returns owned
90        // (delta, ttl_delta) — no escaping reference, no second probe.
91        // Overwrite path drops from 2 probes (live_entry_mut: get+get_mut)
92        // to 1 probe. New-key + expired-removed paths still pay the
93        // insert_entry probe (same as before).
94        if !self.clock_on() {
95            return self.set_value_no_evict(key, new_value, expire, nx, xx);
96        }
97        self.set_value_evict(key, new_value, expire, nx, xx)
98    }
99
100    /// Eviction path (maxmemory > 0) of [`Self::set_value`]: keeps the
101    /// 2-probe shape so `live_entry_mut`'s touch_on_access bookkeeping runs.
102    fn set_value_evict(
103        &mut self,
104        key: &[u8],
105        new_value: Value,
106        expire: Option<Duration>,
107        nx: bool,
108        xx: bool,
109    ) -> bool {
110        let expire_at = expire.map(|d| deadline_at(now_ns(), d));
111        let key_heap = crate::key_heap_bytes_for(key);
112        #[allow(clippy::single_match_else)]
113        // Phase 1: in-place overwrite or remember we need to insert.
114        // The old value (if any) is taken via `mem::replace` so the bio
115        // hand-off in phase 2 happens AFTER `self.live_entry_mut`'s
116        // borrow on `self.map` is released — without splitting the
117        // borrow we couldn't call `self.maybe_offload_drop`.
118        let (outcome, old_value) = match self.live_entry_mut(key) {
119            Some(e) => {
120                if nx {
121                    return false;
122                }
123                let (delta, ttl_delta, old) = overwrite_in_place(e, new_value, expire_at, key_heap);
124                (Ok((delta, ttl_delta)), Some(old))
125            }
126            None => {
127                if xx {
128                    return false;
129                }
130                (Err(Entry::new(new_value, expire_at)), None)
131            }
132        };
133        match outcome {
134            Ok((delta, ttl_delta)) => {
135                self.apply_weight_delta(delta);
136                self.adjust_expires(ttl_delta);
137            }
138            Err(entry) => {
139                self.insert_entry(SmallBytes::from_slice(key), entry);
140            }
141        }
142        // Phase 2: hand the old value off if heavy. Done last so the
143        // critical mutation + bookkeeping commit before any (sub-µs in
144        // steady state) channel send. A cold stub's record dies here.
145        if let Some(old) = old_value {
146            self.tier_note_dead(key_heap, &old);
147            self.maybe_offload_drop(old);
148        }
149        true
150    }
151
152    /// Single-probe overwrite-SET via `kevy_map::RawEntryMut` for the
153    /// `maxmemory == 0` fast path. Skips the `live_entry_mut`
154    /// 2-probe shape: Occupied arm mutates in place + returns owned
155    /// (delta, ttl_delta); Expired arm removes via raw-entry handle + falls
156    /// through to insert; Vacant arm goes to insert.
157    fn set_value_no_evict(
158        &mut self,
159        key: &[u8],
160        new_value: Value,
161        expire: Option<Duration>,
162        nx: bool,
163        xx: bool,
164    ) -> bool {
165        let expire_at = expire.map(|d| deadline_at(now_ns(), d));
166        let key_heap = crate::key_heap_bytes_for(key);
167        // Hold new_value behind Option so the multi-arm consumption
168        // (overwrite arm vs insert-after-expired arm) is moved-once.
169        let mut value_slot = Some(new_value);
170        let outcome = self.set_probe_no_evict(key, &mut value_slot, expire_at, key_heap, nx, xx);
171        // Phase 2: bookkeeping + maybe insert. Borrow on self.map is gone.
172        let old_value: Option<Value> = match outcome {
173            SetOutcome::Refused { drop_first } => {
174                if let Some(old) = drop_first {
175                    // No insert; the expired `Value` still needs
176                    // to drop. Ship via the bio path on its way out.
177                    self.tier_note_dead(key_heap, &old);
178                    self.maybe_offload_drop(old);
179                }
180                return false;
181            }
182            SetOutcome::Updated { delta, ttl_delta, old } => {
183                self.apply_weight_delta(delta);
184                self.adjust_expires(ttl_delta);
185                Some(old)
186            }
187            SetOutcome::ExpiredThenInsert { old } => {
188                let entry = Entry::new(value_slot.take().unwrap(), expire_at);
189                self.insert_entry(SmallBytes::from_slice(key), entry);
190                Some(old)
191            }
192            SetOutcome::NeedInsert => {
193                let entry = Entry::new(value_slot.take().unwrap(), expire_at);
194                self.insert_entry(SmallBytes::from_slice(key), entry);
195                None
196            }
197        };
198        // Phase 3: ship the displaced Value if any. Last so the keyspace
199        // commit precedes any (sub-µs steady-state) channel send. A
200        // displaced cold stub credits its vlog record dead first.
201        if let Some(old) = old_value {
202            self.tier_note_dead(key_heap, &old);
203            self.maybe_offload_drop(old);
204        }
205        true
206    }
207
208    /// Phase 1 of [`Self::set_value_no_evict`]: probe + decide. Four
209    /// outcomes — overwrite-finished (delta + ttl_delta + the displaced
210    /// old `Value` to maybe ship to the bio thread), an expired-removed
211    /// `Entry` whose `Value` ditto needs offload, needs-insert with no
212    /// prior value, or refused by an NX/XX guard.
213    #[inline]
214    fn set_probe_no_evict(
215        &mut self,
216        key: &[u8],
217        value_slot: &mut Option<Value>,
218        expire_at: Option<u64>,
219        key_heap: u64,
220        nx: bool,
221        xx: bool,
222    ) -> SetOutcome {
223        use kevy_map::RawEntryMut;
224        let (uc, cn) = (self.cached_clock, self.cached_ns);
225        match self.map.raw_entry_mut(key) {
226            RawEntryMut::Occupied(mut occ) => {
227                // Decide via shared ref first so we don't touch the entry
228                // (avoiding a needless cache-line dirtying) on the
229                // is_expired+remove path.
230                let expired = occ.get().is_expired(uc, cn);
231                if expired {
232                    let old = occ.remove();
233                    // borrow on self.map released by remove(self).
234                    self.note_expired_removed(&old);
235                    if xx {
236                        return SetOutcome::Refused { drop_first: Some(old.value) };
237                    }
238                    SetOutcome::ExpiredThenInsert { old: old.value }
239                } else {
240                    if nx {
241                        return SetOutcome::Refused { drop_first: None };
242                    }
243                    // Take the old Value before overwriting
244                    // so phase 2 can hand it to the bio thread instead
245                    // of dropping inline (a large-value latency-tail
246                    // amplifier).
247                    let (delta, ttl_delta, old) = overwrite_in_place(
248                        occ.get_mut(),
249                        value_slot.take().unwrap(),
250                        expire_at,
251                        key_heap,
252                    );
253                    SetOutcome::Updated { delta, ttl_delta, old }
254                }
255            }
256            RawEntryMut::Vacant(_) => {
257                if xx {
258                    return SetOutcome::Refused { drop_first: None };
259                }
260                SetOutcome::NeedInsert
261            }
262        }
263    }
264
265    /// Bookkeeping for an entry removed because the SET probe found it
266    /// expired: subtract its weight, decrement the TTL gauge, bump the
267    /// expired counter.
268    #[inline]
269    fn note_expired_removed(&mut self, old: &Entry) {
270        self.used_memory =
271            self.used_memory.saturating_sub(old.weight() + crate::value::ENTRY_OVERHEAD);
272        if old.expire_at_ns.is_some() {
273            self.adjust_expires(-1);
274        }
275        self.expired_keys_total = self.expired_keys_total.saturating_add(1);
276    }
277}
278
279/// Phase-1 verdict of the SET probe (see [`Store::set_probe_no_evict`]).
280enum SetOutcome {
281    Updated {
282        delta: i64,
283        ttl_delta: i64,
284        old: Value,
285    },
286    ExpiredThenInsert {
287        old: Value,
288    },
289    NeedInsert,
290    /// An NX/XX guard stopped the write; `drop_first` carries an
291    /// expired-removed `Value` that still needs the bio-drop hand-off.
292    Refused {
293        drop_first: Option<Value>,
294    },
295}
296
297/// Overwrite one live entry's value + TTL in place; returns
298/// `(weight_delta, ttl_delta, displaced_old_value)`. Shared by the
299/// eviction-path SET ([`Store::set_value`]) and the `maxmemory == 0`
300/// single-probe SET ([`Store::set_probe_no_evict`]) — the displaced
301/// `Value` is returned so the caller can hand it to the
302/// bio thread AFTER the keyspace borrow is released rather than
303/// dropping inline (the Drop of a `Value::ArcBulk` over the heap-heavy
304/// threshold amplifies the large-value SET latency tail).
305#[inline]
306fn overwrite_in_place(
307    e: &mut Entry,
308    new_value: Value,
309    expire_at: Option<u64>,
310    key_heap: u64,
311) -> (i64, i64, Value) {
312    let had_ttl = e.expire_at_ns.is_some();
313    let old = core::mem::replace(&mut e.value, new_value);
314    e.expire_at_ns = expire_at.and_then(crate::pack_deadline);
315    let new_w = key_heap + e.value.weight();
316    let delta = new_w as i64 - e.weight() as i64;
317    let ttl_delta = i64::from(e.expire_at_ns.is_some()) - i64::from(had_ttl);
318    e.set_weight(new_w);
319    (delta, ttl_delta, old)
320}