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