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