lora-ruby 0.18.0 → 0.19.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: 41b5e0a62144ae0a4c9883e0d24c0ee865ade10db38c9ea79a4f24486d153af2
4
- data.tar.gz: '07479b48e2fb8d00de5e1072fbde77ccf15a1172e08f47a9477ce59209554bce'
3
+ metadata.gz: 9219caa445bf7016b0f028045898a8d5f19aed79ef26dec23a37fddeec143a8a
4
+ data.tar.gz: 27bbd9ee828ee1b8ce9b81e17fbf20e6d6ed3226165f542428575c55c0bd55f2
5
5
  SHA512:
6
- metadata.gz: e79be519134ea0065d3b3b8a0d3b22221b2cf01b22e655df01bd974c1f56dfe8d783d8dbe9927e13c02343236767eb9672ec90880e43ad850ecdbd65071503f4
7
- data.tar.gz: 10c8e470fbe1bae4649ddbb5a83b46adce508db7f2d7db9954ea9094e15a8437e954534412a7bba5cc68ae86e22a2c7b974333813903428bdcb905af64cbdd39
6
+ metadata.gz: 5524d305cbd6d90db822fc1eda50ad8c9589cf74b9df4c05015a577c91b2b7daf7dc2ddb8ebb6ddc17b3e2243057150f8011d7b28cf9a0ac1f883cd6504c8c8a
7
+ data.tar.gz: 1d06df901d7c280d740c132d65a3643446390e9f781eedc403f4c5106290cb1093347ee0766b211a4563a76a9ce9a0d7172bc44b55a8ddd70c655aa0068ca722
data/README.md CHANGED
@@ -206,14 +206,24 @@ db = LoraRuby::Database.open_wal(
206
206
 
207
207
  ## Concurrency (GVL release)
208
208
 
209
- `Database#execute` calls `rb_thread_call_without_gvl`, so other Ruby
210
- threads run while the engine is busy. Auto-commit reads can overlap on engine
211
- snapshots; write commits and explicit read-write transactions serialize.
209
+ Every engine call that can block (`execute`, `explain`, `profile`, `clear`,
210
+ snapshot save/load, opening and closing a database) runs under
211
+ `rb_thread_call_without_gvl`, so other Ruby threads run while the engine is
212
+ busy. `node_count` and `relationship_count` read a snapshot without waiting
213
+ and keep the GVL. Auto-commit reads can overlap on engine snapshots; writes
214
+ serialize on the engine's writer lock.
215
+
216
+ A write waiting for that lock waits with the GVL released, and the Ruby
217
+ binding has no interactive transactions: each write takes the lock and
218
+ releases it within one call. So a waiting writer never blocks the writer
219
+ holding the lock, however many threads write at once.
212
220
 
213
221
  The engine has no cancellation hook, so we pass a `NULL` unblock
214
- function. A thread interrupted mid-query (`Thread#kill`) will observe
215
- the interrupt **after** the current query finishes. Keep queries short
216
- if you rely on cooperative cancellation.
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.
217
227
 
218
228
  ## Local development
219
229
 
@@ -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.18.0" unless const_defined?(:VERSION)
13
+ VERSION = "0.19.0" unless const_defined?(:VERSION)
14
14
  end
data/src/gvl.rs CHANGED
@@ -4,6 +4,36 @@ use std::ffi::c_void;
4
4
  use std::fmt;
5
5
  use std::mem::MaybeUninit;
6
6
  use std::panic::{catch_unwind, AssertUnwindSafe};
7
+ use std::sync::atomic::{AtomicUsize, Ordering};
8
+
9
+ /// Calls running with the GVL released right now, process-wide.
10
+ static RELEASED: AtomicUsize = AtomicUsize::new(0);
11
+
12
+ /// Whether no thread runs a GVL-free call. Called with the GVL held, this
13
+ /// means no other Ruby thread can hold or take the engine's writer lock
14
+ /// before the caller does: taking it needs either the GVL or a GVL-free
15
+ /// call (the binding has no interactive transactions or streams, and it
16
+ /// is not Ractor-safe, so there is one GVL). Background threads of a
17
+ /// persistent database are not covered.
18
+ pub(crate) fn nothing_released() -> bool {
19
+ RELEASED.load(Ordering::SeqCst) == 0
20
+ }
21
+
22
+ /// Counts a call in [`RELEASED`] while alive.
23
+ struct Released;
24
+
25
+ impl Released {
26
+ fn enter() -> Self {
27
+ RELEASED.fetch_add(1, Ordering::SeqCst);
28
+ Self
29
+ }
30
+ }
31
+
32
+ impl Drop for Released {
33
+ fn drop(&mut self) {
34
+ RELEASED.fetch_sub(1, Ordering::SeqCst);
35
+ }
36
+ }
7
37
 
8
38
  pub(crate) struct GvlPanic {
9
39
  payload: Box<dyn std::any::Any + Send>,
@@ -37,6 +67,13 @@ impl fmt::Display for GvlPanic {
37
67
 
38
68
  /// Run `f` with Ruby's Global VM Lock released.
39
69
  ///
70
+ /// Every engine call that can wait for the writer lock goes through here.
71
+ /// The binding has no interactive transactions: a writer takes the lock
72
+ /// and releases it inside one call, entirely GVL-free, so the holder never
73
+ /// needs the GVL to finish and a waiter always sees it released. Waiting
74
+ /// with the GVL held would freeze every Ruby thread for the wait (and, with
75
+ /// a lock held across Ruby calls, deadlock).
76
+ ///
40
77
  /// Semantics match `rb_thread_call_without_gvl` — other Ruby threads can
41
78
  /// progress while `f` runs. The closure MUST NOT touch Ruby state (no
42
79
  /// `Value`s, no allocations into the Ruby heap), which we arrange by
@@ -73,6 +110,7 @@ where
73
110
  func: Some(f),
74
111
  result: MaybeUninit::uninit(),
75
112
  };
113
+ let _released = Released::enter();
76
114
 
77
115
  unsafe {
78
116
  rb_sys::rb_thread_call_without_gvl(
data/src/lib.rs CHANGED
@@ -183,17 +183,35 @@ fn database_open_wal(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusErro
183
183
  Ok(Database::from_db(db))
184
184
  }
185
185
 
186
+ /// `clear` takes the engine's writer lock, so it waits for any write in
187
+ /// flight. That wait runs with the GVL released like every other engine
188
+ /// call: holding it would freeze every Ruby thread until the running
189
+ /// writer committed. When no write can be in flight (an in-memory database
190
+ /// and no GVL-free call running, see [`gvl::nothing_released`]) the lock
191
+ /// is free and clear runs at once, so it skips the GVL round trip.
186
192
  fn database_clear(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
187
- database_inner(ruby, rb_self)?.clear();
188
- Ok(())
193
+ let db = database_inner(ruby, rb_self)?;
194
+ if db.wal().is_none() && gvl::nothing_released() {
195
+ return db.try_clear().map_err(|e| query_error_from_anyhow(ruby, e));
196
+ }
197
+ without_gvl_lora_result(ruby, move || db.try_clear())
189
198
  }
190
199
 
200
+ /// Dropping the last handle to a persistent database flushes and closes its
201
+ /// WAL, so that drop runs with the GVL released like any other engine
202
+ /// call. An in-memory database has nothing to flush and drops in place.
191
203
  fn database_close(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
192
- let mut slot = rb_self
204
+ let db = rb_self
193
205
  .db
194
206
  .lock()
195
- .map_err(|_| query_error(ruby, "database lock poisoned"))?;
196
- slot.take();
207
+ .map_err(|_| query_error(ruby, "database lock poisoned"))?
208
+ .take();
209
+ match db {
210
+ Some(db) if db.wal().is_some() => {
211
+ without_gvl(move || drop(db)).map_err(|panic| query_error(ruby, panic.to_string()))?
212
+ }
213
+ _ => {}
214
+ }
197
215
  Ok(())
198
216
  }
199
217
 
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.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LoraDB, Inc.