slatedb 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ use std::sync::Arc;
2
+ use std::thread;
3
+
4
+ use bytes::Bytes;
5
+ use log::error;
6
+ use magnus::rb_sys::{AsRawValue, FromRawValue};
7
+ use magnus::value::ReprValue;
8
+ use magnus::{Error, RHash, Ruby, Value};
9
+ use slatedb::{MergeOperator, MergeOperatorError};
10
+
11
+ use crate::errors::invalid_argument_error;
12
+ use crate::utils::get_optional;
13
+
14
+ struct StringConcatMergeOperator;
15
+
16
+ impl MergeOperator for StringConcatMergeOperator {
17
+ fn merge(
18
+ &self,
19
+ _key: &Bytes,
20
+ existing_value: Option<Bytes>,
21
+ value: Bytes,
22
+ ) -> Result<Bytes, MergeOperatorError> {
23
+ let mut result = existing_value.unwrap_or_default().to_vec();
24
+ result.extend_from_slice(&value);
25
+ Ok(Bytes::from(result))
26
+ }
27
+
28
+ fn merge_batch(
29
+ &self,
30
+ _key: &Bytes,
31
+ existing_value: Option<Bytes>,
32
+ operands: &[Bytes],
33
+ ) -> Result<Bytes, MergeOperatorError> {
34
+ let mut result = existing_value.unwrap_or_default().to_vec();
35
+ for operand in operands {
36
+ result.extend_from_slice(operand);
37
+ }
38
+ Ok(Bytes::from(result))
39
+ }
40
+ }
41
+
42
+ /// A merge operator that calls a Ruby block/proc.
43
+ ///
44
+ /// This stores the raw Ruby VALUE and calls it via `with_gvl` when merge
45
+ /// operations are needed. The proc is called with (key, existing_value, new_value)
46
+ /// and should return the merged value as a String.
47
+ ///
48
+ /// # Thread Safety
49
+ ///
50
+ /// The Ruby proc can only be called from the Ruby thread that created this operator.
51
+ /// If the merge is called from a different thread (e.g., a Tokio worker thread during
52
+ /// background compaction), the merge will use a fallback string concatenation behavior.
53
+ ///
54
+ /// # Safety
55
+ ///
56
+ /// The Ruby proc must be kept alive (not garbage collected) for the lifetime
57
+ /// of this operator. This is typically handled by storing a reference to the
58
+ /// proc in the Ruby Database object.
59
+ pub struct RubyProcMergeOperator {
60
+ /// The raw Ruby VALUE of the proc. We store this as a raw value because
61
+ /// magnus::Value is not Send+Sync, but we need to be thread-safe.
62
+ /// We re-acquire the GVL before using it, which makes this safe.
63
+ proc_value: usize,
64
+ /// The thread ID of the Ruby thread that created this operator.
65
+ /// We can only safely call Ruby from this thread.
66
+ ruby_thread_id: thread::ThreadId,
67
+ }
68
+
69
+ // SAFETY: We only access the proc_value when we hold the GVL via the Ruby thread,
70
+ // which ensures thread-safe access to Ruby objects.
71
+ unsafe impl Send for RubyProcMergeOperator {}
72
+ unsafe impl Sync for RubyProcMergeOperator {}
73
+
74
+ impl RubyProcMergeOperator {
75
+ /// Create a new RubyProcMergeOperator from a Ruby proc/block.
76
+ ///
77
+ /// # Safety
78
+ ///
79
+ /// The caller must ensure the proc remains alive (not GC'd) for the
80
+ /// lifetime of this operator.
81
+ pub fn new(proc: Value) -> Self {
82
+ Self {
83
+ proc_value: proc.as_raw() as usize,
84
+ ruby_thread_id: thread::current().id(),
85
+ }
86
+ }
87
+
88
+ /// Check if we're on the Ruby thread that created this operator.
89
+ fn is_ruby_thread(&self) -> bool {
90
+ thread::current().id() == self.ruby_thread_id
91
+ }
92
+
93
+ /// Call the Ruby proc with the given arguments.
94
+ /// This must only be called from the Ruby thread (after checking is_ruby_thread).
95
+ fn call_proc_on_ruby_thread(
96
+ &self,
97
+ key: &str,
98
+ existing_value: Option<&str>,
99
+ new_value: &str,
100
+ ) -> Result<Bytes, MergeOperatorError> {
101
+ // We're on the Ruby thread, so we can use with_gvl
102
+ // Import here to avoid the circular dependency at module level
103
+ use crate::runtime::with_gvl;
104
+
105
+ let key_owned = key.to_string();
106
+ let existing_owned = existing_value.map(|s| s.to_string());
107
+ let new_owned = new_value.to_string();
108
+
109
+ with_gvl(|| {
110
+ let ruby = Ruby::get().expect("Ruby runtime not available");
111
+
112
+ // Reconstruct the proc Value from the raw pointer
113
+ let proc = unsafe { Value::from_raw(self.proc_value as _) };
114
+
115
+ // Build arguments: (key, existing_value, new_value)
116
+ let existing_arg: Value = match &existing_owned {
117
+ Some(s) => ruby.str_new(s).as_value(),
118
+ None => ruby.qnil().as_value(),
119
+ };
120
+
121
+ // Call the proc
122
+ let result: Result<String, magnus::Error> = proc.funcall(
123
+ "call",
124
+ (
125
+ ruby.str_new(&key_owned),
126
+ existing_arg,
127
+ ruby.str_new(&new_owned),
128
+ ),
129
+ );
130
+
131
+ match result {
132
+ Ok(merged) => Ok(Bytes::from(merged)),
133
+ Err(e) => {
134
+ error!("Ruby merge operator error: {}", e);
135
+ Err(MergeOperatorError::EmptyBatch)
136
+ }
137
+ }
138
+ })
139
+ }
140
+
141
+ /// Fallback merge when we're not on the Ruby thread.
142
+ /// Uses simple concatenation as a safe default.
143
+ fn fallback_merge(
144
+ &self,
145
+ existing_value: Option<&Bytes>,
146
+ new_value: &Bytes,
147
+ ) -> Result<Bytes, MergeOperatorError> {
148
+ error!(
149
+ "Ruby merge operator called from non-Ruby thread, using fallback concatenation. \
150
+ This can happen during background compaction."
151
+ );
152
+ let mut result = existing_value.map(|v| v.to_vec()).unwrap_or_default();
153
+ result.extend_from_slice(new_value);
154
+ Ok(Bytes::from(result))
155
+ }
156
+
157
+ /// Call the Ruby proc with the given arguments, handling thread safety.
158
+ fn call_proc(
159
+ &self,
160
+ key: &Bytes,
161
+ existing_value: Option<&Bytes>,
162
+ new_value: &Bytes,
163
+ ) -> Result<Bytes, MergeOperatorError> {
164
+ let key_str = String::from_utf8_lossy(key);
165
+ let existing_str = existing_value.map(|v| String::from_utf8_lossy(v));
166
+ let new_str = String::from_utf8_lossy(new_value);
167
+
168
+ if self.is_ruby_thread() {
169
+ self.call_proc_on_ruby_thread(&key_str, existing_str.as_deref(), &new_str)
170
+ } else {
171
+ // We're on a worker thread, use fallback
172
+ self.fallback_merge(existing_value, new_value)
173
+ }
174
+ }
175
+ }
176
+
177
+ impl MergeOperator for RubyProcMergeOperator {
178
+ fn merge(
179
+ &self,
180
+ key: &Bytes,
181
+ existing_value: Option<Bytes>,
182
+ value: Bytes,
183
+ ) -> Result<Bytes, MergeOperatorError> {
184
+ self.call_proc(key, existing_value.as_ref(), &value)
185
+ }
186
+
187
+ fn merge_batch(
188
+ &self,
189
+ key: &Bytes,
190
+ existing_value: Option<Bytes>,
191
+ operands: &[Bytes],
192
+ ) -> Result<Bytes, MergeOperatorError> {
193
+ // Apply operands one at a time through the Ruby proc
194
+ let mut current = existing_value;
195
+ for operand in operands {
196
+ current = Some(self.call_proc(key, current.as_ref(), operand)?);
197
+ }
198
+ Ok(current.unwrap_or_default())
199
+ }
200
+ }
201
+
202
+ pub fn parse_merge_operator(
203
+ kwargs: &RHash,
204
+ ) -> Result<Option<Arc<dyn MergeOperator + Send + Sync>>, Error> {
205
+ let merge_operator = get_optional::<String>(kwargs, "merge_operator")?;
206
+ let Some(merge_operator) = merge_operator else {
207
+ return Ok(None);
208
+ };
209
+
210
+ let operator: Arc<dyn MergeOperator + Send + Sync> = match merge_operator.as_str() {
211
+ "string_concat" | "concat" => Arc::new(StringConcatMergeOperator),
212
+ _ => {
213
+ return Err(invalid_argument_error(&format!(
214
+ "invalid merge_operator: {} (expected 'string_concat', 'concat', or use merge_operator_proc for a custom block)",
215
+ merge_operator
216
+ )))
217
+ }
218
+ };
219
+
220
+ Ok(Some(operator))
221
+ }
222
+
223
+ /// Parse a Ruby proc as a merge operator.
224
+ pub fn parse_merge_operator_proc(
225
+ kwargs: &RHash,
226
+ ) -> Result<Option<Arc<dyn MergeOperator + Send + Sync>>, Error> {
227
+ let proc_value = get_optional::<Value>(kwargs, "merge_operator_proc")?;
228
+ let Some(proc) = proc_value else {
229
+ return Ok(None);
230
+ };
231
+
232
+ // Verify it's callable
233
+ if !proc.respond_to("call", false).unwrap_or(false) {
234
+ return Err(invalid_argument_error(
235
+ "merge_operator_proc must respond to 'call'",
236
+ ));
237
+ }
238
+
239
+ Ok(Some(Arc::new(RubyProcMergeOperator::new(proc))))
240
+ }
@@ -0,0 +1,47 @@
1
+ use std::collections::HashMap;
2
+ use std::sync::{Arc, Mutex};
3
+
4
+ use magnus::prelude::*;
5
+ use magnus::{method, Error, Ruby};
6
+ /// Ruby wrapper for SlateDB metrics registry.
7
+ ///
8
+ /// This struct is exposed to Ruby as `SlateDb::Metrics`.
9
+ #[magnus::wrap(class = "SlateDb::Metrics", free_immediately, size)]
10
+ pub struct Metrics {
11
+ inner: Arc<Mutex<HashMap<String, i64>>>,
12
+ }
13
+
14
+ impl Metrics {
15
+ pub fn new(inner: Arc<Mutex<HashMap<String, i64>>>) -> Self {
16
+ Self { inner }
17
+ }
18
+
19
+ /// Return a list of metric names.
20
+ pub fn names(&self) -> Result<magnus::RArray, Error> {
21
+ let metrics = self.inner.lock().expect("metrics mutex poisoned");
22
+ let ruby = Ruby::get().expect("Ruby runtime not available");
23
+ let result = ruby.ary_new_capa(metrics.len());
24
+
25
+ for name in metrics.keys() {
26
+ result.push(ruby.str_new(name))?;
27
+ }
28
+
29
+ Ok(result)
30
+ }
31
+
32
+ /// Get the current value of a metric by name.
33
+ pub fn get(&self, name: String) -> Result<Option<i64>, Error> {
34
+ let metrics = self.inner.lock().expect("metrics mutex poisoned");
35
+ Ok(metrics.get(&name).copied())
36
+ }
37
+ }
38
+
39
+ /// Define the Metrics class on the SlateDb module.
40
+ pub fn define_metrics_class(ruby: &Ruby, module: &magnus::RModule) -> Result<(), Error> {
41
+ let class = module.define_class("Metrics", ruby.class_object())?;
42
+
43
+ class.define_method("names", method!(Metrics::names, 0))?;
44
+ class.define_method("get", method!(Metrics::get, 1))?;
45
+
46
+ Ok(())
47
+ }
@@ -4,6 +4,7 @@ use magnus::prelude::*;
4
4
  use magnus::{function, method, Error, RHash, Ruby};
5
5
  use slatedb::config::{DbReaderOptions, DurabilityLevel, ReadOptions, ScanOptions};
6
6
  use slatedb::DbReader;
7
+ use slatedb::IterationOrder;
7
8
 
8
9
  use crate::errors::invalid_argument_error;
9
10
  use crate::iterator::Iterator;
@@ -26,7 +27,10 @@ impl Reader {
26
27
  /// * `path` - The path identifier for the database
27
28
  /// * `url` - Optional object store URL
28
29
  /// * `checkpoint_id` - Optional checkpoint UUID to read at
29
- /// * `kwargs` - Additional options (manifest_poll_interval, checkpoint_lifetime, max_memtable_bytes)
30
+ /// * `kwargs` - Additional options (manifest_poll_interval, checkpoint_lifetime,
31
+ /// max_memtable_bytes, skip_wal_replay, cache_root, max_open_file_handles).
32
+ /// The local disk cache (and therefore `max_open_file_handles`) is only active
33
+ /// when `cache_root` is set.
30
34
  pub fn open(
31
35
  path: String,
32
36
  url: Option<String>,
@@ -39,6 +43,9 @@ impl Reader {
39
43
  let checkpoint_lifetime = get_optional::<u64>(&kwargs, "checkpoint_lifetime")?
40
44
  .map(std::time::Duration::from_millis);
41
45
  let max_memtable_bytes = get_optional::<u64>(&kwargs, "max_memtable_bytes")?;
46
+ let skip_wal_replay = get_optional::<bool>(&kwargs, "skip_wal_replay")?;
47
+ let max_open_file_handles = get_optional::<usize>(&kwargs, "max_open_file_handles")?;
48
+ let cache_root = get_optional::<String>(&kwargs, "cache_root")?;
42
49
 
43
50
  // Parse checkpoint_id as UUID
44
51
  let checkpoint_uuid =
@@ -51,11 +58,12 @@ impl Reader {
51
58
  };
52
59
 
53
60
  let reader = block_on_result(async {
54
- let object_store: Arc<dyn object_store::ObjectStore> = if let Some(ref url) = url {
55
- resolve_object_store(url)?
56
- } else {
57
- Arc::new(object_store::memory::InMemory::new())
58
- };
61
+ let object_store: Arc<dyn slatedb::object_store::ObjectStore> =
62
+ if let Some(ref url) = url {
63
+ resolve_object_store(url)?
64
+ } else {
65
+ Arc::new(slatedb::object_store::memory::InMemory::new())
66
+ };
59
67
 
60
68
  let mut options = DbReaderOptions::default();
61
69
  if let Some(interval) = manifest_poll_interval {
@@ -67,7 +75,16 @@ impl Reader {
67
75
  if let Some(max_bytes) = max_memtable_bytes {
68
76
  options.max_memtable_bytes = max_bytes;
69
77
  }
70
-
78
+ if let Some(skip_replay) = skip_wal_replay {
79
+ options.skip_wal_replay = skip_replay;
80
+ }
81
+ if let Some(ref root) = cache_root {
82
+ options.object_store_cache_options.root_folder =
83
+ Some(std::path::PathBuf::from(root));
84
+ }
85
+ if let Some(max_handles) = max_open_file_handles {
86
+ options.object_store_cache_options.max_open_file_handles = max_handles;
87
+ }
71
88
  DbReader::open(path, object_store, checkpoint_uuid, options).await
72
89
  })?;
73
90
 
@@ -111,6 +128,10 @@ impl Reader {
111
128
  opts.dirty = dirty;
112
129
  }
113
130
 
131
+ if let Some(cb) = get_optional::<bool>(&kwargs, "cache_blocks")? {
132
+ opts.cache_blocks = cb;
133
+ }
134
+
114
135
  let result =
115
136
  block_on_result(async { self.inner.get_with_options(key.as_bytes(), &opts).await })?;
116
137
  Ok(result.map(|b| String::from_utf8_lossy(&b).to_string()))
@@ -186,6 +207,18 @@ impl Reader {
186
207
  if let Some(mft) = get_optional::<usize>(&kwargs, "max_fetch_tasks")? {
187
208
  opts.max_fetch_tasks = mft;
188
209
  }
210
+ if let Some(order) = get_optional::<String>(&kwargs, "order")? {
211
+ opts.order = match order.as_str() {
212
+ "ascending" | "asc" => IterationOrder::Ascending,
213
+ "descending" | "desc" => IterationOrder::Descending,
214
+ other => {
215
+ return Err(invalid_argument_error(&format!(
216
+ "invalid order: {} (expected 'asc' or 'desc')",
217
+ other
218
+ )))
219
+ }
220
+ };
221
+ }
189
222
 
190
223
  let start_bytes = start.into_bytes();
191
224
  let end_bytes = end_key.map(|e| e.into_bytes());
@@ -200,6 +233,79 @@ impl Reader {
200
233
  Ok(Iterator::new(iter))
201
234
  }
202
235
 
236
+ /// Scan all keys with a given prefix.
237
+ pub fn scan_prefix(&self, prefix: String) -> Result<Iterator, Error> {
238
+ if prefix.is_empty() {
239
+ return Err(invalid_argument_error("prefix cannot be empty"));
240
+ }
241
+
242
+ let iter = block_on_result(async { self.inner.scan_prefix(prefix.as_bytes()).await })?;
243
+
244
+ Ok(Iterator::new(iter))
245
+ }
246
+
247
+ /// Scan all keys with a given prefix with options.
248
+ pub fn scan_prefix_with_options(
249
+ &self,
250
+ prefix: String,
251
+ kwargs: RHash,
252
+ ) -> Result<Iterator, Error> {
253
+ if prefix.is_empty() {
254
+ return Err(invalid_argument_error("prefix cannot be empty"));
255
+ }
256
+
257
+ let mut opts = ScanOptions::default();
258
+
259
+ if let Some(df) = get_optional::<String>(&kwargs, "durability_filter")? {
260
+ opts.durability_filter = match df.as_str() {
261
+ "remote" => DurabilityLevel::Remote,
262
+ "memory" => DurabilityLevel::Memory,
263
+ other => {
264
+ return Err(invalid_argument_error(&format!(
265
+ "invalid durability_filter: {} (expected 'remote' or 'memory')",
266
+ other
267
+ )))
268
+ }
269
+ };
270
+ }
271
+
272
+ if let Some(dirty) = get_optional::<bool>(&kwargs, "dirty")? {
273
+ opts.dirty = dirty;
274
+ }
275
+
276
+ if let Some(rab) = get_optional::<usize>(&kwargs, "read_ahead_bytes")? {
277
+ opts.read_ahead_bytes = rab;
278
+ }
279
+
280
+ if let Some(cb) = get_optional::<bool>(&kwargs, "cache_blocks")? {
281
+ opts.cache_blocks = cb;
282
+ }
283
+
284
+ if let Some(mft) = get_optional::<usize>(&kwargs, "max_fetch_tasks")? {
285
+ opts.max_fetch_tasks = mft;
286
+ }
287
+ if let Some(order) = get_optional::<String>(&kwargs, "order")? {
288
+ opts.order = match order.as_str() {
289
+ "ascending" | "asc" => IterationOrder::Ascending,
290
+ "descending" | "desc" => IterationOrder::Descending,
291
+ other => {
292
+ return Err(invalid_argument_error(&format!(
293
+ "invalid order: {} (expected 'asc' or 'desc')",
294
+ other
295
+ )))
296
+ }
297
+ };
298
+ }
299
+
300
+ let iter = block_on_result(async {
301
+ self.inner
302
+ .scan_prefix_with_options(prefix.as_bytes(), &opts)
303
+ .await
304
+ })?;
305
+
306
+ Ok(Iterator::new(iter))
307
+ }
308
+
203
309
  /// Close the reader.
204
310
  pub fn close(&self) -> Result<(), Error> {
205
311
  block_on_result(async { self.inner.close().await })?;
@@ -220,6 +326,11 @@ pub fn define_reader_class(ruby: &Ruby, module: &magnus::RModule) -> Result<(),
220
326
  class.define_method("get_bytes", method!(Reader::get_bytes, 1))?;
221
327
  class.define_method("_scan", method!(Reader::scan, 2))?;
222
328
  class.define_method("_scan_with_options", method!(Reader::scan_with_options, 3))?;
329
+ class.define_method("_scan_prefix", method!(Reader::scan_prefix, 1))?;
330
+ class.define_method(
331
+ "_scan_prefix_with_options",
332
+ method!(Reader::scan_prefix_with_options, 2),
333
+ )?;
223
334
  class.define_method("close", method!(Reader::close, 0))?;
224
335
 
225
336
  Ok(())
@@ -1,6 +1,6 @@
1
1
  use magnus::Error;
2
2
  use once_cell::sync::OnceCell;
3
- use rb_sys::rb_thread_call_without_gvl;
3
+ use rb_sys::{rb_thread_call_with_gvl, rb_thread_call_without_gvl};
4
4
  use slatedb::Error as SlateError;
5
5
  use std::ffi::c_void;
6
6
  use std::future::Future;
@@ -98,3 +98,54 @@ where
98
98
 
99
99
  closure.result.expect("closure did not run")
100
100
  }
101
+
102
+ /// Execute a closure while holding the Ruby GVL.
103
+ ///
104
+ /// This is the inverse of `without_gvl`. Use this when you need to call back
105
+ /// into Ruby from code that has previously released the GVL (e.g., inside
106
+ /// a future being executed by `block_on`).
107
+ ///
108
+ /// # Safety
109
+ ///
110
+ /// This function can ONLY be called from a Ruby thread that has previously
111
+ /// released the GVL via `without_gvl` or `rb_thread_call_without_gvl`.
112
+ /// Calling it from a non-Ruby thread (like a spawned Tokio task) will cause
113
+ /// a Ruby fatal error.
114
+ ///
115
+ /// # Panics
116
+ ///
117
+ /// Panics if called from a non-Ruby thread.
118
+ pub fn with_gvl<F, T>(f: F) -> T
119
+ where
120
+ F: FnOnce() -> T,
121
+ {
122
+ struct Closure<F, T> {
123
+ f: Option<F>,
124
+ result: Option<T>,
125
+ }
126
+
127
+ extern "C" fn call_closure<F, T>(data: *mut c_void) -> *mut c_void
128
+ where
129
+ F: FnOnce() -> T,
130
+ {
131
+ let closure = unsafe { &mut *(data as *mut Closure<F, T>) };
132
+ if let Some(f) = closure.f.take() {
133
+ closure.result = Some(f());
134
+ }
135
+ std::ptr::null_mut()
136
+ }
137
+
138
+ let mut closure = Closure {
139
+ f: Some(f),
140
+ result: None,
141
+ };
142
+
143
+ unsafe {
144
+ rb_thread_call_with_gvl(
145
+ Some(call_closure::<F, T>),
146
+ &mut closure as *mut _ as *mut c_void,
147
+ );
148
+ }
149
+
150
+ closure.result.expect("closure did not run")
151
+ }
@@ -5,6 +5,7 @@ use magnus::prelude::*;
5
5
  use magnus::{method, Error, RHash, Ruby};
6
6
  use slatedb::config::{DurabilityLevel, ReadOptions, ScanOptions};
7
7
  use slatedb::DbSnapshot;
8
+ use slatedb::IterationOrder;
8
9
 
9
10
  use crate::errors::{closed_error, invalid_argument_error};
10
11
  use crate::iterator::Iterator;
@@ -68,6 +69,10 @@ impl Snapshot {
68
69
  opts.dirty = dirty;
69
70
  }
70
71
 
72
+ if let Some(cb) = get_optional::<bool>(&kwargs, "cache_blocks")? {
73
+ opts.cache_blocks = cb;
74
+ }
75
+
71
76
  let guard = self.inner.borrow();
72
77
  let snapshot = guard
73
78
  .as_ref()
@@ -143,6 +148,18 @@ impl Snapshot {
143
148
  if let Some(mft) = get_optional::<usize>(&kwargs, "max_fetch_tasks")? {
144
149
  opts.max_fetch_tasks = mft;
145
150
  }
151
+ if let Some(order) = get_optional::<String>(&kwargs, "order")? {
152
+ opts.order = match order.as_str() {
153
+ "ascending" | "asc" => IterationOrder::Ascending,
154
+ "descending" | "desc" => IterationOrder::Descending,
155
+ other => {
156
+ return Err(invalid_argument_error(&format!(
157
+ "invalid order: {} (expected 'asc' or 'desc')",
158
+ other
159
+ )))
160
+ }
161
+ };
162
+ }
146
163
 
147
164
  let guard = self.inner.borrow();
148
165
  let snapshot = guard
@@ -162,6 +179,89 @@ impl Snapshot {
162
179
  Ok(Iterator::new(iter))
163
180
  }
164
181
 
182
+ /// Scan all keys with a given prefix from the snapshot.
183
+ pub fn scan_prefix(&self, prefix: String) -> Result<Iterator, Error> {
184
+ if prefix.is_empty() {
185
+ return Err(invalid_argument_error("prefix cannot be empty"));
186
+ }
187
+
188
+ let guard = self.inner.borrow();
189
+ let snapshot = guard
190
+ .as_ref()
191
+ .ok_or_else(|| closed_error("snapshot is closed"))?;
192
+
193
+ let iter = block_on_result(async { snapshot.scan_prefix(prefix.as_bytes()).await })?;
194
+
195
+ Ok(Iterator::new(iter))
196
+ }
197
+
198
+ /// Scan all keys with a given prefix with options from the snapshot.
199
+ pub fn scan_prefix_with_options(
200
+ &self,
201
+ prefix: String,
202
+ kwargs: RHash,
203
+ ) -> Result<Iterator, Error> {
204
+ if prefix.is_empty() {
205
+ return Err(invalid_argument_error("prefix cannot be empty"));
206
+ }
207
+
208
+ let mut opts = ScanOptions::default();
209
+
210
+ if let Some(df) = get_optional::<String>(&kwargs, "durability_filter")? {
211
+ opts.durability_filter = match df.as_str() {
212
+ "remote" => DurabilityLevel::Remote,
213
+ "memory" => DurabilityLevel::Memory,
214
+ other => {
215
+ return Err(invalid_argument_error(&format!(
216
+ "invalid durability_filter: {} (expected 'remote' or 'memory')",
217
+ other
218
+ )))
219
+ }
220
+ };
221
+ }
222
+
223
+ if let Some(dirty) = get_optional::<bool>(&kwargs, "dirty")? {
224
+ opts.dirty = dirty;
225
+ }
226
+
227
+ if let Some(rab) = get_optional::<usize>(&kwargs, "read_ahead_bytes")? {
228
+ opts.read_ahead_bytes = rab;
229
+ }
230
+
231
+ if let Some(cb) = get_optional::<bool>(&kwargs, "cache_blocks")? {
232
+ opts.cache_blocks = cb;
233
+ }
234
+
235
+ if let Some(mft) = get_optional::<usize>(&kwargs, "max_fetch_tasks")? {
236
+ opts.max_fetch_tasks = mft;
237
+ }
238
+ if let Some(order) = get_optional::<String>(&kwargs, "order")? {
239
+ opts.order = match order.as_str() {
240
+ "ascending" | "asc" => IterationOrder::Ascending,
241
+ "descending" | "desc" => IterationOrder::Descending,
242
+ other => {
243
+ return Err(invalid_argument_error(&format!(
244
+ "invalid order: {} (expected 'asc' or 'desc')",
245
+ other
246
+ )))
247
+ }
248
+ };
249
+ }
250
+
251
+ let guard = self.inner.borrow();
252
+ let snapshot = guard
253
+ .as_ref()
254
+ .ok_or_else(|| closed_error("snapshot is closed"))?;
255
+
256
+ let iter = block_on_result(async {
257
+ snapshot
258
+ .scan_prefix_with_options(prefix.as_bytes(), &opts)
259
+ .await
260
+ })?;
261
+
262
+ Ok(Iterator::new(iter))
263
+ }
264
+
165
265
  /// Close the snapshot and release resources.
166
266
  pub fn close(&self) -> Result<(), Error> {
167
267
  let _ = self.inner.borrow_mut().take();
@@ -186,6 +286,11 @@ pub fn define_snapshot_class(ruby: &Ruby, module: &magnus::RModule) -> Result<()
186
286
  "_scan_with_options",
187
287
  method!(Snapshot::scan_with_options, 3),
188
288
  )?;
289
+ class.define_method("_scan_prefix", method!(Snapshot::scan_prefix, 1))?;
290
+ class.define_method(
291
+ "_scan_prefix_with_options",
292
+ method!(Snapshot::scan_prefix_with_options, 2),
293
+ )?;
189
294
  class.define_method("close", method!(Snapshot::close, 0))?;
190
295
  class.define_method("closed?", method!(Snapshot::is_closed, 0))?;
191
296