Skip to main content

rivetkit_core/actor/
kv.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::time::Instant;
6
7#[cfg(test)]
8use std::sync::atomic::{AtomicUsize, Ordering};
9
10use anyhow::Result;
11#[cfg(test)]
12use parking_lot::Mutex;
13use parking_lot::RwLock;
14use rivet_envoy_client::handle::EnvoyHandle;
15
16use crate::error::ActorRuntime;
17use crate::types::ListOpts;
18
19/// Maximum keys per `apply_batch` put or delete list. Mirrors the engine-side
20/// `MAX_KEYS` limit in `engine/packages/pegboard/src/actor_kv/mod.rs`; the
21/// envoy backend rejects requests above this.
22pub(crate) const APPLY_BATCH_CHUNK_SIZE: usize = 128;
23
24#[derive(Clone)]
25pub struct Kv {
26	backend: KvBackend,
27	actor_id: String,
28}
29
30#[derive(Clone)]
31enum KvBackend {
32	Unconfigured,
33	Envoy(EnvoyHandle),
34	InMemory(Arc<InMemoryKv>),
35}
36
37struct InMemoryKv {
38	// Forced-sync: the in-memory backend never holds this guard across `.await`,
39	// and test hook setters are synchronous.
40	store: RwLock<BTreeMap<Vec<u8>, Vec<u8>>>,
41	#[cfg(test)]
42	stats: InMemoryKvStats,
43}
44
45#[cfg(test)]
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub(crate) struct KvApplyBatchSnapshot {
48	pub puts: Vec<(Vec<u8>, Vec<u8>)>,
49	pub deletes: Vec<Vec<u8>>,
50}
51
52#[cfg(test)]
53#[derive(Default)]
54struct InMemoryKvStats {
55	apply_batch_calls: AtomicUsize,
56	batch_get_calls: AtomicUsize,
57	batch_delete_calls: AtomicUsize,
58	// Forced-sync: test instrumentation is synchronous and never awaited under lock.
59	last_apply_batch: Mutex<Option<KvApplyBatchSnapshot>>,
60	apply_batch_before_write_lock: Mutex<Option<Arc<dyn Fn() + Send + Sync + 'static>>>,
61	delete_range_after_write_lock: Mutex<Option<Arc<dyn Fn() + Send + Sync + 'static>>>,
62}
63
64impl Kv {
65	/// `actor_id` stays on `Kv` because envoy-client KV calls require it on every request.
66	pub fn new(handle: EnvoyHandle, actor_id: impl Into<String>) -> Self {
67		Self {
68			backend: KvBackend::Envoy(handle),
69			actor_id: actor_id.into(),
70		}
71	}
72
73	pub fn new_in_memory() -> Self {
74		Self {
75			backend: KvBackend::InMemory(Arc::new(InMemoryKv {
76				store: RwLock::new(BTreeMap::new()),
77				#[cfg(test)]
78				stats: InMemoryKvStats::default(),
79			})),
80			actor_id: String::new(),
81		}
82	}
83
84	pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
85		let mut values = self.batch_get(&[key]).await?;
86		Ok(values.pop().flatten())
87	}
88
89	pub async fn put(&self, key: &[u8], value: &[u8]) -> Result<()> {
90		self.batch_put(&[(key, value)]).await
91	}
92
93	pub async fn delete(&self, key: &[u8]) -> Result<()> {
94		self.batch_delete(&[key]).await
95	}
96
97	pub async fn delete_range(&self, start: &[u8], end: &[u8]) -> Result<()> {
98		let started_at = Instant::now();
99		let result = match &self.backend {
100			KvBackend::Envoy(handle) => {
101				handle
102					.kv_delete_range(self.actor_id.clone(), start.to_vec(), end.to_vec())
103					.await
104			}
105			KvBackend::InMemory(store) => {
106				let start = start.to_vec();
107				let end = end.to_vec();
108				let mut entries = store.store.write();
109
110				#[cfg(test)]
111				{
112					let hook = store.stats.delete_range_after_write_lock.lock().clone();
113					if let Some(hook) = hook {
114						hook();
115					}
116				}
117
118				entries.retain(|key, _| key < &start || key >= &end);
119				Ok(())
120			}
121			KvBackend::Unconfigured => Err(kv_not_configured_error()),
122		};
123		self.log_call("delete_range", None, None, started_at, &result);
124		result
125	}
126
127	pub async fn list_prefix(
128		&self,
129		prefix: &[u8],
130		opts: ListOpts,
131	) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
132		let started_at = Instant::now();
133		let result = match &self.backend {
134			KvBackend::Envoy(handle) => {
135				handle
136					.kv_list_prefix(
137						self.actor_id.clone(),
138						prefix.to_vec(),
139						Some(opts.reverse),
140						opts.limit.map(u64::from),
141					)
142					.await
143			}
144			KvBackend::InMemory(entries) => {
145				let mut listed: Vec<_> = entries
146					.store
147					.read()
148					.iter()
149					.filter(|(key, _)| key.starts_with(prefix))
150					.map(|(key, value)| (key.clone(), value.clone()))
151					.collect();
152				apply_list_opts(&mut listed, opts);
153				Ok(listed)
154			}
155			KvBackend::Unconfigured => Err(kv_not_configured_error()),
156		};
157		let result_count = result.as_ref().ok().map(Vec::len);
158		self.log_call("list_prefix", None, result_count, started_at, &result);
159		result
160	}
161
162	pub async fn list_range(
163		&self,
164		start: &[u8],
165		end: &[u8],
166		opts: ListOpts,
167	) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
168		match &self.backend {
169			KvBackend::Envoy(handle) => {
170				handle
171					.kv_list_range(
172						self.actor_id.clone(),
173						start.to_vec(),
174						end.to_vec(),
175						true,
176						Some(opts.reverse),
177						opts.limit.map(u64::from),
178					)
179					.await
180			}
181			KvBackend::InMemory(entries) => {
182				let mut listed: Vec<_> = entries
183					.store
184					.read()
185					.range(start.to_vec()..end.to_vec())
186					.map(|(key, value)| (key.clone(), value.clone()))
187					.collect();
188				apply_list_opts(&mut listed, opts);
189				Ok(listed)
190			}
191			KvBackend::Unconfigured => Err(kv_not_configured_error()),
192		}
193	}
194
195	pub async fn batch_get(&self, keys: &[&[u8]]) -> Result<Vec<Option<Vec<u8>>>> {
196		let started_at = Instant::now();
197		let result = match &self.backend {
198			KvBackend::Envoy(handle) => {
199				handle
200					.kv_get(
201						self.actor_id.clone(),
202						keys.iter().map(|key| key.to_vec()).collect(),
203					)
204					.await
205			}
206			KvBackend::InMemory(entries) => {
207				#[cfg(test)]
208				entries.stats.batch_get_calls.fetch_add(1, Ordering::SeqCst);
209				let entries = entries.store.read();
210				Ok(keys.iter().map(|key| entries.get(*key).cloned()).collect())
211			}
212			KvBackend::Unconfigured => Err(kv_not_configured_error()),
213		};
214		self.log_call("batch_get", Some(keys.len()), None, started_at, &result);
215		result
216	}
217
218	pub async fn batch_put(&self, entries: &[(&[u8], &[u8])]) -> Result<()> {
219		let started_at = Instant::now();
220		let result = match &self.backend {
221			KvBackend::Envoy(handle) => {
222				handle
223					.kv_put(
224						self.actor_id.clone(),
225						entries
226							.iter()
227							.map(|(key, value)| (key.to_vec(), value.to_vec()))
228							.collect(),
229					)
230					.await
231			}
232			KvBackend::InMemory(store) => {
233				let mut store = store.store.write();
234				for (key, value) in entries {
235					store.insert(key.to_vec(), value.to_vec());
236				}
237				Ok(())
238			}
239			KvBackend::Unconfigured => Err(kv_not_configured_error()),
240		};
241		self.log_call("batch_put", Some(entries.len()), None, started_at, &result);
242		result
243	}
244
245	pub async fn apply_batch(
246		&self,
247		puts: &[(Vec<u8>, Vec<u8>)],
248		deletes: &[Vec<u8>],
249	) -> Result<()> {
250		match &self.backend {
251			KvBackend::Envoy(_) => {
252				if !puts.is_empty() {
253					let put_refs: Vec<(&[u8], &[u8])> = puts
254						.iter()
255						.map(|(key, value)| (key.as_slice(), value.as_slice()))
256						.collect();
257					self.batch_put(&put_refs).await?;
258				}
259
260				if !deletes.is_empty() {
261					let delete_refs: Vec<&[u8]> = deletes.iter().map(Vec::as_slice).collect();
262					self.batch_delete(&delete_refs).await?;
263				}
264
265				Ok(())
266			}
267			KvBackend::InMemory(store) => {
268				#[cfg(test)]
269				{
270					store.stats.apply_batch_calls.fetch_add(1, Ordering::SeqCst);
271					*store.stats.last_apply_batch.lock() = Some(KvApplyBatchSnapshot {
272						puts: puts.to_vec(),
273						deletes: deletes.to_vec(),
274					});
275					let hook = store.stats.apply_batch_before_write_lock.lock().clone();
276					if let Some(hook) = hook {
277						hook();
278					}
279				}
280				let mut store = store.store.write();
281				for key in deletes {
282					store.remove(key);
283				}
284				for (key, value) in puts {
285					store.insert(key.clone(), value.clone());
286				}
287				Ok(())
288			}
289			KvBackend::Unconfigured => Err(kv_not_configured_error()),
290		}
291	}
292
293	pub async fn batch_delete(&self, keys: &[&[u8]]) -> Result<()> {
294		let started_at = Instant::now();
295		let result = match &self.backend {
296			KvBackend::Envoy(handle) => {
297				handle
298					.kv_delete(
299						self.actor_id.clone(),
300						keys.iter().map(|key| key.to_vec()).collect(),
301					)
302					.await
303			}
304			KvBackend::InMemory(entries) => {
305				#[cfg(test)]
306				entries
307					.stats
308					.batch_delete_calls
309					.fetch_add(1, Ordering::SeqCst);
310				let mut entries = entries.store.write();
311				for key in keys {
312					entries.remove(*key);
313				}
314				Ok(())
315			}
316			KvBackend::Unconfigured => Err(kv_not_configured_error()),
317		};
318		self.log_call("delete", Some(keys.len()), None, started_at, &result);
319		result
320	}
321
322	fn backend_label(&self) -> &'static str {
323		match &self.backend {
324			KvBackend::Unconfigured => "unconfigured",
325			KvBackend::Envoy(_) => "envoy",
326			KvBackend::InMemory(_) => "in_memory",
327		}
328	}
329
330	fn log_call<T>(
331		&self,
332		operation: &'static str,
333		key_count: Option<usize>,
334		result_count: Option<usize>,
335		started_at: Instant,
336		result: &Result<T>,
337	) {
338		let elapsed_us = duration_micros(started_at.elapsed());
339		match result {
340			Ok(_) => {
341				tracing::debug!(
342					actor_id = %self.actor_id,
343					backend = self.backend_label(),
344					operation,
345					key_count = ?key_count,
346					result_count = ?result_count,
347					elapsed_us,
348					outcome = "ok",
349					"kv call completed"
350				);
351			}
352			Err(error) => {
353				tracing::debug!(
354					actor_id = %self.actor_id,
355					backend = self.backend_label(),
356					operation,
357					key_count = ?key_count,
358					result_count = ?result_count,
359					elapsed_us,
360					outcome = "error",
361					error = %error,
362					"kv call completed"
363				);
364			}
365		}
366	}
367}
368
369fn kv_not_configured_error() -> anyhow::Error {
370	ActorRuntime::NotConfigured {
371		component: "kv handle".to_owned(),
372	}
373	.build()
374}
375
376fn duration_micros(duration: Duration) -> u64 {
377	duration.as_micros().try_into().unwrap_or(u64::MAX)
378}
379
380impl std::fmt::Debug for Kv {
381	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382		f.debug_struct("Kv")
383			.field(
384				"configured",
385				&!matches!(self.backend, KvBackend::Unconfigured),
386			)
387			.field("in_memory", &matches!(self.backend, KvBackend::InMemory(_)))
388			.field("actor_id", &self.actor_id)
389			.finish()
390	}
391}
392
393impl Default for Kv {
394	fn default() -> Self {
395		Self {
396			backend: KvBackend::Unconfigured,
397			actor_id: String::new(),
398		}
399	}
400}
401
402#[cfg(test)]
403impl Kv {
404	pub(crate) fn test_apply_batch_call_count(&self) -> usize {
405		match &self.backend {
406			KvBackend::InMemory(store) => store.stats.apply_batch_calls.load(Ordering::SeqCst),
407			_ => 0,
408		}
409	}
410
411	pub(crate) fn test_batch_delete_call_count(&self) -> usize {
412		match &self.backend {
413			KvBackend::InMemory(store) => store.stats.batch_delete_calls.load(Ordering::SeqCst),
414			_ => 0,
415		}
416	}
417
418	pub(crate) fn test_batch_get_call_count(&self) -> usize {
419		match &self.backend {
420			KvBackend::InMemory(store) => store.stats.batch_get_calls.load(Ordering::SeqCst),
421			_ => 0,
422		}
423	}
424
425	pub(crate) fn test_last_apply_batch(&self) -> Option<KvApplyBatchSnapshot> {
426		match &self.backend {
427			KvBackend::InMemory(store) => store.stats.last_apply_batch.lock().clone(),
428			_ => None,
429		}
430	}
431
432	pub(crate) fn test_set_delete_range_after_write_lock_hook(
433		&self,
434		hook: impl Fn() + Send + Sync + 'static,
435	) {
436		if let KvBackend::InMemory(store) = &self.backend {
437			*store.stats.delete_range_after_write_lock.lock() = Some(Arc::new(hook));
438		}
439	}
440
441	pub(crate) fn test_set_apply_batch_before_write_lock_hook(
442		&self,
443		hook: impl Fn() + Send + Sync + 'static,
444	) {
445		if let KvBackend::InMemory(store) = &self.backend {
446			*store.stats.apply_batch_before_write_lock.lock() = Some(Arc::new(hook));
447		}
448	}
449}
450
451fn apply_list_opts(entries: &mut Vec<(Vec<u8>, Vec<u8>)>, opts: ListOpts) {
452	if opts.reverse {
453		entries.reverse();
454	}
455	if let Some(limit) = opts.limit {
456		entries.truncate(limit as usize);
457	}
458}
459
460// Test shim keeps moved tests in crate-root tests/ with private-module access.
461#[cfg(test)]
462#[path = "../../tests/kv.rs"]
463pub(crate) mod tests;