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