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 +4 -4
- data/Cargo.toml +1 -0
- data/README.md +23 -9
- data/lib/lora_ruby/version.rb +1 -1
- data/src/from_ruby.rs +8 -6
- data/src/gvl.rs +50 -5
- data/src/lib.rs +225 -55
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb92cbbbc1a715c79951f488d065d6651428754bf356fa2ac9b8d184aafa4e2f
|
|
4
|
+
data.tar.gz: 4246665a896d1ce95590e22a268465b8eed369cf6677a9395e105f566c412dde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca2548fae0133815c95cc2d89e1ae76fd3f648ea1251aa26a7821f2952b98acf5628d5bf612fb43cb2795e5aeb90b7f5df2537da1005ef9700f8a8f8b228bcc0
|
|
7
|
+
data.tar.gz: d8c3f61ee0f03882999b6432674c00d5e120d66a6f4d3737fac2103bdd533c21dd4c1dfc0242cff6722cf8501dca4c83c305f98f1e51edaaee82746c1d53e18e
|
data/Cargo.toml
CHANGED
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)
|
|
84
|
-
db.explain(query, params = nil)
|
|
85
|
-
db.profile(query, params = nil)
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
data/lib/lora_ruby/version.rb
CHANGED
|
@@ -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.
|
|
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::
|
|
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::
|
|
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::
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
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,
|
|
26
|
-
|
|
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
|
-
/// `
|
|
503
|
-
///
|
|
504
|
-
///
|
|
505
|
-
///
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
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 =
|
|
541
|
-
let options = ExecuteOptions {
|
|
542
|
-
format: ResultFormat::
|
|
543
|
-
};
|
|
544
|
-
|
|
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
|
|
548
|
-
QueryResult::
|
|
549
|
-
_ => return Err(query_error(ruby, "expected
|
|
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
|
-
|
|
555
|
-
|
|
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
|
|
560
|
-
for row in &
|
|
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 (
|
|
563
|
-
row_hash.aset(
|
|
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
|
-
|
|
733
|
+
rows_out.push(row_hash)?;
|
|
566
734
|
}
|
|
567
|
-
out.aset(ruby.str_new("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
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 =
|
|
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
|
|