kevy_store/string.rs
1//! `Store` string read commands (GET family) + INCRBY. The SET-family
2//! write path lives in `string_set.rs` (500-LOC house cap).
3
4#[cfg(not(feature = "std"))]
5use crate::nostd_prelude::*;
6use crate::util::{format_i64_into, itoa_i64_stack, parse_i64};
7use crate::value::{SmallBytes, Value};
8use crate::{Entry, Store, StoreError};
9use alloc::borrow::Cow;
10use alloc::sync::Arc;
11
12/// L1 return shape for [`Store::get_for_reply`] — lets the reactor's reply
13/// path choose between memcpy (`Bytes`) and writev zero-copy (`ArcBulk`)
14/// off one keyspace lookup.
15///
16/// ```
17/// use kevy_store::{GetReply, SetCondition, Store};
18/// let mut s = Store::new();
19/// s.set(b"small", b"hi".to_vec(), None, SetCondition::Always);
20/// s.set(b"big", vec![b'x'; 1024], None, SetCondition::Always);
21/// assert!(matches!(s.get_for_reply(b"small")?, Some(GetReply::Bytes(_))));
22/// assert!(matches!(s.get_for_reply(b"big")?, Some(GetReply::ArcBulk(_))));
23/// assert!(s.get_for_reply(b"missing")?.is_none());
24/// # Ok::<(), kevy_store::StoreError>(())
25/// ```
26#[derive(Debug)]
27pub enum GetReply<'a> {
28 /// Inline-encoded value — caller memcpys the bytes into its output Vec
29 /// (small replies; encoding cost is tiny vs the RTT floor).
30 ///
31 /// ```
32 /// use kevy_store::{GetReply, SetCondition, Store};
33 /// let mut s = Store::new();
34 /// s.set(b"n", b"42".to_vec(), None, SetCondition::Always);
35 /// let Some(GetReply::Bytes(b)) = s.get_for_reply(b"n")? else { panic!() };
36 /// assert_eq!(&*b, b"42");
37 /// # Ok::<(), kevy_store::StoreError>(())
38 /// ```
39 Bytes(Cow<'a, [u8]>),
40 /// Arc-backed bulk. The reactor's reply path pushes
41 /// the Arc into the conn's `output_arcs` so the next `writev` iovec
42 /// list points DIRECTLY at the value bytes — skipping the per-GET
43 /// memcpy that valkey's `tryAvoidBulkStrCopyToReply` likewise avoids.
44 ///
45 /// ```
46 /// use kevy_store::{GetReply, SetCondition, Store};
47 /// let mut s = Store::new();
48 /// s.set(b"big", vec![b'x'; 1024], None, SetCondition::Always);
49 /// let Some(GetReply::ArcBulk(a)) = s.get_for_reply(b"big")? else { panic!() };
50 /// // the reply holds the stored bytes themselves, kept alive by the Arc
51 /// assert_eq!(a.len(), 1024);
52 /// # Ok::<(), kevy_store::StoreError>(())
53 /// ```
54 ArcBulk(Arc<Box<[u8]>>),
55}
56
57/// Owned GET result for the FFI zero-copy shared lane
58/// ([`Store::get_shared_owned`]). Bulk values ride out as an Arc clone (no
59/// byte copy); small values as a plain Vec (one alloc — cheaper than a fresh
60/// Arc). The FFI's shared free reconstructs whichever the tag says.
61///
62/// ```
63/// use kevy_store::{GetShared, SetCondition, Store};
64/// let mut s = Store::new();
65/// s.set(b"k", b"v".to_vec(), None, SetCondition::Always);
66/// assert!(matches!(s.get_shared_owned(b"k")?, Some(GetShared::Bytes(_))));
67/// assert!(s.get_shared_owned(b"missing")?.is_none());
68/// # Ok::<(), kevy_store::StoreError>(())
69/// ```
70#[derive(Debug)]
71pub enum GetShared {
72 /// Bulk value — the engine's Arc, cloned. Zero byte copy.
73 ///
74 /// ```
75 /// use kevy_store::{GetShared, SetCondition, Store};
76 /// let mut s = Store::new();
77 /// s.set(b"big", vec![b'x'; 1024], None, SetCondition::Always);
78 /// let Some(GetShared::Arc(a)) = s.get_shared_owned(b"big")? else { panic!() };
79 /// assert_eq!(a.len(), 1024);
80 /// # Ok::<(), kevy_store::StoreError>(())
81 /// ```
82 Arc(Arc<Box<[u8]>>),
83 /// Small value (Str/Int) — a plain owned Vec, one allocation.
84 ///
85 /// ```
86 /// use kevy_store::{GetShared, SetCondition, Store};
87 /// let mut s = Store::new();
88 /// s.set(b"n", b"7".to_vec(), None, SetCondition::Always);
89 /// let Some(GetShared::Bytes(b)) = s.get_shared_owned(b"n")? else { panic!() };
90 /// assert_eq!(b, b"7");
91 /// # Ok::<(), kevy_store::StoreError>(())
92 /// ```
93 Bytes(Vec<u8>),
94}
95
96impl Store {
97 // ---- strings -------------------------------------------------------
98 /// GET variant that exposes the underlying encoding
99 /// so the reactor's reply path can choose zero-copy
100 /// (`Value::ArcBulk` → push the Arc to the conn's `output_arcs` for a
101 /// writev iovec) vs memcpy (`Value::Str` / `Value::Int` → encode bytes
102 /// into the conn's output Vec). ONE keyspace lookup; the variant tag
103 /// chooses the encoding without a second probe.
104 pub fn get_for_reply(&mut self, key: &[u8]) -> Result<Option<GetReply<'_>>, StoreError> {
105 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
106 None => Ok(None),
107 Some(e) => match &e.value {
108 Value::Str(v) => Ok(Some(GetReply::Bytes(Cow::Borrowed(v.as_slice())))),
109 Value::ArcBulk(a) => Ok(Some(GetReply::ArcBulk(Arc::clone(a)))),
110 Value::Int(n) => {
111 let mut tmp = itoa_i64_stack();
112 let s = format_i64_into(*n, &mut tmp);
113 Ok(Some(GetReply::Bytes(Cow::Owned(s.to_vec()))))
114 }
115 _ => Err(StoreError::WrongType),
116 },
117 }
118 }
119
120 /// Owned GET for the FFI scalar *shared* lane (`kevy_get_shared`). Bulk
121 /// values (`Value::ArcBulk`) return an `Arc::clone` — **no byte copy**; the
122 /// FFI holds the Arc alive and hands JS a buffer that views it directly
123 /// (mirrors MMKV's zero-copy mmap-page view, the thing that made kevy lose
124 /// large GET). Small values (`Str`/`Int`) allocate a fresh `Arc<Box<[u8]>>`
125 /// — the same one copy the Vec lane already pays — so the caller's free path
126 /// is uniform. Read-only (`&self`, like [`Self::get_shared`]) so the FFI
127 /// can take it under a SHARED shard lock — no LRU stamp, matching the
128 /// `maxmemory == 0` fast path the mobile door runs on. Wrong type errors
129 /// like [`Self::get_for_reply`].
130 pub fn get_shared_owned(&self, key: &[u8]) -> Result<Option<GetShared>, StoreError> {
131 match self.map.get(key) {
132 None => Ok(None),
133 Some(e) if e.is_expired(self.cached_clock, self.cached_ns) => Ok(None),
134 // Bulk (already Arc-backed) → clone the Arc: ZERO byte copy. Small
135 // (`Str`/`Int`) → a plain Vec (one alloc, cheaper than wrapping a
136 // fresh Arc — the FFI's shared free handles either).
137 Some(e) => match &e.value {
138 Value::ArcBulk(a) => Ok(Some(GetShared::Arc(Arc::clone(a)))),
139 Value::Str(v) => Ok(Some(GetShared::Bytes(v.as_slice().to_vec()))),
140 Value::Int(n) => {
141 let mut tmp = itoa_i64_stack();
142 let s = format_i64_into(*n, &mut tmp);
143 Ok(Some(GetShared::Bytes(s.to_vec())))
144 }
145 // Cold, `&self` shared lane: pread a fresh value — never
146 // promotes, never sets the probation mark (it cannot:
147 // no `&mut`). Documented: shared-lane reads pay a pread
148 // until a `&mut`-path access promotes the key.
149 Value::Cold(c) if c.type_tag == crate::value::COLD_TAG_STRING => {
150 match self.tier_peek_value(key, &e.value).expect("cold peek") {
151 Value::ArcBulk(a) => Ok(Some(GetShared::Arc(a))),
152 v => Ok(Some(GetShared::Bytes(cold_string_bytes(&v)))),
153 }
154 }
155 _ => Err(StoreError::WrongType),
156 },
157 }
158 }
159
160 /// [`Store::get_shared_owned`] that lends the value to `f` instead of
161 /// handing out an owner: the bytes as the store holds them for a hot
162 /// value (an integer formatted on the stack), a read copy for a cold
163 /// one. For a caller that copies the bytes somewhere of its own anyway —
164 /// a binding building its reply — this saves the allocation a small
165 /// value's owner would cost. Non-mutating, like the owner lane.
166 ///
167 /// ```
168 /// let mut s = kevy_store::Store::new();
169 /// s.set(b"k", b"v".to_vec(), None, kevy_store::SetCondition::Always);
170 /// assert_eq!(s.get_shared_with(b"k", |v| v.map(<[u8]>::len)), Ok(Some(1)));
171 /// assert_eq!(s.get_shared_with(b"none", |v| v.is_none()), Ok(true));
172 /// ```
173 pub fn get_shared_with<R>(
174 &self,
175 key: &[u8],
176 f: impl FnOnce(Option<&[u8]>) -> R,
177 ) -> Result<R, StoreError> {
178 let Some(e) =
179 self.map.get(key).filter(|e| !e.is_expired(self.cached_clock, self.cached_ns))
180 else {
181 return Ok(f(None));
182 };
183 match &e.value {
184 Value::ArcBulk(a) => Ok(f(Some(a))),
185 Value::Str(v) => Ok(f(Some(v.as_slice()))),
186 Value::Int(n) => {
187 let mut tmp = itoa_i64_stack();
188 Ok(f(Some(format_i64_into(*n, &mut tmp))))
189 }
190 Value::Cold(c) if c.type_tag == crate::value::COLD_TAG_STRING => {
191 match self.tier_peek_value(key, &e.value).expect("cold peek") {
192 Value::ArcBulk(a) => Ok(f(Some(&a))),
193 v => Ok(f(Some(&cold_string_bytes(&v)))),
194 }
195 }
196 _ => Err(StoreError::WrongType),
197 }
198 }
199
200 /// Fused GET-into-output. Skips the [`GetReply`] enum tag
201 /// round-trip + caller match arm by writing the RESP frame directly into
202 /// `output` (header + bytes + CRLF for Str/Int) or pushing the Arc into
203 /// `output_arcs` at the right offset (ArcBulk zero-copy via writev).
204 /// Returns the same outcomes as [`Self::get_for_reply`]: `Ok(true)` if
205 /// the key was found and emitted, `Ok(false)` if absent (the caller
206 /// emits the `$-1` null bulk — preserves the existing inline-null
207 /// semantics on the reactor side), `Err` for WRONGTYPE.
208 pub fn get_into_output(
209 &mut self,
210 key: &[u8],
211 output: &mut Vec<u8>,
212 output_arcs: &mut Vec<(usize, Arc<Box<[u8]>>)>,
213 ) -> Result<bool, StoreError> {
214 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
215 None => Ok(false),
216 Some(e) => match &e.value {
217 Value::Str(v) => {
218 let bytes = v.as_slice();
219 crate::util::bulk_header_into(output, bytes.len());
220 output.extend_from_slice(bytes);
221 output.extend_from_slice(b"\r\n");
222 Ok(true)
223 }
224 Value::ArcBulk(a) => {
225 crate::util::bulk_header_into(output, a.len());
226 let pos = output.len();
227 output_arcs.push((pos, Arc::clone(a)));
228 output.extend_from_slice(b"\r\n");
229 Ok(true)
230 }
231 Value::Int(n) => {
232 let mut tmp = itoa_i64_stack();
233 let s = format_i64_into(*n, &mut tmp);
234 crate::util::bulk_header_into(output, s.len());
235 output.extend_from_slice(s);
236 output.extend_from_slice(b"\r\n");
237 Ok(true)
238 }
239 _ => Err(StoreError::WrongType),
240 },
241 }
242 }
243
244 /// `GET` — returns a `Cow<[u8]>` so `Value::Int` callers can format the
245 /// integer to ASCII without storing it. `Value::Str`
246 /// returns `Cow::Borrowed` (zero copy); `Value::Int`
247 /// formats to a small owned `Vec<u8>` (up to 20 bytes for `i64::MIN`).
248 pub fn get(&mut self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
249 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
250 None => Ok(None),
251 Some(e) => match &e.value {
252 Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
253 // L1: Arc-backed bulk — return borrow into the Arc's
254 // bytes. Caller can either memcpy via Cow::Borrowed
255 // (default `encode_bulk` path) OR look up the
256 // underlying `Value::ArcBulk(arc)` separately for the
257 // writev zero-copy reply path.
258 Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
259 Value::Int(n) => {
260 let mut tmp = itoa_i64_stack();
261 let s = format_i64_into(*n, &mut tmp);
262 Ok(Some(Cow::Owned(s.to_vec())))
263 }
264 _ => Err(StoreError::WrongType),
265 },
266 }
267 }
268
269 /// Read-only `GET`: `&self`, so concurrent readers can run under a shared
270 /// lock (embedded mode's `RwLock` read path). Expiry is checked against the
271 /// coarse cached clock but an expired key is *not* removed here (no `&mut`)
272 /// — the reaper / next write reclaims it; a reader just sees `None`. LRU is
273 /// not touched, so this path is only used when eviction is off
274 /// (`maxmemory == 0`); with eviction, the caller takes the mutating
275 /// [`Self::get`] under an exclusive lock so access still stamps the LRU.
276 pub fn get_shared(&self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
277 match self.map.get(key) {
278 None => Ok(None),
279 Some(e) if e.is_expired(self.cached_clock, self.cached_ns) => Ok(None),
280 Some(e) => match &e.value {
281 Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
282 Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
283 Value::Int(n) => {
284 let mut tmp = itoa_i64_stack();
285 let s = format_i64_into(*n, &mut tmp);
286 Ok(Some(Cow::Owned(s.to_vec())))
287 }
288 // Cold, `&self` shared lane — see `get_shared_owned`.
289 Value::Cold(c) if c.type_tag == crate::value::COLD_TAG_STRING => {
290 let v = self.tier_peek_value(key, &e.value).expect("cold peek");
291 Ok(Some(Cow::Owned(cold_string_bytes(&v))))
292 }
293 _ => Err(StoreError::WrongType),
294 },
295 }
296 }
297
298 /// Byte length of a string value. A missing key is 0, matching
299 /// STRLEN; an integer-encoded value reports the length it would
300 /// format to, not 8.
301 pub fn strlen(&mut self, key: &[u8]) -> Result<usize, StoreError> {
302 Ok(self.get(key)?.map_or(0, |c| c.len()))
303 }
304
305 /// `INCRBY` family; preserves any TTL.
306 ///
307 /// Following valkey's OBJ_ENCODING_INT approach: the hot path
308 /// matches `Value::Int(n)` and does the increment in place — no parse,
309 /// no format, no allocation. The `Value::Str` arm parses,
310 /// increments, and **promotes** to `Value::Int(next)` so subsequent
311 /// INCRs land on the fast path. Insert-new path also lands as `Int`.
312 pub fn incr_by(&mut self, key: &[u8], delta: i64) -> Result<i64, StoreError> {
313 self.tier_resolve(key, crate::value::COLD_TAG_STRING)?; // cold string pages in
314
315 let outcome = match self.live_entry_mut(key) {
316 Some(e) => match &mut e.value {
317 Value::Int(n) => {
318 let next = n.checked_add(delta).ok_or(StoreError::Overflow)?;
319 *n = next;
320 // In-place i64 mutation — weight unchanged (still 0
321 // heap bytes for an Int). Skip the reweigh entirely.
322 return Ok(next);
323 }
324 Value::Str(v) => {
325 let next = parse_i64(v.as_slice())
326 .ok_or(StoreError::NotInteger)?
327 .checked_add(delta)
328 .ok_or(StoreError::Overflow)?;
329 // Promote to Int: future INCRs hit the fast path.
330 e.value = Value::Int(next);
331 IncrOutcome::Reweigh(next)
332 }
333 Value::ArcBulk(a) => {
334 // L1: large value claimed to be numeric — parse and
335 // promote to Int. Subsequent INCRs hit the fast path.
336 let next = parse_i64(a.as_ref())
337 .ok_or(StoreError::NotInteger)?
338 .checked_add(delta)
339 .ok_or(StoreError::Overflow)?;
340 e.value = Value::Int(next);
341 IncrOutcome::Reweigh(next)
342 }
343 _ => return Err(StoreError::WrongType),
344 },
345 // Absent/expired ⇒ start from 0; 0 + delta can't overflow i64.
346 None => IncrOutcome::Insert(delta),
347 };
348 match outcome {
349 IncrOutcome::Reweigh(next) => {
350 self.reweigh_entry(key);
351 Ok(next)
352 }
353 IncrOutcome::Insert(next) => {
354 self.insert_entry(SmallBytes::from_slice(key), Entry::new(Value::Int(next), None));
355 Ok(next)
356 }
357 }
358 }
359}
360
361enum IncrOutcome {
362 Reweigh(i64),
363 Insert(i64),
364}
365
366/// The bytes a string-class value materializes to on the cold shared
367/// lane (the `Value::Int` re-pick case included — a canonical-integer
368/// spill decodes back through the SET rules).
369fn cold_string_bytes(v: &Value) -> Vec<u8> {
370 match v {
371 Value::Str(s) => s.as_slice().to_vec(),
372 Value::ArcBulk(a) => a.as_ref().to_vec(),
373 Value::Int(n) => {
374 let mut tmp = itoa_i64_stack();
375 format_i64_into(*n, &mut tmp).to_vec()
376 }
377 _ => unreachable!("string-tagged cold record decodes to a string class"),
378 }
379}
380
381#[cfg(test)]
382mod lend_tests {
383 use super::*;
384
385 fn owned(s: &Store, k: &[u8]) -> Result<Option<Vec<u8>>, StoreError> {
386 s.get_shared_owned(k).map(|g| {
387 g.map(|g| match g {
388 GetShared::Arc(a) => a.to_vec(),
389 GetShared::Bytes(b) => b,
390 })
391 })
392 }
393
394 #[test]
395 fn the_lent_bytes_are_the_owned_ones_for_every_string_class() {
396 let mut s = Store::new();
397 s.set(b"small", b"v".to_vec(), None, crate::SetCondition::Always);
398 s.set(b"bulk", vec![7u8; 4096], None, crate::SetCondition::Always);
399 s.set(b"int", b"-12345".to_vec(), None, crate::SetCondition::Always);
400 s.rpush(b"list", &[b"x"]).unwrap();
401 for k in [&b"small"[..], b"bulk", b"int", b"missing", b"list"] {
402 let lent = s.get_shared_with(k, |v| v.map(<[u8]>::to_vec));
403 assert_eq!(lent, owned(&s, k), "{}", String::from_utf8_lossy(k));
404 }
405 }
406}