lora-ruby 0.29.0 → 0.30.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47551a1004dd776c2b4b97bc457cee4cc9b37ba969744656d2099fd8f0daa364
4
- data.tar.gz: 9a6d6e8c1e0024583163bdc0e3a533b7d3d848f5727636d99f138a1f872eed13
3
+ metadata.gz: bb92cbbbc1a715c79951f488d065d6651428754bf356fa2ac9b8d184aafa4e2f
4
+ data.tar.gz: 4246665a896d1ce95590e22a268465b8eed369cf6677a9395e105f566c412dde
5
5
  SHA512:
6
- metadata.gz: 1d72c4d2c91487ef02d73c5ad2caa7fd816a2a0bc8f14134f77b027bec2fadeea30dd42f3f20ebe6aa5fd5a351bcc7b565d9c899508fb519767dd2dfe281afb0
7
- data.tar.gz: f1c81974261b06bc13b9d4219108f77b9aef3d05c77e7241a64fe53695c0aeb3fe47d8584f942c265525f481b3e9856c46fb977509a738d75421e2cf73c5d988
6
+ metadata.gz: ca2548fae0133815c95cc2d89e1ae76fd3f648ea1251aa26a7821f2952b98acf5628d5bf612fb43cb2795e5aeb90b7f5df2537da1005ef9700f8a8f8b228bcc0
7
+ data.tar.gz: d8c3f61ee0f03882999b6432674c00d5e120d66a6f4d3737fac2103bdd533c21dd4c1dfc0242cff6722cf8501dca4c83c305f98f1e51edaaee82746c1d53e18e
data/Cargo.toml CHANGED
@@ -37,6 +37,7 @@ serde_json = { workspace = true }
37
37
  anyhow.workspace = true
38
38
 
39
39
  lora-database.workspace = true
40
+ web-time.workspace = true
40
41
  lora-store.workspace = true
41
42
 
42
43
  [build-dependencies]
data/README.md CHANGED
@@ -80,9 +80,9 @@ LoraRuby::Database.create(database_name = nil, options = nil) # -> Database
80
80
  LoraRuby::Database.new(database_name = nil, options = nil) # -> Database
81
81
  LoraRuby::Database.open_wal(wal_dir, options = nil) # -> Database
82
82
 
83
- db.execute(query, params = nil) # -> { "columns" => [...], "rows" => [...] }
84
- db.explain(query, params = nil) # -> plan Hash; never executes
85
- db.profile(query, params = nil) # -> { "plan" => ..., "metrics" => ... }; PROFILE executes writes
83
+ db.execute(query, params = nil, timeout_ms: nil) # -> { "columns" => [...], "rows" => [...] }
84
+ db.explain(query, params = nil) # -> plan Hash; never executes
85
+ db.profile(query, params = nil, timeout_ms: nil) # -> { "plan" => ..., "metrics" => ... }; PROFILE executes writes
86
86
  db.clear # -> nil
87
87
  db.node_count # -> Integer
88
88
  db.relationship_count # -> Integer
@@ -218,12 +218,26 @@ binding has no interactive transactions: each write takes the lock and
218
218
  releases it within one call. So a waiting writer never blocks the writer
219
219
  holding the lock, however many threads write at once.
220
220
 
221
- The engine has no cancellation hook, so we pass a `NULL` unblock
222
- function. A thread interrupted mid-query (`Thread#kill`, `Ctrl-C`) will
223
- observe the interrupt **after** the current call finishes, including a
224
- write that was still waiting for the writer lock (it runs, then the
225
- interrupt fires). Keep queries short if you rely on cooperative
226
- cancellation.
221
+ ### Timeouts and interrupts
222
+
223
+ Pass `timeout_ms:` to bound a query. When the time runs out the query
224
+ stops at its next check point, rolls back any writes, releases its locks,
225
+ and raises `LoraRuby::QueryError` with code `LORA_TIMEOUT`:
226
+
227
+ ```ruby
228
+ db.execute("MATCH (a), (b) RETURN count(*)", nil, timeout_ms: 2_000)
229
+ db.execute("MATCH (n {id: $id}) RETURN n", { id: 1 }, timeout_ms: 500)
230
+ ```
231
+
232
+ A query with `timeout_ms:` can also be interrupted: `Thread#raise`,
233
+ `Thread#kill`, `Timeout.timeout` and `Ctrl-C` cancel it the same way, and
234
+ the interrupt is then raised as usual. A query without `timeout_ms:` runs
235
+ without a deadline (deadline checks cost a little on large scans) and
236
+ observes an interrupt only **after** it finishes.
237
+
238
+ `timeout_ms` is a reserved keyword: other keywords are still accepted as
239
+ query params (`db.execute(q, name: "Ada")`), but a param named
240
+ `timeout_ms` has to go in an explicit params hash.
227
241
 
228
242
  ## Local development
229
243
 
@@ -10,5 +10,5 @@ module LoraRuby
10
10
  # Guard against redefinition so re-requiring this file (or loading
11
11
  # both paths) doesn't emit a "warning: already initialized constant"
12
12
  # when the native extension loads second with the identical value.
13
- VERSION = "0.29.0" unless const_defined?(:VERSION)
13
+ VERSION = "0.30.0" unless const_defined?(:VERSION)
14
14
  end
data/src/from_ruby.rs CHANGED
@@ -228,17 +228,17 @@ fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusErro
228
228
  }
229
229
  "datetime" => {
230
230
  return parse_tagged(ruby, hash, "datetime", |iso| {
231
- LoraDateTime::parse(iso).map(LoraValue::DateTime)
231
+ LoraDateTime::parse(iso).map(LoraValue::from)
232
232
  });
233
233
  }
234
234
  "localdatetime" => {
235
235
  return parse_tagged(ruby, hash, "localdatetime", |iso| {
236
- LoraLocalDateTime::parse(iso).map(LoraValue::LocalDateTime)
236
+ LoraLocalDateTime::parse(iso).map(LoraValue::from)
237
237
  });
238
238
  }
239
239
  "duration" => {
240
240
  return parse_tagged(ruby, hash, "duration", |iso| {
241
- LoraDuration::parse(iso).map(LoraValue::Duration)
241
+ LoraDuration::parse(iso).map(LoraValue::from)
242
242
  });
243
243
  }
244
244
  "point" => return build_point(ruby, hash),
@@ -291,7 +291,7 @@ fn build_point(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
291
291
  let x = read_f64(ruby, hash, "x")?.ok_or_else(|| invalid_params(ruby, "point.x required"))?;
292
292
  let y = read_f64(ruby, hash, "y")?.ok_or_else(|| invalid_params(ruby, "point.y required"))?;
293
293
  let z = read_f64(ruby, hash, "z")?;
294
- Ok(LoraValue::Point(LoraPoint { x, y, z, srid }))
294
+ Ok(LoraValue::Point(Box::new(LoraPoint { x, y, z, srid })))
295
295
  }
296
296
 
297
297
  fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
@@ -341,7 +341,7 @@ fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
341
341
 
342
342
  let v = LoraVector::try_new(raw, dimension, coordinate_type)
343
343
  .map_err(|e| invalid_params(ruby, e.to_string()))?;
344
- Ok(LoraValue::Vector(v))
344
+ Ok(LoraValue::Vector(Box::new(v)))
345
345
  }
346
346
 
347
347
  fn build_binary(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
@@ -355,7 +355,9 @@ fn build_binary(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
355
355
  .map_err(|_| invalid_params(ruby, "binary.segments entries must be Strings"))?;
356
356
  segments.push(unsafe { segment.as_slice().to_vec() });
357
357
  }
358
- Ok(LoraValue::Binary(LoraBinary::from_segments(segments)))
358
+ Ok(LoraValue::Binary(Box::new(LoraBinary::from_segments(
359
+ segments,
360
+ ))))
359
361
  }
360
362
 
361
363
  fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
data/src/gvl.rs CHANGED
@@ -5,6 +5,9 @@ use std::fmt;
5
5
  use std::mem::MaybeUninit;
6
6
  use std::panic::{catch_unwind, AssertUnwindSafe};
7
7
  use std::sync::atomic::{AtomicUsize, Ordering};
8
+ use std::sync::Mutex;
9
+
10
+ use lora_database::CancellableDeadline;
8
11
 
9
12
  /// Calls running with the GVL released right now, process-wide.
10
13
  static RELEASED: AtomicUsize = AtomicUsize::new(0);
@@ -81,6 +84,41 @@ impl fmt::Display for GvlPanic {
81
84
  /// `database_execute`'s closure is pure Rust on pre-extracted data, so
82
85
  /// this is sound.
83
86
  pub(crate) fn without_gvl<F, R>(f: F) -> Result<R, GvlPanic>
87
+ where
88
+ F: FnOnce() -> R,
89
+ F: Send,
90
+ R: Send,
91
+ {
92
+ call_without_gvl(f, None)
93
+ }
94
+
95
+ /// [`without_gvl`] for a query bounded by `cancel`: when Ruby interrupts
96
+ /// the thread (`Thread#raise`, `Thread#kill`, `Timeout.timeout`, Ctrl-C)
97
+ /// its unblock function cancels the query, which then stops at its next
98
+ /// check point, rolls back, and releases its locks before this returns.
99
+ pub(crate) fn without_gvl_cancellable<F, R>(
100
+ f: F,
101
+ cancel: &Mutex<CancellableDeadline>,
102
+ ) -> Result<R, GvlPanic>
103
+ where
104
+ F: FnOnce() -> R,
105
+ F: Send,
106
+ R: Send,
107
+ {
108
+ call_without_gvl(f, Some(cancel))
109
+ }
110
+
111
+ /// Ruby's unblock function for [`without_gvl_cancellable`]. Runs on the
112
+ /// interrupting thread while the query thread is GVL-free, so it only
113
+ /// touches the (Rust) cancellation state.
114
+ unsafe extern "C" fn cancel_query(data: *mut c_void) {
115
+ let cancel = &*(data as *const Mutex<CancellableDeadline>);
116
+ let _ = catch_unwind(AssertUnwindSafe(|| {
117
+ cancel.lock().unwrap_or_else(|p| p.into_inner()).cancel();
118
+ }));
119
+ }
120
+
121
+ fn call_without_gvl<F, R>(f: F, cancel: Option<&Mutex<CancellableDeadline>>) -> Result<R, GvlPanic>
84
122
  where
85
123
  F: FnOnce() -> R,
86
124
  F: Send,
@@ -113,14 +151,21 @@ where
113
151
  let _released = Released::enter();
114
152
 
115
153
  unsafe {
154
+ // Without a cancellation handle there is no unblock function: the
155
+ // call cannot be stopped early, and a forced longjmp out of a
156
+ // mutex-holding section would be worse than waiting.
157
+ let (ubf, ubf_data) = match cancel {
158
+ Some(cancel) => (
159
+ Some(cancel_query as unsafe extern "C" fn(*mut c_void)),
160
+ cancel as *const Mutex<CancellableDeadline> as *mut c_void,
161
+ ),
162
+ None => (None, std::ptr::null_mut()),
163
+ };
116
164
  rb_sys::rb_thread_call_without_gvl(
117
165
  Some(trampoline::<F, R>),
118
166
  &mut data as *mut _ as *mut c_void,
119
- // No unblock function — the engine doesn't implement
120
- // cooperative cancellation, and a forced longjmp out of a
121
- // mutex-holding section would be worse than waiting.
122
- None,
123
- std::ptr::null_mut(),
167
+ ubf,
168
+ ubf_data,
124
169
  );
125
170
  data.result.assume_init().map_err(GvlPanic::new)
126
171
  }
data/src/lib.rs CHANGED
@@ -14,7 +14,10 @@
14
14
  //! tagged `Hash`es (string keys) with a `"kind"` discriminator.
15
15
 
16
16
  use std::collections::BTreeMap;
17
+ use std::sync::atomic::{AtomicBool, Ordering};
17
18
  use std::sync::{Arc, Mutex};
19
+ use std::thread::JoinHandle;
20
+ use std::time::Duration;
18
21
 
19
22
  use magnus::{
20
23
  function, method, prelude::*, value::ReprValue, Error as MagnusError, RHash, RString, Ruby,
@@ -22,8 +25,9 @@ use magnus::{
22
25
  };
23
26
 
24
27
  use lora_database::{
25
- Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions, InMemoryGraph, QueryResult,
26
- ResultFormat, SnapshotConfig, SnapshotOptions, WalConfig,
28
+ CancellableDeadline, Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions,
29
+ InMemoryGraph, LoraError, LoraValue, QueryResult, ResultFormat, SnapshotConfig,
30
+ SnapshotOptions, WalConfig,
27
31
  };
28
32
 
29
33
  mod errors;
@@ -33,7 +37,7 @@ mod to_ruby;
33
37
 
34
38
  use errors::{invalid_params, query_error, query_error_from_anyhow};
35
39
  use from_ruby::{hash_get_any, read_nonnegative_u64, ruby_optional_to_json, ruby_value_to_params};
36
- use gvl::without_gvl;
40
+ use gvl::{without_gvl, without_gvl_cancellable};
37
41
  use to_ruby::{lora_value_to_ruby, query_plan_to_ruby, query_profile_to_ruby};
38
42
 
39
43
  // ============================================================================
@@ -83,6 +87,10 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
83
87
  database.define_method("save_snapshot", method!(database_save_snapshot, -1))?;
84
88
  database.define_method("load_snapshot", method!(database_load_snapshot, -1))?;
85
89
 
90
+ // Wait for databases the GC handed to background droppers before the
91
+ // process exits, so their WALs are flushed and closed (see `Drop`).
92
+ unsafe { rb_sys::rb_set_end_proc(Some(finish_background_drops), 0) };
93
+
86
94
  // `LoraRuby::VERSION` is owned by `lib/lora_ruby/version.rb` so the
87
95
  // gem can expose a version before the native extension compiles
88
96
  // (during `gem build` / `bundle install`). Defining it here too
@@ -141,11 +149,78 @@ impl Database {
141
149
  }
142
150
  }
143
151
 
152
+ /// Drops of persistent databases handed off by the GC, not yet finished.
153
+ static BACKGROUND_DROPS: Mutex<Vec<JoinHandle<()>>> = Mutex::new(Vec::new());
154
+
155
+ /// Set once the process is exiting: drops then run in place.
156
+ static EXITING: AtomicBool = AtomicBool::new(false);
157
+
158
+ /// The GC frees an unreachable handle during a sweep, with the GVL held.
159
+ /// Dropping the last handle to a persistent database flushes and closes its
160
+ /// WAL (`fsync`, joining its flusher), which would stall every Ruby thread
161
+ /// for the duration, so that drop moves to a thread of its own, like
162
+ /// `close` runs it GVL-free. An in-memory database, or any drop at exit,
163
+ /// runs in place. Opening a database first waits for pending drops (see
164
+ /// [`wait_background_drops`]) so a reopened WAL directory is free.
165
+ impl Drop for Database {
166
+ fn drop(&mut self) {
167
+ let slot = self.db.get_mut().unwrap_or_else(|p| p.into_inner()).take();
168
+ let Some(db) = slot else { return };
169
+ if db.wal().is_none() || EXITING.load(Ordering::SeqCst) {
170
+ return;
171
+ }
172
+ let mut pending = BACKGROUND_DROPS.lock().unwrap_or_else(|p| p.into_inner());
173
+ pending.retain(|handle| !handle.is_finished());
174
+ // Spawning moves `db` into the closure even when it fails, so a
175
+ // failed spawn drops it here, in place.
176
+ if let Ok(handle) = std::thread::Builder::new()
177
+ .name("lora-ruby-drop".into())
178
+ .spawn(move || drop(db))
179
+ {
180
+ pending.push(handle);
181
+ }
182
+ }
183
+ }
184
+
185
+ /// Join every pending background drop.
186
+ fn join_background_drops() {
187
+ let pending = std::mem::take(&mut *BACKGROUND_DROPS.lock().unwrap_or_else(|p| p.into_inner()));
188
+ for handle in pending {
189
+ let _ = handle.join();
190
+ }
191
+ }
192
+
193
+ /// Before opening a database: let GC-dropped databases finish closing, with
194
+ /// the GVL released, so their WAL directories are free again.
195
+ fn wait_background_drops() {
196
+ let idle = BACKGROUND_DROPS
197
+ .lock()
198
+ .map(|pending| pending.iter().all(|h| h.is_finished()))
199
+ .unwrap_or(false);
200
+ if idle {
201
+ BACKGROUND_DROPS
202
+ .lock()
203
+ .map(|mut pending| pending.clear())
204
+ .ok();
205
+ return;
206
+ }
207
+ let _ = without_gvl(join_background_drops);
208
+ }
209
+
210
+ /// `at_exit` hook: drop in place from now on and wait for pending drops.
211
+ unsafe extern "C" fn finish_background_drops(_data: rb_sys::VALUE) {
212
+ EXITING.store(true, Ordering::SeqCst);
213
+ let _ = without_gvl(join_background_drops);
214
+ }
215
+
144
216
  // Constructors — we expose `Database.create` and `Database.new` as
145
217
  // singletons so callers can use whichever idiom they prefer; both are
146
218
  // cost-equivalent.
147
219
  fn database_new(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
148
220
  let (database_name, options) = database_open_args(ruby, args)?;
221
+ if database_name.is_some() {
222
+ wait_background_drops();
223
+ }
149
224
  let db = without_gvl_string_result(ruby, move || open_database(database_name, options))?;
150
225
  Ok(Database::from_db(db))
151
226
  }
@@ -179,6 +254,7 @@ fn database_open_wal(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusErro
179
254
  ));
180
255
  }
181
256
  options.wal_dir = Some(wal_dir);
257
+ wait_background_drops();
182
258
  let db = without_gvl_string_result(ruby, move || open_wal_database(options))?;
183
259
  Ok(Database::from_db(db))
184
260
  }
@@ -499,72 +575,164 @@ where
499
575
  .map_err(|e| query_error_from_anyhow(ruby, e))
500
576
  }
501
577
 
502
- /// `execute(query, params = nil)` — `-1` arity so `params` is optional and
503
- /// we can distinguish "not passed" from `nil`/`{}` (both map to empty
504
- /// params). Everything that touches Ruby values happens under the GVL;
505
- /// only the pure-Rust engine call is run GVL-released.
506
- fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
507
- let (query, params_value): (String, Option<Value>) = match args.len() {
508
- 1 => {
509
- let q = RString::try_convert(args[0])?.to_string()?;
510
- (q, None)
511
- }
512
- 2 => {
513
- let q = RString::try_convert(args[0])?.to_string()?;
514
- let p = if args[1].is_nil() {
515
- None
516
- } else {
517
- Some(args[1])
518
- };
519
- (q, p)
520
- }
521
- n => {
522
- return Err(MagnusError::new(
523
- ruby.exception_arg_error(),
524
- format!("wrong number of arguments (given {n}, expected 1..2)"),
525
- ));
578
+ /// Run `call` with the GVL released. With a `timeout` it runs under a
579
+ /// cancellable deadline: it fails with `LORA_TIMEOUT` once the timeout
580
+ /// passes, and a Ruby interrupt of this thread (`Thread#raise`,
581
+ /// `Thread#kill`, `Timeout.timeout`, Ctrl-C) cancels it. A cancelled query
582
+ /// rolls back and releases its locks; the interrupt is then delivered
583
+ /// instead of the timeout error.
584
+ ///
585
+ /// Without a timeout the query runs without a deadline and cannot be
586
+ /// interrupted: the engine reads the clock at every deadline check, which
587
+ /// costs roughly 10% on scans, so plain calls do not pay for it.
588
+ fn run_query<T>(
589
+ ruby: &Ruby,
590
+ timeout: Option<Duration>,
591
+ call: impl FnOnce(Option<web_time::Instant>) -> Result<T, LoraError> + Send,
592
+ ) -> Result<T, MagnusError>
593
+ where
594
+ T: Send,
595
+ {
596
+ let Some(timeout) = timeout else {
597
+ return without_gvl_lora_result(ruby, move || call(None));
598
+ };
599
+ let cancel = Mutex::new(CancellableDeadline::new(Some(timeout)));
600
+ let deadline = cancel.lock().unwrap_or_else(|p| p.into_inner()).deadline();
601
+ let outcome = without_gvl_cancellable(move || call(Some(deadline)), &cancel);
602
+ if cancel
603
+ .lock()
604
+ .unwrap_or_else(|p| p.into_inner())
605
+ .is_cancelled()
606
+ {
607
+ ruby.thread_check_ints()?;
608
+ }
609
+ outcome
610
+ .map_err(|panic| query_error(ruby, panic.to_string()))?
611
+ .map_err(|e| query_error_from_anyhow(ruby, e))
612
+ }
613
+
614
+ /// Arguments of `execute` / `profile`: `(query, params = nil, timeout_ms: nil)`.
615
+ struct QueryArgs {
616
+ query: String,
617
+ params: Option<BTreeMap<String, LoraValue>>,
618
+ timeout: Option<Duration>,
619
+ }
620
+
621
+ /// Parse `(query, params = nil, timeout_ms: nil)`. Keywords other than
622
+ /// `timeout_ms` are query parameters when no positional `params` hash is
623
+ /// given, so `execute(q, name: "Ada")` keeps working.
624
+ fn parse_query_args(ruby: &Ruby, args: &[Value]) -> Result<QueryArgs, MagnusError> {
625
+ // SAFETY: called from a method body, while Ruby's frame for this
626
+ // call is current.
627
+ let keywords_given = unsafe { rb_sys::rb_keyword_given_p() } != 0;
628
+ let (positional, keywords) = match (keywords_given, args.split_last()) {
629
+ (true, Some((last, rest))) => (rest, Some(RHash::try_convert(*last)?)),
630
+ _ => (args, None),
631
+ };
632
+ let (query, params_value) = parse_query_params(ruby, positional)?;
633
+
634
+ let mut timeout = None;
635
+ let mut keyword_params = None;
636
+ if let Some(keywords) = keywords {
637
+ let timeout_value = keywords.delete(ruby.to_symbol("timeout_ms"))?;
638
+ timeout = parse_timeout_ms(ruby, timeout_value)?;
639
+ if !keywords.is_empty() {
640
+ if params_value.is_some() {
641
+ return Err(MagnusError::new(
642
+ ruby.exception_arg_error(),
643
+ "unknown keyword: only timeout_ms is accepted next to a params hash",
644
+ ));
645
+ }
646
+ keyword_params = Some(keywords.as_value());
526
647
  }
648
+ }
649
+ let params = match params_value.or(keyword_params) {
650
+ Some(v) => Some(ruby_value_to_params(ruby, v)?),
651
+ None => None,
652
+ };
653
+ Ok(QueryArgs {
654
+ query,
655
+ params,
656
+ timeout,
657
+ })
658
+ }
659
+
660
+ /// `timeout_ms:` is `nil` (no timeout) or a positive integer.
661
+ fn parse_timeout_ms(ruby: &Ruby, value: Option<Value>) -> Result<Option<Duration>, MagnusError> {
662
+ let Some(value) = value.filter(|v| !v.is_nil()) else {
663
+ return Ok(None);
527
664
  };
665
+ let ms = read_nonnegative_u64(ruby, value)?;
666
+ if ms == 0 {
667
+ return Err(invalid_params(
668
+ ruby,
669
+ "timeout_ms must be a positive integer",
670
+ ));
671
+ }
672
+ Ok(Some(Duration::from_millis(ms)))
673
+ }
528
674
 
675
+ /// `execute(query, params = nil, timeout_ms: nil)` — `-1` arity so
676
+ /// `params` is optional and we can distinguish "not passed" from `nil`/`{}`
677
+ /// (both map to empty params). Everything that touches Ruby values happens
678
+ /// under the GVL; only the pure-Rust engine call is run GVL-released.
679
+ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
529
680
  // Parse params while we still hold the GVL — touching Ruby `RHash` /
530
681
  // `RArray` from a GVL-released region is undefined behaviour.
531
- let params_map = match params_value {
532
- Some(v) => ruby_value_to_params(ruby, v)?,
533
- None => BTreeMap::new(),
534
- };
682
+ let QueryArgs {
683
+ query,
684
+ params,
685
+ timeout,
686
+ } = parse_query_args(ruby, args)?;
687
+ let params_map = params.unwrap_or_default();
535
688
 
536
689
  // Run the engine with the GVL released. Everything inside the closure
537
690
  // is pure Rust — no Ruby values cross the boundary — which keeps this
538
- // sound.
691
+ // sound. `Rows` hands back the engine's rows without the per-cell
692
+ // clones of the `RowArrays` projection; the columns are the first
693
+ // row's names, exactly as `RowArrays` infers them.
539
694
  let db = database_inner(ruby, rb_self)?;
540
- let exec_result = without_gvl_lora_result(ruby, move || {
541
- let options = ExecuteOptions {
542
- format: ResultFormat::RowArrays,
543
- };
544
- db.execute_with_params(&query, Some(options), params_map)
695
+ let exec_result = run_query(ruby, timeout, move |deadline| {
696
+ let options = Some(ExecuteOptions {
697
+ format: ResultFormat::Rows,
698
+ });
699
+ match deadline {
700
+ Some(deadline) => {
701
+ db.execute_with_params_deadline(&query, options, params_map, deadline)
702
+ }
703
+ None => db.execute_with_params(&query, options, params_map),
704
+ }
545
705
  })?;
546
706
 
547
- let row_arrays = match exec_result {
548
- QueryResult::RowArrays(r) => r,
549
- _ => return Err(query_error(ruby, "expected RowArrays result")),
707
+ let rows = match exec_result {
708
+ QueryResult::Rows(r) => r.rows,
709
+ _ => return Err(query_error(ruby, "expected Rows result")),
550
710
  };
551
711
 
552
712
  let out = ruby.hash_new();
553
713
  let columns = ruby.ary_new();
554
- for c in &row_arrays.columns {
555
- columns.push(ruby.str_new(c))?;
714
+ let mut column_names = Vec::new();
715
+ if let Some(first) = rows.first() {
716
+ for (_, name, _) in first.iter_named() {
717
+ columns.push(ruby.str_new(&name))?;
718
+ // A frozen key is stored as is; an unfrozen one would be
719
+ // copied and frozen again for every row.
720
+ let key = ruby.str_new(&name);
721
+ key.freeze();
722
+ column_names.push(key);
723
+ }
556
724
  }
557
725
  out.aset(ruby.str_new("columns"), columns)?;
558
726
 
559
- let rows = ruby.ary_new();
560
- for row in &row_arrays.rows {
727
+ let rows_out = ruby.ary_new_capa(rows.len());
728
+ for row in &rows {
561
729
  let row_hash = ruby.hash_new();
562
- for (col, val) in row_arrays.columns.iter().zip(row.iter()) {
563
- row_hash.aset(ruby.str_new(col), lora_value_to_ruby(ruby, val)?)?;
730
+ for (name, (_, val)) in column_names.iter().zip(row.iter()) {
731
+ row_hash.aset(*name, lora_value_to_ruby(ruby, val)?)?;
564
732
  }
565
- rows.push(row_hash)?;
733
+ rows_out.push(row_hash)?;
566
734
  }
567
- out.aset(ruby.str_new("rows"), rows)?;
735
+ out.aset(ruby.str_new("rows"), rows_out)?;
568
736
  Ok(out)
569
737
  }
570
738
 
@@ -582,20 +750,22 @@ fn database_explain(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
582
750
  query_plan_to_ruby(ruby, &plan)
583
751
  }
584
752
 
585
- /// `profile(query, params = nil)` — execute a query and return the plan
753
+ /// `profile(query, params = nil, timeout_ms: nil)` — execute a query and return the plan
586
754
  /// plus runtime metrics as a Ruby `Hash`.
587
755
  ///
588
756
  /// **PROFILE executes the query for real.** Mutating queries are
589
757
  /// persisted exactly as in `execute`. Use `explain` to inspect a
590
758
  /// mutating plan without running it.
591
759
  fn database_profile(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
592
- let (query, params_value) = parse_query_params(ruby, args)?;
593
- let params_map = match params_value {
594
- Some(v) => Some(ruby_value_to_params(ruby, v)?),
595
- None => None,
596
- };
760
+ let QueryArgs {
761
+ query,
762
+ params,
763
+ timeout,
764
+ } = parse_query_args(ruby, args)?;
597
765
  let db = database_inner(ruby, rb_self)?;
598
- let prof = without_gvl_lora_result(ruby, move || db.profile(&query, params_map))?;
766
+ let prof = run_query(ruby, timeout, move |deadline| {
767
+ db.profile_with_deadline(&query, params, deadline)
768
+ })?;
599
769
  query_profile_to_ruby(ruby, &prof)
600
770
  }
601
771
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lora-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LoraDB, Inc.