Skip to main content

kevy_store/
string.rs

1//! `Store` string commands.
2
3use crate::util::{format_i64_into, itoa_i64_stack, parse_canonical_i64, parse_i64};
4use crate::value::{BULK_THRESHOLD, SmallBytes, Value};
5use crate::{Entry, Store, StoreError, deadline_at, now_ns};
6use std::borrow::Cow;
7use std::sync::Arc;
8use std::time::Duration;
9
10/// L1 return shape for [`Store::get_for_reply`] — lets the reactor's reply
11/// path choose between memcpy (`Bytes`) and writev zero-copy (`ArcBulk`)
12/// off one keyspace lookup.
13pub enum GetReply<'a> {
14    /// Inline-encoded value — caller memcpys the bytes into its output Vec
15    /// (small replies; encoding cost is tiny vs the RTT floor).
16    Bytes(Cow<'a, [u8]>),
17    /// L1 (2026-06-21): Arc-backed bulk. The reactor's reply path pushes
18    /// the Arc into the conn's `output_arcs` so the next `writev` iovec
19    /// list points DIRECTLY at the value bytes — skipping the per-GET
20    /// memcpy that valkey's `tryAvoidBulkStrCopyToReply` likewise avoids.
21    ArcBulk(Arc<Box<[u8]>>),
22}
23
24/// L2 + L1: pick the optimal encoding for `bytes` at SET time:
25/// 1. Canonical i64 ASCII → `Value::Int(n)` (smallest + INCR fast path)
26/// 2. > [`BULK_THRESHOLD`] bytes → `Value::ArcBulk(Arc<[u8]>)` (lets the
27///    reactor reply path borrow the bytes for `writev` zero-copy GET)
28/// 3. Else → `Value::Str(SmallBytes::from_slice(bytes))` (inline-cache-
29///    line storage, beats Arc indirection for small values)
30#[inline]
31fn pick_value_for_set(bytes: &[u8]) -> Value {
32    if let Some(n) = parse_canonical_i64(bytes) {
33        return Value::Int(n);
34    }
35    if bytes.len() > BULK_THRESHOLD {
36        return Value::ArcBulk(Arc::new(Box::<[u8]>::from(bytes)));
37    }
38    Value::Str(SmallBytes::from_slice(bytes))
39}
40
41#[inline]
42pub(crate) fn pick_value_for_set_owned(bytes: Vec<u8>) -> Value {
43    if let Some(n) = parse_canonical_i64(&bytes) {
44        return Value::Int(n);
45    }
46    if bytes.len() > BULK_THRESHOLD {
47        // v1.29 Option A — `Arc::new(box)` is zero-copy when `len ==
48        // capacity` (shrink-to-fit no-ops). See `Value::ArcBulk` doc.
49        return Value::ArcBulk(Arc::new(bytes.into_boxed_slice()));
50    }
51    Value::Str(SmallBytes::from_vec(bytes))
52}
53
54impl Store {
55    // ---- strings -------------------------------------------------------
56
57    /// `SET` — overwrites any existing value/type. NX/XX guards; clears TTL.
58    /// Takes an owned `Vec` so a >22 B value's allocation is adopted as-is
59    /// (no copy). For callers holding a borrowed slice, prefer
60    /// [`Self::set_slice`] — it skips the `to_vec` entirely for values that
61    /// inline.
62    pub fn set(
63        &mut self,
64        key: &[u8],
65        value: Vec<u8>,
66        expire: Option<Duration>,
67        nx: bool,
68        xx: bool,
69    ) -> bool {
70        self.set_value(key, pick_value_for_set_owned(value), expire, nx, xx)
71    }
72
73    /// [`Self::set`] for a borrowed value. Values ≤ 22 B store inline in the
74    /// entry — zero allocator traffic, where `set(key, value.to_vec(), …)`
75    /// paid a malloc for the `Vec` and a free when the inline copy dropped
76    /// it (the dominant overwrite-SET pattern). Larger values pay the same
77    /// single allocation either way.
78    pub fn set_slice(
79        &mut self,
80        key: &[u8],
81        value: &[u8],
82        expire: Option<Duration>,
83        nx: bool,
84        xx: bool,
85    ) -> bool {
86        self.set_value(key, pick_value_for_set(value), expire, nx, xx)
87    }
88
89    fn set_value(
90        &mut self,
91        key: &[u8],
92        new_value: Value,
93        expire: Option<Duration>,
94        nx: bool,
95        xx: bool,
96    ) -> bool {
97        // F1 (v1.25): single-probe overwrite-SET fast path for default
98        // `maxmemory == 0` (the bench and production-common case). Goes
99        // through `kevy_map::RawEntryMut` (B.1, commit 1a9f9af) so the
100        // Occupied arm mutates the entry in place and returns owned
101        // (delta, ttl_delta) — no escaping reference, no second probe.
102        // Overwrite path drops from 2 probes (live_entry_mut: get+get_mut)
103        // to 1 probe. New-key + expired-removed paths still pay the
104        // insert_entry probe (same as before).
105        if self.maxmemory == 0 {
106            return self.set_value_no_evict(key, new_value, expire, nx, xx);
107        }
108        // Eviction path (maxmemory > 0): keep the 2-probe shape so
109        // `live_entry_mut`'s touch_on_access bookkeeping runs.
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 had_ttl = e.expire_at_ns.is_some();
124                // v1.25 A.3: capture the old Value so it can be shipped
125                // to the bio thread post-borrow rather than dropped
126                // inline (the Drop of a Value::ArcBulk over the heap
127                // heavy threshold is the Axis I tail amplifier).
128                let old = std::mem::replace(&mut e.value, new_value);
129                e.expire_at_ns = expire_at.and_then(crate::pack_deadline);
130                let new_w = key_heap + e.value.weight();
131                let delta = new_w as i64 - e.weight() as i64;
132                let ttl_delta = i64::from(e.expire_at_ns.is_some()) - i64::from(had_ttl);
133                e.set_weight(new_w);
134                (Ok((delta, ttl_delta)), Some(old))
135            }
136            None => {
137                if xx {
138                    return false;
139                }
140                (Err(Entry::new(new_value, expire_at)), None)
141            }
142        };
143        match outcome {
144            Ok((delta, ttl_delta)) => {
145                self.apply_weight_delta(delta);
146                self.adjust_expires(ttl_delta);
147            }
148            Err(entry) => {
149                self.insert_entry(SmallBytes::from_slice(key), entry);
150            }
151        }
152        // Phase 2: hand the old value off if heavy. Done last so the
153        // critical mutation + bookkeeping commit before any (sub-µs in
154        // steady state) channel send.
155        if let Some(old) = old_value {
156            self.maybe_offload_drop(old);
157        }
158        true
159    }
160
161    /// Single-probe overwrite-SET via `kevy_map::RawEntryMut` for the
162    /// `maxmemory == 0` fast path (F1, v1.25). Skips the `live_entry_mut`
163    /// 2-probe shape: Occupied arm mutates in place + returns owned
164    /// (delta, ttl_delta); Expired arm removes via raw-entry handle + falls
165    /// through to insert; Vacant arm goes to insert.
166    fn set_value_no_evict(
167        &mut self,
168        key: &[u8],
169        new_value: Value,
170        expire: Option<Duration>,
171        nx: bool,
172        xx: bool,
173    ) -> bool {
174        use kevy_map::RawEntryMut;
175        let expire_at = expire.map(|d| deadline_at(now_ns(), d));
176        let key_heap = crate::key_heap_bytes_for(key);
177        let (uc, cn) = (self.cached_clock, self.cached_ns);
178        // Hold new_value behind Option so the multi-arm consumption
179        // (overwrite arm vs insert-after-expired arm) is moved-once.
180        let mut value_slot = Some(new_value);
181
182        // Phase 1: probe + decide. Three outcomes — overwrite-finished
183        // (delta + ttl_delta + the displaced old `Value` to maybe ship
184        // to the bio thread), an expired-removed `Entry` whose `Value`
185        // ditto needs offload, or needs-insert with no prior value.
186        enum Outcome {
187            Updated { delta: i64, ttl_delta: i64, old: Value },
188            ExpiredThenInsert { old: Value },
189            NeedInsert,
190        }
191        let outcome = match self.map.raw_entry_mut(key) {
192            RawEntryMut::Occupied(mut occ) => {
193                // Decide via shared ref first so we don't touch the entry
194                // (avoiding a needless cache-line dirtying) on the
195                // is_expired+remove path.
196                let expired = occ.get().is_expired(uc, cn);
197                if expired {
198                    let old = occ.remove();
199                    // borrow on self.map released by remove(self).
200                    self.used_memory = self
201                        .used_memory
202                        .saturating_sub(old.weight() + crate::value::ENTRY_OVERHEAD);
203                    if old.expire_at_ns.is_some() {
204                        self.adjust_expires(-1);
205                    }
206                    self.expired_keys_total =
207                        self.expired_keys_total.saturating_add(1);
208                    if xx {
209                        // No insert; the expired `Value` still needs
210                        // to drop. Ship via the bio path on its way out.
211                        self.maybe_offload_drop(old.value);
212                        return false;
213                    }
214                    Outcome::ExpiredThenInsert { old: old.value }
215                } else {
216                    if nx {
217                        return false;
218                    }
219                    let e = occ.get_mut();
220                    let had_ttl = e.expire_at_ns.is_some();
221                    // v1.25 A.3: take the old Value before overwriting
222                    // so phase 2 can hand it to the bio thread instead
223                    // of dropping inline (the Axis I tail amplifier).
224                    let old = std::mem::replace(&mut e.value, value_slot.take().unwrap());
225                    e.expire_at_ns = expire_at.and_then(crate::pack_deadline);
226                    let new_w = key_heap + e.value.weight();
227                    let delta = new_w as i64 - e.weight() as i64;
228                    let ttl_delta = i64::from(e.expire_at_ns.is_some())
229                        - i64::from(had_ttl);
230                    e.set_weight(new_w);
231                    Outcome::Updated { delta, ttl_delta, old }
232                }
233            }
234            RawEntryMut::Vacant(_) => {
235                if xx {
236                    return false;
237                }
238                Outcome::NeedInsert
239            }
240        };
241
242        // Phase 2: bookkeeping + maybe insert. Borrow on self.map is gone.
243        let old_value: Option<Value> = match outcome {
244            Outcome::Updated { delta, ttl_delta, old } => {
245                self.apply_weight_delta(delta);
246                self.adjust_expires(ttl_delta);
247                Some(old)
248            }
249            Outcome::ExpiredThenInsert { old } => {
250                let entry = Entry::new(value_slot.take().unwrap(), expire_at);
251                self.insert_entry(SmallBytes::from_slice(key), entry);
252                Some(old)
253            }
254            Outcome::NeedInsert => {
255                let entry = Entry::new(value_slot.take().unwrap(), expire_at);
256                self.insert_entry(SmallBytes::from_slice(key), entry);
257                None
258            }
259        };
260        // Phase 3: ship the displaced Value if any. Last so the keyspace
261        // commit precedes any (sub-µs steady-state) channel send.
262        if let Some(old) = old_value {
263            self.maybe_offload_drop(old);
264        }
265        true
266    }
267
268    /// L1 (2026-06-21): GET variant that exposes the underlying encoding
269    /// so the reactor's reply path can choose zero-copy
270    /// (`Value::ArcBulk` → push the Arc to the conn's `output_arcs` for a
271    /// writev iovec) vs memcpy (`Value::Str` / `Value::Int` → encode bytes
272    /// into the conn's output Vec). ONE keyspace lookup; the variant tag
273    /// chooses the encoding without a second probe.
274    pub fn get_for_reply(&mut self, key: &[u8]) -> Result<Option<GetReply<'_>>, StoreError> {
275        match self.live_entry(key) {
276            None => Ok(None),
277            Some(e) => match &e.value {
278                Value::Str(v) => Ok(Some(GetReply::Bytes(Cow::Borrowed(v.as_slice())))),
279                Value::ArcBulk(a) => Ok(Some(GetReply::ArcBulk(Arc::clone(a)))),
280                Value::Int(n) => {
281                    let mut tmp = itoa_i64_stack();
282                    let s = format_i64_into(*n, &mut tmp);
283                    Ok(Some(GetReply::Bytes(Cow::Owned(s.to_vec()))))
284                }
285                _ => Err(StoreError::WrongType),
286            },
287        }
288    }
289
290    /// A.6 (v1.25): fused GET-into-output. Skips the [`GetReply`] enum tag
291    /// round-trip + caller match arm by writing the RESP frame directly into
292    /// `output` (header + bytes + CRLF for Str/Int) or pushing the Arc into
293    /// `output_arcs` at the right offset (ArcBulk zero-copy via writev).
294    /// Returns the same outcomes as [`Self::get_for_reply`]: `Ok(true)` if
295    /// the key was found and emitted, `Ok(false)` if absent (the caller
296    /// emits the `$-1` null bulk — preserves the existing inline-null
297    /// semantics on the reactor side), `Err` for WRONGTYPE.
298    pub fn get_into_output(
299        &mut self,
300        key: &[u8],
301        output: &mut Vec<u8>,
302        output_arcs: &mut Vec<(usize, Arc<Box<[u8]>>)>,
303    ) -> Result<bool, StoreError> {
304        match self.live_entry(key) {
305            None => Ok(false),
306            Some(e) => match &e.value {
307                Value::Str(v) => {
308                    let bytes = v.as_slice();
309                    crate::util::bulk_header_into(output, bytes.len());
310                    output.extend_from_slice(bytes);
311                    output.extend_from_slice(b"\r\n");
312                    Ok(true)
313                }
314                Value::ArcBulk(a) => {
315                    crate::util::bulk_header_into(output, a.len());
316                    let pos = output.len();
317                    output_arcs.push((pos, Arc::clone(a)));
318                    output.extend_from_slice(b"\r\n");
319                    Ok(true)
320                }
321                Value::Int(n) => {
322                    let mut tmp = itoa_i64_stack();
323                    let s = format_i64_into(*n, &mut tmp);
324                    crate::util::bulk_header_into(output, s.len());
325                    output.extend_from_slice(s);
326                    output.extend_from_slice(b"\r\n");
327                    Ok(true)
328                }
329                _ => Err(StoreError::WrongType),
330            },
331        }
332    }
333
334    /// `GET` — returns a `Cow<[u8]>` so `Value::Int` callers can format the
335    /// integer to ASCII without storing it. L2 (2026-06-21): `Value::Str`
336    /// returns `Cow::Borrowed` (zero copy, same as before); `Value::Int`
337    /// formats to a small owned `Vec<u8>` (up to 20 bytes for `i64::MIN`).
338    pub fn get(&mut self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
339        match self.live_entry(key) {
340            None => Ok(None),
341            Some(e) => match &e.value {
342                Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
343                // L1: Arc-backed bulk — return borrow into the Arc's
344                // bytes. Caller can either memcpy via Cow::Borrowed
345                // (default `encode_bulk` path) OR look up the
346                // underlying `Value::ArcBulk(arc)` separately for the
347                // writev zero-copy reply path.
348                Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
349                Value::Int(n) => {
350                    let mut tmp = itoa_i64_stack();
351                    let s = format_i64_into(*n, &mut tmp);
352                    Ok(Some(Cow::Owned(s.to_vec())))
353                }
354                _ => Err(StoreError::WrongType),
355            },
356        }
357    }
358
359    /// Read-only `GET`: `&self`, so concurrent readers can run under a shared
360    /// lock (embedded mode's `RwLock` read path). Expiry is checked against the
361    /// coarse cached clock but an expired key is *not* removed here (no `&mut`)
362    /// — the reaper / next write reclaims it; a reader just sees `None`. LRU is
363    /// not touched, so this path is only used when eviction is off
364    /// (`maxmemory == 0`); with eviction, the caller takes the mutating
365    /// [`Self::get`] under an exclusive lock so access still stamps the LRU.
366    pub fn get_shared(&self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
367        match self.map.get(key) {
368            None => Ok(None),
369            Some(e) if e.is_expired(self.cached_clock, self.cached_ns) => Ok(None),
370            Some(e) => match &e.value {
371                Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
372                Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
373                Value::Int(n) => {
374                    let mut tmp = itoa_i64_stack();
375                    let s = format_i64_into(*n, &mut tmp);
376                    Ok(Some(Cow::Owned(s.to_vec())))
377                }
378                _ => Err(StoreError::WrongType),
379            },
380        }
381    }
382
383    pub fn strlen(&mut self, key: &[u8]) -> Result<usize, StoreError> {
384        Ok(self.get(key)?.map_or(0, |c| c.len()))
385    }
386
387    /// `INCRBY` family; preserves any TTL.
388    ///
389    /// L2 (2026-06-21, lessons from valkey OBJ_ENCODING_INT): the hot path
390    /// matches `Value::Int(n)` and does the increment in place — no parse,
391    /// no format, no allocation. The legacy `Value::Str` arm parses,
392    /// increments, and **promotes** to `Value::Int(next)` so subsequent
393    /// INCRs land on the fast path. Insert-new path also lands as `Int`.
394    pub fn incr_by(&mut self, key: &[u8], delta: i64) -> Result<i64, StoreError> {
395        let outcome = match self.live_entry_mut(key) {
396            Some(e) => match &mut e.value {
397                Value::Int(n) => {
398                    let next = n.checked_add(delta).ok_or(StoreError::Overflow)?;
399                    *n = next;
400                    // In-place i64 mutation — weight unchanged (still 0
401                    // heap bytes for an Int). Skip the reweigh entirely.
402                    return Ok(next);
403                }
404                Value::Str(v) => {
405                    let next = parse_i64(v.as_slice())
406                        .ok_or(StoreError::NotInteger)?
407                        .checked_add(delta)
408                        .ok_or(StoreError::Overflow)?;
409                    // Promote to Int: future INCRs hit the fast path.
410                    e.value = Value::Int(next);
411                    IncrOutcome::Reweigh(next)
412                }
413                Value::ArcBulk(a) => {
414                    // L1: large value claimed to be numeric — parse and
415                    // promote to Int. Subsequent INCRs hit the fast path.
416                    let next = parse_i64(a.as_ref())
417                        .ok_or(StoreError::NotInteger)?
418                        .checked_add(delta)
419                        .ok_or(StoreError::Overflow)?;
420                    e.value = Value::Int(next);
421                    IncrOutcome::Reweigh(next)
422                }
423                _ => return Err(StoreError::WrongType),
424            },
425            // Absent/expired ⇒ start from 0; 0 + delta can't overflow i64.
426            None => IncrOutcome::Insert(delta),
427        };
428        match outcome {
429            IncrOutcome::Reweigh(next) => {
430                self.reweigh_entry(key);
431                Ok(next)
432            }
433            IncrOutcome::Insert(next) => {
434                self.insert_entry(
435                    SmallBytes::from_slice(key),
436                    Entry::new(Value::Int(next), None),
437                );
438                Ok(next)
439            }
440        }
441    }
442
443}
444
445enum IncrOutcome {
446    Reweigh(i64),
447    Insert(i64),
448}
449