slatedb 0.1.1 → 0.2.0

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.
@@ -2,8 +2,10 @@ use std::cell::RefCell;
2
2
 
3
3
  use magnus::prelude::*;
4
4
  use magnus::{method, Error, RHash, Ruby};
5
- use slatedb::config::{DurabilityLevel, PutOptions, ReadOptions, ScanOptions, Ttl, WriteOptions};
6
- use slatedb::DBTransaction;
5
+ use slatedb::config::{
6
+ DurabilityLevel, MergeOptions, PutOptions, ReadOptions, ScanOptions, Ttl, WriteOptions,
7
+ };
8
+ use slatedb::DbTransaction;
7
9
 
8
10
  use crate::errors::{closed_error, invalid_argument_error, map_error};
9
11
  use crate::iterator::Iterator;
@@ -16,12 +18,12 @@ use crate::utils::get_optional;
16
18
  /// After commit or rollback, the transaction is closed.
17
19
  #[magnus::wrap(class = "SlateDb::Transaction", free_immediately, size)]
18
20
  pub struct Transaction {
19
- inner: RefCell<Option<DBTransaction>>,
21
+ inner: RefCell<Option<DbTransaction>>,
20
22
  }
21
23
 
22
24
  impl Transaction {
23
- /// Create a new Transaction from a DBTransaction.
24
- pub fn new(txn: DBTransaction) -> Self {
25
+ /// Create a new Transaction from a DbTransaction.
26
+ pub fn new(txn: DbTransaction) -> Self {
25
27
  Self {
26
28
  inner: RefCell::new(Some(txn)),
27
29
  }
@@ -135,6 +137,48 @@ impl Transaction {
135
137
  Ok(())
136
138
  }
137
139
 
140
+ /// Merge a value within the transaction.
141
+ pub fn merge(&self, key: String, value: String) -> Result<(), Error> {
142
+ if key.is_empty() {
143
+ return Err(invalid_argument_error("key cannot be empty"));
144
+ }
145
+
146
+ let guard = self.inner.borrow();
147
+ let txn = guard
148
+ .as_ref()
149
+ .ok_or_else(|| closed_error("transaction is closed"))?;
150
+
151
+ txn.merge(key.as_bytes(), value.as_bytes())
152
+ .map_err(map_error)?;
153
+
154
+ Ok(())
155
+ }
156
+
157
+ /// Merge a value with options within the transaction.
158
+ pub fn merge_with_options(&self, key: String, value: String, kwargs: RHash) -> Result<(), Error> {
159
+ if key.is_empty() {
160
+ return Err(invalid_argument_error("key cannot be empty"));
161
+ }
162
+
163
+ let ttl = get_optional::<u64>(&kwargs, "ttl")?;
164
+ let merge_opts = MergeOptions {
165
+ ttl: match ttl {
166
+ Some(ms) => Ttl::ExpireAfter(ms),
167
+ None => Ttl::Default,
168
+ },
169
+ };
170
+
171
+ let guard = self.inner.borrow();
172
+ let txn = guard
173
+ .as_ref()
174
+ .ok_or_else(|| closed_error("transaction is closed"))?;
175
+
176
+ txn.merge_with_options(key.as_bytes(), value.as_bytes(), &merge_opts)
177
+ .map_err(map_error)?;
178
+
179
+ Ok(())
180
+ }
181
+
138
182
  /// Scan a range of keys within the transaction.
139
183
  pub fn scan(&self, start: String, end_key: Option<String>) -> Result<Iterator, Error> {
140
184
  if start.is_empty() {
@@ -219,6 +263,93 @@ impl Transaction {
219
263
  Ok(Iterator::new(iter))
220
264
  }
221
265
 
266
+ /// Scan all keys with a given prefix within the transaction.
267
+ pub fn scan_prefix(&self, prefix: String) -> Result<Iterator, Error> {
268
+ if prefix.is_empty() {
269
+ return Err(invalid_argument_error("prefix cannot be empty"));
270
+ }
271
+
272
+ let guard = self.inner.borrow();
273
+ let txn = guard
274
+ .as_ref()
275
+ .ok_or_else(|| closed_error("transaction is closed"))?;
276
+
277
+ let iter = block_on_result(async { txn.scan_prefix(prefix.as_bytes()).await })?;
278
+
279
+ Ok(Iterator::new(iter))
280
+ }
281
+
282
+ /// Scan all keys with a given prefix with options within the transaction.
283
+ pub fn scan_prefix_with_options(
284
+ &self,
285
+ prefix: String,
286
+ kwargs: RHash,
287
+ ) -> Result<Iterator, Error> {
288
+ if prefix.is_empty() {
289
+ return Err(invalid_argument_error("prefix cannot be empty"));
290
+ }
291
+
292
+ let mut opts = ScanOptions::default();
293
+
294
+ if let Some(df) = get_optional::<String>(&kwargs, "durability_filter")? {
295
+ opts.durability_filter = match df.as_str() {
296
+ "remote" => DurabilityLevel::Remote,
297
+ "memory" => DurabilityLevel::Memory,
298
+ other => {
299
+ return Err(invalid_argument_error(&format!(
300
+ "invalid durability_filter: {} (expected 'remote' or 'memory')",
301
+ other
302
+ )))
303
+ }
304
+ };
305
+ }
306
+
307
+ if let Some(dirty) = get_optional::<bool>(&kwargs, "dirty")? {
308
+ opts.dirty = dirty;
309
+ }
310
+
311
+ if let Some(rab) = get_optional::<usize>(&kwargs, "read_ahead_bytes")? {
312
+ opts.read_ahead_bytes = rab;
313
+ }
314
+
315
+ if let Some(cb) = get_optional::<bool>(&kwargs, "cache_blocks")? {
316
+ opts.cache_blocks = cb;
317
+ }
318
+
319
+ if let Some(mft) = get_optional::<usize>(&kwargs, "max_fetch_tasks")? {
320
+ opts.max_fetch_tasks = mft;
321
+ }
322
+
323
+ let guard = self.inner.borrow();
324
+ let txn = guard
325
+ .as_ref()
326
+ .ok_or_else(|| closed_error("transaction is closed"))?;
327
+
328
+ let iter =
329
+ block_on_result(async { txn.scan_prefix_with_options(prefix.as_bytes(), &opts).await })?;
330
+
331
+ Ok(Iterator::new(iter))
332
+ }
333
+
334
+ /// Mark keys as read for conflict detection.
335
+ ///
336
+ /// This explicitly tracks reads for conflict checking in serializable isolation,
337
+ /// even when the keys weren't actually read via get().
338
+ ///
339
+ /// # Arguments
340
+ /// * `keys` - Array of keys to mark as read
341
+ pub fn mark_read(&self, keys: Vec<String>) -> Result<(), Error> {
342
+ let guard = self.inner.borrow();
343
+ let txn = guard
344
+ .as_ref()
345
+ .ok_or_else(|| closed_error("transaction is closed"))?;
346
+
347
+ let key_bytes: Vec<&[u8]> = keys.iter().map(|k| k.as_bytes()).collect();
348
+ txn.mark_read(&key_bytes).map_err(map_error)?;
349
+
350
+ Ok(())
351
+ }
352
+
222
353
  /// Commit the transaction.
223
354
  pub fn commit(&self) -> Result<(), Error> {
224
355
  let txn = self
@@ -275,11 +406,22 @@ pub fn define_transaction_class(ruby: &Ruby, module: &magnus::RModule) -> Result
275
406
  method!(Transaction::put_with_options, 3),
276
407
  )?;
277
408
  class.define_method("_delete", method!(Transaction::delete, 1))?;
409
+ class.define_method("_merge", method!(Transaction::merge, 2))?;
410
+ class.define_method(
411
+ "_merge_with_options",
412
+ method!(Transaction::merge_with_options, 3),
413
+ )?;
278
414
  class.define_method("_scan", method!(Transaction::scan, 2))?;
279
415
  class.define_method(
280
416
  "_scan_with_options",
281
417
  method!(Transaction::scan_with_options, 3),
282
418
  )?;
419
+ class.define_method("_scan_prefix", method!(Transaction::scan_prefix, 1))?;
420
+ class.define_method(
421
+ "_scan_prefix_with_options",
422
+ method!(Transaction::scan_prefix_with_options, 2),
423
+ )?;
424
+ class.define_method("_mark_read", method!(Transaction::mark_read, 1))?;
283
425
  class.define_method("commit", method!(Transaction::commit, 0))?;
284
426
  class.define_method(
285
427
  "_commit_with_options",
@@ -2,8 +2,8 @@ use std::sync::Arc;
2
2
 
3
3
  use magnus::value::ReprValue;
4
4
  use magnus::{Error, RHash, Ruby, TryConvert};
5
- use object_store::aws::AmazonS3Builder;
6
- use object_store::ObjectStoreScheme;
5
+ use slatedb::object_store::aws::AmazonS3Builder;
6
+ use slatedb::object_store::{Error as ObjectStoreError, ObjectStore, ObjectStoreScheme};
7
7
  use slatedb::{Db, Error as SlateError};
8
8
  use url::Url;
9
9
 
@@ -24,7 +24,7 @@ pub fn get_optional<T: TryConvert>(hash: &RHash, key: &str) -> Result<Option<T>,
24
24
  }
25
25
 
26
26
  /// Convert an object_store error to a SlateDB error
27
- fn to_slate_error(e: object_store::Error) -> SlateError {
27
+ fn to_slate_error(e: ObjectStoreError) -> SlateError {
28
28
  SlateError::unavailable(e.to_string())
29
29
  }
30
30
 
@@ -33,7 +33,7 @@ fn to_slate_error(e: object_store::Error) -> SlateError {
33
33
  /// This function handles S3 URLs specially to ensure environment variables
34
34
  /// like AWS_ACCESS_KEY_ID are properly recognized (the default object_store
35
35
  /// registry only recognizes lowercase variants like aws_access_key_id).
36
- pub fn resolve_object_store(url: &str) -> Result<Arc<dyn object_store::ObjectStore>, SlateError> {
36
+ pub fn resolve_object_store(url: &str) -> Result<Arc<dyn ObjectStore>, SlateError> {
37
37
  let parsed_url: Url = url
38
38
  .try_into()
39
39
  .map_err(|e: url::ParseError| SlateError::invalid(format!("invalid URL: {}", e)))?;
@@ -44,6 +44,7 @@ pub fn resolve_object_store(url: &str) -> Result<Arc<dyn object_store::ObjectSto
44
44
  match scheme {
45
45
  ObjectStoreScheme::AmazonS3 => {
46
46
  // Use from_env() to properly handle uppercase AWS_* environment variables
47
+ // (the default object_store registry only recognizes lowercase variants)
47
48
  let store = AmazonS3Builder::from_env()
48
49
  .with_url(url)
49
50
  .build()
@@ -2,7 +2,7 @@ use std::cell::RefCell;
2
2
 
3
3
  use magnus::prelude::*;
4
4
  use magnus::{function, method, Error, RHash, Ruby};
5
- use slatedb::config::{PutOptions, Ttl};
5
+ use slatedb::config::{MergeOptions, PutOptions, Ttl};
6
6
  use slatedb::WriteBatch as SlateWriteBatch;
7
7
 
8
8
  use crate::errors::invalid_argument_error;
@@ -72,6 +72,43 @@ impl WriteBatch {
72
72
  Ok(())
73
73
  }
74
74
 
75
+ /// Add a merge operation to the batch.
76
+ pub fn merge(&self, key: String, value: String) -> Result<(), Error> {
77
+ if key.is_empty() {
78
+ return Err(invalid_argument_error("key cannot be empty"));
79
+ }
80
+
81
+ self.inner
82
+ .borrow_mut()
83
+ .merge(key.as_bytes(), value.as_bytes());
84
+
85
+ Ok(())
86
+ }
87
+
88
+ /// Add a merge operation with options to the batch.
89
+ ///
90
+ /// Options:
91
+ /// - ttl: Time-to-live in milliseconds
92
+ pub fn merge_with_options(&self, key: String, value: String, kwargs: RHash) -> Result<(), Error> {
93
+ if key.is_empty() {
94
+ return Err(invalid_argument_error("key cannot be empty"));
95
+ }
96
+
97
+ let ttl = get_optional::<u64>(&kwargs, "ttl")?;
98
+ let merge_opts = MergeOptions {
99
+ ttl: match ttl {
100
+ Some(ms) => Ttl::ExpireAfter(ms),
101
+ None => Ttl::Default,
102
+ },
103
+ };
104
+
105
+ self.inner
106
+ .borrow_mut()
107
+ .merge_with_options(key.as_bytes(), value.as_bytes(), &merge_opts);
108
+
109
+ Ok(())
110
+ }
111
+
75
112
  /// Take ownership of the inner WriteBatch (consumes it).
76
113
  /// Used internally when writing the batch to the database.
77
114
  pub fn take(&self) -> Result<SlateWriteBatch, Error> {
@@ -93,6 +130,11 @@ pub fn define_write_batch_class(ruby: &Ruby, module: &magnus::RModule) -> Result
93
130
  method!(WriteBatch::put_with_options, 3),
94
131
  )?;
95
132
  class.define_method("_delete", method!(WriteBatch::delete, 1))?;
133
+ class.define_method("_merge", method!(WriteBatch::merge, 2))?;
134
+ class.define_method(
135
+ "_merge_with_options",
136
+ method!(WriteBatch::merge_with_options, 3),
137
+ )?;
96
138
 
97
139
  Ok(())
98
140
  }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SlateDb
4
- class Database
4
+ class Database # rubocop:disable Metrics/ClassLength
5
5
  private_class_method :new
6
6
 
7
7
  class << self
@@ -9,6 +9,9 @@ module SlateDb
9
9
  #
10
10
  # @param path [String] The path identifier for the database
11
11
  # @param url [String, nil] Optional object store URL (e.g., "s3://bucket/path")
12
+ # @param merge_operator [Symbol, String, Proc, nil] Optional merge operator.
13
+ # Can be a symbol/string ("string_concat" or "concat") or a Proc/lambda
14
+ # that takes (key, existing_value, new_value) and returns the merged value.
12
15
  # @yield [db] If a block is given, yields the database and ensures it's closed
13
16
  # @return [Database] The opened database (or block result if block given)
14
17
  #
@@ -25,8 +28,29 @@ module SlateDb
25
28
  # @example Open with S3 backend
26
29
  # db = SlateDb::Database.open("/tmp/mydb", url: "s3://mybucket/path")
27
30
  #
28
- def open(path, url: nil)
29
- db = _open(path, url)
31
+ # @example Open with a custom merge operator (Proc)
32
+ # # Custom merge that adds numbers
33
+ # db = SlateDb::Database.open("/tmp/mydb", merge_operator: ->(key, existing, new_val) {
34
+ # existing_num = existing ? existing.to_i : 0
35
+ # (existing_num + new_val.to_i).to_s
36
+ # })
37
+ # db.merge("counter", "5")
38
+ # db.merge("counter", "3")
39
+ # db.get("counter") # => "8"
40
+ #
41
+ def open(path, url: nil, merge_operator: nil)
42
+ opts = {}
43
+
44
+ case merge_operator
45
+ when Symbol, String
46
+ opts[:merge_operator] = merge_operator.to_s
47
+ when Proc
48
+ # Store the proc to prevent GC and pass to Rust
49
+ @_merge_operator_proc = merge_operator
50
+ opts[:merge_operator_proc] = merge_operator
51
+ end
52
+
53
+ db = _open(path, url, opts)
30
54
 
31
55
  if block_given?
32
56
  begin
@@ -168,6 +192,43 @@ module SlateDb
168
192
  end
169
193
  end
170
194
 
195
+ # Scan all keys with a given prefix.
196
+ #
197
+ # @param prefix [String] The key prefix to scan
198
+ # @param durability_filter [String, nil] Filter by durability level ("remote" or "memory")
199
+ # @param dirty [Boolean, nil] Whether to include uncommitted data
200
+ # @param read_ahead_bytes [Integer, nil] Number of bytes to read ahead
201
+ # @param cache_blocks [Boolean, nil] Whether to cache blocks
202
+ # @param max_fetch_tasks [Integer, nil] Maximum number of fetch tasks
203
+ # @return [Iterator] An iterator over key-value pairs
204
+ #
205
+ # @example Scan all user keys
206
+ # db.scan_prefix("user:") do |key, value|
207
+ # puts "#{key}: #{value}"
208
+ # end
209
+ #
210
+ def scan_prefix(prefix, durability_filter: nil, dirty: nil,
211
+ read_ahead_bytes: nil, cache_blocks: nil, max_fetch_tasks: nil, &)
212
+ opts = {}
213
+ opts[:durability_filter] = durability_filter.to_s if durability_filter
214
+ opts[:dirty] = dirty unless dirty.nil?
215
+ opts[:read_ahead_bytes] = read_ahead_bytes if read_ahead_bytes
216
+ opts[:cache_blocks] = cache_blocks unless cache_blocks.nil?
217
+ opts[:max_fetch_tasks] = max_fetch_tasks if max_fetch_tasks
218
+
219
+ iter = if opts.empty?
220
+ _scan_prefix(prefix)
221
+ else
222
+ _scan_prefix_with_options(prefix, opts)
223
+ end
224
+
225
+ if block_given?
226
+ iter.each(&)
227
+ else
228
+ iter
229
+ end
230
+ end
231
+
171
232
  # Write a batch of operations atomically.
172
233
  #
173
234
  # @param batch [WriteBatch] The batch to write
@@ -195,6 +256,31 @@ module SlateDb
195
256
  end
196
257
  end
197
258
 
259
+ # Merge a value into the database.
260
+ #
261
+ # @param key [String] The key to merge into
262
+ # @param value [String] The merge operand to apply
263
+ # @param ttl [Integer, nil] Time-to-live in milliseconds
264
+ # @param await_durable [Boolean] Whether to wait for durability (default: true)
265
+ # @return [void]
266
+ #
267
+ # @example Merge with string concatenation operator
268
+ # db = SlateDb::Database.open("/tmp/mydb", merge_operator: :string_concat)
269
+ # db.merge("key", "part1")
270
+ # db.merge("key", "part2")
271
+ #
272
+ def merge(key, value, ttl: nil, await_durable: nil)
273
+ opts = {}
274
+ opts[:ttl] = ttl if ttl
275
+ opts[:await_durable] = await_durable unless await_durable.nil?
276
+
277
+ if opts.empty?
278
+ _merge(key, value)
279
+ else
280
+ _merge_with_options(key, value, opts)
281
+ end
282
+ end
283
+
198
284
  # Create and write a batch using a block.
199
285
  #
200
286
  # @param await_durable [Boolean] Whether to wait for durability (default: true)
@@ -308,5 +394,25 @@ module SlateDb
308
394
  snap
309
395
  end
310
396
  end
397
+
398
+ # Create a checkpoint of the database.
399
+ #
400
+ # @param lifetime [Integer, nil] Checkpoint lifetime in milliseconds
401
+ # @param name [String, nil] Optional name for the checkpoint
402
+ # @return [Hash] Hash with :id (UUID string) and :manifest_id (integer)
403
+ #
404
+ # @example Create a named checkpoint
405
+ # checkpoint = db.create_checkpoint(name: "before-migration")
406
+ # puts "Checkpoint ID: #{checkpoint[:id]}"
407
+ #
408
+ # @example Create a checkpoint with lifetime
409
+ # checkpoint = db.create_checkpoint(lifetime: 3600_000) # 1 hour
410
+ #
411
+ def create_checkpoint(lifetime: nil, name: nil)
412
+ opts = {}
413
+ opts[:lifetime] = lifetime if lifetime
414
+ opts[:name] = name if name
415
+ _create_checkpoint(opts)
416
+ end
311
417
  end
312
418
  end
@@ -11,6 +11,7 @@ module SlateDb
11
11
  # @param manifest_poll_interval [Integer, nil] Poll interval in milliseconds
12
12
  # @param checkpoint_lifetime [Integer, nil] Checkpoint lifetime in milliseconds
13
13
  # @param max_memtable_bytes [Integer, nil] Maximum memtable size in bytes
14
+ # @param merge_operator [Symbol, String, nil] Optional merge operator ("string_concat" or "concat")
14
15
  # @yield [reader] If a block is given, yields the reader and ensures it's closed
15
16
  # @return [Reader] The opened reader (or block result if block given)
16
17
  #
@@ -29,11 +30,12 @@ module SlateDb
29
30
  #
30
31
  def open(path, url: nil, checkpoint_id: nil,
31
32
  manifest_poll_interval: nil, checkpoint_lifetime: nil,
32
- max_memtable_bytes: nil)
33
+ max_memtable_bytes: nil, merge_operator: nil)
33
34
  opts = {}
34
35
  opts[:manifest_poll_interval] = manifest_poll_interval if manifest_poll_interval
35
36
  opts[:checkpoint_lifetime] = checkpoint_lifetime if checkpoint_lifetime
36
37
  opts[:max_memtable_bytes] = max_memtable_bytes if max_memtable_bytes
38
+ opts[:merge_operator] = merge_operator.to_s if merge_operator
37
39
 
38
40
  reader = _open(path, url, checkpoint_id, opts)
39
41
 
@@ -101,5 +103,37 @@ module SlateDb
101
103
  iter
102
104
  end
103
105
  end
106
+
107
+ # Scan all keys with a given prefix.
108
+ #
109
+ # @param prefix [String] The key prefix to scan
110
+ # @param durability_filter [String, nil] Filter by durability level
111
+ # @param dirty [Boolean, nil] Whether to include uncommitted data
112
+ # @param read_ahead_bytes [Integer, nil] Number of bytes to read ahead
113
+ # @param cache_blocks [Boolean, nil] Whether to cache blocks
114
+ # @param max_fetch_tasks [Integer, nil] Maximum number of fetch tasks
115
+ # @return [Iterator] An iterator over key-value pairs
116
+ #
117
+ def scan_prefix(prefix, durability_filter: nil, dirty: nil,
118
+ read_ahead_bytes: nil, cache_blocks: nil, max_fetch_tasks: nil, &)
119
+ opts = {}
120
+ opts[:durability_filter] = durability_filter.to_s if durability_filter
121
+ opts[:dirty] = dirty unless dirty.nil?
122
+ opts[:read_ahead_bytes] = read_ahead_bytes if read_ahead_bytes
123
+ opts[:cache_blocks] = cache_blocks unless cache_blocks.nil?
124
+ opts[:max_fetch_tasks] = max_fetch_tasks if max_fetch_tasks
125
+
126
+ iter = if opts.empty?
127
+ _scan_prefix(prefix)
128
+ else
129
+ _scan_prefix_with_options(prefix, opts)
130
+ end
131
+
132
+ if block_given?
133
+ iter.each(&)
134
+ else
135
+ iter
136
+ end
137
+ end
104
138
  end
105
139
  end
@@ -50,5 +50,37 @@ module SlateDb
50
50
  iter
51
51
  end
52
52
  end
53
+
54
+ # Scan all keys with a given prefix from the snapshot.
55
+ #
56
+ # @param prefix [String] The key prefix to scan
57
+ # @param durability_filter [String, nil] Filter by durability level
58
+ # @param dirty [Boolean, nil] Whether to include uncommitted data
59
+ # @param read_ahead_bytes [Integer, nil] Number of bytes to read ahead
60
+ # @param cache_blocks [Boolean, nil] Whether to cache blocks
61
+ # @param max_fetch_tasks [Integer, nil] Maximum number of fetch tasks
62
+ # @return [Iterator] An iterator over key-value pairs
63
+ #
64
+ def scan_prefix(prefix, durability_filter: nil, dirty: nil,
65
+ read_ahead_bytes: nil, cache_blocks: nil, max_fetch_tasks: nil, &)
66
+ opts = {}
67
+ opts[:durability_filter] = durability_filter.to_s if durability_filter
68
+ opts[:dirty] = dirty unless dirty.nil?
69
+ opts[:read_ahead_bytes] = read_ahead_bytes if read_ahead_bytes
70
+ opts[:cache_blocks] = cache_blocks unless cache_blocks.nil?
71
+ opts[:max_fetch_tasks] = max_fetch_tasks if max_fetch_tasks
72
+
73
+ iter = if opts.empty?
74
+ _scan_prefix(prefix)
75
+ else
76
+ _scan_prefix_with_options(prefix, opts)
77
+ end
78
+
79
+ if block_given?
80
+ iter.each(&)
81
+ else
82
+ iter
83
+ end
84
+ end
53
85
  end
54
86
  end
@@ -47,6 +47,21 @@ module SlateDb
47
47
  _delete(key)
48
48
  end
49
49
 
50
+ # Merge a value within the transaction.
51
+ #
52
+ # @param key [String] The key to merge into
53
+ # @param value [String] The merge operand to apply
54
+ # @param ttl [Integer, nil] Time-to-live in milliseconds
55
+ # @return [void]
56
+ #
57
+ def merge(key, value, ttl: nil)
58
+ if ttl
59
+ _merge_with_options(key, value, { ttl: ttl })
60
+ else
61
+ _merge(key, value)
62
+ end
63
+ end
64
+
50
65
  # Scan a range of keys within the transaction.
51
66
  #
52
67
  # @param start_key [String] The start key (inclusive)
@@ -74,5 +89,57 @@ module SlateDb
74
89
  iter
75
90
  end
76
91
  end
92
+
93
+ # Scan all keys with a given prefix within the transaction.
94
+ #
95
+ # @param prefix [String] The key prefix to scan
96
+ # @param durability_filter [String, nil] Filter by durability level
97
+ # @param dirty [Boolean, nil] Whether to include uncommitted data
98
+ # @param read_ahead_bytes [Integer, nil] Number of bytes to read ahead
99
+ # @param cache_blocks [Boolean, nil] Whether to cache blocks
100
+ # @param max_fetch_tasks [Integer, nil] Maximum number of fetch tasks
101
+ # @return [Iterator] An iterator over key-value pairs
102
+ #
103
+ def scan_prefix(prefix, durability_filter: nil, dirty: nil,
104
+ read_ahead_bytes: nil, cache_blocks: nil, max_fetch_tasks: nil, &)
105
+ opts = {}
106
+ opts[:durability_filter] = durability_filter.to_s if durability_filter
107
+ opts[:dirty] = dirty unless dirty.nil?
108
+ opts[:read_ahead_bytes] = read_ahead_bytes if read_ahead_bytes
109
+ opts[:cache_blocks] = cache_blocks unless cache_blocks.nil?
110
+ opts[:max_fetch_tasks] = max_fetch_tasks if max_fetch_tasks
111
+
112
+ iter = if opts.empty?
113
+ _scan_prefix(prefix)
114
+ else
115
+ _scan_prefix_with_options(prefix, opts)
116
+ end
117
+
118
+ if block_given?
119
+ iter.each(&)
120
+ else
121
+ iter
122
+ end
123
+ end
124
+
125
+ # Mark keys as read for conflict detection.
126
+ #
127
+ # This explicitly tracks reads for conflict checking in serializable isolation,
128
+ # allowing selective read-write conflict detection even when keys weren't
129
+ # actually read via get().
130
+ #
131
+ # @param keys [Array<String>] The keys to mark as read
132
+ # @return [void]
133
+ #
134
+ # @example Mark keys for conflict detection
135
+ # db.transaction(isolation: :serializable) do |txn|
136
+ # txn.mark_read(["key1", "key2"])
137
+ # # These keys will now be checked for conflicts on commit
138
+ # txn.put("key3", "value")
139
+ # end
140
+ #
141
+ def mark_read(keys)
142
+ _mark_read(Array(keys))
143
+ end
77
144
  end
78
145
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SlateDb
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -34,5 +34,25 @@ module SlateDb
34
34
  _delete(key)
35
35
  self
36
36
  end
37
+
38
+ # Add a merge operation to the batch.
39
+ #
40
+ # @param key [String] The key to merge into
41
+ # @param value [String] The merge operand to apply
42
+ # @param ttl [Integer, nil] Time-to-live in milliseconds
43
+ # @return [self] Returns self for method chaining
44
+ #
45
+ # @example
46
+ # batch.merge("key", "part1")
47
+ # batch.merge("key", "part2", ttl: 30_000)
48
+ #
49
+ def merge(key, value, ttl: nil)
50
+ if ttl
51
+ _merge_with_options(key, value, { ttl: ttl })
52
+ else
53
+ _merge(key, value)
54
+ end
55
+ self
56
+ end
37
57
  end
38
58
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slatedb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SlateDB Contributors
@@ -97,6 +97,7 @@ files:
97
97
  - ext/slatedb/src/errors.rs
98
98
  - ext/slatedb/src/iterator.rs
99
99
  - ext/slatedb/src/lib.rs
100
+ - ext/slatedb/src/merge_ops.rs
100
101
  - ext/slatedb/src/reader.rs
101
102
  - ext/slatedb/src/runtime.rs
102
103
  - ext/slatedb/src/snapshot.rs
@@ -134,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
135
  - !ruby/object:Gem::Version
135
136
  version: '0'
136
137
  requirements: []
137
- rubygems_version: 3.7.2
138
+ rubygems_version: 4.0.2
138
139
  specification_version: 4
139
140
  summary: Ruby bindings for SlateDB
140
141
  test_files: []