beagle-turso 0.3.0 → 0.4.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: f49be5566a3b64416bccffc8c792e167e770139827016890070836eac6ab2484
4
- data.tar.gz: c8be2046fb4e78a337008dcdd488f5b8587d754c77692d73e15f90dc0e3d2f8b
3
+ metadata.gz: 5e39443726eb8f83cd09daee0845abd245e7cde71e4ccde2517129f3340dd80d
4
+ data.tar.gz: bf31133bd580a883078fdb07ec997b364965c03136d573d311bb7af2e21204a2
5
5
  SHA512:
6
- metadata.gz: 591b0e8a2e9e71d767008bac9a2742a2c93e4bb74424f586d2af035339aa1510c6fb883e770b20440a9ad561b5c88e9b113f63f8b4b506355e18f4e39442ee23
7
- data.tar.gz: 53782d7a3a82cffb627bdbc107ab022619cae5fe49a7fdc85d233aa04910497da7932de863b384d14199d77f63d35473021bf5adc8d95d5d97c13b00355d3145
6
+ metadata.gz: 3b1cd258ea7a8945cad36c7a18011bd3477bfc43d7b861d71f6105c23fb7f8dbde2ba1d180cb3cbdf2582ec345555117411f8efa72c6fc3acd522953ea7807e7
7
+ data.tar.gz: 0272a225ec262995350a0a9e5d8dff4aba8f2d49bf0ecf911363919fe4f64e7d5d1956a28c550bd32e5a339309ba30148ce533df3722b30e96c9aa21ccad76d3
data/README.md CHANGED
@@ -125,8 +125,26 @@ still a `RuntimeError`. A replica file already held by another process raises
125
125
  - One `Database` may be shared by threads. Give each thread its own
126
126
  `Connection`; a shared `Connection` works but serializes its callers.
127
127
  - `open`, `connect`, `push` and `pull` release the GVL, so other threads keep
128
- running while they wait on the network. Queries keep it; they are local and
129
- fast.
128
+ running while they wait on the network.
129
+ - Statements (`execute`, `query`, `query_result`, `query_result_with_types`,
130
+ `execute_batch`) also release the GVL, so a long query or bulk import doesn't
131
+ stop other threads. Statements on one database file still run one at a
132
+ time, as they did under the GVL. That covers every connection of every
133
+ `Database` the process opened on that file, such as one per connection in
134
+ an ActiveRecord pool. A statement waits for the one in flight, never for a
135
+ whole transaction or import. Reads wait too: a long `SELECT` holds up every
136
+ other statement on that file until it finishes. The engine can't run them
137
+ side by side safely, so put long reports on a dedicated thread or worker.
138
+ - A statement never waits inside the engine for a busy write lock: it raises
139
+ `BusyError` at once, and `PRAGMA busy_timeout` has no effect. A wait there
140
+ would block the transaction holding the lock from committing. Retry
141
+ `BusyError` in your own code, as the Sequel adapter does.
142
+ - To keep statements under the GVL (the 0.3.0 behaviour), set
143
+ `BEAGLE_TURSO_STATEMENT_GVL=hold` (read once, when the gem loads; any case)
144
+ or call `Beagle::Turso.statement_gvl = :hold`; `:release` is the default.
145
+ Any other value of the variable is ignored, with a warning.
146
+ `Beagle::Turso.statement_gvl` returns the current mode. The setting is
147
+ process-wide.
130
148
  - While one thread pushes, `closed?` returns immediately on other threads, and
131
149
  queries on other connections proceed.
132
150
  - On a synced database, `connect` called while another thread is pushing waits
@@ -136,14 +154,19 @@ still a `RuntimeError`. A replica file already held by another process raises
136
154
  request.
137
155
  - `close` during an in-flight `push` is safe: the push finishes or raises, and
138
156
  later calls raise `ClosedError`.
139
- - A blocked native call cannot be interrupted: `Timeout` and `Thread#kill`
140
- take effect when it returns.
157
+ - A statement still waiting for another on the same file stops at once for
158
+ `Timeout`, `Thread#raise` or `Thread#kill`; it has not run. Once a statement
159
+ has started, it cannot be interrupted, and neither can `open`, `connect`,
160
+ `push` or `pull`: `Timeout` and `Thread#kill` take effect when the call
161
+ returns.
141
162
  - **Falcon / Async:** on Ruby 4.0+ with `async` 2.25+ (enable its worker pool
142
163
  with `ASYNC_SCHEDULER_WORKER_POOL=true` or
143
164
  `Async::Scheduler.new(worker_pool: true)`), a fiber scheduler runs these
144
- calls on a worker thread, so the reactor keeps serving other requests during
145
- a sync. On Ruby 3.3/3.4 the GVL is still released, but the scheduler's own
146
- thread blocks. Run sync from a dedicated thread there. Run one Falcon
165
+ calls and statements on a worker thread, so the reactor keeps serving other
166
+ requests during a sync or a long statement. A fiber's query waits (off the
167
+ reactor) for any statement in flight on the same file. On Ruby 3.3/3.4
168
+ the GVL is still released, but nothing is offloaded: the scheduler's own
169
+ thread blocks. Run sync and long imports from a dedicated thread there. Run one Falcon
147
170
  process per replica (`--count 1`): the replica file is locked to one
148
171
  process.
149
172
  - **Fork:** handles are bound to the process that opened them. In a forked
@@ -218,6 +241,20 @@ db.close
218
241
  (each worker gets its own) and call `stop` from `on_worker_shutdown` or
219
242
  `at_exit`.
220
243
 
244
+ ## Known engine issue: self-referencing foreign keys
245
+
246
+ Turso's engine has open bugs with foreign keys that reference their own table
247
+ (such as `parent_id`): a multi-row `DELETE`, `UPDATE` or `DROP` that SQLite accepts can fail with
248
+ `FOREIGN KEY constraint failed`, and `ON DELETE SET NULL` / `CASCADE` can leave
249
+ rows behind or orphaned
250
+ ([tursodatabase/turso#8713](https://github.com/tursodatabase/turso/issues/8713),
251
+ [#9279](https://github.com/tursodatabase/turso/issues/9279),
252
+ [#9293](https://github.com/tursodatabase/turso/issues/9293),
253
+ [#9294](https://github.com/tursodatabase/turso/issues/9294)). Foreign keys
254
+ between two different tables are unaffected. Workaround: avoid `ON DELETE`
255
+ actions on self-referencing foreign keys, and delete children before parents,
256
+ one level at a time.
257
+
221
258
  ## Credential safety
222
259
 
223
260
  `auth_token` is never included in `inspect` output or in error messages —
@@ -9,5 +9,5 @@ crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
11
  magnus = { version = "0.8", features = ["rb-sys"] }
12
- beagle_turso_core = "0.2.0"
12
+ beagle_turso_core = "0.3.0"
13
13
  rb-sys = "0.9"
@@ -23,6 +23,9 @@ pub fn raise_core(ruby: &Ruby, e: CoreError) -> Error {
23
23
  CoreError::Busy(_) => "BusyError",
24
24
  CoreError::Closed => "ClosedError",
25
25
  CoreError::Forked { .. } => "ForkedError",
26
+ // Never raised: run_statement retries an interrupted wait, or Ruby
27
+ // raises its pending interrupt instead. The base class, to be safe.
28
+ CoreError::Interrupted => "Error",
26
29
  // Value conversion problems stay Ruby's TypeError, as in 0.1.x.
27
30
  CoreError::Type(_) => return Error::new(ruby.exception_type_error(), e.to_string()),
28
31
  };
@@ -14,22 +14,71 @@
14
14
  // identical; the code is simpler and compiles with zero deprecation
15
15
  // warnings.
16
16
 
17
- // GVL: open_local, open, connect, push and pull (the calls that can wait on the
18
- // network or disk for a long time) run via nogvl::without_gvl with the GVL
19
- // released. Queries/execute stay under the GVL: they are short, local, and
20
- // release/reacquire would cost more than it saves.
17
+ // GVL: open_local, open, connect, push and pull, and the five statement
18
+ // methods (execute, query, query_result, query_result_with_types,
19
+ // execute_batch) run via nogvl::without_gvl with the GVL released, so other
20
+ // threads run and a worker-pool fiber scheduler offloads them off its reactor.
21
+ // Statement params are converted to core values before the call and Ruby rows
22
+ // built after it; the closure touches no Ruby object. The core serialises
23
+ // statements per database file (its statement lock, shared by every Database
24
+ // in the process opened on that file), so releasing the GVL adds no new write
25
+ // overlap. `Beagle::Turso.statement_gvl = :hold` (or
26
+ // BEAGLE_TURSO_STATEMENT_GVL=hold at load) runs statements under the GVL
27
+ // instead, as in 0.3.0. last_insert_rowid, close and closed? stay under the
28
+ // GVL: they are cheap.
21
29
 
22
30
  mod errors;
23
31
  mod nogvl;
24
32
 
25
- use beagle_turso_core::{Connection, Database, OpenOptions, Value};
33
+ use std::sync::atomic::{AtomicBool, Ordering};
34
+
35
+ use beagle_turso_core::{Connection, Database, Error as CoreError, OpenOptions, Value};
26
36
  use errors::raise_core;
27
- use nogvl::without_gvl;
37
+ use nogvl::{without_gvl, without_gvl_interruptible};
28
38
  use magnus::{
29
39
  encoding::EncodingCapable, function, method, prelude::*, Error, Float, Integer, IntoValue,
30
40
  RArray, RString, Ruby,
31
41
  };
32
42
 
43
+ // Process-wide: true runs statements with the GVL held (`statement_gvl :hold`).
44
+ static HOLD_STATEMENT_GVL: AtomicBool = AtomicBool::new(false);
45
+
46
+ fn set_statement_gvl_hold(hold: bool) {
47
+ HOLD_STATEMENT_GVL.store(hold, Ordering::Relaxed);
48
+ }
49
+
50
+ fn statement_gvl_hold() -> bool {
51
+ HOLD_STATEMENT_GVL.load(Ordering::Relaxed)
52
+ }
53
+
54
+ // Never set: the interrupt flag for statements run under the GVL.
55
+ static NEVER_INTERRUPTED: AtomicBool = AtomicBool::new(false);
56
+
57
+ // Runs one statement: without the GVL by default, or directly under it when
58
+ // the switch is `:hold`. `f` must touch no Ruby object; it returns the core
59
+ // Result, which the caller maps to a Ruby exception once the GVL is back.
60
+ //
61
+ // Without the GVL, `f` gets the flag Ruby's unblocking function sets, so
62
+ // Thread#raise/kill and Timeout stop a statement still waiting for another on
63
+ // the same file (the core returns Interrupted; one already running finishes).
64
+ // Ruby then raises its pending exception from without_gvl_interruptible. If it
65
+ // has none to raise (a trap handler ran, say), the statement waits again.
66
+ fn run_statement<F, R>(mut f: F) -> Result<Result<R, CoreError>, Error>
67
+ where
68
+ F: FnMut(&AtomicBool) -> Result<R, CoreError> + Send,
69
+ R: Send,
70
+ {
71
+ if statement_gvl_hold() {
72
+ return Ok(f(&NEVER_INTERRUPTED));
73
+ }
74
+ loop {
75
+ match without_gvl_interruptible(|flag| f(flag))? {
76
+ Err(CoreError::Interrupted) => continue,
77
+ done => return Ok(done),
78
+ }
79
+ }
80
+ }
81
+
33
82
  #[magnus::wrap(class = "Beagle::Turso::Database", free_immediately)]
34
83
  struct RbDatabase {
35
84
  inner: Database,
@@ -166,17 +215,15 @@ fn rows_to_ruby(ruby: &Ruby, rows: Vec<beagle_turso_core::Row>) -> Result<RArray
166
215
  impl RbConnection {
167
216
  fn execute(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<u64, Error> {
168
217
  let p = ruby_array_to_params(ruby, params)?;
169
- rb_self
170
- .inner
171
- .execute(&sql, &p)
218
+ let inner = &rb_self.inner;
219
+ run_statement(|i| inner.execute_interruptible(&sql, &p, i))?
172
220
  .map_err(|e| raise_core(ruby, e))
173
221
  }
174
222
 
175
223
  fn query(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<RArray, Error> {
176
224
  let p = ruby_array_to_params(ruby, params)?;
177
- let rows = rb_self
178
- .inner
179
- .query(&sql, &p)
225
+ let inner = &rb_self.inner;
226
+ let rows = run_statement(|i| inner.query_result_interruptible(&sql, &p, i).map(|r| r.rows))?
180
227
  .map_err(|e| raise_core(ruby, e))?;
181
228
  rows_to_ruby(ruby, rows)
182
229
  }
@@ -192,10 +239,10 @@ impl RbConnection {
192
239
  params: RArray,
193
240
  ) -> Result<RArray, Error> {
194
241
  let p = ruby_array_to_params(ruby, params)?;
195
- let result = rb_self
196
- .inner
197
- .query_result(&sql, &p)
198
- .map_err(|e| raise_core(ruby, e))?;
242
+ let inner = &rb_self.inner;
243
+ let result =
244
+ run_statement(|i| inner.query_result_interruptible(&sql, &p, i))?
245
+ .map_err(|e| raise_core(ruby, e))?;
199
246
  let columns = ruby.ary_new();
200
247
  for name in &result.columns {
201
248
  columns.push(name.clone().into_value_with(ruby))?;
@@ -217,10 +264,10 @@ impl RbConnection {
217
264
  params: RArray,
218
265
  ) -> Result<RArray, Error> {
219
266
  let p = ruby_array_to_params(ruby, params)?;
220
- let result = rb_self
221
- .inner
222
- .query_result(&sql, &p)
223
- .map_err(|e| raise_core(ruby, e))?;
267
+ let inner = &rb_self.inner;
268
+ let result =
269
+ run_statement(|i| inner.query_result_interruptible(&sql, &p, i))?
270
+ .map_err(|e| raise_core(ruby, e))?;
224
271
  let columns = ruby.ary_new();
225
272
  for name in result.columns {
226
273
  columns.push(name)?;
@@ -244,9 +291,8 @@ impl RbConnection {
244
291
  }
245
292
 
246
293
  fn execute_batch(ruby: &Ruby, rb_self: &Self, sql: String) -> Result<(), Error> {
247
- rb_self
248
- .inner
249
- .execute_batch(&sql)
294
+ let inner = &rb_self.inner;
295
+ run_statement(|i| inner.execute_batch_interruptible(&sql, i))?
250
296
  .map_err(|e| raise_core(ruby, e))
251
297
  }
252
298
 
@@ -265,6 +311,12 @@ impl RbConnection {
265
311
  #[magnus::init]
266
312
  fn init(ruby: &Ruby) -> Result<(), Error> {
267
313
  let turso = ruby.define_module("Beagle")?.define_module("Turso")?;
314
+ // Back Beagle::Turso.statement_gvl / statement_gvl= (lib/beagle/turso.rb).
315
+ turso.define_module_function(
316
+ "_set_statement_gvl_hold",
317
+ function!(set_statement_gvl_hold, 1),
318
+ )?;
319
+ turso.define_module_function("_statement_gvl_hold?", function!(statement_gvl_hold, 0))?;
268
320
  let db = turso.define_class("Database", ruby.class_object())?;
269
321
  db.define_singleton_method("open_local", function!(RbDatabase::open_local, 1))?;
270
322
  // Positional primitive; the public keyword-arg `Database.open` wrapper
@@ -3,16 +3,20 @@
3
3
  // move the call to a worker thread (keeping its reactor live).
4
4
  //
5
5
  // The closure must not touch any Ruby object: convert arguments before calling
6
- // and build Ruby results after it returns. No unblocking function is passed,
7
- // so Thread#raise/kill (and Timeout) are delivered only once the call returns:
8
- // rb_nogvl then raises the pending interrupt by longjmp. That is caught with
9
- // rb_protect (magnus::rb_sys::protect), so it never unwinds through Rust
10
- // frames; any value the native call produced is dropped normally (for `open`,
11
- // the Database is closed) and the exception comes back as `Err`.
6
+ // and build Ruby results after it returns. `without_gvl` passes no unblocking
7
+ // function, so Thread#raise/kill (and Timeout) are delivered only once the
8
+ // call returns. `without_gvl_interruptible` passes one that sets a flag the
9
+ // closure can watch, so it can stop *waiting* early; work it has begun still
10
+ // runs to the end. Either way, rb_nogvl then raises the pending interrupt by
11
+ // longjmp. That is caught with rb_protect (magnus::rb_sys::protect), so it
12
+ // never unwinds through Rust frames; any value the native call produced is
13
+ // dropped normally (for `open`, the Database is closed) and the exception
14
+ // comes back as `Err`.
12
15
 
13
16
  use std::ffi::c_void;
14
17
  use std::panic::{self, AssertUnwindSafe};
15
18
  use std::ptr;
19
+ use std::sync::atomic::{AtomicBool, Ordering};
16
20
 
17
21
  use magnus::Error;
18
22
 
@@ -36,19 +40,50 @@ where
36
40
  ptr::null_mut()
37
41
  }
38
42
 
43
+ // The unblocking function: Ruby calls it, holding the GVL, from the thread that
44
+ // wants to interrupt this one. It only sets the flag.
45
+ unsafe extern "C" fn set_flag(data: *mut c_void) {
46
+ (*(data as *const AtomicBool)).store(true, Ordering::Release);
47
+ }
48
+
39
49
  pub fn without_gvl<F, R>(f: F) -> Result<R, Error>
50
+ where
51
+ F: FnOnce() -> R + Send,
52
+ R: Send,
53
+ {
54
+ call_without_gvl(f, None)
55
+ }
56
+
57
+ pub fn without_gvl_interruptible<F, R>(f: F) -> Result<R, Error>
58
+ where
59
+ F: FnOnce(&AtomicBool) -> R + Send,
60
+ R: Send,
61
+ {
62
+ let flag = AtomicBool::new(false);
63
+ let flag = &flag;
64
+ call_without_gvl(move || f(flag), Some(flag))
65
+ }
66
+
67
+ fn call_without_gvl<F, R>(f: F, interrupt: Option<&AtomicBool>) -> Result<R, Error>
40
68
  where
41
69
  F: FnOnce() -> R + Send,
42
70
  R: Send,
43
71
  {
44
72
  let mut call = Call { f: Some(f), out: None };
73
+ let (ubf, ubf_data) = match interrupt {
74
+ Some(flag) => (
75
+ Some(set_flag as unsafe extern "C" fn(*mut c_void)),
76
+ flag as *const AtomicBool as *mut c_void,
77
+ ),
78
+ None => (None, ptr::null_mut()),
79
+ };
45
80
  let protected = magnus::rb_sys::protect(|| {
46
81
  unsafe {
47
82
  rb_sys::rb_nogvl(
48
83
  Some(trampoline::<F, R>),
49
84
  &mut call as *mut Call<F, R> as *mut c_void,
50
- None,
51
- ptr::null_mut(),
85
+ ubf,
86
+ ubf_data,
52
87
  RB_NOGVL_OFFLOAD_SAFE,
53
88
  );
54
89
  }
@@ -1,5 +1,5 @@
1
1
  module Beagle
2
2
  module Turso
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
data/lib/beagle/turso.rb CHANGED
@@ -15,6 +15,33 @@ end
15
15
 
16
16
  module Beagle
17
17
  module Turso
18
+ # Whether statements (Connection#execute, #query, #query_result,
19
+ # #query_result_with_types, #execute_batch) release the GVL: :release (the
20
+ # default) lets other threads run and a worker-pool fiber scheduler offload
21
+ # them; :hold keeps them under the GVL, as in 0.3.0. Process-wide.
22
+ def self.statement_gvl
23
+ _statement_gvl_hold? ? :hold : :release
24
+ end
25
+
26
+ def self.statement_gvl=(mode)
27
+ unless mode == :hold || mode == :release
28
+ raise ArgumentError, "statement_gvl must be :hold or :release"
29
+ end
30
+ _set_statement_gvl_hold(mode == :hold)
31
+ end
32
+
33
+ private_class_method :_statement_gvl_hold?, :_set_statement_gvl_hold
34
+
35
+ # Case and surrounding space don't matter. Anything else is most likely a
36
+ # typo in a rollback attempt, so it is reported rather than ignored.
37
+ case (env_gvl = ENV["BEAGLE_TURSO_STATEMENT_GVL"].to_s).strip.downcase
38
+ when "hold" then self.statement_gvl = :hold
39
+ when "", "release" then nil
40
+ else
41
+ warn "beagle-turso: ignoring BEAGLE_TURSO_STATEMENT_GVL=#{env_gvl.inspect} " \
42
+ "(expected hold or release); statements release the GVL"
43
+ end
44
+
18
45
  class Database
19
46
  # Opens a local-only database, or one synced with a remote Turso
20
47
  # database when both `remote_url` and `auth_token` are given (see
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: beagle-turso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BeagleSoftwareUK
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys