beagle-turso 0.1.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f35de8fc3b33e04d7d170ee1032ee2d57af85642fcec1b120d591700baa9532c
4
- data.tar.gz: 67bbe8aaa50468fb2cf0ef2c44e208fd414c4dff3694e9ae2a8dc8394ae671b7
3
+ metadata.gz: e153a3302d14dc6f72dcd76b510a0dadb1e16f4f50235258fd81a6ef67be4e91
4
+ data.tar.gz: b52eeb81d1fb68ce40efbe8691202f8c70e9cdceb6491fdce31ad58e4a6714d6
5
5
  SHA512:
6
- metadata.gz: ce2cef361285eed71718e587fbbe3a1a3d321e2e45ba269b1274edbbc622d073a0b55274ea2950e89e67eafeaceb70a0f3e469e0bd0bf29b59ac2ffedc205095
7
- data.tar.gz: edd3d0df3ce5ba628e4ca6923c9df228c9f480d9a05ba4bf879f95a599adeed5d362f38f10b8621ee1d0fa01a0ca07ae1a69e9b85065c0fd089b59e3510706be
6
+ metadata.gz: bef6dc8666bdf7b163eb8b2c710b61b4db7cb33ee2b914b8c60904b2c13a333fb0a656f780ad6e909b186e273b1dc113558b5a0161b55c5cf8473760bcddc3b0
7
+ data.tar.gz: fd867e70cc1657e4c5922cf635b80eb4bade93805f41fcd343353975a582812bb18b7b405ecc3ec538fada798d665d76b2409e01ec09d83b4e588869631cd7ce
data/README.md CHANGED
@@ -68,7 +68,8 @@ db.pull # pull remote changes down to the local file (returns true/false)
68
68
  `Database.open` also accepts `bootstrap_if_empty:` (defaults to `true`),
69
69
  which pulls all remote data down on first open if the local file is new/empty.
70
70
  Calling `push`/`pull` on a database opened via `open_local` (or via `open`
71
- without both `remote_url:` and `auth_token:`) raises `RuntimeError`.
71
+ without both `remote_url:` and `auth_token:`) raises
72
+ `Beagle::Turso::NotSyncedError`.
72
73
 
73
74
  ### Durability note: writes commit on-sync, not synchronously
74
75
 
@@ -103,6 +104,54 @@ Two things to avoid:
103
104
  A single remote database, dedicated to this engine and written by one process
104
105
  at a time, is the model it's built for.
105
106
 
107
+ ## Errors
108
+
109
+ Every error the driver raises is a `Beagle::Turso::Error`, a `RuntimeError`
110
+ subclass: `OpenError`, `ConnectError`, `QueryError`, `SyncError`,
111
+ `NotSyncedError` (push/pull on a local-only database), `BusyError` (another
112
+ session or process holds the database), `ClosedError` and `ForkedError`. An
113
+ unsupported bind parameter type raises Ruby's `TypeError`. Auth, network and
114
+ protocol failures during sync all arrive as `SyncError` (or `OpenError` at
115
+ open): the engine does not distinguish them. Messages never contain the auth
116
+ token.
117
+
118
+ Busy conditions now raise `BusyError` with a message beginning
119
+ `database is busy:` (0.1.x said `query failed:` / `sync failed:`); it is
120
+ still a `RuntimeError`. A replica file already held by another process raises
121
+ `OpenError` ("... File is locked by another process").
122
+
123
+ ## Threads, fibers and fork
124
+
125
+ - One `Database` may be shared by threads. Give each thread its own
126
+ `Connection`; a shared `Connection` works but serializes its callers.
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.
130
+ - While one thread pushes, `closed?` returns immediately on other threads, and
131
+ queries on other connections proceed.
132
+ - On a synced database, `connect` called while another thread is pushing waits
133
+ inside the engine until the push finishes (about the push's duration); the
134
+ GVL is released while it waits, so other threads and fibers keep running.
135
+ Open connections up front (e.g. at boot / pool warm-up) rather than per
136
+ request.
137
+ - `close` during an in-flight `push` is safe: the push finishes or raises, and
138
+ later calls raise `ClosedError`.
139
+ - A blocked native call cannot be interrupted: `Timeout` and `Thread#kill`
140
+ take effect when it returns.
141
+ - **Falcon / Async:** on Ruby 4.0+ with `async` 2.25+ (enable its worker pool
142
+ with `ASYNC_SCHEDULER_WORKER_POOL=true` or
143
+ `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
147
+ process per replica (`--count 1`): the replica file is locked to one
148
+ process.
149
+ - **Fork:** handles are bound to the process that opened them. In a forked
150
+ child, `open`, `connect`, `push`, `pull` and queries on an inherited handle
151
+ raise `ForkedError`; open a new database there. `close` is a no-op and
152
+ `closed?` is safe (it reports the handle as closed if it can't be inspected),
153
+ so inherited handles need no cleanup in the child.
154
+
106
155
  ## Credential safety
107
156
 
108
157
  `auth_token` is never included in `inspect` output or in error messages —
@@ -8,5 +8,6 @@ publish = false
8
8
  crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
- magnus = "0.8"
12
- beagle_turso_core = "0.1.3"
11
+ magnus = { version = "0.8", features = ["rb-sys"] }
12
+ beagle_turso_core = "0.2.0"
13
+ rb-sys = "0.9"
@@ -0,0 +1,30 @@
1
+ // Maps beagle_turso_core errors onto the Ruby hierarchy defined in
2
+ // lib/beagle/turso/errors.rb. Classes are looked up by name at raise time
3
+ // (the error path only), so the Ruby file stays the single definition.
4
+
5
+ use beagle_turso_core::Error as CoreError;
6
+ use magnus::{exception::ExceptionClass, prelude::*, Error, RModule, Ruby};
7
+
8
+ pub fn error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
9
+ ruby.class_object()
10
+ .const_get::<_, RModule>("Beagle")
11
+ .and_then(|m| m.const_get::<_, RModule>("Turso"))
12
+ .and_then(|m| m.const_get::<_, ExceptionClass>(name))
13
+ .unwrap_or_else(|_| ruby.exception_runtime_error())
14
+ }
15
+
16
+ pub fn raise_core(ruby: &Ruby, e: CoreError) -> Error {
17
+ let name = match &e {
18
+ CoreError::Open(_) => "OpenError",
19
+ CoreError::Connect(_) => "ConnectError",
20
+ CoreError::Query(_) => "QueryError",
21
+ CoreError::Sync(_) => "SyncError",
22
+ CoreError::NotSynced(_) => "NotSyncedError",
23
+ CoreError::Busy(_) => "BusyError",
24
+ CoreError::Closed => "ClosedError",
25
+ CoreError::Forked { .. } => "ForkedError",
26
+ // Value conversion problems stay Ruby's TypeError, as in 0.1.x.
27
+ CoreError::Type(_) => return Error::new(ruby.exception_type_error(), e.to_string()),
28
+ };
29
+ Error::new(error_class(ruby, name), e.to_string())
30
+ }
@@ -14,7 +14,17 @@
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.
21
+
22
+ mod errors;
23
+ mod nogvl;
24
+
17
25
  use beagle_turso_core::{Connection, Database, OpenOptions, Value};
26
+ use errors::raise_core;
27
+ use nogvl::without_gvl;
18
28
  use magnus::{
19
29
  encoding::EncodingCapable, function, method, prelude::*, Error, Float, Integer, IntoValue,
20
30
  RArray, RString, Ruby,
@@ -30,10 +40,6 @@ struct RbConnection {
30
40
  inner: Connection,
31
41
  }
32
42
 
33
- fn rt_err(ruby: &Ruby, msg: String) -> Error {
34
- Error::new(ruby.exception_runtime_error(), msg)
35
- }
36
-
37
43
  fn ruby_array_to_params(ruby: &Ruby, arr: RArray) -> Result<Vec<Value>, Error> {
38
44
  // Safe: `from_value` below is a raw type check on the Ruby object (exact
39
45
  // class match — no `to_int`/`to_f`/`to_str` coercion method dispatch), so
@@ -92,7 +98,7 @@ impl RbDatabase {
92
98
  auth_token: None,
93
99
  bootstrap_if_empty: true,
94
100
  };
95
- let db = Database::open(opts).map_err(|e| rt_err(ruby, e.to_string()))?;
101
+ let db = without_gvl(|| Database::open(opts))?.map_err(|e| raise_core(ruby, e))?;
96
102
  Ok(RbDatabase { inner: db })
97
103
  }
98
104
 
@@ -113,30 +119,24 @@ impl RbDatabase {
113
119
  auth_token,
114
120
  bootstrap_if_empty,
115
121
  };
116
- let db = Database::open(opts).map_err(|e| rt_err(ruby, e.to_string()))?;
122
+ let db = without_gvl(|| Database::open(opts))?.map_err(|e| raise_core(ruby, e))?;
117
123
  Ok(RbDatabase { inner: db })
118
124
  }
119
125
 
120
126
  fn connect(ruby: &Ruby, rb_self: &Self) -> Result<RbConnection, Error> {
121
- let c = rb_self
122
- .inner
123
- .connect()
124
- .map_err(|e| rt_err(ruby, e.to_string()))?;
127
+ let inner = &rb_self.inner;
128
+ let c = without_gvl(|| inner.connect())?.map_err(|e| raise_core(ruby, e))?;
125
129
  Ok(RbConnection { inner: c })
126
130
  }
127
131
 
128
132
  fn push(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
129
- rb_self
130
- .inner
131
- .push()
132
- .map_err(|e| rt_err(ruby, e.to_string()))
133
+ let inner = &rb_self.inner;
134
+ without_gvl(|| inner.push())?.map_err(|e| raise_core(ruby, e))
133
135
  }
134
136
 
135
137
  fn pull(ruby: &Ruby, rb_self: &Self) -> Result<bool, Error> {
136
- rb_self
137
- .inner
138
- .pull()
139
- .map_err(|e| rt_err(ruby, e.to_string()))
138
+ let inner = &rb_self.inner;
139
+ without_gvl(|| inner.pull())?.map_err(|e| raise_core(ruby, e))
140
140
  }
141
141
 
142
142
  // Release the underlying engine handle and, for a synced database, end its
@@ -151,13 +151,25 @@ impl RbDatabase {
151
151
  }
152
152
  }
153
153
 
154
+ fn rows_to_ruby(ruby: &Ruby, rows: Vec<beagle_turso_core::Row>) -> Result<RArray, Error> {
155
+ let out = ruby.ary_new();
156
+ for row in rows {
157
+ let rb_row = ruby.ary_new();
158
+ for val in &row.values {
159
+ rb_row.push(value_to_ruby(ruby, val))?;
160
+ }
161
+ out.push(rb_row)?;
162
+ }
163
+ Ok(out)
164
+ }
165
+
154
166
  impl RbConnection {
155
167
  fn execute(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<u64, Error> {
156
168
  let p = ruby_array_to_params(ruby, params)?;
157
169
  rb_self
158
170
  .inner
159
171
  .execute(&sql, &p)
160
- .map_err(|e| rt_err(ruby, e.to_string()))
172
+ .map_err(|e| raise_core(ruby, e))
161
173
  }
162
174
 
163
175
  fn query(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<RArray, Error> {
@@ -165,16 +177,8 @@ impl RbConnection {
165
177
  let rows = rb_self
166
178
  .inner
167
179
  .query(&sql, &p)
168
- .map_err(|e| rt_err(ruby, e.to_string()))?;
169
- let out = ruby.ary_new();
170
- for row in rows {
171
- let rb_row = ruby.ary_new();
172
- for val in &row.values {
173
- rb_row.push(value_to_ruby(ruby, val))?;
174
- }
175
- out.push(rb_row)?;
176
- }
177
- Ok(out)
180
+ .map_err(|e| raise_core(ruby, e))?;
181
+ rows_to_ruby(ruby, rows)
178
182
  }
179
183
 
180
184
  // Like `query`, but also returns column names — needed by callers (e.g.
@@ -191,37 +195,59 @@ impl RbConnection {
191
195
  let result = rb_self
192
196
  .inner
193
197
  .query_result(&sql, &p)
194
- .map_err(|e| rt_err(ruby, e.to_string()))?;
198
+ .map_err(|e| raise_core(ruby, e))?;
195
199
  let columns = ruby.ary_new();
196
200
  for name in &result.columns {
197
201
  columns.push(name.clone().into_value_with(ruby))?;
198
202
  }
199
- let rows = ruby.ary_new();
200
- for row in result.rows {
201
- let rb_row = ruby.ary_new();
202
- for val in &row.values {
203
- rb_row.push(value_to_ruby(ruby, val))?;
204
- }
205
- rows.push(rb_row)?;
206
- }
203
+ let rows = rows_to_ruby(ruby, result.rows)?;
207
204
  let out = ruby.ary_new();
208
205
  out.push(columns)?;
209
206
  out.push(rows)?;
210
207
  Ok(out)
211
208
  }
212
209
 
210
+ // Like `query_result`, plus each column's declared type (String or nil),
211
+ // which a Sequel adapter needs to pick value conversions. Returns
212
+ // `[columns, decl_types, rows]`.
213
+ fn query_result_with_types(
214
+ ruby: &Ruby,
215
+ rb_self: &Self,
216
+ sql: String,
217
+ params: RArray,
218
+ ) -> Result<RArray, Error> {
219
+ 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))?;
224
+ let columns = ruby.ary_new();
225
+ for name in result.columns {
226
+ columns.push(name)?;
227
+ }
228
+ let types = ruby.ary_new();
229
+ for t in result.decl_types {
230
+ types.push(t)?; // Option<String> -> String or nil
231
+ }
232
+ let out = ruby.ary_new();
233
+ out.push(columns)?;
234
+ out.push(types)?;
235
+ out.push(rows_to_ruby(ruby, result.rows)?)?;
236
+ Ok(out)
237
+ }
238
+
213
239
  fn last_insert_rowid(ruby: &Ruby, rb_self: &Self) -> Result<i64, Error> {
214
240
  rb_self
215
241
  .inner
216
242
  .last_insert_rowid()
217
- .map_err(|e| rt_err(ruby, e.to_string()))
243
+ .map_err(|e| raise_core(ruby, e))
218
244
  }
219
245
 
220
246
  fn execute_batch(ruby: &Ruby, rb_self: &Self, sql: String) -> Result<(), Error> {
221
247
  rb_self
222
248
  .inner
223
249
  .execute_batch(&sql)
224
- .map_err(|e| rt_err(ruby, e.to_string()))
250
+ .map_err(|e| raise_core(ruby, e))
225
251
  }
226
252
 
227
253
  // Release the underlying connection handle instead of waiting for GC.
@@ -253,6 +279,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
253
279
  conn.define_method("execute", method!(RbConnection::execute, 2))?;
254
280
  conn.define_method("query", method!(RbConnection::query, 2))?;
255
281
  conn.define_method("query_result", method!(RbConnection::query_result, 2))?;
282
+ conn.define_method(
283
+ "query_result_with_types",
284
+ method!(RbConnection::query_result_with_types, 2),
285
+ )?;
256
286
  conn.define_method(
257
287
  "last_insert_rowid",
258
288
  method!(RbConnection::last_insert_rowid, 0),
@@ -0,0 +1,63 @@
1
+ // Runs blocking Rust work with Ruby's GVL released, so other Ruby threads keep
2
+ // running and a fiber scheduler that implements `blocking_operation_wait` can
3
+ // move the call to a worker thread (keeping its reactor live).
4
+ //
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`.
12
+
13
+ use std::ffi::c_void;
14
+ use std::panic::{self, AssertUnwindSafe};
15
+ use std::ptr;
16
+
17
+ use magnus::Error;
18
+
19
+ // From ruby/thread.h. Rubies that predate it test only the INTR_FAIL (0x1) and
20
+ // UBF_ASYNC_SAFE (0x2) bits and ignore this one (Task 1, F2).
21
+ const RB_NOGVL_OFFLOAD_SAFE: i32 = 0x4;
22
+
23
+ struct Call<F, R> {
24
+ f: Option<F>,
25
+ out: Option<std::thread::Result<R>>,
26
+ }
27
+
28
+ unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
29
+ where
30
+ F: FnOnce() -> R,
31
+ {
32
+ let call = &mut *(data as *mut Call<F, R>);
33
+ let f = call.f.take().expect("nogvl closure run twice");
34
+ // Never unwind across the C frame.
35
+ call.out = Some(panic::catch_unwind(AssertUnwindSafe(f)));
36
+ ptr::null_mut()
37
+ }
38
+
39
+ pub fn without_gvl<F, R>(f: F) -> Result<R, Error>
40
+ where
41
+ F: FnOnce() -> R + Send,
42
+ R: Send,
43
+ {
44
+ let mut call = Call { f: Some(f), out: None };
45
+ let protected = magnus::rb_sys::protect(|| {
46
+ unsafe {
47
+ rb_sys::rb_nogvl(
48
+ Some(trampoline::<F, R>),
49
+ &mut call as *mut Call<F, R> as *mut c_void,
50
+ None,
51
+ ptr::null_mut(),
52
+ RB_NOGVL_OFFLOAD_SAFE,
53
+ );
54
+ }
55
+ 0 // Qfalse; the value is unused
56
+ });
57
+ // On Err, `call` (and anything the closure produced) drops here normally.
58
+ protected?;
59
+ match call.out.expect("rb_nogvl returned without running the closure") {
60
+ Ok(r) => Ok(r),
61
+ Err(payload) => panic::resume_unwind(payload),
62
+ }
63
+ }
@@ -0,0 +1,19 @@
1
+ module Beagle
2
+ module Turso
3
+ # Base class for every error the native driver raises. A RuntimeError so
4
+ # code written against 0.1.x, which raised bare RuntimeErrors, still rescues.
5
+ class Error < RuntimeError; end
6
+
7
+ class OpenError < Error; end
8
+ class ConnectError < Error; end
9
+ class QueryError < Error; end
10
+ class SyncError < Error; end
11
+ # push/pull on a database opened without remote_url:/auth_token:.
12
+ class NotSyncedError < Error; end
13
+ # The engine reported the database busy (another session or process holds it).
14
+ class BusyError < Error; end
15
+ class ClosedError < Error; end
16
+ # A handle opened in a parent process was used after fork; open a new one.
17
+ class ForkedError < Error; end
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  module Beagle
2
2
  module Turso
3
- VERSION = "0.1.3"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
data/lib/beagle/turso.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "turso/version"
2
+ require_relative "turso/errors"
2
3
 
3
4
  # Load the native extension. Precompiled ("fat") gems ship the .so under a
4
5
  # per-Ruby-ABI subdir (lib/beagle_turso/<major.minor>/beagle_turso.so); a
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.1.3
4
+ version: 0.2.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-08-09 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -40,8 +40,11 @@ files:
40
40
  - README.md
41
41
  - ext/beagle_turso/Cargo.toml
42
42
  - ext/beagle_turso/extconf.rb
43
+ - ext/beagle_turso/src/errors.rs
43
44
  - ext/beagle_turso/src/lib.rs
45
+ - ext/beagle_turso/src/nogvl.rs
44
46
  - lib/beagle/turso.rb
47
+ - lib/beagle/turso/errors.rb
45
48
  - lib/beagle/turso/version.rb
46
49
  homepage: https://github.com/BeagleSoftwareUK/beagle-turso
47
50
  licenses: