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#[derive(Debug)]
16pub enum GetReply<'a> {
17 /// Inline-encoded value — caller memcpys the bytes into its output Vec
18 /// (small replies; encoding cost is tiny vs the RTT floor).
19 Bytes(Cow<'a, [u8]>),
20 /// Arc-backed bulk. The reactor's reply path pushes
21 /// the Arc into the conn's `output_arcs` so the next `writev` iovec
22 /// list points DIRECTLY at the value bytes — skipping the per-GET
23 /// memcpy that valkey's `tryAvoidBulkStrCopyToReply` likewise avoids.
24 ArcBulk(Arc<Box<[u8]>>),
25}
26
27/// Owned GET result for the FFI zero-copy shared lane
28/// ([`Store::get_shared_owned`]). Bulk values ride out as an Arc clone (no
29/// byte copy); small values as a plain Vec (one alloc — cheaper than a fresh
30/// Arc). The FFI's shared free reconstructs whichever the tag says.
31#[derive(Debug)]
32pub enum GetShared {
33 /// Bulk value — the engine's Arc, cloned. Zero byte copy.
34 Arc(Arc<Box<[u8]>>),
35 /// Small value (Str/Int) — a plain owned Vec, one allocation.
36 Bytes(Vec<u8>),
37}
38
39impl Store {
40 // ---- strings -------------------------------------------------------
41 /// GET variant that exposes the underlying encoding
42 /// so the reactor's reply path can choose zero-copy
43 /// (`Value::ArcBulk` → push the Arc to the conn's `output_arcs` for a
44 /// writev iovec) vs memcpy (`Value::Str` / `Value::Int` → encode bytes
45 /// into the conn's output Vec). ONE keyspace lookup; the variant tag
46 /// chooses the encoding without a second probe.
47 pub fn get_for_reply(&mut self, key: &[u8]) -> Result<Option<GetReply<'_>>, StoreError> {
48 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
49 None => Ok(None),
50 Some(e) => match &e.value {
51 Value::Str(v) => Ok(Some(GetReply::Bytes(Cow::Borrowed(v.as_slice())))),
52 Value::ArcBulk(a) => Ok(Some(GetReply::ArcBulk(Arc::clone(a)))),
53 Value::Int(n) => {
54 let mut tmp = itoa_i64_stack();
55 let s = format_i64_into(*n, &mut tmp);
56 Ok(Some(GetReply::Bytes(Cow::Owned(s.to_vec()))))
57 }
58 _ => Err(StoreError::WrongType),
59 },
60 }
61 }
62
63 /// Owned GET for the FFI scalar *shared* lane (`kevy_get_shared`). Bulk
64 /// values (`Value::ArcBulk`) return an `Arc::clone` — **no byte copy**; the
65 /// FFI holds the Arc alive and hands JS a buffer that views it directly
66 /// (mirrors MMKV's zero-copy mmap-page view, the thing that made kevy lose
67 /// large GET). Small values (`Str`/`Int`) allocate a fresh `Arc<Box<[u8]>>`
68 /// — the same one copy the Vec lane already pays — so the caller's free path
69 /// is uniform. Read-only (`&self`, like [`Self::get_shared`]) so the FFI
70 /// can take it under a SHARED shard lock — no LRU stamp, matching the
71 /// `maxmemory == 0` fast path the mobile door runs on. Wrong type errors
72 /// like [`Self::get_for_reply`].
73 pub fn get_shared_owned(&self, key: &[u8]) -> Result<Option<GetShared>, StoreError> {
74 match self.map.get(key) {
75 None => Ok(None),
76 Some(e) if e.is_expired(self.cached_clock, self.cached_ns) => Ok(None),
77 // Bulk (already Arc-backed) → clone the Arc: ZERO byte copy. Small
78 // (`Str`/`Int`) → a plain Vec (one alloc, cheaper than wrapping a
79 // fresh Arc — the FFI's shared free handles either).
80 Some(e) => match &e.value {
81 Value::ArcBulk(a) => Ok(Some(GetShared::Arc(Arc::clone(a)))),
82 Value::Str(v) => Ok(Some(GetShared::Bytes(v.as_slice().to_vec()))),
83 Value::Int(n) => {
84 let mut tmp = itoa_i64_stack();
85 let s = format_i64_into(*n, &mut tmp);
86 Ok(Some(GetShared::Bytes(s.to_vec())))
87 }
88 // Cold, `&self` shared lane: pread a fresh value — never
89 // promotes, never sets the probation mark (it cannot:
90 // no `&mut`). Documented: shared-lane reads pay a pread
91 // until a `&mut`-path access promotes the key.
92 Value::Cold(c) if c.type_tag == crate::value::COLD_TAG_STRING => {
93 match self.tier_peek_value(key, &e.value).expect("cold peek") {
94 Value::ArcBulk(a) => Ok(Some(GetShared::Arc(a))),
95 v => Ok(Some(GetShared::Bytes(cold_string_bytes(&v)))),
96 }
97 }
98 _ => Err(StoreError::WrongType),
99 },
100 }
101 }
102
103 /// Fused GET-into-output. Skips the [`GetReply`] enum tag
104 /// round-trip + caller match arm by writing the RESP frame directly into
105 /// `output` (header + bytes + CRLF for Str/Int) or pushing the Arc into
106 /// `output_arcs` at the right offset (ArcBulk zero-copy via writev).
107 /// Returns the same outcomes as [`Self::get_for_reply`]: `Ok(true)` if
108 /// the key was found and emitted, `Ok(false)` if absent (the caller
109 /// emits the `$-1` null bulk — preserves the existing inline-null
110 /// semantics on the reactor side), `Err` for WRONGTYPE.
111 pub fn get_into_output(
112 &mut self,
113 key: &[u8],
114 output: &mut Vec<u8>,
115 output_arcs: &mut Vec<(usize, Arc<Box<[u8]>>)>,
116 ) -> Result<bool, StoreError> {
117 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
118 None => Ok(false),
119 Some(e) => match &e.value {
120 Value::Str(v) => {
121 let bytes = v.as_slice();
122 crate::util::bulk_header_into(output, bytes.len());
123 output.extend_from_slice(bytes);
124 output.extend_from_slice(b"\r\n");
125 Ok(true)
126 }
127 Value::ArcBulk(a) => {
128 crate::util::bulk_header_into(output, a.len());
129 let pos = output.len();
130 output_arcs.push((pos, Arc::clone(a)));
131 output.extend_from_slice(b"\r\n");
132 Ok(true)
133 }
134 Value::Int(n) => {
135 let mut tmp = itoa_i64_stack();
136 let s = format_i64_into(*n, &mut tmp);
137 crate::util::bulk_header_into(output, s.len());
138 output.extend_from_slice(s);
139 output.extend_from_slice(b"\r\n");
140 Ok(true)
141 }
142 _ => Err(StoreError::WrongType),
143 },
144 }
145 }
146
147 /// `GET` — returns a `Cow<[u8]>` so `Value::Int` callers can format the
148 /// integer to ASCII without storing it. `Value::Str`
149 /// returns `Cow::Borrowed` (zero copy); `Value::Int`
150 /// formats to a small owned `Vec<u8>` (up to 20 bytes for `i64::MIN`).
151 pub fn get(&mut self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
152 match self.tier_serve(key, crate::value::COLD_TAG_STRING)? {
153 None => Ok(None),
154 Some(e) => match &e.value {
155 Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
156 // L1: Arc-backed bulk — return borrow into the Arc's
157 // bytes. Caller can either memcpy via Cow::Borrowed
158 // (default `encode_bulk` path) OR look up the
159 // underlying `Value::ArcBulk(arc)` separately for the
160 // writev zero-copy reply path.
161 Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
162 Value::Int(n) => {
163 let mut tmp = itoa_i64_stack();
164 let s = format_i64_into(*n, &mut tmp);
165 Ok(Some(Cow::Owned(s.to_vec())))
166 }
167 _ => Err(StoreError::WrongType),
168 },
169 }
170 }
171
172 /// Read-only `GET`: `&self`, so concurrent readers can run under a shared
173 /// lock (embedded mode's `RwLock` read path). Expiry is checked against the
174 /// coarse cached clock but an expired key is *not* removed here (no `&mut`)
175 /// — the reaper / next write reclaims it; a reader just sees `None`. LRU is
176 /// not touched, so this path is only used when eviction is off
177 /// (`maxmemory == 0`); with eviction, the caller takes the mutating
178 /// [`Self::get`] under an exclusive lock so access still stamps the LRU.
179 pub fn get_shared(&self, key: &[u8]) -> Result<Option<Cow<'_, [u8]>>, StoreError> {
180 match self.map.get(key) {
181 None => Ok(None),
182 Some(e) if e.is_expired(self.cached_clock, self.cached_ns) => Ok(None),
183 Some(e) => match &e.value {
184 Value::Str(v) => Ok(Some(Cow::Borrowed(v.as_slice()))),
185 Value::ArcBulk(a) => Ok(Some(Cow::Borrowed(a.as_ref()))),
186 Value::Int(n) => {
187 let mut tmp = itoa_i64_stack();
188 let s = format_i64_into(*n, &mut tmp);
189 Ok(Some(Cow::Owned(s.to_vec())))
190 }
191 // Cold, `&self` shared lane — see `get_shared_owned`.
192 Value::Cold(c) if c.type_tag == crate::value::COLD_TAG_STRING => {
193 let v = self.tier_peek_value(key, &e.value).expect("cold peek");
194 Ok(Some(Cow::Owned(cold_string_bytes(&v))))
195 }
196 _ => Err(StoreError::WrongType),
197 },
198 }
199 }
200
201 /// Byte length of a string value. A missing key is 0, matching
202 /// STRLEN; an integer-encoded value reports the length it would
203 /// format to, not 8.
204 pub fn strlen(&mut self, key: &[u8]) -> Result<usize, StoreError> {
205 Ok(self.get(key)?.map_or(0, |c| c.len()))
206 }
207
208 /// `INCRBY` family; preserves any TTL.
209 ///
210 /// Following valkey's OBJ_ENCODING_INT approach: the hot path
211 /// matches `Value::Int(n)` and does the increment in place — no parse,
212 /// no format, no allocation. The `Value::Str` arm parses,
213 /// increments, and **promotes** to `Value::Int(next)` so subsequent
214 /// INCRs land on the fast path. Insert-new path also lands as `Int`.
215 pub fn incr_by(&mut self, key: &[u8], delta: i64) -> Result<i64, StoreError> {
216 self.tier_resolve(key, crate::value::COLD_TAG_STRING)?; // cold string pages in
217
218 let outcome = match self.live_entry_mut(key) {
219 Some(e) => match &mut e.value {
220 Value::Int(n) => {
221 let next = n.checked_add(delta).ok_or(StoreError::Overflow)?;
222 *n = next;
223 // In-place i64 mutation — weight unchanged (still 0
224 // heap bytes for an Int). Skip the reweigh entirely.
225 return Ok(next);
226 }
227 Value::Str(v) => {
228 let next = parse_i64(v.as_slice())
229 .ok_or(StoreError::NotInteger)?
230 .checked_add(delta)
231 .ok_or(StoreError::Overflow)?;
232 // Promote to Int: future INCRs hit the fast path.
233 e.value = Value::Int(next);
234 IncrOutcome::Reweigh(next)
235 }
236 Value::ArcBulk(a) => {
237 // L1: large value claimed to be numeric — parse and
238 // promote to Int. Subsequent INCRs hit the fast path.
239 let next = parse_i64(a.as_ref())
240 .ok_or(StoreError::NotInteger)?
241 .checked_add(delta)
242 .ok_or(StoreError::Overflow)?;
243 e.value = Value::Int(next);
244 IncrOutcome::Reweigh(next)
245 }
246 _ => return Err(StoreError::WrongType),
247 },
248 // Absent/expired ⇒ start from 0; 0 + delta can't overflow i64.
249 None => IncrOutcome::Insert(delta),
250 };
251 match outcome {
252 IncrOutcome::Reweigh(next) => {
253 self.reweigh_entry(key);
254 Ok(next)
255 }
256 IncrOutcome::Insert(next) => {
257 self.insert_entry(SmallBytes::from_slice(key), Entry::new(Value::Int(next), None));
258 Ok(next)
259 }
260 }
261 }
262}
263
264enum IncrOutcome {
265 Reweigh(i64),
266 Insert(i64),
267}
268
269/// The bytes a string-class value materializes to on the cold shared
270/// lane (the `Value::Int` re-pick case included — a canonical-integer
271/// spill decodes back through the SET rules).
272fn cold_string_bytes(v: &Value) -> Vec<u8> {
273 match v {
274 Value::Str(s) => s.as_slice().to_vec(),
275 Value::ArcBulk(a) => a.as_ref().to_vec(),
276 Value::Int(n) => {
277 let mut tmp = itoa_i64_stack();
278 format_i64_into(*n, &mut tmp).to_vec()
279 }
280 _ => unreachable!("string-tagged cold record decodes to a string class"),
281 }
282}